// 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" "encoding/json" "log/slog" "net" "net/http" "strings" "time" "archivdms/config" "archivdms/internal/audit" "archivdms/internal/auth" "archivdms/internal/ldapauth" "archivdms/internal/ldapstore" "archivdms/internal/mailer" "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 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 // 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). func (s *Server) SetStorageConfig(cfg config.StorageConfig) { s.storageCfg = cfg } // 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), } s.routes() 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) 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-/-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) // 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 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. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.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 }