- 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
637 lines
29 KiB
Go
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
|
|
}
|