Files
patrick 89de794356
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled
FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI
- FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs
- FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig
- FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
2026-08-11 22:27:52 +02:00

637 lines
29 KiB
Go

// Package api is the archivdms HTTP API server, ported from archivmail's
// internal/api/server.go pattern: net/http ServeMux, an s.auth/s.authAdmin
// middleware chain, JWT session extraction, and application-level
// tenant-context propagation (tenantFromCtx). No mail-specific routes.
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"time"
"archivdms/config"
"archivdms/internal/audit"
"archivdms/internal/auth"
"archivdms/internal/ldapauth"
"archivdms/internal/ldapstore"
"archivdms/internal/mailer"
"archivdms/internal/objectstore"
"archivdms/internal/ocr"
"archivdms/internal/pagesplit"
"archivdms/internal/storage"
"archivdms/internal/tenantstore"
"archivdms/internal/thumbnail"
"archivdms/internal/userstore"
)
type contextKey string
const (
sessionKey contextKey = "session"
tenantKey contextKey = "tenant_id"
)
// Server is the archivdms HTTP API server.
type Server struct {
cfg config.APIConfig
storageCfg config.StorageConfig
startTime time.Time
store *storage.Store
authMgr *auth.Manager
users *userstore.Store
audlog *audit.Logger
logger *slog.Logger
mux *http.ServeMux
ocr *ocr.Extractor
// objects is the WORM object-storage driver (internal/objectstore): the
// only place archived files are written, read or unlinked. Wired by
// SetStorageConfig/SetObjectStore.
objects objectstore.Store
thumbs *thumbnail.Generator
// pagesplitter performs barcode separator-page splitting of multi-page PDF
// uploads before archival (internal/pagesplit). May be nil / disabled, in
// which case every upload is archived as a single document as before.
pagesplitter *pagesplit.Detector
tenantStore *tenantstore.Store
mailer *mailer.Mailer
fqdn string
appVersion string
// ldapStore/ldapAuth are wired via SetLDAP. Both may be nil when LDAP is
// unconfigured — the config endpoints then return 503.
ldapStore *ldapstore.Store
ldapAuth *ldapauth.Authenticator
// shareLimiter rate-limits the unauthenticated public share endpoints
// (per client IP) to blunt token/password enumeration.
shareLimiter *ipRateLimiter
// metrics ist die prozesslokale Metrik-Registry (FDN-08,
// internal/api/observability.go). Kein globaler Zustand: hängt am Server.
metrics *metricsRegistry
// baseHandler ist die in New() gebaute Middleware-Kette um s.mux
// (requestID -> metrics -> recover). Einmal gebaut, danach nur gelesen —
// kein Lazy-Init in ServeHTTP (Data Race).
baseHandler http.Handler
// accountingLimiter rate-limits the Bearer-key Buchhaltungs-Pull-API
// (per client IP) to blunt API-key guessing. Separate bucket set from
// shareLimiter so a busy accounting client cannot starve share downloads.
accountingLimiter *ipRateLimiter
}
// SetStorageConfig wires the storage configuration (inbox/store/ocr-tmp
// paths, max upload size) into the API server. Needed by
// handleUploadDocument, which cannot rely solely on the storage.Store
// (that only knows its own base dir, not the inbox/ocr-tmp layout).
//
// It also constructs the object-storage driver (internal/objectstore), the
// single place where archived files are written, read and unlinked. Call
// SetFQDN before this if generated signed URLs should be absolute.
func (s *Server) SetStorageConfig(cfg config.StorageConfig) {
s.storageCfg = cfg
secret := s.cfg.Secret
if strings.TrimSpace(secret) == "" {
// No master secret configured: fall back to an ephemeral, per-process
// key so file access keeps working; signed URLs then simply do not
// survive a restart. Never fail startup over this.
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
secret = strconv.FormatInt(time.Now().UnixNano(), 36)
} else {
secret = hex.EncodeToString(buf)
}
s.logger.Warn("objectstore: api.secret unset, using ephemeral signing key")
}
driver, err := objectstore.NewLocalStore(cfg, secret, s.publicBaseURL())
if err != nil {
s.logger.Error("objectstore init failed", "err", err)
return
}
s.objects = driver
}
// SetObjectStore overrides the object-storage driver (tests / alternative
// wiring). Normally set implicitly by SetStorageConfig.
func (s *Server) SetObjectStore(o objectstore.Store) {
s.objects = o
}
// publicBaseURL is the origin used for generated links. Empty FQDN yields
// site-relative URLs.
func (s *Server) publicBaseURL() string {
if strings.TrimSpace(s.fqdn) == "" {
return ""
}
return "https://" + strings.TrimSuffix(strings.TrimSpace(s.fqdn), "/")
}
// SetOCR wires the OCR extractor into the API server. May be nil, in which
// case uploads succeed with an empty ocr_text and an audit warning.
func (s *Server) SetOCR(e *ocr.Extractor) {
s.ocr = e
}
// SetThumbnailer wires the preview-thumbnail generator. May be nil, in which
// case the thumbnail endpoint returns 404 and the UI falls back to a generic
// file icon.
func (s *Server) SetThumbnailer(g *thumbnail.Generator) {
s.thumbs = g
}
// SetPageSplitter wires the barcode separator-page detector used at ingest.
// May be nil or disabled (config.PageSplitConfig.Enabled=false, the default),
// in which case multi-page uploads are archived unsplit as before.
func (s *Server) SetPageSplitter(d *pagesplit.Detector) {
s.pagesplitter = d
}
// SetTenants wires the tenant store into the API server after construction.
func (s *Server) SetTenants(ts *tenantstore.Store) {
s.tenantStore = ts
}
// SetLDAP wires the per-tenant LDAP config store and authenticator into the
// API server. Both may be nil (LDAP unconfigured); the config endpoints then
// respond 503.
func (s *Server) SetLDAP(store *ldapstore.Store, authn *ldapauth.Authenticator) {
s.ldapStore = store
s.ldapAuth = authn
}
// SetMailer wires the outbound mailer into the API server.
func (s *Server) SetMailer(m *mailer.Mailer) {
s.mailer = m
}
// SetFQDN wires the server FQDN for link generation in emails.
func (s *Server) SetFQDN(fqdn string) {
s.fqdn = fqdn
}
// SetVersion wires the app version into the API server.
func (s *Server) SetVersion(v string) {
s.appVersion = v
}
// New creates and wires up a new API server.
func New(
cfg config.APIConfig,
store *storage.Store,
authMgr *auth.Manager,
users *userstore.Store,
audlog *audit.Logger,
logger *slog.Logger,
) *Server {
s := &Server{
cfg: cfg,
store: store,
authMgr: authMgr,
users: users,
audlog: audlog,
logger: logger,
mux: http.NewServeMux(),
startTime: time.Now(),
// 20 requests burst, refilled at 1/sec per client IP.
shareLimiter: newIPRateLimiter(20, 1.0),
// Batch pulls are legitimate here: 60 requests burst, refilled at 5/sec.
accountingLimiter: newIPRateLimiter(60, 5.0),
metrics: newMetricsRegistry(),
}
s.routes()
s.baseHandler = s.requestIDMiddleware(s.metricsMiddleware(s.recoverMiddleware(s.mux)))
return s
}
// auth wraps a handler with authentication + tenant context propagation.
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
return s.authMiddleware(s.tenantMiddleware(h))
}
// authAdmin wraps a handler requiring at least domain_admin role.
func (s *Server) authAdmin(h http.HandlerFunc) http.HandlerFunc {
return s.authMiddleware(s.tenantMiddleware(s.requireRole(userstore.RoleDomainAdmin, h)))
}
func (s *Server) routes() {
s.mux.HandleFunc("GET /api/health", s.handleHealth)
s.mux.HandleFunc("GET /api/version", s.handleVersion)
// Prometheus-Scrape-Endpunkt (FDN-08, internal/api/metrics_handlers.go).
// Bewusst ohne s.auth — ein Scraper hat keine Session; der Zugriff wird
// stattdessen per Quell-IP begrenzt (loopback + api.metrics_allowed_ips).
s.mux.HandleFunc("GET /metrics", s.handleMetrics)
s.mux.HandleFunc("POST /api/auth/login", s.handleLogin)
s.mux.HandleFunc("GET /api/auth/me", s.auth(s.handleMe))
s.mux.HandleFunc("POST /api/auth/logout", s.auth(s.handleLogout))
s.mux.HandleFunc("GET /api/users", s.authAdmin(s.handleListUsers))
s.mux.HandleFunc("POST /api/users", s.authAdmin(s.handleCreateUser))
s.mux.HandleFunc("PATCH /api/users/{id}", s.authAdmin(s.handleUpdateUser))
s.mux.HandleFunc("DELETE /api/users/{id}", s.authAdmin(s.handleDeleteUser))
s.mux.HandleFunc("GET /api/audit", s.auth(s.requireRole(userstore.RoleDomainAdmin, s.handleAuditLog)))
// Tenant management: superadmin-only (internal/api/tenant_handlers.go).
s.mux.HandleFunc("POST /api/tenants", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCreateTenant)))
s.mux.HandleFunc("GET /api/tenants", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleListTenants)))
// Dashboard (aggregated tenant key figures, internal/api/dashboard_handlers.go)
s.mux.HandleFunc("GET /api/dashboard", s.auth(s.handleDashboard))
// Documents (core model)
s.mux.HandleFunc("GET /api/documents", s.auth(s.handleListDocuments))
s.mux.HandleFunc("POST /api/documents", s.auth(s.handleCreateDocument))
s.mux.HandleFunc("POST /api/documents/upload", s.auth(s.handleUploadDocument))
// Full-text + attribute search (internal/api/search_handlers.go). Registered
// as a literal path; Go 1.22 ServeMux prefers it over /api/documents/{id}.
s.mux.HandleFunc("GET /api/documents/search", s.auth(s.handleSearchDocuments))
// Bulk-Export: ZIP mit doc-<id>/-Ordner je Dokument + index.csv
// (internal/api/document_bulk_export_handlers.go). Literaler Pfad, daher
// kein Konflikt mit /api/documents/{id}.
s.mux.HandleFunc("POST /api/documents/export", s.auth(s.handleBulkExportDocuments))
s.mux.HandleFunc("GET /api/documents/{id}", s.auth(s.handleGetDocument))
s.mux.HandleFunc("GET /api/documents/{id}/file", s.auth(s.handleGetDocumentFile))
s.mux.HandleFunc("GET /api/documents/{id}/thumbnail", s.auth(s.handleGetDocumentThumbnail))
s.mux.HandleFunc("GET /api/documents/{id}/audit", s.auth(s.handleDocumentAuditLog))
// Einzel-Dokument-Export: ZIP (Originaldatei + metadata.json + ocr_text.txt).
s.mux.HandleFunc("GET /api/documents/{id}/export", s.auth(s.handleExportDocument))
// OCR-Wortkoordinaten für das Text-Overlay (internal/api/ocr_word_handlers.go).
s.mux.HandleFunc("GET /api/documents/{id}/ocr-words", s.auth(s.handleListDocumentOCRWords))
s.mux.HandleFunc("PATCH /api/documents/{id}", s.auth(s.handleUpdateDocumentTitle))
s.mux.HandleFunc("PUT /api/documents/{id}/doc-type", s.auth(s.handleSetDocumentDocType))
s.mux.HandleFunc("PUT /api/documents/{id}/correspondent", s.auth(s.handleSetDocumentCorrespondent))
s.mux.HandleFunc("PUT /api/documents/{id}/document-date", s.auth(s.handleSetDocumentDate))
s.mux.HandleFunc("DELETE /api/documents/{id}", s.auth(s.handleDeleteDocument))
s.mux.HandleFunc("POST /api/documents/{id}/reprocess", s.auth(s.handleReprocessDocument))
// Status/manueller Retry der asynchronen Verarbeitungswarteschlange
// (internal/api/processing_job_handlers.go). Das Frontend pollt den
// GET-Endpunkt nur solange ein Dokument nicht 'done' ist.
s.mux.HandleFunc("GET /api/documents/{id}/processing-job", s.auth(s.handleGetProcessingJob))
s.mux.HandleFunc("POST /api/documents/{id}/processing-job/retry", s.auth(s.handleRetryProcessingJob))
// Akte-Zuordnung eines Dokuments (internal/api/akte_handlers.go). Strikt
// 1:n: Zuordnung ist nur documents.akte_id setzen/nullen.
s.mux.HandleFunc("PUT /api/documents/{id}/akte", s.auth(s.handleSetDocumentAkte))
// Freitext-Notizen pro Dokument (internal/api/document_note_handlers.go).
s.mux.HandleFunc("GET /api/documents/{id}/notes", s.auth(s.handleListDocumentNotes))
s.mux.HandleFunc("POST /api/documents/{id}/notes", s.auth(s.handleCreateDocumentNote))
s.mux.HandleFunc("DELETE /api/documents/{id}/notes/{noteId}", s.auth(s.handleDeleteDocumentNote))
// Gespeicherte Suchansichten (SavedViews, Paperless-ngx inspiriert —
// internal/api/saved_view_handlers.go). Tenant-/user-weit, nicht
// dokument-gebunden, daher eigener Block. Liste enthält eigene + geteilte
// Views; PATCH/DELETE nur durch den Ersteller.
s.mux.HandleFunc("GET /api/saved-views", s.auth(s.handleListSavedViews))
s.mux.HandleFunc("POST /api/saved-views", s.auth(s.handleCreateSavedView))
s.mux.HandleFunc("PATCH /api/saved-views/{id}", s.auth(s.handleUpdateSavedView))
s.mux.HandleFunc("DELETE /api/saved-views/{id}", s.auth(s.handleDeleteSavedView))
// Digitale Akten (digitaler Aktenordner — internal/api/akte_handlers.go).
// Strikt 1:n zu Dokumenten via documents.akte_id. Keine eigene ACL — die
// Sichtbarkeit erbt von den enthaltenen Dokumenten (GET .../{id} liefert die
// ACL-gefilterte Dokumentliste). Tenant-scoped (s.auth).
s.mux.HandleFunc("GET /api/akten", s.auth(s.handleListAkten))
s.mux.HandleFunc("POST /api/akten", s.auth(s.handleCreateAkte))
s.mux.HandleFunc("GET /api/akten/{id}", s.auth(s.handleGetAkte))
s.mux.HandleFunc("PATCH /api/akten/{id}", s.auth(s.handleUpdateAkte))
s.mux.HandleFunc("POST /api/akten/{id}/close", s.auth(s.handleCloseAkte))
s.mux.HandleFunc("DELETE /api/akten/{id}", s.auth(s.handleDeleteAkte))
// Trash + gestaffeltes Löschkonzept (internal/api/trash_handlers.go).
// DELETE /api/documents/{id} above is now a soft-delete into the trash.
s.mux.HandleFunc("GET /api/trash", s.auth(s.handleListTrash))
s.mux.HandleFunc("POST /api/trash/{id}/restore", s.auth(s.handleRestoreDocument))
s.mux.HandleFunc("GET /api/trash/{id}/delete-requests", s.auth(s.handleListDeleteRequests))
s.mux.HandleFunc("POST /api/trash/{id}/delete-requests", s.auth(s.handleCreateDeleteRequest))
s.mux.HandleFunc("DELETE /api/trash/{id}/delete-requests/{reqId}", s.auth(s.handleCancelDeleteRequest))
// Confirm executes the physical deletion -> domain_admin (User B) required.
s.mux.HandleFunc("POST /api/trash/{id}/delete-requests/{reqId}/confirm", s.authAdmin(s.handleConfirmDeleteRequest))
// Wiedervorlage (reminders)
s.mux.HandleFunc("POST /api/documents/{id}/reminders", s.auth(s.handleCreateReminder))
s.mux.HandleFunc("GET /api/reminders", s.auth(s.handleListReminders))
s.mux.HandleFunc("PATCH /api/reminders/{id}", s.auth(s.handleUpdateReminder))
s.mux.HandleFunc("DELETE /api/reminders/{id}", s.auth(s.handleDeleteReminder))
// Structured taxonomy entities (tags/document_types/correspondents)
s.mux.HandleFunc("GET /api/tags", s.auth(s.handleListTaxonomy("tags")))
s.mux.HandleFunc("POST /api/tags", s.auth(s.handleCreateTaxonomy("tags")))
s.mux.HandleFunc("PATCH /api/tags/{id}", s.auth(s.handleUpdateTaxonomy("tags")))
s.mux.HandleFunc("DELETE /api/tags/{id}", s.auth(s.handleDeleteTaxonomy("tags")))
s.mux.HandleFunc("GET /api/document-types", s.auth(s.handleListTaxonomy("document_types")))
s.mux.HandleFunc("POST /api/document-types", s.auth(s.handleCreateTaxonomy("document_types")))
s.mux.HandleFunc("PATCH /api/document-types/{id}", s.auth(s.handleUpdateTaxonomy("document_types")))
s.mux.HandleFunc("DELETE /api/document-types/{id}", s.auth(s.handleDeleteTaxonomy("document_types")))
s.mux.HandleFunc("GET /api/correspondents", s.auth(s.handleListTaxonomy("correspondents")))
s.mux.HandleFunc("POST /api/correspondents", s.auth(s.handleCreateTaxonomy("correspondents")))
s.mux.HandleFunc("PATCH /api/correspondents/{id}", s.auth(s.handleUpdateTaxonomy("correspondents")))
s.mux.HandleFunc("DELETE /api/correspondents/{id}", s.auth(s.handleDeleteTaxonomy("correspondents")))
// Manual tag attach/detach on a document
s.mux.HandleFunc("GET /api/documents/{id}/tags", s.auth(s.handleListDocumentTags))
s.mux.HandleFunc("POST /api/documents/{id}/tags/{tagId}", s.auth(s.handleAttachTag))
s.mux.HandleFunc("DELETE /api/documents/{id}/tags/{tagId}", s.auth(s.handleDetachTag))
// Custom fields (definitions, document-type assignments, document values)
s.mux.HandleFunc("GET /api/custom-fields", s.auth(s.handleListCustomFields))
s.mux.HandleFunc("POST /api/custom-fields", s.authAdmin(s.handleCreateCustomField))
s.mux.HandleFunc("PATCH /api/custom-fields/{id}", s.authAdmin(s.handleUpdateCustomField))
s.mux.HandleFunc("DELETE /api/custom-fields/{id}", s.authAdmin(s.handleDeleteCustomField))
s.mux.HandleFunc("GET /api/document-types/{id}/fields", s.auth(s.handleListDocumentTypeFields))
s.mux.HandleFunc("PUT /api/document-types/{id}/fields", s.authAdmin(s.handleSetDocumentTypeFields))
s.mux.HandleFunc("GET /api/documents/{id}/fields", s.auth(s.handleListDocumentFieldValues))
s.mux.HandleFunc("PUT /api/documents/{id}/fields", s.auth(s.handleSetDocumentFieldValues))
// Classification templates (Klassifizierungsvorlagen,
// internal/api/classification_template_handlers.go). CRUD + tag / field-
// default bulk replace are domain_admin-only (s.authAdmin); applying a
// template to a document is a normal authenticated working action (s.auth).
s.mux.HandleFunc("GET /api/classification-templates", s.auth(s.handleListTemplates))
s.mux.HandleFunc("POST /api/classification-templates", s.authAdmin(s.handleCreateTemplate))
s.mux.HandleFunc("GET /api/classification-templates/{id}", s.auth(s.handleGetTemplate))
s.mux.HandleFunc("PUT /api/classification-templates/{id}", s.authAdmin(s.handleUpdateTemplate))
s.mux.HandleFunc("DELETE /api/classification-templates/{id}", s.authAdmin(s.handleDeleteTemplate))
s.mux.HandleFunc("PUT /api/classification-templates/{id}/tags", s.authAdmin(s.handleSetTemplateTags))
s.mux.HandleFunc("PUT /api/classification-templates/{id}/field-defaults", s.authAdmin(s.handleSetTemplateFieldDefaults))
s.mux.HandleFunc("POST /api/documents/{id}/apply-template", s.auth(s.handleApplyTemplate))
// GoBD-Aufbewahrungsregeln (internal/api/retention_rule_handlers.go).
// Lesen (Liste/eligible/preview) ist normale Tenant-Aktion; Anlegen/Ändern/
// Löschen ist compliance-kritisch und erfordert domain_admin.
s.mux.HandleFunc("GET /api/retention-rules", s.auth(s.handleListRetentionRules))
s.mux.HandleFunc("POST /api/retention-rules", s.authAdmin(s.handleCreateRetentionRule))
s.mux.HandleFunc("GET /api/retention-rules/eligible", s.auth(s.handleListEligibleForDisposition))
s.mux.HandleFunc("GET /api/retention-rules/preview", s.auth(s.handlePreviewRetentionRules))
s.mux.HandleFunc("PATCH /api/retention-rules/{id}", s.authAdmin(s.handleUpdateRetentionRule))
s.mux.HandleFunc("DELETE /api/retention-rules/{id}", s.authAdmin(s.handleDeleteRetentionRule))
// GoBD-Verfahrensdokumentation als Markdown-Entwurf
// (internal/api/compliance_handlers.go). domain_admin+ für den eigenen
// Mandanten; superadmin darf per ?tenant_id=N einen fremden Mandanten
// exportieren (Prüfung im Handler).
s.mux.HandleFunc("GET /api/compliance/procedure-documentation", s.authAdmin(s.handleProcedureDocumentation))
// Workflows / Consumption-Regeln (internal/api/workflow_handlers.go).
// Administration (CRUD + action bulk replace) is domain_admin-only
// (s.authAdmin); the dry-run test and the runs overview are normal
// authenticated tenant actions (s.auth). Automatic on_upload execution is
// wired into the upload pipeline (storeUploadedFile), not exposed as a route.
s.mux.HandleFunc("GET /api/workflows", s.auth(s.handleListWorkflows))
s.mux.HandleFunc("POST /api/workflows", s.authAdmin(s.handleCreateWorkflow))
s.mux.HandleFunc("GET /api/workflows/{id}", s.auth(s.handleGetWorkflow))
s.mux.HandleFunc("PUT /api/workflows/{id}", s.authAdmin(s.handleUpdateWorkflow))
s.mux.HandleFunc("DELETE /api/workflows/{id}", s.authAdmin(s.handleDeleteWorkflow))
s.mux.HandleFunc("PUT /api/workflows/{id}/actions", s.authAdmin(s.handleSetWorkflowActions))
s.mux.HandleFunc("POST /api/workflows/{id}/test", s.auth(s.handleTestWorkflow))
s.mux.HandleFunc("GET /api/workflows/{id}/runs", s.auth(s.handleListWorkflowRuns))
// Heuristische Metadaten-Vorschläge (internal/api/metadata_suggestion_handlers.go).
// All three are normal authenticated tenant actions; nothing here applies a
// suggestion — accepting a suggested field goes through the normal edit
// endpoints (PATCH title, tag-attach, ...).
s.mux.HandleFunc("POST /api/documents/{id}/suggest-metadata", s.auth(s.handleGenerateSuggestions))
s.mux.HandleFunc("GET /api/documents/{id}/suggest-metadata", s.auth(s.handleGetLatestSuggestion))
s.mux.HandleFunc("POST /api/documents/{id}/suggest-metadata/{suggestionId}/reviewed", s.auth(s.handleMarkSuggestionReviewed))
// Permission model (group-resolved document ACL, internal/api/permission_handlers.go).
// Group + grant administration is domain_admin-only (s.authAdmin).
s.mux.HandleFunc("GET /api/permission-groups", s.authAdmin(s.handleListPermissionGroups))
s.mux.HandleFunc("POST /api/permission-groups", s.authAdmin(s.handleCreatePermissionGroup))
s.mux.HandleFunc("DELETE /api/permission-groups/{id}", s.authAdmin(s.handleDeletePermissionGroup))
s.mux.HandleFunc("GET /api/permission-groups/{id}/members", s.authAdmin(s.handleListGroupMembers))
s.mux.HandleFunc("POST /api/permission-groups/{id}/members", s.authAdmin(s.handleAddGroupMember))
s.mux.HandleFunc("DELETE /api/permission-groups/{id}/members/{userId}", s.authAdmin(s.handleRemoveGroupMember))
s.mux.HandleFunc("GET /api/document-types/{id}/grants", s.authAdmin(s.handleListDocumentTypeGrants))
s.mux.HandleFunc("POST /api/document-types/{id}/grants", s.authAdmin(s.handleSetDocumentTypeGrant))
s.mux.HandleFunc("DELETE /api/document-types/{id}/grants", s.authAdmin(s.handleDeleteDocumentTypeGrant))
s.mux.HandleFunc("GET /api/tags/{id}/grants", s.authAdmin(s.handleListTagGrants))
s.mux.HandleFunc("POST /api/tags/{id}/grants", s.authAdmin(s.handleSetTagGrant))
s.mux.HandleFunc("DELETE /api/tags/{id}/grants", s.authAdmin(s.handleDeleteTagGrant))
s.mux.HandleFunc("GET /api/documents/{id}/grants", s.authAdmin(s.handleListDocumentGrants))
s.mux.HandleFunc("POST /api/documents/{id}/grants", s.authAdmin(s.handleSetDocumentGrant))
s.mux.HandleFunc("DELETE /api/documents/{id}/grants", s.authAdmin(s.handleDeleteDocumentGrant))
// External share-links (internal/api/share_handlers.go). Create/list/revoke
// are authenticated + tenant-scoped; the tenant-wide overview is domain_admin.
s.mux.HandleFunc("POST /api/documents/{id}/shares", s.auth(s.handleCreateShare))
s.mux.HandleFunc("GET /api/documents/{id}/shares", s.auth(s.handleListDocumentShares))
s.mux.HandleFunc("DELETE /api/shares/{share_id}", s.auth(s.handleRevokeShare))
s.mux.HandleFunc("GET /api/shares", s.authAdmin(s.handleListTenantShares))
// Public share endpoints (internal/api/public_share_handlers.go) — served
// WITHOUT the s.auth wrapper by design: the share token is the credential.
// Lookup is always by token_hash, rate-limited per IP, every attempt logged.
s.mux.HandleFunc("GET /public/share/{token}", s.handlePublicShareMeta)
s.mux.HandleFunc("POST /public/share/{token}/download", s.handlePublicShareDownload)
// Signed, time-limited download URLs (internal/api/signed_url_handlers.go).
// Issuing is authenticated + tenant-scoped; redeeming runs WITHOUT s.auth
// because the HMAC signature in the query string is the credential.
s.mux.HandleFunc("POST /api/documents/{id}/signed-url", s.auth(s.handleCreateDocumentSignedURL))
s.mux.HandleFunc("GET /public/files", s.handleSignedFileDownload)
// Buchhaltungs-Pull-API (internal/api/accounting_handlers.go).
// Key administration runs on the normal session auth and is domain_admin-only
// (a key grants tenant-wide read access to archived documents).
s.mux.HandleFunc("POST /api/accounting/api-keys", s.authAdmin(s.handleCreateAccountingAPIKey))
s.mux.HandleFunc("GET /api/accounting/api-keys", s.authAdmin(s.handleListAccountingAPIKeys))
s.mux.HandleFunc("DELETE /api/accounting/api-keys/{id}", s.authAdmin(s.handleRevokeAccountingAPIKey))
// The pull endpoints themselves are served WITHOUT s.auth by design: the
// Authorization: Bearer <key> API key is the credential, and the tenant id
// comes exclusively from resolving that key (s.accountingAuth) — never from
// a query parameter.
s.mux.HandleFunc("GET /api/v1/accounting/documents", s.accountingAuth(s.handleAccountingListDocuments))
s.mux.HandleFunc("GET /api/v1/accounting/documents/{id}/file", s.accountingAuth(s.handleAccountingDocumentFile))
// Per-tenant LDAP directory config (internal/api/ldap_handlers.go).
// domain_admin manages its own tenant; superadmin may target any tenant
// via ?tenant_id=. The bind password is never returned.
s.mux.HandleFunc("GET /api/ldap-config", s.authAdmin(s.handleGetLDAPConfig))
s.mux.HandleFunc("PUT /api/ldap-config", s.authAdmin(s.handleUpsertLDAPConfig))
// Per-tenant settings (internal/api/tenant_settings_handlers.go).
// domain_admin manages its own tenant; superadmin may target any tenant
// via ?tenant_id=. Currently: the placeholder-title date format.
s.mux.HandleFunc("GET /api/tenant-settings", s.authAdmin(s.handleGetTenantSettings))
s.mux.HandleFunc("PUT /api/tenant-settings", s.authAdmin(s.handleUpdateTenantSettings))
// Per-tenant external-Ollama connection config (internal/api/ollama_config_handlers.go).
// domain_admin manages its own tenant; superadmin may target any tenant via
// ?tenant_id=. Gates the optional 'ollama' metadata-suggestion provider.
s.mux.HandleFunc("GET /api/ollama-config", s.authAdmin(s.handleGetOllamaConfig))
s.mux.HandleFunc("PUT /api/ollama-config", s.authAdmin(s.handleUpsertOllamaConfig))
s.mux.HandleFunc("GET /api/ollama-config/models", s.authAdmin(s.handleListOllamaModels))
// SFTP credentials (embedded per-tenant SFTP server, internal/sftpserver)
s.mux.HandleFunc("POST /api/admin/sftp-credentials", s.authAdmin(s.handleCreateSFTPCredential))
s.mux.HandleFunc("GET /api/admin/sftp-credentials", s.authAdmin(s.handleListSFTPCredentials))
s.mux.HandleFunc("DELETE /api/admin/sftp-credentials/{id}", s.authAdmin(s.handleRevokeSFTPCredential))
}
// ServeHTTP implements http.Handler.
//
// Die Basis-Middleware-Kette (FDN-08) liegt bewusst hier und nicht an den
// einzelnen Routen, damit sie ausnahmslos für JEDE Anfrage gilt — auch für
// /public/*, /metrics und nicht gefundene Pfade:
//
// requestID -> metrics -> recover -> ServeMux
//
// Reihenfolge: requestID zuerst, damit Metrik- und Panic-Log die
// Korrelations-ID haben; recover innen, damit der 500 noch über den
// statusRecorder der Metrik-Middleware läuft und dort gezählt wird.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if s.baseHandler == nil {
// Server wurde nicht über New() gebaut (Tests): ohne Kette bedienen.
s.mux.ServeHTTP(w, r)
return
}
s.baseHandler.ServeHTTP(w, r)
}
// --- system handlers ---
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"version": s.appVersion})
}
// --- middleware ---
const sessionCookieName = "archivdms_session"
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := ""
if c, err := r.Cookie(sessionCookieName); err == nil {
token = c.Value
}
if token == "" {
token = extractBearerToken(r)
}
if token == "" {
writeError(w, http.StatusUnauthorized, "missing authorization")
return
}
sess, err := s.authMgr.ValidateToken(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
ctx := context.WithValue(r.Context(), sessionKey, sess)
next(w, r.WithContext(ctx))
}
}
func (s *Server) requireRole(role string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess == nil || !auth.HasRole(sess.Role, role) {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
next(w, r)
}
}
// --- helpers ---
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func extractBearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return ""
}
func sessionFromCtx(ctx context.Context) *auth.Session {
v := ctx.Value(sessionKey)
if v == nil {
return &auth.Session{}
}
if s, ok := v.(*auth.Session); ok {
return s
}
return &auth.Session{}
}
// tenantMiddleware extracts the tenant_id from the session and stores it in
// the request context, making it available to all downstream handlers.
func (s *Server) tenantMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := sessionFromCtx(r.Context())
if session != nil && session.TenantID != nil {
ctx := context.WithValue(r.Context(), tenantKey, session.TenantID)
next(w, r.WithContext(ctx))
return
}
next(w, r)
}
}
// tenantFromCtx extracts the tenant_id from context. Returns nil for a
// global (superadmin/tenant-less) context.
func tenantFromCtx(ctx context.Context) *int64 {
v, _ := ctx.Value(tenantKey).(*int64)
return v
}
// remoteIP returns the real client IP. X-Forwarded-For is only trusted when
// the direct connection comes from a configured trusted proxy.
func (s *Server) remoteIP(r *http.Request) string {
directIP, _, _ := net.SplitHostPort(r.RemoteAddr)
if directIP == "" {
directIP = r.RemoteAddr
}
if len(s.cfg.TrustedProxies) > 0 && isTrustedProxy(directIP, s.cfg.TrustedProxies) {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return strings.TrimSpace(strings.Split(fwd, ",")[0])
}
}
return directIP
}
func isTrustedProxy(ip string, proxies []string) bool {
parsed := net.ParseIP(ip)
for _, p := range proxies {
if strings.Contains(p, "/") {
_, cidr, err := net.ParseCIDR(p)
if err == nil && cidr.Contains(parsed) {
return true
}
} else if p == ip {
return true
}
}
return false
}