FDN-01: repository & projektgerüst

Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
2026-08-11 21:27:53 +02:00
parent 40ed80da71
commit 9a24ea29e1
274 changed files with 53708 additions and 0 deletions
+391
View File
@@ -0,0 +1,391 @@
// Buchhaltungs-Pull-API: a reduced, read-only, machine-to-machine export path
// so an accounting system (DATEV-Vorerfassung, Kanzlei-Software, ...) can pull
// belegdatum-scored documents out of archivdms without a browser session.
//
// Two clearly separated halves:
//
// 1. Key administration — normal JWT-cookie/session endpoints (domain_admin+,
// same pattern as retention_rule_handlers.go):
//
// POST /api/accounting/api-keys create, returns the plaintext key ONCE
// GET /api/accounting/api-keys list (label/timestamps only, no key)
// DELETE /api/accounting/api-keys/{id} revoke (never hard-deleted)
//
// 2. The pull endpoints themselves — NOT wrapped in s.auth. They use
// s.accountingAuth (Authorization: Bearer <key>) instead:
//
// GET /api/v1/accounting/documents keyset-paginated metadata
// GET /api/v1/accounting/documents/{id}/file streams the WORM file
//
// TENANT ISOLATION (critical — this is the only non-browser access path):
// s.accountingAuth resolves the raw bearer key to a tenant id via
// storage.ResolveAccountingAPIKey and puts ONLY that id into the request
// context (accountingTenantKey). The pull handlers read the tenant id
// exclusively from that context via accountingCtxFromRequest; there is no code
// path in which a tenant_id from the query string, a header or a body is
// consulted. The store functions they call (ListAccountingDocuments,
// GetAccountingDocumentFile) take tenantID as a mandatory first argument and
// have no unscoped variant. A document belonging to another tenant is
// indistinguishable from a nonexistent one (404, never 403).
package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
const (
accountingTenantKey contextKey = "accounting_tenant_id"
accountingKeyIDKey contextKey = "accounting_key_id"
)
// accountingMaxLimit caps the page size a client may request.
const accountingMaxLimit = 500
// accountingDefaultLimit is used when no (or an invalid) limit is given.
const accountingDefaultLimit = 100
// --- key administration (session-authenticated, domain_admin+) ---
// createAccountingKeyRequest is the JSON body for POST /api/accounting/api-keys.
type createAccountingKeyRequest struct {
Label string `json:"label"`
}
// createAccountingKeyResponse is the ONLY place the plaintext key is ever
// returned. It is not persisted anywhere in plaintext and cannot be retrieved
// again.
type createAccountingKeyResponse struct {
Key storage.AccountingAPIKey `json:"key"`
// PlaintextKey is shown exactly once — the caller must store it now.
PlaintextKey string `json:"plaintext_key"`
}
// handleCreateAccountingAPIKey handles POST /api/accounting/api-keys (domain_admin+).
func (s *Server) handleCreateAccountingAPIKey(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req createAccountingKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
label := strings.TrimSpace(req.Label)
if label == "" {
writeError(w, http.StatusBadRequest, "label is required")
return
}
userID := sess.UserID
key, plaintext, err := s.store.CreateAccountingAPIKey(r.Context(), *sess.TenantID, label, &userID)
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingKeyCreated, Username: sess.Username, IPAddress: s.remoteIP(r),
TenantID: sess.TenantID, Success: false,
Detail: "accounting_key_create label:" + label + " err:" + err.Error(),
})
writeError(w, http.StatusInternalServerError, "create accounting api key failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingKeyCreated, Username: sess.Username, IPAddress: s.remoteIP(r),
TenantID: sess.TenantID, Success: true,
Detail: "accounting_key_create id:" + strconv.FormatInt(key.ID, 10) + " label:" + label,
})
writeJSON(w, http.StatusCreated, createAccountingKeyResponse{Key: *key, PlaintextKey: plaintext})
}
// handleListAccountingAPIKeys handles GET /api/accounting/api-keys (domain_admin+).
// Never returns the plaintext key or its hash.
func (s *Server) handleListAccountingAPIKeys(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
keys, err := s.store.ListAccountingAPIKeys(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list accounting api keys failed")
return
}
if keys == nil {
keys = []storage.AccountingAPIKey{}
}
writeJSON(w, http.StatusOK, keys)
}
// handleRevokeAccountingAPIKey handles DELETE /api/accounting/api-keys/{id}
// (domain_admin+). Revoke only — the row stays so the audit trail of past
// pulls remains resolvable.
func (s *Server) handleRevokeAccountingAPIKey(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.RevokeAccountingAPIKey(r.Context(), id, *sess.TenantID); err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingKeyRevoked, Username: sess.Username, IPAddress: s.remoteIP(r),
TenantID: sess.TenantID, Success: false,
Detail: "accounting_key_revoke id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
})
if errors.Is(err, storage.ErrAccountingKeyNotFound) {
writeError(w, http.StatusNotFound, "accounting api key not found")
return
}
writeError(w, http.StatusInternalServerError, "revoke accounting api key failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingKeyRevoked, Username: sess.Username, IPAddress: s.remoteIP(r),
TenantID: sess.TenantID, Success: true,
Detail: "accounting_key_revoke id:" + strconv.FormatInt(id, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// --- bearer-key middleware for the pull endpoints ---
// accountingAuth is the API-key middleware for the pull endpoints. It is
// deliberately separate from s.authMiddleware (JWT cookie): no session, no
// role, no user — just a tenant-scoped machine credential.
//
// It puts the tenant id resolved FROM THE KEY into the request context. This is
// the single source of truth for tenant scoping downstream; handlers must never
// read a tenant id from the request itself.
func (s *Server) accountingAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := s.remoteIP(r)
// Per-IP rate limit blunts key guessing on this unauthenticated-until-
// resolved path (same limiter type as the public share endpoints).
if !s.accountingLimiter.allow(ip) {
writeError(w, http.StatusTooManyRequests, "too many requests")
return
}
rawKey := extractBearerToken(r)
if rawKey == "" {
w.Header().Set("WWW-Authenticate", "Bearer")
writeError(w, http.StatusUnauthorized, "missing bearer api key")
return
}
tenantID, keyID, err := s.store.ResolveAccountingAPIKey(r.Context(), rawKey)
if err != nil {
if !errors.Is(err, storage.ErrAccountingKeyNotFound) {
s.logger.Error("accounting api key resolve failed", "err", err)
}
// Unknown, revoked and broken keys are indistinguishable.
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: ip,
Success: false, Detail: "accounting_auth rejected path:" + r.URL.Path,
})
w.Header().Set("WWW-Authenticate", "Bearer")
writeError(w, http.StatusUnauthorized, "invalid api key")
return
}
ctx := context.WithValue(r.Context(), accountingTenantKey, tenantID)
ctx = context.WithValue(ctx, accountingKeyIDKey, keyID)
h(w, r.WithContext(ctx))
}
}
// accountingCtxFromRequest returns the tenant id and key id that
// accountingAuth resolved. ok is false only if the handler was somehow reached
// without the middleware — handlers then must refuse to do anything.
func accountingCtxFromRequest(ctx context.Context) (tenantID, keyID int64, ok bool) {
t, tOK := ctx.Value(accountingTenantKey).(int64)
k, kOK := ctx.Value(accountingKeyIDKey).(int64)
if !tOK || !kOK {
return 0, 0, false
}
return t, k, true
}
// --- pull endpoints (bearer-key authenticated) ---
// handleAccountingListDocuments handles
// GET /api/v1/accounting/documents?since=&until=&doc_type_id=&min_date_score=&cursor=&limit=
//
// since/until are dates (YYYY-MM-DD or RFC3339) bounding document_date;
// min_date_score gates on the belegdatum confidence (e.g. 0.75); cursor/limit
// drive keyset pagination over (created_at, id). Any tenant_id query parameter
// is ignored — scoping comes from the API key alone.
func (s *Server) handleAccountingListDocuments(w http.ResponseWriter, r *http.Request) {
tenantID, keyID, ok := accountingCtxFromRequest(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "invalid api key")
return
}
q := r.URL.Query()
filter := storage.AccountingDocumentFilter{
Cursor: q.Get("cursor"),
Limit: accountingDefaultLimit,
}
if v := strings.TrimSpace(q.Get("since")); v != "" {
t, err := parseAccountingDate(v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid since (expected YYYY-MM-DD or RFC3339)")
return
}
filter.Since = &t
}
if v := strings.TrimSpace(q.Get("until")); v != "" {
t, err := parseAccountingDate(v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid until (expected YYYY-MM-DD or RFC3339)")
return
}
filter.Until = &t
}
if v := strings.TrimSpace(q.Get("doc_type_id")); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid doc_type_id")
return
}
filter.DocTypeID = &id
}
if v := strings.TrimSpace(q.Get("min_date_score")); v != "" {
score, err := strconv.ParseFloat(v, 64)
if err != nil || score < 0 || score > 1 {
writeError(w, http.StatusBadRequest, "invalid min_date_score (expected 0..1)")
return
}
filter.MinDateScore = &score
}
if v := strings.TrimSpace(q.Get("limit")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
if n > accountingMaxLimit {
n = accountingMaxLimit
}
filter.Limit = n
}
page, err := s.store.ListAccountingDocuments(r.Context(), tenantID, filter)
if err != nil {
if errors.Is(err, storage.ErrInvalidAccountingCursor) {
writeError(w, http.StatusBadRequest, "invalid cursor")
return
}
s.logger.Error("accounting list failed", "tenant_id", tenantID, "err", err)
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
TenantID: &tenantID, Success: false,
Detail: "accounting_pull list key:" + strconv.FormatInt(keyID, 10) + " err:" + err.Error(),
})
writeError(w, http.StatusInternalServerError, "list documents failed")
return
}
if page.Documents == nil {
page.Documents = []storage.AccountingDocument{}
}
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
TenantID: &tenantID, Success: true,
Detail: "accounting_pull list key:" + strconv.FormatInt(keyID, 10) +
" count:" + strconv.Itoa(len(page.Documents)) +
" range:" + accountingIDRange(page.Documents),
})
writeJSON(w, http.StatusOK, page)
}
// handleAccountingDocumentFile handles GET /api/v1/accounting/documents/{id}/file.
// Streams the archived WORM file through the handler — storage_path is never
// exposed. Scoped to the API key's tenant; a foreign or unknown document both
// yield 404 (no existence leak, mirroring handleGetDocumentFile).
func (s *Server) handleAccountingDocumentFile(w http.ResponseWriter, r *http.Request) {
tenantID, keyID, ok := accountingCtxFromRequest(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "invalid api key")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
ref, err := s.store.GetAccountingDocumentFile(r.Context(), id, tenantID)
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
TenantID: &tenantID, DocumentID: strconv.FormatInt(id, 10), Success: false,
Detail: "accounting_pull file key:" + strconv.FormatInt(keyID, 10) + " not_found",
})
writeError(w, http.StatusNotFound, "document not found")
return
}
f, err := os.Open(ref.StoragePath())
if err != nil {
s.logger.Error("accounting file open failed", "document_id", ref.DocumentID, "tenant_id", tenantID, "err", err)
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
TenantID: &tenantID, DocumentID: strconv.FormatInt(id, 10), Success: false,
Detail: "accounting_pull file key:" + strconv.FormatInt(keyID, 10) + " open_failed",
})
writeError(w, http.StatusInternalServerError, "file unavailable")
return
}
defer f.Close()
s.audlog.Log(audit.Entry{
EventType: audit.EventAccountingPull, Username: "accounting_api", IPAddress: s.remoteIP(r),
TenantID: &tenantID, DocumentID: strconv.FormatInt(id, 10), Success: true,
Detail: "accounting_pull file key:" + strconv.FormatInt(keyID, 10),
})
ext := filepath.Ext(ref.StoragePath())
w.Header().Set("Content-Type", detectMimeType("", ext, ref.StoragePath()))
w.Header().Set("Content-Disposition", "attachment; filename=\""+safeDownloadName(ref.Title, ext)+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
if _, err := io.Copy(w, f); err != nil {
s.logger.Warn("accounting file stream interrupted", "document_id", ref.DocumentID, "err", err)
}
}
// parseAccountingDate accepts either a plain date (YYYY-MM-DD, interpreted as
// UTC midnight) or a full RFC3339 timestamp.
func parseAccountingDate(v string) (time.Time, error) {
if t, err := time.Parse("2006-01-02", v); err == nil {
return t, nil
}
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return time.Time{}, err
}
return t, nil
}
// accountingIDRange renders "first-last" document ids of a page for the audit
// Detail, so a later GoBD audit can reconstruct what a pull actually returned.
func accountingIDRange(docs []storage.AccountingDocument) string {
if len(docs) == 0 {
return "-"
}
return strconv.FormatInt(docs[0].ID, 10) + "-" + strconv.FormatInt(docs[len(docs)-1].ID, 10)
}
+280
View File
@@ -0,0 +1,280 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"archivdms/internal/audit"
"archivdms/internal/auth"
"archivdms/internal/storage"
"archivdms/internal/userstore"
)
// akteACLUserID returns the ACL user filter for the caller: nil for
// domain_admin/superadmin (see every document in the tenant), a non-nil user ID
// for role 'user' (filtered against document_visibility). Mirrors the logic in
// handleListDocuments.
func akteACLUserID(sess *auth.Session) *int64 {
if auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
return nil
}
uid := sess.UserID
return &uid
}
func (s *Server) handleListAkten(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
akten, err := s.store.ListAkten(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list akten failed")
return
}
writeJSON(w, http.StatusOK, akten)
}
type createAkteRequest struct {
Titel string `json:"titel"`
Beschreibung string `json:"beschreibung"`
CorrespondentID *int64 `json:"correspondent_id"`
}
func (s *Server) handleCreateAkte(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
ctx := r.Context()
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req createAkteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
titel := strings.TrimSpace(req.Titel)
if titel == "" {
writeError(w, http.StatusBadRequest, "titel is required")
return
}
// Guard against cross-tenant references: the correspondent must belong to
// the same tenant.
if req.CorrespondentID != nil && !s.taxonomyEntityBelongsToTenant(ctx, "correspondents", *req.CorrespondentID, *sess.TenantID) {
s.audlog.Log(audit.Entry{EventType: audit.EventAkteCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "invalid_correspondent_id"})
writeError(w, http.StatusBadRequest, "invalid correspondent_id")
return
}
akte, err := s.store.CreateAkte(ctx, *sess.TenantID, titel, strings.TrimSpace(req.Beschreibung), req.CorrespondentID, sess.UserID)
if err != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventAkteCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: err.Error()})
writeError(w, http.StatusInternalServerError, "create akte failed")
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventAkteCreate, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "akte_id=" + strconv.FormatInt(akte.ID, 10)})
writeJSON(w, http.StatusCreated, akte)
}
// akteDetailResponse is the GET /api/akten/{id} payload: the akte plus its
// ACL-filtered documents.
type akteDetailResponse struct {
*storage.Akte
Documents []storage.Document `json:"documents"`
}
func (s *Server) handleGetAkte(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
ctx := r.Context()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid akte id")
return
}
akte, err := s.store.GetAkte(ctx, id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusNotFound, "akte not found")
return
}
docs, err := s.store.ListAkteDocuments(ctx, id, *sess.TenantID, akteACLUserID(sess))
if err != nil {
writeError(w, http.StatusInternalServerError, "list akte documents failed")
return
}
writeJSON(w, http.StatusOK, akteDetailResponse{Akte: akte, Documents: docs})
}
type updateAkteRequest struct {
Titel string `json:"titel"`
Beschreibung string `json:"beschreibung"`
CorrespondentID *int64 `json:"correspondent_id"`
}
func (s *Server) handleUpdateAkte(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
ctx := r.Context()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid akte id")
return
}
var req updateAkteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
titel := strings.TrimSpace(req.Titel)
if titel == "" {
writeError(w, http.StatusBadRequest, "titel is required")
return
}
if req.CorrespondentID != nil && !s.taxonomyEntityBelongsToTenant(ctx, "correspondents", *req.CorrespondentID, *sess.TenantID) {
s.audlog.Log(audit.Entry{EventType: audit.EventAkteUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "invalid_correspondent_id"})
writeError(w, http.StatusBadRequest, "invalid correspondent_id")
return
}
if err := s.store.UpdateAkte(ctx, id, *sess.TenantID, titel, strings.TrimSpace(req.Beschreibung), req.CorrespondentID); err != nil {
status := http.StatusInternalServerError
msg := "update akte failed"
if errors.Is(err, storage.ErrAkteNotFound) {
status = http.StatusNotFound
msg = "akte not found"
}
s.audlog.Log(audit.Entry{EventType: audit.EventAkteUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, status, msg)
return
}
akte, err := s.store.GetAkte(ctx, id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusNotFound, "akte not found")
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventAkteUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
writeJSON(w, http.StatusOK, akte)
}
func (s *Server) handleCloseAkte(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
ctx := r.Context()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid akte id")
return
}
if err := s.store.CloseAkte(ctx, id, *sess.TenantID); err != nil {
status := http.StatusInternalServerError
msg := "close akte failed"
if errors.Is(err, storage.ErrAkteNotFound) {
status = http.StatusNotFound
msg = "akte not found"
}
s.audlog.Log(audit.Entry{EventType: audit.EventAkteClose, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, status, msg)
return
}
akte, err := s.store.GetAkte(ctx, id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusNotFound, "akte not found")
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventAkteClose, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
writeJSON(w, http.StatusOK, akte)
}
func (s *Server) handleDeleteAkte(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
ctx := r.Context()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid akte id")
return
}
if err := s.store.DeleteAkte(ctx, id, *sess.TenantID); err != nil {
status := http.StatusInternalServerError
msg := "delete akte failed"
if errors.Is(err, storage.ErrAkteNotFound) {
status = http.StatusNotFound
msg = "akte not found"
}
s.audlog.Log(audit.Entry{EventType: audit.EventAkteDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventAkteDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// setDocumentAkteRequest uses a pointer so an explicit JSON null removes the
// assignment while an omitted field is rejected (see handleSetDocumentAkte).
type setDocumentAkteRequest struct {
AkteID *int64 `json:"akte_id"`
}
// handleSetDocumentAkte assigns (or clears) a document's akte membership
// (PUT /api/documents/{id}/akte, body {"akte_id": number|null}). A null value
// removes the assignment. The akte is not part of the document ACL, so
// SetDocumentAkte only re-syncs the search index. Audited as
// EventAkteDocumentAdd (assign) or EventAkteDocumentRemove (clear).
func (s *Server) handleSetDocumentAkte(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
ctx := r.Context()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
var req setDocumentAkteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
event := audit.EventAkteDocumentRemove
if req.AkteID != nil {
event = audit.EventAkteDocumentAdd
}
// Ownership check: the document must belong to the caller's tenant.
if _, err := s.store.GetDocument(ctx, id, *sess.TenantID); err != nil {
s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "document_not_found"})
writeError(w, http.StatusNotFound, "document not found")
return
}
// Guard against cross-tenant references: the akte must belong to the same
// tenant (nil means "remove", which needs no lookup).
if req.AkteID != nil {
if _, err := s.store.GetAkte(ctx, *req.AkteID, *sess.TenantID); err != nil {
s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "invalid_akte_id"})
writeError(w, http.StatusBadRequest, "invalid akte_id")
return
}
}
if err := s.store.SetDocumentAkte(ctx, id, *sess.TenantID, req.AkteID); err != nil {
status := http.StatusInternalServerError
msg := "set akte failed"
if errors.Is(err, storage.ErrDocumentNotFound) {
status = http.StatusNotFound
msg = "document not found"
}
s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, status, msg)
return
}
doc, err := s.store.GetDocument(ctx, id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
detail := "akte_id=cleared"
if req.AkteID != nil {
detail = "akte_id=" + strconv.FormatInt(*req.AkteID, 10)
}
s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: detail})
writeJSON(w, http.StatusOK, doc)
}
+51
View File
@@ -0,0 +1,51 @@
package api
import (
"net/http"
"strconv"
"archivdms/internal/audit"
)
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
entries, total, err := s.audlog.Query(audit.QueryFilter{
TenantID: sess.TenantID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "audit query failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "total": total})
}
// handleDocumentAuditLog returns the audit trail scoped to a single document
// (GET /api/documents/{id}/audit). Unlike handleAuditLog (domain_admin+, full
// tenant log) this is available to every authenticated user, but only after an
// ownership/ACL check: GetDocument filters WHERE tenant_id (and the document
// ACL), so a caller who may not see the document gets a 404 and never its
// history. The document_id filter uses the exact same string format
// (strconv.FormatInt(id, 10)) that document_handlers.go writes into the audit
// entries, otherwise the filter would match nothing.
func (s *Server) handleDocumentAuditLog(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
// ACL/tenant check: only callers who may see the document may see its history.
if _, err := s.store.GetDocument(r.Context(), id, *sess.TenantID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
entries, total, err := s.audlog.Query(audit.QueryFilter{
TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(id, 10),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "audit query failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "total": total})
}
+99
View File
@@ -0,0 +1,99 @@
package api
import (
"encoding/json"
"net/http"
"archivdms/internal/audit"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
ip := s.remoteIP(r)
token, user, err := s.authMgr.LoginFrom(r.Context(), req.Username, req.Password, ip)
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventLogin,
Username: req.Username,
IPAddress: ip,
Success: false,
Detail: "invalid_credentials",
})
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: s.cfg.SecureCookies,
SameSite: http.SameSiteLaxMode,
MaxAge: 8 * 60 * 60,
})
_ = s.users.UpdateLastLogin(user.ID)
s.audlog.Log(audit.Entry{
EventType: audit.EventLogin,
Username: user.Username,
IPAddress: ip,
TenantID: user.TenantID,
Success: true,
})
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": user})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
user, err := s.users.GetByID(sess.UserID)
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
writeJSON(w, http.StatusOK, user)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
token := ""
if c, err := r.Cookie(sessionCookieName); err == nil {
token = c.Value
}
if token == "" {
token = extractBearerToken(r)
}
if token != "" {
_ = s.authMgr.Logout(token)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
s.audlog.Log(audit.Entry{
EventType: audit.EventLogout,
Username: sess.Username,
IPAddress: s.remoteIP(r),
TenantID: sess.TenantID,
Success: true,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
}
@@ -0,0 +1,404 @@
// Classification-template ("Klassifizierungsvorlagen") HTTP handlers (see
// internal/storage/classification_templates.go +
// classification_templates_apply.go):
//
// GET/POST /api/classification-templates GET/PUT/DELETE /api/classification-templates/{id}
// PUT /api/classification-templates/{id}/tags
// PUT /api/classification-templates/{id}/field-defaults
// POST /api/documents/{id}/apply-template
//
// Template administration (CRUD + tag / field-default bulk replace) requires
// domain_admin (s.authAdmin). Applying a template to a document is a normal
// working action and only requires an authenticated tenant context (s.auth).
// Ownership is enforced in the store layer (id+tenant_id). Every mutation is
// audit-logged, including failures.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
type templateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
DocTypeID *int64 `json:"doc_type_id"`
RetainYears *int `json:"retain_years"`
Active *bool `json:"active"`
TitleTemplate *string `json:"title_template"`
}
// handleListTemplates handles GET /api/classification-templates (optional
// ?doc_type_id= filter).
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var docTypeID *int64
if raw := r.URL.Query().Get("doc_type_id"); raw != "" {
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid doc_type_id")
return
}
docTypeID = &id
}
tmpls, err := s.store.ListTemplates(r.Context(), *sess.TenantID, docTypeID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list classification templates failed")
return
}
writeJSON(w, http.StatusOK, tmpls)
}
// handleGetTemplate handles GET /api/classification-templates/{id} (resolved).
func (s *Server) handleGetTemplate(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
if err != nil {
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
writeError(w, http.StatusNotFound, "classification template not found")
return
}
writeError(w, http.StatusInternalServerError, "get classification template failed")
return
}
writeJSON(w, http.StatusOK, tmpl)
}
// handleCreateTemplate handles POST /api/classification-templates (domain_admin+).
func (s *Server) handleCreateTemplate(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req templateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
active := true
if req.Active != nil {
active = *req.Active
}
if req.TitleTemplate != nil {
if err := storage.ValidateTitleTemplate(*req.TitleTemplate); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
tmpl, err := s.store.CreateTemplate(r.Context(), *sess.TenantID, storage.CreateTemplateRequest{
Name: req.Name, Description: req.Description, DocTypeID: req.DocTypeID,
RetainYears: req.RetainYears, Active: active, CreatedBy: &sess.UserID,
TitleTemplate: req.TitleTemplate,
})
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrDuplicateTemplateName) {
status = http.StatusConflict
}
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_create err:" + err.Error()})
writeError(w, status, "create classification template failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventTemplateCreate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "template_create id:" + strconv.FormatInt(tmpl.ID, 10) + " name:" + tmpl.Name,
})
writeJSON(w, http.StatusCreated, tmpl)
}
// handleUpdateTemplate handles PUT /api/classification-templates/{id} (domain_admin+).
func (s *Server) handleUpdateTemplate(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req templateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
active := true
if req.Active != nil {
active = *req.Active
}
if req.TitleTemplate != nil {
if err := storage.ValidateTitleTemplate(*req.TitleTemplate); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
err = s.store.UpdateTemplate(r.Context(), id, *sess.TenantID, storage.UpdateTemplateRequest{
Name: req.Name, Description: req.Description, DocTypeID: req.DocTypeID,
RetainYears: req.RetainYears, Active: active,
TitleTemplate: req.TitleTemplate,
})
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
status = http.StatusNotFound
} else if errors.Is(err, storage.ErrDuplicateTemplateName) {
status = http.StatusConflict
}
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, "update classification template failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "template_update id:" + strconv.FormatInt(id, 10),
})
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "reload classification template failed")
return
}
writeJSON(w, http.StatusOK, tmpl)
}
// handleDeleteTemplate handles DELETE /api/classification-templates/{id} (domain_admin+).
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.DeleteTemplate(r.Context(), id, *sess.TenantID); err != nil {
status := http.StatusNotFound
if !errors.Is(err, storage.ErrClassificationTemplateNotFound) {
status = http.StatusInternalServerError
}
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateDelete, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, "delete classification template failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventTemplateDelete, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "template_delete id:" + strconv.FormatInt(id, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
type templateTagsRequest struct {
TagIDs []int64 `json:"tag_ids"`
}
// handleSetTemplateTags handles PUT /api/classification-templates/{id}/tags (domain_admin+).
func (s *Server) handleSetTemplateTags(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req templateTagsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := s.store.SetTemplateTags(r.Context(), id, *sess.TenantID, req.TagIDs); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
status = http.StatusNotFound
} else if errors.Is(err, storage.ErrTaxonomyNotFound) {
status = http.StatusBadRequest
}
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_tags_set id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, "set template tags failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "template_tags_set id:" + strconv.FormatInt(id, 10) + " count:" + strconv.Itoa(len(req.TagIDs)),
})
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "reload classification template failed")
return
}
writeJSON(w, http.StatusOK, tmpl)
}
type templateFieldDefaultRequest struct {
FieldID int64 `json:"field_id"`
ValueText *string `json:"value_text"`
ValueNumber *float64 `json:"value_number"`
ValueDate *string `json:"value_date"`
ValueBool *bool `json:"value_bool"`
Overwrite bool `json:"overwrite"`
}
// handleSetTemplateFieldDefaults handles PUT
// /api/classification-templates/{id}/field-defaults (bulk replace, domain_admin+).
func (s *Server) handleSetTemplateFieldDefaults(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var reqs []templateFieldDefaultRequest
if err := json.NewDecoder(r.Body).Decode(&reqs); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body (expected array)")
return
}
defaults := make([]storage.TemplateFieldDefaultInput, 0, len(reqs))
for _, d := range reqs {
defaults = append(defaults, storage.TemplateFieldDefaultInput{
FieldID: d.FieldID, ValueText: d.ValueText, ValueNumber: d.ValueNumber,
ValueDate: d.ValueDate, ValueBool: d.ValueBool, Overwrite: d.Overwrite,
})
}
if err := s.store.SetTemplateFieldDefaults(r.Context(), id, *sess.TenantID, defaults); err != nil {
status := http.StatusInternalServerError
msg := "set template field defaults failed"
if errors.Is(err, storage.ErrClassificationTemplateNotFound) {
status = http.StatusNotFound
} else if errors.Is(err, storage.ErrCustomFieldNotFound) {
status = http.StatusBadRequest
msg = "unknown custom field"
} else if strings.Contains(err.Error(), "invalid date") || strings.Contains(err.Error(), "not in enum options") {
status = http.StatusBadRequest
msg = err.Error()
}
s.audlog.Log(audit.Entry{EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "template_field_defaults_set id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventTemplateUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "template_field_defaults_set id:" + strconv.FormatInt(id, 10) + " count:" + strconv.Itoa(len(defaults)),
})
tmpl, err := s.store.GetTemplate(r.Context(), id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "reload classification template failed")
return
}
writeJSON(w, http.StatusOK, tmpl)
}
type applyTemplateRequest struct {
TemplateID int64 `json:"template_id"`
DryRun bool `json:"dry_run"`
Overwrite bool `json:"overwrite"`
}
// handleApplyTemplate handles POST /api/documents/{id}/apply-template. Any
// authenticated tenant user may apply a template (normal working action). With
// dry_run=true it only previews (no writes). A rejected retain_until shortening
// (RetainUntilBlocked) is still audit-logged for GoBD traceability.
func (s *Server) handleApplyTemplate(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
var req applyTemplateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.TemplateID == 0 {
writeError(w, http.StatusBadRequest, "template_id is required")
return
}
docRef := strconv.FormatInt(docID, 10)
if req.DryRun {
res, err := s.store.PreviewApplyTemplate(r.Context(), docID, req.TemplateID, *sess.TenantID)
if err != nil {
s.writeTemplateApplyError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
return
}
res, err := s.store.ApplyTemplate(r.Context(), docID, req.TemplateID, *sess.TenantID, req.Overwrite)
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventTemplateApplied, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: docRef, Success: false, Detail: "template_apply template:" + strconv.FormatInt(req.TemplateID, 10) + " err:" + err.Error(),
})
s.writeTemplateApplyError(w, err)
return
}
detail := "template_apply template:" + strconv.FormatInt(req.TemplateID, 10) +
" tags_added:" + strconv.Itoa(len(res.TagsToAdd)) +
" fields_set:" + strconv.Itoa(len(res.FieldsToSet)) +
" fields_overwritten:" + strconv.Itoa(len(res.FieldsOverwritten))
if res.RetainUntilBlocked {
detail += " retain_until_shortening_rejected"
}
s.audlog.Log(audit.Entry{
EventType: audit.EventTemplateApplied, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: docRef, Success: true, Detail: detail,
})
writeJSON(w, http.StatusOK, res)
}
// writeTemplateApplyError maps store errors from the apply/preview path to HTTP
// status codes.
func (s *Server) writeTemplateApplyError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, storage.ErrDocumentNotFound):
writeError(w, http.StatusNotFound, "document not found")
case errors.Is(err, storage.ErrClassificationTemplateNotFound):
writeError(w, http.StatusNotFound, "classification template not found")
case errors.Is(err, storage.ErrRequiredFieldMissing):
writeError(w, http.StatusBadRequest, err.Error())
default:
writeError(w, http.StatusInternalServerError, "apply template failed")
}
}
+535
View File
@@ -0,0 +1,535 @@
// GoBD-Verfahrensdokumentation: Entwurfs-Generator.
//
// GET /api/compliance/procedure-documentation[?tenant_id=N]
//
// Erzeugt live aus dem aktuellen DB-Stand einen Markdown-Baustein einer
// GoBD-Verfahrensdokumentation für GENAU EINEN Mandanten (kein Caching, kein
// Vermischen mehrerer Mandanten). Konzept/Gliederung siehe
// .claude/agent-memory/retention-compliance/project_gobd_verfahrensdokumentation.md
//
// Auth (Muster wie retention_rule_handlers.go): domain_admin+ (s.authAdmin) für
// den EIGENEN Mandanten. Ein superadmin darf zusätzlich per ?tenant_id=N einen
// fremden Mandanten exportieren; für alle anderen Rollen ist ein abweichender
// tenant_id-Parameter ein 403. Sämtliche Queries sind strikt auf die eine
// aufgelöste tenant_id gefiltert (applikationsseitige Mandantentrennung, kein
// Postgres-RLS).
//
// Das Ergebnis ist ausdrücklich ein ENTWURF und kein rechtsverbindliches
// Fertigdokument — der Hinweis steht als erste Zeile im Dokument.
package api
import (
"context"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"archivdms/internal/audit"
"archivdms/internal/storage"
"archivdms/internal/userstore"
)
// handleProcedureDocumentation handles GET /api/compliance/procedure-documentation.
func (s *Server) handleProcedureDocumentation(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil && sess.Role != userstore.RoleSuperAdmin {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
// Ziel-Mandant auflösen: Default ist der eigene Mandant. Ein expliziter
// ?tenant_id= ist nur für superadmin erlaubt (Cross-Tenant), für alle
// anderen nur, wenn er dem eigenen Mandanten entspricht.
tenantID := int64(0)
if sess.TenantID != nil {
tenantID = *sess.TenantID
}
if raw := strings.TrimSpace(r.URL.Query().Get("tenant_id")); raw != "" {
requested, err := strconv.ParseInt(raw, 10, 64)
if err != nil || requested <= 0 {
writeError(w, http.StatusBadRequest, "invalid tenant_id")
return
}
if sess.Role != userstore.RoleSuperAdmin && requested != tenantID {
s.audlog.Log(audit.Entry{
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "procedure_doc cross-tenant denied requested:" + raw,
})
writeError(w, http.StatusForbidden, "cross-tenant export requires superadmin")
return
}
tenantID = requested
}
if tenantID <= 0 {
writeError(w, http.StatusBadRequest, "tenant_id required")
return
}
tenantName := "Mandant " + strconv.FormatInt(tenantID, 10)
tenantSlug := strconv.FormatInt(tenantID, 10)
if s.tenantStore != nil {
t, err := s.tenantStore.GetByID(r.Context(), tenantID)
if err != nil || t == nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "procedure_doc tenant lookup failed tenant:" + strconv.FormatInt(tenantID, 10),
})
writeError(w, http.StatusNotFound, "tenant not found")
return
}
tenantName = t.Name
if t.Slug != "" {
tenantSlug = t.Slug
}
}
md, err := s.buildProcedureDocumentation(r.Context(), tenantID, tenantName)
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "procedure_doc tenant:" + strconv.FormatInt(tenantID, 10) + " err:" + err.Error(),
})
writeError(w, http.StatusInternalServerError, "generate procedure documentation failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "procedure_doc generated tenant:" + strconv.FormatInt(tenantID, 10),
})
filename := fmt.Sprintf("verfahrensdokumentation-entwurf-%s-%s.md",
safeFilenamePart(tenantSlug), time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(md))
}
// safeFilenamePart reduces a tenant slug to [a-z0-9-] so it can never break out
// of the Content-Disposition filename.
func safeFilenamePart(in string) string {
var b strings.Builder
for _, r := range strings.ToLower(in) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_':
b.WriteRune('-')
}
}
out := b.String()
if out == "" {
return "mandant"
}
if len(out) > 60 {
out = out[:60]
}
return out
}
// buildProcedureDocumentation assembles the Markdown draft for one tenant.
// Every store call below is tenant-scoped; nothing is queried tenant-wide.
func (s *Server) buildProcedureDocumentation(ctx context.Context, tenantID int64, tenantName string) (string, error) {
now := time.Now()
rules, err := s.store.ListRetentionRules(ctx, tenantID)
if err != nil {
return "", fmt.Errorf("list retention rules: %w", err)
}
docTypes, err := s.store.ListTaxonomyEntities(ctx, "document_types", tenantID)
if err != nil {
return "", fmt.Errorf("list document types: %w", err)
}
tags, err := s.store.ListTaxonomyEntities(ctx, "tags", tenantID)
if err != nil {
return "", fmt.Errorf("list tags: %w", err)
}
groups, err := s.store.ListPermissionGroups(ctx, tenantID)
if err != nil {
return "", fmt.Errorf("list permission groups: %w", err)
}
workflows, err := s.store.ListWorkflows(ctx, tenantID)
if err != nil {
return "", fmt.Errorf("list workflows: %w", err)
}
templates, err := s.store.ListTemplates(ctx, tenantID, nil)
if err != nil {
return "", fmt.Errorf("list classification templates: %w", err)
}
stats, err := s.store.ComplianceStatsForTenant(ctx, tenantID)
if err != nil {
return "", fmt.Errorf("compliance stats: %w", err)
}
docTypeName := map[int64]string{}
for _, dt := range docTypes {
docTypeName[dt.ID] = dt.Name
}
var b strings.Builder
// --- Kopf / Entwurfskennzeichnung -----------------------------------
b.WriteString("> **ENTWURF** — automatisch generierter Baustein einer GoBD-Verfahrensdokumentation, Stand " +
now.Format("02.01.2006 15:04:05 MST") + ".\n" +
"> Ersetzt keine rechtliche Prüfung, muss um Organisationsbeschreibung/Verantwortlichkeiten/Backup-Notfallkonzept " +
"ergänzt und von fachkundiger Stelle geprüft werden.\n\n")
b.WriteString("# Verfahrensdokumentation (Entwurf) — " + tenantName + "\n\n")
b.WriteString("| | |\n|---|---|\n")
b.WriteString("| Mandant | " + mdCell(tenantName) + " |\n")
b.WriteString("| Mandanten-ID | " + strconv.FormatInt(tenantID, 10) + " |\n")
b.WriteString("| Stand der Generierung | " + now.Format("02.01.2006 15:04:05 MST") + " |\n")
b.WriteString("| System | archivdms (Dokumentenmanagementsystem) |\n")
b.WriteString("| Aktive Dokumente | " + strconv.FormatInt(stats.Documents, 10) + " |\n")
b.WriteString("| Davon mit Aufbewahrungsfrist (retain_until) | " + strconv.FormatInt(stats.DocumentsWithRetain, 10) + " |\n")
b.WriteString("| Dokumente im Papierkorb | " + strconv.FormatInt(stats.DocumentsInTrash, 10) + " |\n\n")
b.WriteString("Dieses Dokument beschreibt ausschließlich die im System hinterlegte Konfiguration des oben " +
"genannten Mandanten. Daten anderer Mandanten sind nicht enthalten (mandantengetrennte Auswertung).\n\n")
// --- 1. Aufbewahrungsfristen ----------------------------------------
b.WriteString("## 1. Aufbewahrungsfristen\n\n")
b.WriteString("Aufbewahrungsfristen werden als Regeln je Dokumenttyp gepflegt. Eine Regel ohne Dokumenttyp " +
"gilt als Mandanten-Default mit niedrigster Präzedenz; eine dokumenttyp-spezifische Regel hat Vorrang. " +
"Aus Fristbeginn (Trigger) und Frist berechnet das System je Dokument ein Datum `retain_until`; " +
"bis zu diesem Datum ist eine endgültige Löschung technisch blockiert.\n\n")
b.WriteString("Fristbeginn (Trigger-Typen): `document_date` = Belegdatum (ersatzweise Uploaddatum), " +
"`upload_date` = Uploaddatum, `fixed_date` = fixes Stichtagsdatum, " +
"`event` = ereignisgesteuert (wird nicht automatisch berechnet, erfordert manuelle Fristsetzung).\n\n")
if len(rules) == 0 {
b.WriteString("**Es sind derzeit keine Aufbewahrungsregeln konfiguriert.** [MANUELL ZU ERGÄNZEN: " +
"gesetzliche Fristen (z. B. § 147 AO, § 257 HGB) je Dokumentart benennen und im System hinterlegen.]\n\n")
} else {
b.WriteString("| Regel | Geltungsbereich | Fristbeginn | Frist | Rechtsgrundlage | Löschfreigabe nötig | DSGVO-Konflikt | Aktiv |\n")
b.WriteString("|---|---|---|---|---|---|---|---|\n")
for _, ru := range rules {
scope := "Mandanten-Default (alle Dokumenttypen ohne eigene Regel)"
if ru.DocTypeID != nil {
if n, ok := docTypeName[*ru.DocTypeID]; ok {
scope = "Dokumenttyp: " + n
} else {
scope = "Dokumenttyp-ID " + strconv.FormatInt(*ru.DocTypeID, 10)
}
}
trigger := ru.TriggerType
if ru.TriggerReference != "" {
trigger += " (" + ru.TriggerReference + ")"
}
b.WriteString("| " + mdCell(ru.Name) + " | " + mdCell(scope) + " | " + mdCell(trigger) + " | " +
mdCell(retentionPeriodText(ru)) + " | " + mdCell(orDash(ru.LegalBasis)) + " | " +
jaNein(ru.RequiresApprovalForDestroy) + " | " + jaNein(ru.DSGVOConflict) + " | " +
jaNein(ru.Active) + " |\n")
}
b.WriteString("\n")
}
b.WriteString("[MANUELL ZU ERGÄNZEN: Prüfung, ob die hinterlegten Fristen den für dieses Unternehmen " +
"einschlägigen handels- und steuerrechtlichen Vorgaben entsprechen.]\n\n")
// --- 2. Zugriffsschutz ----------------------------------------------
b.WriteString("## 2. Zugriffsschutz und Berechtigungen\n\n")
b.WriteString("### 2.1 Rollenmodell\n\n")
b.WriteString("- `superadmin` — mandantenübergreifende Systemverwaltung (Anlage von Mandanten).\n")
b.WriteString("- `domain_admin` — Administration innerhalb des eigenen Mandanten: Benutzer, Berechtigungsgruppen, " +
"Aufbewahrungsregeln, Workflows, Klassifizierungsvorlagen, Bestätigung endgültiger Löschungen.\n")
b.WriteString("- `user` — Erfassen, Suchen und Bearbeiten von Dokumenten im Rahmen der erteilten Berechtigungen.\n\n")
b.WriteString("Die Anmeldung erfolgt passwortbasiert (bcrypt-Hash, Kostenfaktor 12) bzw. optional gegen ein " +
"Verzeichnis (LDAP); die Sitzung wird über ein signiertes, nicht per JavaScript auslesbares Sitzungs-Cookie " +
"geführt. Mandantentrennung erfolgt applikationsseitig: jede Datenbankabfrage ist auf den Mandanten des " +
"angemeldeten Benutzers eingeschränkt.\n\n")
if s.users != nil {
users, err := s.users.ListByTenant(ctx, tenantID)
if err != nil {
return "", fmt.Errorf("list users: %w", err)
}
roleCount := map[string]int{}
for _, u := range users {
if !u.Active {
roleCount["(inaktiv)"]++
continue
}
roleCount[u.Role]++
}
keys := make([]string, 0, len(roleCount))
for k := range roleCount {
keys = append(keys, k)
}
sort.Strings(keys)
b.WriteString("Benutzerbestand dieses Mandanten: ")
if len(keys) == 0 {
b.WriteString("keine Benutzer erfasst.\n\n")
} else {
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s: %d", k, roleCount[k]))
}
b.WriteString(strings.Join(parts, ", ") + ".\n\n")
}
}
b.WriteString("### 2.2 Berechtigungsgruppen\n\n")
if len(groups) == 0 {
b.WriteString("Es sind keine Berechtigungsgruppen angelegt; der Zugriff wird ausschließlich über das " +
"Rollenmodell gesteuert. [MANUELL ZU ERGÄNZEN: Begründung, falls keine feinere Zugriffssteuerung " +
"erforderlich ist.]\n\n")
} else {
b.WriteString("| Gruppe | Mitglieder |\n|---|---|\n")
for _, g := range groups {
members, err := s.store.ListGroupMembersDetailed(ctx, g.ID, tenantID)
if err != nil {
return "", fmt.Errorf("list group members: %w", err)
}
names := make([]string, 0, len(members))
for _, m := range members {
names = append(names, m.Username)
}
if len(names) == 0 {
names = append(names, "—")
}
b.WriteString("| " + mdCell(g.Name) + " | " + mdCell(strings.Join(names, ", ")) + " |\n")
}
b.WriteString("\n")
}
b.WriteString("### 2.3 Berechtigungsebenen\n\n")
b.WriteString("Der Zugriff auf ein einzelnes Dokument ergibt sich aus drei Ebenen; die speziellere Ebene " +
"überschreibt die allgemeinere:\n\n")
b.WriteString("1. **Dokumenttyp-Berechtigung** (Grundeinstellung je Dokumenttyp)\n")
b.WriteString("2. **Schlagwort-Berechtigung** (Grants über die Tags eines Dokuments)\n")
b.WriteString("3. **Einzeldokument-Berechtigung** (Übersteuerung für ein konkretes Dokument, inkl. Entzug/`deny`)\n\n")
b.WriteString(fmt.Sprintf("Aktuell vergeben: %d Dokumenttyp-Berechtigungen, %d Schlagwort-Berechtigungen, "+
"%d Einzeldokument-Berechtigungen bei %d Berechtigungsgruppen.\n\n",
stats.DocTypeGrants, stats.TagGrants, stats.DocumentGrants, stats.PermissionGroups))
// Dokumenttyp-Grants im Detail.
dtRows := 0
var dtBuf strings.Builder
for _, dt := range docTypes {
grants, err := s.store.ListDocumentTypeGrants(ctx, tenantID, dt.ID)
if err != nil {
return "", fmt.Errorf("list document type grants: %w", err)
}
for _, g := range grants {
dtBuf.WriteString("| " + mdCell(dt.Name) + " | " + mdCell(g.GroupName) + " | " + mdCell(g.Access) + " |\n")
dtRows++
}
}
if dtRows > 0 {
b.WriteString("**Dokumenttyp-Berechtigungen**\n\n| Dokumenttyp | Gruppe | Zugriff |\n|---|---|---|\n")
b.WriteString(dtBuf.String() + "\n")
}
tagRows := 0
var tagBuf strings.Builder
for _, tg := range tags {
grants, err := s.store.ListTagGrants(ctx, tenantID, tg.ID)
if err != nil {
return "", fmt.Errorf("list tag grants: %w", err)
}
for _, g := range grants {
tagBuf.WriteString("| " + mdCell(tg.Name) + " | " + mdCell(g.GroupName) + " | " + mdCell(g.Access) + " |\n")
tagRows++
}
}
if tagRows > 0 {
b.WriteString("**Schlagwort-Berechtigungen**\n\n| Schlagwort | Gruppe | Zugriff |\n|---|---|---|\n")
b.WriteString(tagBuf.String() + "\n")
}
if stats.DocumentGrants > 0 {
b.WriteString(fmt.Sprintf("Zusätzlich bestehen %d dokumentbezogene Einzelberechtigungen. "+
"Jede Änderung daran ist im Änderungsprotokoll nachvollziehbar.\n\n", stats.DocumentGrants))
}
// --- 3. Löschkonzept -------------------------------------------------
b.WriteString("## 3. Löschkonzept und Unveränderbarkeit\n\n")
b.WriteString("### 3.1 Unveränderbarkeit der Ablage (WORM)\n\n")
b.WriteString("Archivierte Dokumente werden im Dateisystem nach dem WORM-Prinzip abgelegt " +
"(*write once, read many*): die Datei wird einmalig geschrieben und anschließend schreibgeschützt gesetzt " +
"(Dateirechte 0440, nur lesend). Der Ablagepfad wird nach Mandant, Jahr und Monat gegliedert; der Dateiname " +
"ist der SHA-256-Hash des Inhalts. Dieser Prüfwert wird zusätzlich in der Datenbank geführt und dient als " +
"fälschungssensibler Fingerabdruck: eine nachträgliche inhaltliche Veränderung würde den Hash verändern und " +
"wäre damit erkennbar. Ein erneuter Upload desselben Inhalts wird über den Hash als Dublette erkannt.\n\n")
b.WriteString("### 3.2 Zweistufiges Löschverfahren\n\n")
b.WriteString("Ein Dokument kann nicht unmittelbar aus dem Archiv entfernt werden. Der Ablauf ist zweistufig " +
"und folgt dem Vier-Augen-Prinzip:\n\n")
b.WriteString("1. **Papierkorb (Soft-Delete):** Das Dokument wird als gelöscht markiert (`deleted_at`, " +
"`deleted_by`), bleibt aber gespeichert und wiederherstellbar.\n")
b.WriteString("2. **Löschantrag:** Ein Benutzer beantragt die endgültige Löschung (Status `pending`).\n")
b.WriteString("3. **Bestätigung durch eine zweite Person:** Ein Administrator (`domain_admin`) bestätigt den " +
"Antrag. Eine Bestätigung durch dieselbe Person, die den Antrag gestellt hat, wird technisch " +
"zurückgewiesen (Vier-Augen-Prinzip).\n")
b.WriteString("4. **Fristprüfung:** Besteht noch eine laufende Aufbewahrungsfrist (`retain_until` in der " +
"Zukunft), wird die Löschung blockiert (Status `blocked_retention`).\n")
b.WriteString("5. **Ausführung:** Erst danach wird die WORM-Datei entfernt (Status `executed`). Der " +
"Löschvorgang wird protokolliert (Antragsteller, Bestätigender, Zeitpunkte, Titel, Inhalts-Hash, " +
"Fristzustand) — es verbleibt ein Nachweis über die erfolgte Löschung.\n\n")
b.WriteString("Ein Antrag kann bis zur Bestätigung zurückgezogen werden (Status `cancelled`).\n\n")
if len(stats.DeleteRequestsByStat) > 0 {
statuses := make([]string, 0, len(stats.DeleteRequestsByStat))
for k := range stats.DeleteRequestsByStat {
statuses = append(statuses, k)
}
sort.Strings(statuses)
b.WriteString("Bisherige Löschanträge dieses Mandanten:\n\n| Status | Anzahl |\n|---|---|\n")
for _, st := range statuses {
b.WriteString("| " + mdCell(st) + " | " + strconv.FormatInt(stats.DeleteRequestsByStat[st], 10) + " |\n")
}
b.WriteString("\n")
} else {
b.WriteString("Für diesen Mandanten wurden bislang keine endgültigen Löschungen beantragt.\n\n")
}
// --- 4. Erfassungsautomatisierung ------------------------------------
b.WriteString("## 4. Erfassung und automatisierte Verarbeitung\n\n")
b.WriteString("Dokumente gelangen über den Weg des Uploads über die Weboberfläche oder über eine " +
"mandantenbezogene SFTP-Ablage (Posteingangsverzeichnis) in das System. Nach der Übernahme wird der " +
"Dokumenteninhalt maschinell ausgelesen (Texterkennung/OCR), ein Prüfwert gebildet und das Dokument in " +
"die revisionssichere Ablage überführt. Die Verarbeitung erfolgt über eine Warteschlange; der " +
"Verarbeitungsstatus je Dokument ist im System einsehbar.\n\n")
b.WriteString("### 4.1 Regeln zur automatischen Zuordnung (Workflows)\n\n")
if len(workflows) == 0 {
b.WriteString("Es sind keine Workflow-Regeln konfiguriert; die Verschlagwortung erfolgt manuell " +
"bzw. über die Mustererkennung der Stammdaten.\n\n")
} else {
b.WriteString("| Regel | Auslöser | Aktiv | Priorität |\n|---|---|---|---|\n")
for _, wf := range workflows {
b.WriteString("| " + mdCell(wf.Name) + " | " + mdCell(wf.TriggerType) + " | " +
jaNein(wf.Enabled) + " | " + strconv.Itoa(wf.Priority) + " |\n")
}
b.WriteString("\nJede Ausführung einer Workflow-Regel wird protokolliert (Regel, Dokument, Treffer, " +
"angewandte Aktionen), sodass die maschinelle Zuordnung nachvollziehbar bleibt.\n\n")
}
b.WriteString("### 4.2 Klassifizierungsvorlagen\n\n")
if len(templates) == 0 {
b.WriteString("Es sind keine Klassifizierungsvorlagen hinterlegt.\n\n")
} else {
b.WriteString("| Vorlage | Dokumenttyp | Aufbewahrung (Jahre) | Aktiv | Beschreibung |\n|---|---|---|---|---|\n")
for _, t := range templates {
dt := "—"
if t.DocTypeID != nil {
if n, ok := docTypeName[*t.DocTypeID]; ok {
dt = n
} else {
dt = "Dokumenttyp-ID " + strconv.FormatInt(*t.DocTypeID, 10)
}
}
ry := "—"
if t.RetainYears != nil {
ry = strconv.Itoa(*t.RetainYears)
}
b.WriteString("| " + mdCell(t.Name) + " | " + mdCell(dt) + " | " + ry + " | " +
jaNein(t.Active) + " | " + mdCell(orDash(t.Description)) + " |\n")
}
b.WriteString("\nDie Anwendung einer Vorlage auf ein Dokument wird protokolliert. Eine spätere Änderung " +
"einer Vorlage wirkt nicht rückwirkend auf bereits klassifizierte Dokumente.\n\n")
}
b.WriteString("### 4.3 Stammdaten der Indizierung\n\n")
b.WriteString(fmt.Sprintf("Für diesen Mandanten sind %d Dokumenttypen und %d Schlagworte gepflegt. "+
"Dokumenttypen und Schlagworte können mit Erkennungsmustern versehen werden, über die eine Zuordnung "+
"beim Einlesen automatisch vorgeschlagen bzw. gesetzt wird.\n\n", len(docTypes), len(tags)))
if len(docTypes) > 0 {
names := make([]string, 0, len(docTypes))
for _, dt := range docTypes {
names = append(names, dt.Name)
}
b.WriteString("Dokumenttypen: " + strings.Join(names, ", ") + "\n\n")
}
// --- 5. Nachvollziehbarkeit ------------------------------------------
b.WriteString("## 5. Nachvollziehbarkeit (Änderungsprotokoll)\n\n")
b.WriteString("Das System führt ein fortschreibendes, nur ergänzbares Änderungsprotokoll (Audit-Log). " +
"Bestehende Protokolleinträge können weder verändert noch gelöscht werden; entsprechende " +
"Datenbankoperationen werden auf Datenbankebene unterbunden. Jeder Eintrag enthält Zeitstempel, " +
"Ereignisart, Benutzername, IP-Adresse, betroffenes Dokument, Erfolg/Misserfolg sowie eine " +
"Detailangabe. Auch fehlgeschlagene Versuche werden protokolliert.\n\n")
b.WriteString("Protokollierte Ereignisarten (Auszug):\n\n")
b.WriteString("- **Anmeldung/Sitzung:** Anmeldung, Abmeldung, fehlgeschlagene Anmeldung, Verzeichnisanmeldung (LDAP)\n")
b.WriteString("- **Dokumente:** Anlage, Änderung, erneute Verarbeitung, Seitentrennung, Zuordnung von " +
"Dokumenttyp/Korrespondent/Belegdatum, Notizen\n")
b.WriteString("- **Löschung:** Verschieben in den Papierkorb, Wiederherstellung, Löschantrag, Bestätigung, " +
"Ausführung, Ablehnung wegen laufender Aufbewahrungsfrist\n")
b.WriteString("- **Aufbewahrung:** Anlage/Änderung/Löschung von Aufbewahrungsregeln, Anwendung der Fristenläufe\n")
b.WriteString("- **Berechtigungen:** Änderungen an Berechtigungen und Gruppen, Benutzerverwaltung\n")
b.WriteString("- **Automatisierung:** Workflow-Ausführungen, Anwendung von Klassifizierungsvorlagen, " +
"maschinelle Metadatenvorschläge\n")
b.WriteString("- **Weitergabe:** Erstellung, Widerruf und Abruf von Freigabelinks\n")
b.WriteString("- **Schnittstellen:** SFTP-Zugangsdaten und SFTP-Anmeldungen, Konfigurationsänderungen\n")
b.WriteString("- **Compliance:** Erzeugung dieser Verfahrensdokumentation\n\n")
b.WriteString("Das Protokoll ist für Administratoren einsehbar und je Dokument filterbar.\n\n")
// --- 6. Manuell zu ergänzende Kapitel --------------------------------
b.WriteString("## 6. Allgemeine Beschreibung des Unternehmens und der Organisation\n\n")
b.WriteString("[MANUELL ZU ERGÄNZEN: Unternehmensgegenstand, Aufbau- und Ablauforganisation, welche " +
"Belegarten anfallen, welche Vorsysteme (z. B. Kasse, Warenwirtschaft, Buchhaltung) bestehen und wie " +
"diese mit dem Archiv zusammenwirken.]\n\n")
b.WriteString("## 7. Verantwortliche Personen und Vertretungsregelung\n\n")
b.WriteString("[MANUELL ZU ERGÄNZEN: Namentlich Verantwortliche für Archivierung, Berechtigungsvergabe, " +
"Löschfreigabe und Systembetrieb; Vertretungsregelung; Arbeitsanweisungen und deren Bekanntgabe an die " +
"Mitarbeitenden.]\n\n")
b.WriteString("## 8. Technische Systemdokumentation, Server-, Backup- und Notfallkonzept\n\n")
b.WriteString("[MANUELL ZU ERGÄNZEN: eingesetzte Hardware/Server, Standort und Betreiber, Betriebssystem- " +
"und Datenbankstand, Datensicherungsverfahren (Umfang, Häufigkeit, Aufbewahrung der Sicherungen, " +
"Auslagerung), Rücksicherungstests, Notfall- und Wiederanlaufplan, Verfahren bei Systemwechsel/Migration " +
"und Sicherstellung der Lesbarkeit über die gesamte Aufbewahrungsdauer.]\n\n")
b.WriteString("## 9. Änderungshistorie dieser Verfahrensdokumentation\n\n")
b.WriteString("[MANUELL ZU ERGÄNZEN: Versionsstände, Datum, Bearbeiter und Anlass der Änderung. Der " +
"vorliegende Entwurf gibt den Systemstand vom " + now.Format("02.01.2006 15:04:05 MST") +
" wieder und wird bei jedem Export neu erzeugt.]\n\n")
b.WriteString("---\n\n")
b.WriteString("*Automatisch erzeugter Entwurf aus dem archivdms-Systemstand. Kein rechtsverbindliches " +
"Fertigdokument — vor Verwendung gegenüber Dritten (z. B. im Rahmen einer Betriebsprüfung) durch eine " +
"fachkundige Stelle prüfen und um die als [MANUELL ZU ERGÄNZEN] gekennzeichneten Abschnitte vervollständigen.*\n")
return b.String(), nil
}
// retentionPeriodText renders the retention period of a rule in German prose.
func retentionPeriodText(ru storage.RetentionRule) string {
parts := []string{}
if ru.RetentionYears != nil && *ru.RetentionYears > 0 {
parts = append(parts, strconv.Itoa(*ru.RetentionYears)+" Jahre")
}
if ru.RetentionDays != nil && *ru.RetentionDays > 0 {
parts = append(parts, strconv.Itoa(*ru.RetentionDays)+" Tage")
}
if len(parts) == 0 {
return "nicht gesetzt"
}
return strings.Join(parts, " + ")
}
// mdCell escapes the characters that would break a Markdown table cell.
func mdCell(in string) string {
out := strings.ReplaceAll(in, "|", "\\|")
out = strings.ReplaceAll(out, "\r", " ")
out = strings.ReplaceAll(out, "\n", " ")
return strings.TrimSpace(out)
}
// orDash returns "—" for an empty string.
func orDash(in string) string {
if strings.TrimSpace(in) == "" {
return "—"
}
return in
}
// jaNein renders a bool in German.
func jaNein(b bool) string {
if b {
return "ja"
}
return "nein"
}
+320
View File
@@ -0,0 +1,320 @@
// Custom-fields HTTP handlers (see internal/storage/custom_fields.go):
//
// GET/POST /api/custom-fields PATCH/DELETE /api/custom-fields/{id}
// GET/PUT /api/document-types/{id}/fields
// GET/PUT /api/documents/{id}/fields
//
// Definition create/update/delete require domain_admin (s.authAdmin); listing
// and value-setting require an authenticated tenant context (s.auth).
// Ownership is enforced in the store layer (id+tenant_id). Every mutation is
// audit-logged, including failures, using the document lifecycle event types.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
type customFieldRequest struct {
Name string `json:"name"`
Label string `json:"label"`
FieldType string `json:"field_type"`
EnumOptions []string `json:"enum_options"`
Currency string `json:"currency"`
}
// handleListCustomFields handles GET /api/custom-fields.
func (s *Server) handleListCustomFields(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
defs, err := s.store.ListCustomFieldDefs(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list custom fields failed")
return
}
writeJSON(w, http.StatusOK, defs)
}
// handleCreateCustomField handles POST /api/custom-fields (domain_admin+).
func (s *Server) handleCreateCustomField(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req customFieldRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.Label == "" || req.FieldType == "" {
writeError(w, http.StatusBadRequest, "name, label and field_type are required")
return
}
def, err := s.store.CreateCustomFieldDef(r.Context(), *sess.TenantID, storage.CustomFieldDefRequest{
Name: req.Name, Label: req.Label, FieldType: req.FieldType,
EnumOptions: req.EnumOptions, Currency: req.Currency,
})
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrDuplicateCustomFieldName) {
status = http.StatusConflict
} else if strings.Contains(err.Error(), "invalid field_type") {
status = http.StatusBadRequest
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "custom_field_create err:" + err.Error()})
writeError(w, status, "create custom field failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "custom_field_create id:" + strconv.FormatInt(def.ID, 10) + " name:" + def.Name,
})
writeJSON(w, http.StatusCreated, def)
}
// handleUpdateCustomField handles PATCH /api/custom-fields/{id} (domain_admin+).
func (s *Server) handleUpdateCustomField(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req customFieldRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Label == "" {
writeError(w, http.StatusBadRequest, "label is required")
return
}
def, err := s.store.UpdateCustomFieldDef(r.Context(), id, *sess.TenantID, req.Label, req.EnumOptions, req.Currency)
if err != nil {
status := http.StatusNotFound
if !errors.Is(err, storage.ErrCustomFieldNotFound) {
status = http.StatusInternalServerError
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "custom_field_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, "update custom field failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "custom_field_update id:" + strconv.FormatInt(def.ID, 10),
})
writeJSON(w, http.StatusOK, def)
}
// handleDeleteCustomField handles DELETE /api/custom-fields/{id} (domain_admin+).
func (s *Server) handleDeleteCustomField(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.DeleteCustomFieldDef(r.Context(), id, *sess.TenantID); err != nil {
status := http.StatusNotFound
msg := "delete custom field failed"
if errors.Is(err, storage.ErrCustomFieldInUse) {
status = http.StatusConflict
msg = "custom field still has values"
} else if !errors.Is(err, storage.ErrCustomFieldNotFound) {
status = http.StatusInternalServerError
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "custom_field_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "custom_field_delete id:" + strconv.FormatInt(id, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- document-type field assignments ---
type docTypeFieldAssignmentRequest struct {
FieldID int64 `json:"field_id"`
Required bool `json:"required"`
Visible bool `json:"visible"`
SortOrder int `json:"sort_order"`
}
// handleListDocumentTypeFields handles GET /api/document-types/{id}/fields.
func (s *Server) handleListDocumentTypeFields(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document type id")
return
}
fields, err := s.store.ListDocumentTypeFields(r.Context(), docTypeID, *sess.TenantID)
if err != nil {
if errors.Is(err, storage.ErrTaxonomyNotFound) {
writeError(w, http.StatusNotFound, "document type not found")
return
}
writeError(w, http.StatusInternalServerError, "list document type fields failed")
return
}
writeJSON(w, http.StatusOK, fields)
}
// handleSetDocumentTypeFields handles PUT /api/document-types/{id}/fields
// (bulk replace, domain_admin+).
func (s *Server) handleSetDocumentTypeFields(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document type id")
return
}
var reqs []docTypeFieldAssignmentRequest
if err := json.NewDecoder(r.Body).Decode(&reqs); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body (expected array)")
return
}
assignments := make([]storage.DocumentTypeFieldAssignment, 0, len(reqs))
for _, a := range reqs {
assignments = append(assignments, storage.DocumentTypeFieldAssignment{
FieldID: a.FieldID, Required: a.Required, Visible: a.Visible, SortOrder: a.SortOrder,
})
}
if err := s.store.SetDocumentTypeFields(r.Context(), docTypeID, *sess.TenantID, assignments); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrTaxonomyNotFound) {
status = http.StatusNotFound
} else if errors.Is(err, storage.ErrCustomFieldNotFound) {
status = http.StatusBadRequest
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "doc_type_fields_set doc_type:" + strconv.FormatInt(docTypeID, 10) + " err:" + err.Error()})
writeError(w, status, "set document type fields failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "doc_type_fields_set doc_type:" + strconv.FormatInt(docTypeID, 10) + " count:" + strconv.Itoa(len(assignments)),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
// --- document field values ---
// handleListDocumentFieldValues handles GET /api/documents/{id}/fields.
func (s *Server) handleListDocumentFieldValues(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
values, err := s.store.ListDocumentFieldValues(r.Context(), docID, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list document field values failed")
return
}
writeJSON(w, http.StatusOK, values)
}
// handleSetDocumentFieldValues handles PUT /api/documents/{id}/fields (batch).
func (s *Server) handleSetDocumentFieldValues(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
var inputs []storage.DocumentFieldValueInput
if err := json.NewDecoder(r.Body).Decode(&inputs); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body (expected array)")
return
}
changed, err := s.store.SetDocumentFieldValues(r.Context(), docID, *sess.TenantID, inputs)
if err != nil {
status := http.StatusInternalServerError
msg := "set document field values failed"
if errors.Is(err, storage.ErrRequiredFieldMissing) {
status = http.StatusBadRequest
msg = err.Error()
} else if errors.Is(err, storage.ErrCustomFieldNotFound) {
status = http.StatusBadRequest
msg = "unknown custom field"
} else if strings.Contains(err.Error(), "invalid date") || strings.Contains(err.Error(), "not in enum options") {
status = http.StatusBadRequest
msg = err.Error()
}
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: false, Detail: "custom_field_values_set err:" + err.Error(),
})
writeError(w, status, msg)
return
}
for _, name := range changed {
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentUpdate, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: true, Detail: "custom_field:" + name + " changed",
})
}
// Re-sync the search index: custom-field values are (Phase 1) not yet a
// dedicated indexed field, but the document projection is refreshed so the
// index stays consistent and a later phase can start indexing field text
// without a backfill gap. Best-effort, never fails the request.
s.store.SyncIndex(r.Context(), docID)
values, err := s.store.ListDocumentFieldValues(r.Context(), docID, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "reload document field values failed")
return
}
writeJSON(w, http.StatusOK, values)
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"net/http"
"archivdms/internal/storage"
)
// handleDashboard returns aggregated, tenant-scoped key figures for the
// dashboard (GET /api/dashboard). Any authenticated user may view their own
// tenant's stats — no admin role required. Read-only, so no audit-log entry.
// Superadmin accounts have no tenant_id (by design, see auth.Manager.issueToken);
// they get an empty/zeroed snapshot instead of a 403, since there is no
// single tenant to scope the query to.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeJSON(w, http.StatusOK, &storage.DashboardStats{})
return
}
stats, err := s.store.GetDashboardStats(r.Context(), *sess.TenantID, sess.UserID)
if err != nil {
s.logger.Error("dashboard stats failed", "err", err)
writeError(w, http.StatusInternalServerError, "dashboard stats failed")
return
}
writeJSON(w, http.StatusOK, stats)
}
+216
View File
@@ -0,0 +1,216 @@
package api
import (
"regexp"
"strconv"
"strings"
"time"
)
// extractDocumentDate scans OCR text for the most plausible document/invoice
// date and returns it, or nil if none is found. It is a deliberately simple,
// rule-based (no NLP, no external library, CGO-free) heuristic in the same
// spirit as titleFromOCRText.
//
// Recognised formats:
// - DD.MM.YYYY (German, e.g. 31.12.2024)
// - DD.MM.YY (German short year, e.g. 31.12.24 -> 2024)
// - DD/MM/YYYY (slash variant, incl. DD/MM/YY)
// - YYYY-MM-DD (ISO 8601, e.g. 2024-12-31)
// - "15. März 2026" / "15. Mär. 2026" (spelled-out German month names)
//
// Plausibility: month 1-12, day 1-31 with an explicit calendar check (time.Date
// would normalise an impossible day, so we reject e.g. "31.02." rather than
// silently shifting it to March), year not before dateMinYear, and never a date
// in the future beyond today + dateFutureToleranceDays (a small tolerance for
// timezone/clock skew). These filters drop copyright years, footer years and
// stray digit runs.
//
// Scoring (see scoreForDatePosition): each candidate gets a confidence based on
// signal words in a small text window around it ("Rechnungsdatum", "vom", ...).
// The candidate with the highest score wins; on a tie the earliest occurrence in
// the text wins (document head = usually the issue date). Callers that only need
// the date use this function; callers that also need the confidence use
// extractDocumentDateWithScore.
const (
dateMinYear = 1990
dateFutureToleranceDays = 2
dateWindowRadius = 40
dateScoreNoContext = 0.4
)
// dateKeyword pairs a lowercase signal word with the confidence a date near it
// receives. Ordered by descending score: scoreForDatePosition returns the score
// of the first (=highest) keyword found in the window. Kept short and flat on
// purpose — no weighting engine, GoBD-traceable.
type dateKeyword struct {
word string
score float64
}
var dateKeywords = []dateKeyword{
{"rechnungsdatum", 0.9},
{"belegdatum", 0.9},
{"ausstellungsdatum", 0.9},
{"rechnung vom", 0.9},
{"beleg vom", 0.9},
{"datum", 0.75},
{"vom", 0.55},
}
// dateGermanMonths maps lowercased German month names and common abbreviations
// (with the trailing dot already stripped) to their month number.
var dateGermanMonths = map[string]int{
"januar": 1, "jan": 1,
"februar": 2, "feb": 2,
"märz": 3, "maerz": 3, "mär": 3, "mrz": 3,
"april": 4, "apr": 4,
"mai": 5,
"juni": 6, "jun": 6,
"juli": 7, "jul": 7,
"august": 8, "aug": 8,
"september": 9, "sep": 9, "sept": 9,
"oktober": 10, "okt": 10,
"november": 11, "nov": 11,
"dezember": 12, "dez": 12,
}
// dateCandidateRe matches every supported format in a single alternation. Named
// groups keep the branch handling readable. Word boundaries avoid gluing onto
// surrounding digits (e.g. a phone number). Case-insensitive for month names.
var dateCandidateRe = regexp.MustCompile(
`(?i)(?:\b(?P<gd>\d{1,2})\.(?P<gm>\d{1,2})\.(?P<gy>\d{4}|\d{2})\b)` +
`|(?:\b(?P<sd>\d{1,2})/(?P<sm>\d{1,2})/(?P<sy>\d{4}|\d{2})\b)` +
`|(?:\b(?P<iy>\d{4})-(?P<im>\d{1,2})-(?P<id>\d{1,2})\b)` +
`|(?:\b(?P<td>\d{1,2})\.?\s+(?P<tmon>[A-Za-zäöüÄÖÜ]+)\.?\s+(?P<ty>\d{4})\b)`,
)
// sameDate reports whether two optional dates refer to the same calendar day
// (or are both nil). Used by reprocess to skip a no-op document_date update.
func sameDate(a, b *time.Time) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
ay, am, ad := a.Date()
by, bm, bd := b.Date()
return ay == by && am == bm && ad == bd
}
// scoreForDatePosition returns the confidence for a date match found at byte
// offset start in text, based on signal words within dateWindowRadius chars.
func scoreForDatePosition(lowerText string, start int) float64 {
lo := start - dateWindowRadius
if lo < 0 {
lo = 0
}
hi := start + dateWindowRadius
if hi > len(lowerText) {
hi = len(lowerText)
}
window := lowerText[lo:hi]
for _, kw := range dateKeywords {
if strings.Contains(window, kw.word) {
return kw.score
}
}
return dateScoreNoContext
}
// parseDateMatch turns one regex submatch into a validated calendar date, or
// returns ok=false if the match is implausible.
func parseDateMatch(names, m []string) (time.Time, bool) {
now := time.Now()
maxDate := now.AddDate(0, 0, dateFutureToleranceDays)
var day, month, year int
var monthName string
for i, name := range names {
if m[i] == "" {
continue
}
switch name {
case "gd", "sd", "id", "td":
day, _ = strconv.Atoi(m[i])
case "gm", "sm", "im":
month, _ = strconv.Atoi(m[i])
case "gy", "sy":
y, _ := strconv.Atoi(m[i])
if len(m[i]) == 2 {
// Two-digit year: interpret as 2000-2099. Anything above the
// future tolerance is rejected below.
y += 2000
}
year = y
case "iy", "ty":
year, _ = strconv.Atoi(m[i])
case "tmon":
monthName = m[i]
}
}
if monthName != "" {
mn, ok := dateGermanMonths[strings.ToLower(monthName)]
if !ok {
return time.Time{}, false
}
month = mn
}
if year < dateMinYear {
return time.Time{}, false
}
if month < 1 || month > 12 {
return time.Time{}, false
}
if day < 1 || day > 31 {
return time.Time{}, false
}
d := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
// Reject normalised-away impossible days (e.g. 31.02. -> 03.03.).
if d.Day() != day || int(d.Month()) != month || d.Year() != year {
return time.Time{}, false
}
// No future dates beyond today + tolerance.
if d.After(maxDate) {
return time.Time{}, false
}
return d, true
}
// extractDocumentDateWithScore returns the best belegdatum candidate and its
// confidence. found=false when no plausible date exists in the text.
func extractDocumentDateWithScore(ocrText string) (best time.Time, score float64, found bool) {
if ocrText == "" {
return time.Time{}, 0, false
}
lower := strings.ToLower(ocrText)
idxMatches := dateCandidateRe.FindAllStringSubmatchIndex(ocrText, -1)
names := dateCandidateRe.SubexpNames()
for _, loc := range idxMatches {
m := make([]string, len(names))
for g := range names {
s, e := loc[2*g], loc[2*g+1]
if s >= 0 {
m[g] = ocrText[s:e]
}
}
d, ok := parseDateMatch(names, m)
if !ok {
continue
}
sc := scoreForDatePosition(lower, loc[0])
// Strictly greater keeps the earliest occurrence on a tie (matches are
// returned in reading order).
if !found || sc > score {
best, score, found = d, sc, true
}
}
return best, score, found
}
// extractDocumentDate returns just the best belegdatum candidate (or nil),
// preserving the original signature for callers that do not need the score.
func extractDocumentDate(ocrText string) *time.Time {
d, _, found := extractDocumentDateWithScore(ocrText)
if !found {
return nil
}
return &d
}
@@ -0,0 +1,426 @@
package api
import (
"archive/zip"
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"archivdms/internal/audit"
"archivdms/internal/auth"
"archivdms/internal/storage"
"archivdms/internal/userstore"
)
// maxBulkExportDocuments caps how many documents a single bulk export may
// stream. Explicit ID lists beyond this are rejected with 400; a filter
// selection resolving to more documents is likewise rejected so the caller
// narrows the filter instead of silently receiving a truncated archive
// (GoBD-Vollständigkeit: a partial export must never look complete).
const maxBulkExportDocuments = 500
// bulkExportRequest is the POST /api/documents/export body. Either IDs or
// Filter is used — IDs takes precedence when both are present.
//
// {"ids": [1,2,3]}
// {"filter": {"doc_type_id": 4, "tag_ids": [7,9],
// "document_date_from": "2026-01-01", "document_date_to": "2026-03-31"}}
type bulkExportRequest struct {
IDs []int64 `json:"ids"`
Filter *bulkExportFilter `json:"filter"`
}
// bulkExportFilter mirrors the filter dimensions the list/search endpoints
// already expose (doc type, correspondent, tags, time range). It is applied on
// top of the tenant- and ACL-scoped result of Store.ListDocuments, so no new
// SQL predicate — and no new place a tenant_id filter could be forgotten.
type bulkExportFilter struct {
DocTypeID *int64 `json:"doc_type_id"`
CorrespondentID *int64 `json:"correspondent_id"`
TagIDs []int64 `json:"tag_ids"`
DocumentDateFrom string `json:"document_date_from"` // YYYY-MM-DD, inclusive
DocumentDateTo string `json:"document_date_to"` // YYYY-MM-DD, inclusive
UploadedFrom string `json:"uploaded_from"` // YYYY-MM-DD, inclusive
UploadedTo string `json:"uploaded_to"` // YYYY-MM-DD, inclusive (whole day)
}
// bulkExportCSVHeader is the index.csv header. Column names are deliberately
// identical to the metadata.json field names (snake_case) so the later DATEV
// formatter can map from one shared vocabulary.
var bulkExportCSVHeader = []string{
"document_id", "title", "doc_type", "correspondent",
"document_date", "tags", "uploaded_at",
}
// handleBulkExportDocuments streams a multi-document ZIP
// (POST /api/documents/export):
//
// doc-<id>/<title>.<ext> original WORM file
// doc-<id>/metadata.json same shape as the single-document export
// doc-<id>/ocr_text.txt only when OCR text exists
// index.csv one row per exported document
// errors.txt only when documents were skipped
//
// ACL: identical to the single export — tenant scoping plus, for role 'user',
// the per-document document_visibility check. Documents the caller may not see
// (or that fail to read) are skipped and listed in errors.txt rather than
// aborting the whole request.
//
// Audit: exactly ONE EventDocumentBulkExport entry per call, carrying the
// exported/skipped counts.
func (s *Server) handleBulkExportDocuments(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
tenantID := *sess.TenantID
fail := func(status int, msg, detail string) {
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentBulkExport, Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: detail,
})
writeError(w, status, msg)
}
var req bulkExportRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
fail(http.StatusBadRequest, "invalid request body", "decode_failed: "+err.Error())
return
}
if len(req.IDs) == 0 && req.Filter == nil {
fail(http.StatusBadRequest, "ids oder filter erforderlich", "empty_selection")
return
}
if len(req.IDs) > maxBulkExportDocuments {
fail(http.StatusBadRequest,
fmt.Sprintf("maximal %d Dokumente pro Export (angefragt: %d)", maxBulkExportDocuments, len(req.IDs)),
fmt.Sprintf("too_many_ids: %d", len(req.IDs)))
return
}
// Role 'user' gets the group-resolved ACL applied by the store; domain
// admins and superadmins see the whole tenant (roles are the outer boundary).
var aclUserID *int64
if !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
uid := sess.UserID
aclUserID = &uid
}
// skipped collects IDs that were requested but not exported, with a reason.
skipped := make([]string, 0, 8)
// Resolve the selection into a concrete, ordered document list.
docs := make([]storage.Document, 0, len(req.IDs))
if len(req.IDs) > 0 {
for _, id := range req.IDs {
doc, err := s.store.GetDocument(r.Context(), id, tenantID)
if err != nil || doc == nil {
skipped = append(skipped, fmt.Sprintf("%d: nicht gefunden", id))
continue
}
if aclUserID != nil {
visible, err := s.store.IsDocumentVisible(r.Context(), id, tenantID, *aclUserID)
if err != nil {
skipped = append(skipped, fmt.Sprintf("%d: Sichtbarkeitsprüfung fehlgeschlagen", id))
continue
}
if !visible {
skipped = append(skipped, fmt.Sprintf("%d: nicht sichtbar", id))
continue
}
}
docs = append(docs, *doc)
}
} else {
all, err := s.store.ListDocuments(r.Context(), tenantID, aclUserID)
if err != nil {
fail(http.StatusInternalServerError, "export failed", "list_failed: "+err.Error())
return
}
filtered, err := s.filterBulkExportDocs(r, tenantID, all, req.Filter)
if err != nil {
fail(http.StatusBadRequest, err.Error(), "filter_invalid: "+err.Error())
return
}
if len(filtered) > maxBulkExportDocuments {
fail(http.StatusBadRequest,
fmt.Sprintf("Filter trifft %d Dokumente, maximal %d pro Export — Filter eingrenzen", len(filtered), maxBulkExportDocuments),
fmt.Sprintf("filter_too_broad: %d", len(filtered)))
return
}
docs = filtered
}
if len(docs) == 0 && len(skipped) == 0 {
fail(http.StatusNotFound, "keine Dokumente für den Export gefunden", "empty_result")
return
}
ts := time.Now().UTC().Format("20060102-150405")
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", "attachment; filename=\"export-bulk-"+ts+".zip\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
zw := zip.NewWriter(w)
writeEntry := func(name string, rd io.Reader) error {
entry, err := zw.Create(name)
if err != nil {
return err
}
_, err = io.Copy(entry, rd)
return err
}
csvBuf := &bytes.Buffer{}
// UTF-8 BOM so Excel opens the CSV with correct umlauts.
csvBuf.WriteString("\xef\xbb\xbf")
cw := csv.NewWriter(csvBuf)
cw.Comma = ';'
_ = cw.Write(bulkExportCSVHeader)
exported := 0
var streamErr error
for i := range docs {
doc := docs[i]
row, err := s.writeBulkExportDoc(r, writeEntry, sess.Username, tenantID, &doc)
if err != nil {
// A single unreadable document must not kill the archive — unless the
// ZIP writer itself failed, which we detect on Close below.
s.logger.Warn("bulk export: document skipped", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
skipped = append(skipped, fmt.Sprintf("%d: %v", doc.ID, err))
continue
}
_ = cw.Write(row)
exported++
}
cw.Flush()
if streamErr == nil {
streamErr = writeEntry("index.csv", bytes.NewReader(csvBuf.Bytes()))
}
if streamErr == nil && len(skipped) > 0 {
var b strings.Builder
b.WriteString("Übersprungene Dokumente (nicht sichtbar, nicht gefunden oder Lesefehler):\n")
for _, line := range skipped {
b.WriteString(line)
b.WriteString("\n")
}
streamErr = writeEntry("errors.txt", strings.NewReader(b.String()))
}
if closeErr := zw.Close(); streamErr == nil {
streamErr = closeErr
}
detail := fmt.Sprintf("zip_bulk_export: exported=%d skipped=%d", exported, len(skipped))
if streamErr != nil {
// Headers are already out — audit the partial export, no HTTP error.
s.logger.Warn("bulk document export stream failed", "tenant_id", tenantID, "err", streamErr)
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentBulkExport, Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: detail + " stream_failed: " + streamErr.Error(),
})
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentBulkExport, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: detail,
})
}
// writeBulkExportDoc writes the doc-<id>/ folder of one document and returns
// its index.csv row. Any error means "skip this document" — the caller keeps
// the archive going and records the ID in errors.txt.
func (s *Server) writeBulkExportDoc(
r *http.Request,
writeEntry func(string, io.Reader) error,
username string,
tenantID int64,
doc *storage.Document,
) ([]string, error) {
ctx := r.Context()
prefix := "doc-" + strconv.FormatInt(doc.ID, 10) + "/"
docTypeName, correspondentName, err := s.store.DocumentTaxonomyNames(ctx, doc.ID, tenantID)
if err != nil {
return nil, fmt.Errorf("Taxonomie nicht lesbar: %w", err)
}
// Bestandsschutz: fall back to the deprecated free-text columns.
if docTypeName == "" {
docTypeName = doc.DocType
}
if correspondentName == "" {
correspondentName = doc.Correspondent
}
tagEntities, err := s.store.ListDocumentTags(ctx, doc.ID, tenantID)
if err != nil {
return nil, fmt.Errorf("Tags nicht lesbar: %w", err)
}
tags := make([]string, 0, len(tagEntities))
for _, t := range tagEntities {
tags = append(tags, t.Name)
}
fieldValues, err := s.store.ListDocumentFieldValues(ctx, doc.ID, tenantID)
if err != nil {
return nil, fmt.Errorf("Zusatzfelder nicht lesbar: %w", err)
}
f, err := os.Open(doc.StoragePath)
if err != nil {
return nil, fmt.Errorf("Datei nicht lesbar: %w", err)
}
defer f.Close()
meta := documentExportMetadata{
DocumentID: doc.ID,
TenantID: doc.TenantID,
Title: doc.Title,
DocType: docTypeName,
Correspondent: correspondentName,
Tags: tags,
DocumentDateScore: doc.DocumentDateScore,
UploadedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
CreatedBy: s.exportCreatorName(doc),
ContentHash: doc.ContentHash,
OriginalFilename: filepath.Base(doc.StoragePath),
CustomFields: exportCustomFields(fieldValues),
ExportedAt: time.Now().UTC(),
ExportedBy: username,
}
docDate := ""
if doc.DocumentDate != nil {
docDate = doc.DocumentDate.Format("2006-01-02")
meta.DocumentDate = &docDate
}
if doc.RetainUntil != nil {
d := doc.RetainUntil.Format("2006-01-02")
meta.RetainUntil = &d
}
metaJSON, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return nil, fmt.Errorf("Metadaten nicht serialisierbar: %w", err)
}
ext := filepath.Ext(doc.StoragePath)
if err := writeEntry(prefix+safeDownloadName(doc.Title, ext), f); err != nil {
return nil, fmt.Errorf("ZIP-Eintrag fehlgeschlagen: %w", err)
}
if err := writeEntry(prefix+"metadata.json", bytes.NewReader(metaJSON)); err != nil {
return nil, fmt.Errorf("ZIP-Eintrag fehlgeschlagen: %w", err)
}
if doc.OCRText != "" {
if err := writeEntry(prefix+"ocr_text.txt", strings.NewReader(doc.OCRText)); err != nil {
return nil, fmt.Errorf("ZIP-Eintrag fehlgeschlagen: %w", err)
}
}
return []string{
strconv.FormatInt(doc.ID, 10),
doc.Title,
docTypeName,
correspondentName,
docDate,
strings.Join(tags, ", "),
doc.CreatedAt.UTC().Format(time.RFC3339),
}, nil
}
// filterBulkExportDocs narrows an already tenant- and ACL-scoped document list
// by the requested filter. Tag filtering needs a per-document lookup, so it is
// applied last, after the cheap in-memory predicates.
func (s *Server) filterBulkExportDocs(r *http.Request, tenantID int64, docs []storage.Document, f *bulkExportFilter) ([]storage.Document, error) {
docFrom, err := parseBulkExportDate(f.DocumentDateFrom)
if err != nil {
return nil, fmt.Errorf("ungültiges document_date_from (erwartet YYYY-MM-DD)")
}
docTo, err := parseBulkExportDate(f.DocumentDateTo)
if err != nil {
return nil, fmt.Errorf("ungültiges document_date_to (erwartet YYYY-MM-DD)")
}
upFrom, err := parseBulkExportDate(f.UploadedFrom)
if err != nil {
return nil, fmt.Errorf("ungültiges uploaded_from (erwartet YYYY-MM-DD)")
}
upTo, err := parseBulkExportDate(f.UploadedTo)
if err != nil {
return nil, fmt.Errorf("ungültiges uploaded_to (erwartet YYYY-MM-DD)")
}
out := make([]storage.Document, 0, len(docs))
for i := range docs {
d := docs[i]
if f.DocTypeID != nil && (d.DocTypeID == nil || *d.DocTypeID != *f.DocTypeID) {
continue
}
if f.CorrespondentID != nil && (d.CorrespondentID == nil || *d.CorrespondentID != *f.CorrespondentID) {
continue
}
if docFrom != nil || docTo != nil {
if d.DocumentDate == nil {
continue
}
day := d.DocumentDate.UTC().Truncate(24 * time.Hour)
if docFrom != nil && day.Before(*docFrom) {
continue
}
if docTo != nil && day.After(*docTo) {
continue
}
}
if upFrom != nil && d.CreatedAt.UTC().Before(*upFrom) {
continue
}
if upTo != nil && d.CreatedAt.UTC().After(upTo.Add(24*time.Hour-time.Nanosecond)) {
continue
}
out = append(out, d)
}
if len(f.TagIDs) == 0 {
return out, nil
}
want := make(map[int64]struct{}, len(f.TagIDs))
for _, id := range f.TagIDs {
want[id] = struct{}{}
}
tagged := make([]storage.Document, 0, len(out))
for i := range out {
tags, err := s.store.ListDocumentTags(r.Context(), out[i].ID, tenantID)
if err != nil {
return nil, fmt.Errorf("Tag-Filter fehlgeschlagen")
}
for _, t := range tags {
if _, ok := want[t.ID]; ok {
tagged = append(tagged, out[i])
break
}
}
}
return tagged, nil
}
// parseBulkExportDate parses an optional YYYY-MM-DD filter bound (UTC).
func parseBulkExportDate(s string) (*time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil
}
t, err := time.ParseInLocation("2006-01-02", s, time.UTC)
if err != nil {
return nil, fmt.Errorf("parse date %q: %w", s, err)
}
return &t, nil
}
+265
View File
@@ -0,0 +1,265 @@
package api
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"archivdms/internal/audit"
"archivdms/internal/auth"
"archivdms/internal/storage"
"archivdms/internal/userstore"
)
// documentExportMetadata is the metadata.json payload of a single-document
// export. Field names are snake_case and mirror the API's document JSON so an
// exported package stays readable/parsable without the API at hand (GoBD:
// Verständlichkeit/Nachvollziehbarkeit of the exported archive package).
type documentExportMetadata struct {
DocumentID int64 `json:"document_id"`
TenantID int64 `json:"tenant_id"`
Title string `json:"title"`
DocType string `json:"doc_type"`
Correspondent string `json:"correspondent"`
Tags []string `json:"tags"`
DocumentDate *string `json:"document_date"`
DocumentDateScore *float64 `json:"document_date_score"`
UploadedAt time.Time `json:"uploaded_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by"`
ContentHash string `json:"content_hash"`
OriginalFilename string `json:"original_filename"`
RetainUntil *string `json:"retain_until"`
CustomFields []exportedCustomField `json:"custom_fields"`
ExportedAt time.Time `json:"exported_at"`
ExportedBy string `json:"exported_by"`
}
// exportedCustomField is one custom-field value in metadata.json. Exactly one
// of the value pointers is populated, matching the field's type.
type exportedCustomField struct {
Name string `json:"name"`
Label string `json:"label"`
FieldType string `json:"field_type"`
Currency string `json:"currency,omitempty"`
ValueText *string `json:"value_text,omitempty"`
ValueNumber *float64 `json:"value_number,omitempty"`
ValueDate *string `json:"value_date,omitempty"`
ValueBool *bool `json:"value_bool,omitempty"`
}
// handleExportDocument streams a ZIP package for a single document
// (GET /api/documents/{id}/export) containing:
//
// <title>.<ext> the original file, read from the WORM store via this handler
// (never handing out storage_path itself)
// metadata.json title, taxonomy, tags, belegdatum + score, timestamps,
// creator and custom-field values
// ocr_text.txt the OCR full text, only when the document has one
//
// ACL: tenant scoping via GetDocument (WHERE tenant_id) plus — for role 'user' —
// the same document_visibility rule as the list endpoint (IsDocumentVisible).
// domain_admin/superadmin skip the per-document check, roles being the outer
// boundary, exactly like handleListDocuments.
//
// Unlike the plain download/preview endpoints this IS audit-logged (success and
// failure): a complete metadata+content package leaving the system is treated
// like a mutation for GoBD traceability, consistent with the compliance export
// and the accounting pull API.
func (s *Server) handleExportDocument(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
fail := func(status int, msg, detail string) {
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentExport, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: idStr, Success: false, Detail: detail,
})
writeError(w, status, msg)
}
doc, err := s.store.GetDocument(r.Context(), id, *sess.TenantID)
if err != nil {
fail(http.StatusNotFound, "document not found", "not_found")
return
}
// Group-resolved ACL for plain users; 404 (not 403) so the endpoint never
// reveals the existence of a document the caller may not see.
if !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
visible, err := s.store.IsDocumentVisible(r.Context(), id, *sess.TenantID, sess.UserID)
if err != nil {
fail(http.StatusInternalServerError, "export failed", "visibility_check_failed: "+err.Error())
return
}
if !visible {
fail(http.StatusNotFound, "document not found", "not_visible")
return
}
}
// Gather metadata BEFORE any byte is written — once the ZIP stream has
// started, the status code can no longer be changed.
docTypeName, correspondentName, err := s.store.DocumentTaxonomyNames(r.Context(), id, *sess.TenantID)
if err != nil {
fail(http.StatusInternalServerError, "export failed", "taxonomy_lookup_failed: "+err.Error())
return
}
// Bestandsschutz: fall back to the deprecated free-text columns when no
// structured entity is assigned.
if docTypeName == "" {
docTypeName = doc.DocType
}
if correspondentName == "" {
correspondentName = doc.Correspondent
}
tagEntities, err := s.store.ListDocumentTags(r.Context(), id, *sess.TenantID)
if err != nil {
fail(http.StatusInternalServerError, "export failed", "tag_lookup_failed: "+err.Error())
return
}
tags := make([]string, 0, len(tagEntities))
for _, t := range tagEntities {
tags = append(tags, t.Name)
}
fieldValues, err := s.store.ListDocumentFieldValues(r.Context(), id, *sess.TenantID)
if err != nil {
fail(http.StatusInternalServerError, "export failed", "field_lookup_failed: "+err.Error())
return
}
f, err := os.Open(doc.StoragePath)
if err != nil {
s.logger.Error("export: document file open failed", "document_id", doc.ID, "tenant_id", *sess.TenantID, "err", err)
fail(http.StatusInternalServerError, "file unavailable", "file_open_failed: "+err.Error())
return
}
defer f.Close()
meta := documentExportMetadata{
DocumentID: doc.ID,
TenantID: doc.TenantID,
Title: doc.Title,
DocType: docTypeName,
Correspondent: correspondentName,
Tags: tags,
DocumentDateScore: doc.DocumentDateScore,
UploadedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
CreatedBy: s.exportCreatorName(doc),
ContentHash: doc.ContentHash,
OriginalFilename: filepath.Base(doc.StoragePath),
CustomFields: exportCustomFields(fieldValues),
ExportedAt: time.Now().UTC(),
ExportedBy: sess.Username,
}
if doc.DocumentDate != nil {
d := doc.DocumentDate.Format("2006-01-02")
meta.DocumentDate = &d
}
if doc.RetainUntil != nil {
d := doc.RetainUntil.Format("2006-01-02")
meta.RetainUntil = &d
}
metaJSON, err := json.MarshalIndent(meta, "", " ")
if err != nil {
fail(http.StatusInternalServerError, "export failed", "metadata_marshal_failed: "+err.Error())
return
}
ext := filepath.Ext(doc.StoragePath)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", "attachment; filename=\"export-"+idStr+".zip\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
zw := zip.NewWriter(w)
writeEntry := func(name string, r io.Reader) error {
entry, err := zw.Create(name)
if err != nil {
return err
}
_, err = io.Copy(entry, r)
return err
}
var streamErr error
// 1. Original file (read through this handler, never exposing storage_path).
if streamErr = writeEntry(safeDownloadName(doc.Title, ext), f); streamErr == nil {
// 2. metadata.json
streamErr = writeEntry("metadata.json", bytes.NewReader(metaJSON))
}
// 3. ocr_text.txt (only when OCR text exists)
if streamErr == nil && doc.OCRText != "" {
streamErr = writeEntry("ocr_text.txt", bytes.NewReader([]byte(doc.OCRText)))
}
if closeErr := zw.Close(); streamErr == nil {
streamErr = closeErr
}
if streamErr != nil {
// Headers are already out — log + audit the partial export, no HTTP error.
s.logger.Warn("document export stream failed", "document_id", doc.ID, "err", streamErr)
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentExport, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: idStr, Success: false, Detail: "stream_failed: " + streamErr.Error(),
})
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentExport, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: idStr, Success: true, Detail: "zip_export",
})
}
// exportCreatorName resolves the uploading user's username for metadata.json.
// Returns "" when the document has no created_by (e.g. SFTP watcher ingest) or
// the user has since been deleted — the export must never fail over this.
func (s *Server) exportCreatorName(doc *storage.Document) string {
if doc.CreatedBy == nil || s.users == nil {
return ""
}
u, err := s.users.GetByID(*doc.CreatedBy)
if err != nil || u == nil {
return ""
}
return u.Username
}
// exportCustomFields maps stored custom-field values to their export shape,
// normalising dates to ISO strings. Always a non-nil slice so metadata.json
// carries [] rather than null.
func exportCustomFields(values []storage.DocumentFieldValue) []exportedCustomField {
out := make([]exportedCustomField, 0, len(values))
for _, v := range values {
e := exportedCustomField{
Name: v.Name,
Label: v.Label,
FieldType: v.FieldType,
Currency: v.Currency,
ValueText: v.ValueText,
ValueNumber: v.ValueNumber,
ValueBool: v.ValueBool,
}
if v.ValueDate != nil {
d := v.ValueDate.Format("2006-01-02")
e.ValueDate = &d
}
out = append(out, e)
}
return out
}
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"archivdms/internal/audit"
"archivdms/internal/auth"
"archivdms/internal/storage"
"archivdms/internal/userstore"
)
// handleListDocumentNotes returns all free-text notes on a document
// (GET /api/documents/{id}/notes). Tenant-scoped: ListDocumentNotes filters
// WHERE tenant_id, so a foreign-tenant id simply yields an empty list. Pure
// read — no audit entry, consistent with the other GET handlers.
func (s *Server) handleListDocumentNotes(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
notes, err := s.store.ListDocumentNotes(r.Context(), id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list notes failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": notes})
}
type createNoteRequest struct {
Text string `json:"text"`
}
// handleCreateDocumentNote adds a free-text note to a document
// (POST /api/documents/{id}/notes, body {"text": "..."}). The author is the
// authenticated user. CreateDocumentNote verifies the document belongs to the
// tenant before inserting (IDOR guard).
func (s *Server) handleCreateDocumentNote(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
var req createNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
text := strings.TrimSpace(req.Text)
if text == "" {
writeError(w, http.StatusBadRequest, "text is required")
return
}
note, err := s.store.CreateDocumentNote(r.Context(), id, *sess.TenantID, sess.UserID, text)
if err != nil {
status := http.StatusInternalServerError
msg := "create note failed"
if errors.Is(err, storage.ErrDocumentNotFound) {
status = http.StatusNotFound
msg = "document not found"
}
s.audlog.Log(audit.Entry{EventType: audit.EventNoteCreate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventNoteCreate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "note_id=" + strconv.FormatInt(note.ID, 10)})
writeJSON(w, http.StatusCreated, note)
}
// handleDeleteDocumentNote hard-deletes a note
// (DELETE /api/documents/{id}/notes/{noteId}). Only the note's author or a
// domain admin may delete it. 403 when not permitted, 404 when the note does
// not exist for this document/tenant.
func (s *Server) handleDeleteDocumentNote(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
noteID, err := strconv.ParseInt(r.PathValue("noteId"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid note id")
return
}
isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin)
if err := s.store.DeleteDocumentNote(r.Context(), noteID, id, *sess.TenantID, sess.UserID, isAdmin); err != nil {
status := http.StatusInternalServerError
msg := "delete note failed"
if errors.Is(err, storage.ErrNoteForbidden) {
status = http.StatusForbidden
msg = "not allowed to delete this note"
} else if errors.Is(err, storage.ErrNoteNotFound) {
status = http.StatusNotFound
msg = "note not found"
}
s.audlog.Log(audit.Entry{EventType: audit.EventNoteDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "note_id=" + r.PathValue("noteId") + ": " + err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventNoteDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "note_id=" + r.PathValue("noteId")})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"archivdms/internal/audit"
"archivdms/internal/ldapstore"
"archivdms/internal/userstore"
)
// resolveLDAPTenant determines which tenant's LDAP config the request targets.
// domain_admin is pinned to its own session tenant. superadmin (which has no
// session tenant) must pass ?tenant_id=; may also override to inspect any
// tenant. Returns the tenant ID and false when the request is not authorised
// or the tenant cannot be determined (the caller has already written nothing).
func (s *Server) resolveLDAPTenant(w http.ResponseWriter, r *http.Request) (int64, bool) {
sess := sessionFromCtx(r.Context())
// superadmin may target any tenant via ?tenant_id=.
if sess.Role == userstore.RoleSuperAdmin {
q := r.URL.Query().Get("tenant_id")
if q == "" {
writeError(w, http.StatusBadRequest, "tenant_id query parameter required for superadmin")
return 0, false
}
tid, err := strconv.ParseInt(q, 10, 64)
if err != nil || tid <= 0 {
writeError(w, http.StatusBadRequest, "invalid tenant_id")
return 0, false
}
return tid, true
}
// domain_admin: always scoped to its own tenant (IDOR-safe: query params
// are ignored, the tenant comes from the signed session).
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "no tenant context")
return 0, false
}
return *sess.TenantID, true
}
// handleGetLDAPConfig returns the tenant's LDAP config WITHOUT the bind
// password (only bind_password_set). Returns 404 when none is configured.
func (s *Server) handleGetLDAPConfig(w http.ResponseWriter, r *http.Request) {
if s.ldapStore == nil {
writeError(w, http.StatusServiceUnavailable, "ldap not configured on this server")
return
}
tenantID, ok := s.resolveLDAPTenant(w, r)
if !ok {
return
}
cfg, err := s.ldapStore.Get(r.Context(), tenantID)
if errors.Is(err, ldapstore.ErrNotFound) {
writeError(w, http.StatusNotFound, "no ldap config for this tenant")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "load ldap config failed")
return
}
writeJSON(w, http.StatusOK, cfg)
}
type upsertLDAPConfigRequest struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
UseTLS string `json:"use_tls"`
BindDN string `json:"bind_dn"`
BindPassword *string `json:"bind_password"` // nil = keep existing
BaseDN string `json:"base_dn"`
UserFilter string `json:"user_filter"`
AttrUsername string `json:"attr_username"`
AttrEmail string `json:"attr_email"`
AttrName string `json:"attr_name"`
GroupBaseDN string `json:"group_base_dn"`
GroupFilter string `json:"group_filter"`
AdminGroupDN string `json:"admin_group_dn"`
}
// handleUpsertLDAPConfig creates or updates the tenant's LDAP config. The bind
// password is optional (omit to keep the stored one); on creation it is
// mandatory. Every attempt — success or failure — is audit-logged.
func (s *Server) handleUpsertLDAPConfig(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if s.ldapStore == nil {
writeError(w, http.StatusServiceUnavailable, "ldap not configured on this server")
return
}
tenantID, ok := s.resolveLDAPTenant(w, r)
if !ok {
return
}
logFail := func(detail string) {
tid := tenantID
s.audlog.Log(audit.Entry{
EventType: audit.EventLdapConfigChanged, Username: sess.Username,
TenantID: &tid, Success: false, Detail: detail,
})
}
var req upsertLDAPConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logFail("invalid_body")
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Host == "" || req.BindDN == "" || req.BaseDN == "" {
logFail("missing_fields")
writeError(w, http.StatusBadRequest, "host, bind_dn and base_dn are required")
return
}
// Defaults mirroring the schema so a minimal request still yields a
// working config.
if req.UserFilter == "" {
req.UserFilter = "(uid=%s)"
}
if req.AttrUsername == "" {
req.AttrUsername = "uid"
}
if req.AttrEmail == "" {
req.AttrEmail = "mail"
}
if req.AttrName == "" {
req.AttrName = "cn"
}
cfg := ldapstore.Config{
TenantID: tenantID,
Enabled: req.Enabled,
Host: req.Host,
Port: req.Port,
UseTLS: req.UseTLS,
BindDN: req.BindDN,
BaseDN: req.BaseDN,
UserFilter: req.UserFilter,
AttrUsername: req.AttrUsername,
AttrEmail: req.AttrEmail,
AttrName: req.AttrName,
GroupBaseDN: req.GroupBaseDN,
GroupFilter: req.GroupFilter,
AdminGroupDN: req.AdminGroupDN,
}
saved, err := s.ldapStore.Upsert(r.Context(), cfg, req.BindPassword)
if err != nil {
logFail("upsert_failed")
writeError(w, http.StatusBadRequest, "save ldap config failed: "+err.Error())
return
}
tid := tenantID
s.audlog.Log(audit.Entry{
EventType: audit.EventLdapConfigChanged, Username: sess.Username,
TenantID: &tid, Success: true,
Detail: "ldap_config_saved host:" + saved.Host,
})
writeJSON(w, http.StatusOK, saved)
}
@@ -0,0 +1,154 @@
// Heuristic metadata-suggestion HTTP handlers (see
// internal/storage/metadata_suggestions.go):
//
// POST /api/documents/{id}/suggest-metadata
// GET /api/documents/{id}/suggest-metadata
// POST /api/documents/{id}/suggest-metadata/{suggestionId}/reviewed
//
// All three are normal authenticated tenant actions (s.auth). Suggestions are
// rule-based (no LLM) and NON-binding: nothing here applies a suggested field —
// accepting one goes through the normal edit endpoints (PATCH title,
// tag-attach, ...). Ownership is enforced in the store layer (id+tenant_id).
package api
import (
"errors"
"net/http"
"strconv"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// handleGenerateSuggestions handles POST /api/documents/{id}/suggest-metadata.
// Triggers a fresh heuristic suggestion run and returns the persisted result.
func (s *Server) handleGenerateSuggestions(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
docRef := strconv.FormatInt(docID, 10)
// provider selects the suggestion engine: "heuristic" (default, rule-based,
// always available) or "ollama" (external LLM, only when the tenant has it
// enabled). On an Ollama failure there is NO silent fallback to heuristic —
// the error is surfaced so the frontend knows which provider did not answer.
provider := r.URL.Query().Get("provider")
if provider == "" {
provider = "heuristic"
}
var sug *storage.MetadataSuggestion
switch provider {
case "heuristic":
sug, err = s.store.GenerateHeuristicSuggestions(r.Context(), docID, *sess.TenantID, &sess.UserID)
case "ollama":
cfg, cfgErr := s.store.GetOllamaConfig(r.Context(), *sess.TenantID)
if cfgErr != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata ollama config err:" + cfgErr.Error()})
writeError(w, http.StatusInternalServerError, "load ollama config failed")
return
}
if !cfg.Enabled {
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata ollama not enabled"})
writeError(w, http.StatusBadRequest, "ollama provider is not enabled for this tenant")
return
}
sug, err = s.store.GenerateOllamaSuggestions(r.Context(), docID, *sess.TenantID, &sess.UserID, *cfg)
case "naive_bayes":
// Trained, dependency-free ML classifier (internal/classifier). Yields no
// candidates for a kind whose model is untrained/below threshold — that is
// not an error. Any real failure is surfaced (no silent fallback).
sug, err = s.store.GenerateNaiveBayesSuggestions(r.Context(), docID, *sess.TenantID, &sess.UserID)
default:
writeError(w, http.StatusBadRequest, "unknown provider (use 'heuristic', 'ollama' or 'naive_bayes')")
return
}
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrDocumentNotFound) {
status = http.StatusNotFound
} else if provider == "ollama" {
// Ollama unreachable/timeout/bad-response: a dependency failure, not a
// server bug. 502 signals "upstream provider failed".
status = http.StatusBadGateway
}
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata provider:" + provider + " err:" + err.Error()})
writeError(w, status, "generate metadata suggestions failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: docRef, Success: true, Detail: "suggest_metadata provider:" + provider + " id:" + strconv.FormatInt(sug.ID, 10),
})
writeJSON(w, http.StatusOK, sug)
}
// handleGetLatestSuggestion handles GET /api/documents/{id}/suggest-metadata.
// Returns the most recent suggestion run for the document.
func (s *Server) handleGetLatestSuggestion(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
sug, err := s.store.GetLatestSuggestion(r.Context(), docID, *sess.TenantID)
if err != nil {
if errors.Is(err, storage.ErrSuggestionNotFound) {
writeError(w, http.StatusNotFound, "no metadata suggestion found")
return
}
writeError(w, http.StatusInternalServerError, "get metadata suggestion failed")
return
}
writeJSON(w, http.StatusOK, sug)
}
// handleMarkSuggestionReviewed handles
// POST /api/documents/{id}/suggest-metadata/{suggestionId}/reviewed. Flags a
// suggestion as reviewed (the user acted on it in the UI). Which fields were
// accepted went through the normal edit endpoints, not this call.
func (s *Server) handleMarkSuggestionReviewed(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
suggestionID, err := strconv.ParseInt(r.PathValue("suggestionId"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid suggestion id")
return
}
docRef := strconv.FormatInt(docID, 10)
if err := s.store.MarkSuggestionReviewed(r.Context(), suggestionID, *sess.TenantID, sess.UserID); err != nil {
status := http.StatusNotFound
if !errors.Is(err, storage.ErrSuggestionNotFound) {
status = http.StatusInternalServerError
}
s.audlog.Log(audit.Entry{EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docRef, Success: false, Detail: "suggest_metadata_reviewed id:" + strconv.FormatInt(suggestionID, 10) + " err:" + err.Error()})
writeError(w, status, "mark suggestion reviewed failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventSuggestionGenerated, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: docRef, Success: true, Detail: "suggest_metadata_reviewed id:" + strconv.FormatInt(suggestionID, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "reviewed"})
}
+75
View File
@@ -0,0 +1,75 @@
package api
import (
"net/http"
"strconv"
)
// ocrWordResponse is the wire shape of one OCR word box. It is a dedicated DTO
// (not storage.OCRWord, which carries no json tags and exposes the internal row
// id / document_id) so the overlay renderer gets a compact, stable payload.
// Coordinates are in the original file's coordinate space — see
// internal/ocr/coords.go.
type ocrWordResponse struct {
Text string `json:"text"`
Left int `json:"left"`
Top int `json:"top"`
Width int `json:"width"`
Height int `json:"height"`
Confidence float64 `json:"confidence"`
Page int `json:"page"`
Block int `json:"block"`
Par int `json:"par"`
Line int `json:"line"`
}
// handleListDocumentOCRWords returns the stored word-level bounding boxes of a
// document (GET /api/documents/{id}/ocr-words), Phase 3 of the OCR
// text-highlight/overlay feature.
//
// Tenant/ACL: ocr_words has no tenant_id column, access is only ever mediated
// through document_id. The handler therefore performs the exact same ownership
// check as handleDocumentAuditLog / handleGetDocumentFile — GetDocument(id,
// tenantID) filters WHERE tenant_id and yields 404 for both "unknown id" and
// "foreign tenant", so the endpoint never reveals whether a document exists
// outside the caller's tenant — BEFORE any ocr_words row is read.
//
// Pure read: no audit entry, consistent with the other document GET handlers.
// Empty result serializes as [] (never null).
func (s *Server) handleListDocumentOCRWords(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
// ACL/tenant check first — must precede the ocr_words lookup.
if _, err := s.store.GetDocument(r.Context(), id, *sess.TenantID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
words, err := s.store.ListOCRWords(r.Context(), id)
if err != nil {
s.logger.Error("list ocr words failed", "document_id", id, "tenant_id", *sess.TenantID, "err", err)
writeError(w, http.StatusInternalServerError, "list ocr words failed")
return
}
out := make([]ocrWordResponse, 0, len(words))
for _, wd := range words {
out = append(out, ocrWordResponse{
Text: wd.Word,
Left: wd.Left,
Top: wd.Top,
Width: wd.Width,
Height: wd.Height,
Confidence: wd.Confidence,
Page: wd.Page,
Block: wd.Block,
Par: wd.Par,
Line: wd.Line,
})
}
writeJSON(w, http.StatusOK, out)
}
+134
View File
@@ -0,0 +1,134 @@
// Per-tenant configuration for an EXTERNAL, already-running Ollama server
// (never installed on the archivdms host — the base URL/port comes from the
// tenant admin). Gates the optional 'ollama' metadata-suggestion provider.
//
// GET /api/ollama-config
// PUT /api/ollama-config
//
// Both are admin-only (domain_admin manages its own tenant; superadmin must
// pass ?tenant_id=), mirroring the LDAP-config and tenant-settings handlers.
// The base URL is an internal network URL, not a secret, and is returned as-is.
package api
import (
"encoding/json"
"net/http"
"time"
"archivdms/internal/audit"
"archivdms/internal/llm"
)
// handleGetOllamaConfig returns the tenant's Ollama connection config. A tenant
// that has never configured Ollama gets a zero/default (disabled) config, not a
// 404 — the frontend always renders an editable form.
func (s *Server) handleGetOllamaConfig(w http.ResponseWriter, r *http.Request) {
tenantID, ok := s.resolveTenantSettingsTenant(w, r)
if !ok {
return
}
cfg, err := s.store.GetOllamaConfig(r.Context(), tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "load ollama config failed")
return
}
writeJSON(w, http.StatusOK, cfg)
}
// handleListOllamaModels queries an Ollama server for the models actually
// installed there, so the frontend can offer a picklist instead of a free-text
// field. Live call, no caching. Prefers the not-yet-saved ?base_url= query
// param (lets the admin test a URL before hitting "Speichern"); falls back to
// the persisted config's base_url when the param is absent. 400 if neither is
// set. When the external Ollama server is unreachable the failure is the
// external dependency's, not ours → 502 Bad Gateway, not 500.
func (s *Server) handleListOllamaModels(w http.ResponseWriter, r *http.Request) {
tenantID, ok := s.resolveTenantSettingsTenant(w, r)
if !ok {
return
}
cfg, err := s.store.GetOllamaConfig(r.Context(), tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "load ollama config failed")
return
}
baseURL := r.URL.Query().Get("base_url")
if baseURL == "" {
baseURL = cfg.BaseURL
}
if baseURL == "" {
writeError(w, http.StatusBadRequest, "Server-URL muss zuerst eingetragen werden")
return
}
// Short, listing-specific timeout — independent of the (possibly long)
// generate timeout. Cap the stored value so a large generate timeout does
// not make the picklist request hang for minutes.
timeout := 10 * time.Second
if cfg.TimeoutSeconds > 0 && cfg.TimeoutSeconds < 10 {
timeout = time.Duration(cfg.TimeoutSeconds) * time.Second
}
models, err := llm.ListModels(r.Context(), baseURL, timeout)
if err != nil {
writeError(w, http.StatusBadGateway, "ollama nicht erreichbar: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string][]string{"models": models})
}
type upsertOllamaConfigRequest struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
TimeoutSeconds int `json:"timeout_seconds"`
}
// handleUpsertOllamaConfig creates or updates the tenant's Ollama connection
// config. Validation (enabled requires base_url+model, http(s) prefix, timeout
// range) lives in the store. Every attempt — success or failure — is
// audit-logged (GoBD).
func (s *Server) handleUpsertOllamaConfig(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
tenantID, ok := s.resolveTenantSettingsTenant(w, r)
if !ok {
return
}
logFail := func(detail string) {
tid := tenantID
s.audlog.Log(audit.Entry{
EventType: audit.EventOllamaConfigUpdate, Username: sess.Username,
TenantID: &tid, Success: false, Detail: detail,
})
}
var req upsertOllamaConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logFail("ollama_config invalid_body")
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := s.store.UpsertOllamaConfig(r.Context(), tenantID, req.Enabled, req.BaseURL, req.Model, req.TimeoutSeconds); err != nil {
logFail("ollama_config upsert_failed:" + err.Error())
writeError(w, http.StatusBadRequest, err.Error())
return
}
tid := tenantID
s.audlog.Log(audit.Entry{
EventType: audit.EventOllamaConfigUpdate, Username: sess.Username,
TenantID: &tid, Success: true, Detail: "ollama_config_saved",
})
// Reload so the response reflects the persisted (normalised) state.
cfg, err := s.store.GetOllamaConfig(r.Context(), tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "load ollama config failed")
return
}
writeJSON(w, http.StatusOK, cfg)
}
+458
View File
@@ -0,0 +1,458 @@
// Permission-model HTTP handlers (see internal/storage/permissions.go):
//
// POST/GET/DELETE /api/permission-groups
// GET/POST/DELETE /api/permission-groups/{id}/members
// GET/POST/DELETE /api/document-types/{id}/grants
// GET/POST/DELETE /api/tags/{id}/grants
// GET/POST/DELETE /api/documents/{id}/grants (access may be 'deny')
//
// Group and grant administration require domain_admin (s.authAdmin). Ownership
// is enforced in the store layer (id + tenant_id). Every mutation is
// audit-logged (EventPermissionGrantChanged), including failures.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// logGrant records a permission mutation, success or failure.
func (s *Server) logGrant(r *http.Request, tenantID *int64, username, detail string, ok bool) {
s.audlog.Log(audit.Entry{
EventType: audit.EventPermissionGrantChanged, Username: username, TenantID: tenantID,
IPAddress: s.remoteIP(r), Success: ok, Detail: detail,
})
}
// grantStatus maps store errors to an HTTP status.
func grantStatus(err error) int {
switch {
case errors.Is(err, storage.ErrPermissionGroupNotFound):
return http.StatusNotFound
case errors.Is(err, storage.ErrGrantNotFound):
return http.StatusNotFound
case errors.Is(err, storage.ErrDuplicatePermissionGroup):
return http.StatusConflict
default:
return http.StatusInternalServerError
}
}
// --- permission groups ---
type permissionGroupRequest struct {
Name string `json:"name"`
}
// handleListPermissionGroups handles GET /api/permission-groups.
func (s *Server) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
groups, err := s.store.ListPermissionGroups(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list permission groups failed")
return
}
writeJSON(w, http.StatusOK, groups)
}
// handleCreatePermissionGroup handles POST /api/permission-groups (domain_admin+).
func (s *Server) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req permissionGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
g, err := s.store.CreatePermissionGroup(r.Context(), *sess.TenantID, req.Name)
if err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "group_create name:"+req.Name+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "create permission group failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "group_create id:"+strconv.FormatInt(g.ID, 10)+" name:"+g.Name, true)
writeJSON(w, http.StatusCreated, g)
}
// handleDeletePermissionGroup handles DELETE /api/permission-groups/{id} (domain_admin+).
func (s *Server) handleDeletePermissionGroup(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.DeletePermissionGroup(r.Context(), id, *sess.TenantID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "group_delete id:"+strconv.FormatInt(id, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "delete permission group failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "group_delete id:"+strconv.FormatInt(id, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- group membership ---
type groupMemberRequest struct {
UserID int64 `json:"user_id"`
}
// handleAddGroupMember handles POST /api/permission-groups/{id}/members (domain_admin+).
func (s *Server) handleAddGroupMember(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
groupID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group id")
return
}
var req groupMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.UserID == 0 {
writeError(w, http.StatusBadRequest, "user_id is required")
return
}
if err := s.store.AddGroupMember(r.Context(), groupID, req.UserID, *sess.TenantID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "member_add group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(req.UserID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "add group member failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "member_add group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(req.UserID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "added"})
}
// handleRemoveGroupMember handles DELETE /api/permission-groups/{id}/members/{userId} (domain_admin+).
func (s *Server) handleRemoveGroupMember(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
groupID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group id")
return
}
userID, err := strconv.ParseInt(r.PathValue("userId"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user id")
return
}
if err := s.store.RemoveGroupMember(r.Context(), groupID, userID, *sess.TenantID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "member_remove group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(userID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "remove group member failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "member_remove group:"+strconv.FormatInt(groupID, 10)+" user:"+strconv.FormatInt(userID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
}
// handleListGroupMembers handles GET /api/permission-groups/{id}/members (domain_admin+).
func (s *Server) handleListGroupMembers(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
groupID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid group id")
return
}
members, err := s.store.ListGroupMembersDetailed(r.Context(), groupID, *sess.TenantID)
if err != nil {
writeError(w, grantStatus(err), "list group members failed")
return
}
writeJSON(w, http.StatusOK, members)
}
// --- grants (document-type / tag / document) ---
type grantRequest struct {
GroupID int64 `json:"group_id"`
Access string `json:"access"`
}
// handleSetDocumentTypeGrant handles POST /api/document-types/{id}/grants (domain_admin+).
func (s *Server) handleSetDocumentTypeGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document type id")
return
}
var req grantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Access == "" {
req.Access = "read"
}
if err := s.store.SetDocumentTypeGrant(r.Context(), *sess.TenantID, docTypeID, req.GroupID, req.Access); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_set type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatusValidated(err), "set document type grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_set type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" access:"+req.Access, true)
writeJSON(w, http.StatusOK, map[string]string{"status": "granted"})
}
// handleListDocumentTypeGrants handles GET /api/document-types/{id}/grants (domain_admin+).
func (s *Server) handleListDocumentTypeGrants(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document type id")
return
}
grants, err := s.store.ListDocumentTypeGrants(r.Context(), *sess.TenantID, docTypeID)
if err != nil {
writeError(w, grantStatus(err), "list document type grants failed")
return
}
writeJSON(w, http.StatusOK, grants)
}
// handleDeleteDocumentTypeGrant handles DELETE /api/document-types/{id}/grants (domain_admin+).
func (s *Server) handleDeleteDocumentTypeGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docTypeID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document type id")
return
}
groupID, err := grantGroupID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "group_id is required")
return
}
if err := s.store.DeleteDocumentTypeGrant(r.Context(), *sess.TenantID, docTypeID, groupID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_delete type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(groupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "delete document type grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "type_grant_delete type:"+strconv.FormatInt(docTypeID, 10)+" group:"+strconv.FormatInt(groupID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// handleSetTagGrant handles POST /api/tags/{id}/grants (domain_admin+).
func (s *Server) handleSetTagGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
tagID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tag id")
return
}
var req grantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Access == "" {
req.Access = "read"
}
if err := s.store.SetTagGrant(r.Context(), *sess.TenantID, tagID, req.GroupID, req.Access); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_set tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatusValidated(err), "set tag grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_set tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" access:"+req.Access, true)
writeJSON(w, http.StatusOK, map[string]string{"status": "granted"})
}
// handleListTagGrants handles GET /api/tags/{id}/grants (domain_admin+).
func (s *Server) handleListTagGrants(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
tagID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tag id")
return
}
grants, err := s.store.ListTagGrants(r.Context(), *sess.TenantID, tagID)
if err != nil {
writeError(w, grantStatus(err), "list tag grants failed")
return
}
writeJSON(w, http.StatusOK, grants)
}
// handleDeleteTagGrant handles DELETE /api/tags/{id}/grants (domain_admin+).
func (s *Server) handleDeleteTagGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
tagID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tag id")
return
}
groupID, err := grantGroupID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "group_id is required")
return
}
if err := s.store.DeleteTagGrant(r.Context(), *sess.TenantID, tagID, groupID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_delete tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(groupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "delete tag grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "tag_grant_delete tag:"+strconv.FormatInt(tagID, 10)+" group:"+strconv.FormatInt(groupID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// handleSetDocumentGrant handles POST /api/documents/{id}/grants (domain_admin+).
// access may be 'read', 'write' or 'deny'.
func (s *Server) handleSetDocumentGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
var req grantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Access == "" {
writeError(w, http.StatusBadRequest, "access is required (read|write|deny)")
return
}
if err := s.store.SetDocumentGrant(r.Context(), *sess.TenantID, docID, req.GroupID, sess.UserID, req.Access); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_set doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatusValidated(err), "set document grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_set doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(req.GroupID, 10)+" access:"+req.Access, true)
writeJSON(w, http.StatusOK, map[string]string{"status": "granted"})
}
// handleListDocumentGrants handles GET /api/documents/{id}/grants (domain_admin+).
// access may be 'read', 'write' or 'deny'.
func (s *Server) handleListDocumentGrants(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
grants, err := s.store.ListDocumentGrants(r.Context(), *sess.TenantID, docID)
if err != nil {
writeError(w, grantStatus(err), "list document grants failed")
return
}
writeJSON(w, http.StatusOK, grants)
}
// handleDeleteDocumentGrant handles DELETE /api/documents/{id}/grants (domain_admin+).
func (s *Server) handleDeleteDocumentGrant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
groupID, err := grantGroupID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "group_id is required")
return
}
if err := s.store.DeleteDocumentGrant(r.Context(), *sess.TenantID, docID, groupID); err != nil {
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_delete doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(groupID, 10)+" err:"+err.Error(), false)
writeError(w, grantStatus(err), "delete document grant failed")
return
}
s.logGrant(r, sess.TenantID, sess.Username, "doc_grant_delete doc:"+strconv.FormatInt(docID, 10)+" group:"+strconv.FormatInt(groupID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// grantGroupID resolves the target group for a grant DELETE, from either the
// ?group_id query param or a JSON body {"group_id": N}.
func grantGroupID(r *http.Request) (int64, error) {
if q := r.URL.Query().Get("group_id"); q != "" {
return strconv.ParseInt(q, 10, 64)
}
var req grantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return 0, err
}
if req.GroupID == 0 {
return 0, errors.New("group_id is required")
}
return req.GroupID, nil
}
// grantStatusValidated maps an "invalid access" validation error to 400,
// otherwise defers to grantStatus.
func grantStatusValidated(err error) int {
if err == nil {
return http.StatusInternalServerError
}
if strings.Contains(err.Error(), "invalid access") {
return http.StatusBadRequest
}
return grantStatus(err)
}
+122
View File
@@ -0,0 +1,122 @@
package api
import (
"errors"
"net/http"
"strconv"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// processingJobResponse ist die schlanke Sicht auf einen Queue-Job, die das
// Frontend für Statusbadge und Retry-Button braucht. Bewusst nicht der volle
// storage.ProcessingJob: interne Felder (derive_title, next_attempt_at,
// tenant_id) haben in der UI nichts zu suchen.
type processingJobResponse struct {
DocumentID int64 `json:"document_id"`
Status string `json:"status"`
RetryCount int `json:"retry_count"`
ErrorMessage string `json:"error_message,omitempty"`
}
// handleGetProcessingJob liefert den Verarbeitungsstatus eines Dokuments
// (GET /api/documents/{id}/processing-job).
//
// ACL wie bei allen Dokument-Sub-Routen: erst GetDocument(id, tenantID) — der
// tenant_id-Filter dort ist der IDOR-Guard, ein fremdes Dokument liefert 404
// noch bevor irgendein Job gelesen wird.
//
// Hat ein Dokument keinen Job (kompletter Altbestand vor Einführung der
// Queue), wird KEIN 404 geliefert, sondern der processing_status des
// Dokuments selbst (Spalten-Default 'done'). Damit muss das Frontend keinen
// Sonderfall kennen: es bekommt immer einen Status.
func (s *Server) handleGetProcessingJob(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
tenantID := *sess.TenantID
doc, err := s.store.GetDocument(r.Context(), id, tenantID)
if err != nil {
if errors.Is(err, storage.ErrDocumentNotFound) {
writeError(w, http.StatusNotFound, "document not found")
return
}
writeError(w, http.StatusInternalServerError, "load document failed")
return
}
job, err := s.store.GetJobForDocument(r.Context(), id, tenantID)
if err != nil {
if errors.Is(err, storage.ErrNoJob) {
status := doc.ProcessingStatus
if status == "" {
status = storage.JobStatusDone
}
writeJSON(w, http.StatusOK, processingJobResponse{DocumentID: id, Status: status})
return
}
writeError(w, http.StatusInternalServerError, "load processing job failed")
return
}
writeJSON(w, http.StatusOK, processingJobResponse{
DocumentID: id,
Status: job.Status,
RetryCount: job.RetryCount,
ErrorMessage: job.ErrorMessage,
})
}
// handleRetryProcessingJob stellt einen dauerhaft fehlgeschlagenen Job manuell
// zurück in die Queue (POST /api/documents/{id}/processing-job/retry).
//
// Nur aus dem Status 'failed' heraus erlaubt — ein laufender oder bereits
// fertiger Job darf nicht zurückgesetzt werden (409), sonst könnte ein Klick
// eine gerade laufende Verarbeitung doppelt anstoßen. Der eigentliche Retry
// läuft danach ganz normal über den Dispatcher.
func (s *Server) handleRetryProcessingJob(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
tenantID := *sess.TenantID
if _, err := s.store.GetDocument(r.Context(), id, tenantID); err != nil {
if errors.Is(err, storage.ErrDocumentNotFound) {
writeError(w, http.StatusNotFound, "document not found")
return
}
writeError(w, http.StatusInternalServerError, "load document failed")
return
}
job, err := s.store.GetJobForDocument(r.Context(), id, tenantID)
if err != nil {
if errors.Is(err, storage.ErrNoJob) {
writeError(w, http.StatusNotFound, "no processing job for document")
return
}
writeError(w, http.StatusInternalServerError, "load processing job failed")
return
}
if job.Status != storage.JobStatusFailed {
writeError(w, http.StatusConflict, "processing job is not in failed state")
return
}
if err := s.store.RequeueJob(r.Context(), job.ID, tenantID); err != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentProcessed, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "manual retry failed: " + err.Error()})
writeError(w, http.StatusInternalServerError, "requeue failed")
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentProcessed, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "manual retry requeued job_id=" + strconv.FormatInt(job.ID, 10)})
writeJSON(w, http.StatusOK, processingJobResponse{DocumentID: id, Status: storage.JobStatusQueued})
}
+281
View File
@@ -0,0 +1,281 @@
// Public (unauthenticated) share-link handlers. These are wired into the mux
// WITHOUT the s.auth middleware (see server.go): the share token itself is the
// only credential. Every lookup goes through the SHA-256 token_hash, never an
// id; every attempt is rate-limited per client IP and recorded in
// document_share_accesses (and, for downloads, the audit log). The archived
// file is streamed straight from the WORM store — storage_path/content_hash are
// never exposed to the client.
//
// GET /public/share/{token} metadata (title, whether a password is needed)
// POST /public/share/{token}/download body optional {password}; streams the file
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// publicShareMeta is the safe, minimal public view of a share.
type publicShareMeta struct {
Title string `json:"title"`
RequiresPassword bool `json:"requires_password"`
ExpiresAt time.Time `json:"expires_at"`
}
// publicDownloadRequest is the optional JSON body for the download endpoint.
type publicDownloadRequest struct {
Password string `json:"password"`
}
// handlePublicShareMeta handles GET /public/share/{token}. It reveals only the
// document title, whether a password is required, and the expiry — never the
// file. Revoked/expired/max-reached shares are reported as such but never leak
// the title.
func (s *Server) handlePublicShareMeta(w http.ResponseWriter, r *http.Request) {
ip := s.remoteIP(r)
if !s.shareLimiter.allow(ip) {
writeError(w, http.StatusTooManyRequests, "too many requests")
return
}
token := r.PathValue("token")
rs, err := s.store.ResolveShareByToken(r.Context(), token)
if err != nil {
// Unknown token: indistinguishable 404, nothing to log (no share_id).
writeError(w, http.StatusNotFound, "share not found")
return
}
if _, stateErr := rs.VerifyState(time.Now()); stateErr != nil {
writeError(w, shareStateStatus(stateErr), shareStateMessage(stateErr))
return
}
writeJSON(w, http.StatusOK, publicShareMeta{
Title: rs.DocumentTitle,
RequiresPassword: rs.HasPassword(),
ExpiresAt: rs.ExpiresAt(),
})
}
// handlePublicShareDownload handles POST /public/share/{token}/download. Check
// order: rate-limit -> resolve -> revoked -> expired -> max_accesses ->
// password -> deliver (atomic access_count++). Every branch records an access
// row and the terminal outcome is audit-logged (EventShareAccessed).
func (s *Server) handlePublicShareDownload(w http.ResponseWriter, r *http.Request) {
ip := s.remoteIP(r)
token := r.PathValue("token")
rs, err := s.store.ResolveShareByToken(r.Context(), token)
if err != nil {
// Unknown token: 404, no share to attach an access row to.
writeError(w, http.StatusNotFound, "share not found")
return
}
// Rate limit now that we have a share_id to log a 'rate_limited' attempt.
if !s.shareLimiter.allow(ip) {
s.recordShareAccess(r, rs, ip, storage.ShareResultRateLimited)
writeError(w, http.StatusTooManyRequests, "too many requests")
return
}
if result, stateErr := rs.VerifyState(time.Now()); stateErr != nil {
s.recordShareAccess(r, rs, ip, result)
writeError(w, shareStateStatus(stateErr), shareStateMessage(stateErr))
return
}
var body publicDownloadRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
}
if err := rs.VerifyPassword(body.Password); err != nil {
s.recordShareAccess(r, rs, ip, storage.ShareResultBadPassword)
writeError(w, http.StatusUnauthorized, "password required or incorrect")
return
}
// Atomically claim one access slot (closes the max_accesses race).
ok, err := s.store.IncrementShareAccess(r.Context(), rs.ShareID())
if err != nil {
s.logger.Error("share access increment failed", "share_id", rs.ShareID(), "err", err)
writeError(w, http.StatusInternalServerError, "download failed")
return
}
if !ok {
// Lost the race (revoked/expired/max between our check and the update).
s.recordShareAccess(r, rs, ip, storage.ShareResultMaxReached)
writeError(w, http.StatusForbidden, "share no longer available")
return
}
f, err := os.Open(rs.StoragePath())
if err != nil {
s.logger.Error("share file open failed", "share_id", rs.ShareID(), "err", err)
writeError(w, http.StatusInternalServerError, "download failed")
return
}
defer f.Close()
s.recordShareAccess(r, rs, ip, storage.ShareResultSuccess)
ext := filepath.Ext(rs.StoragePath())
w.Header().Set("Content-Type", detectMimeType("", ext, rs.StoragePath()))
w.Header().Set("Content-Disposition", "attachment; filename=\""+safeDownloadName(rs.DocumentTitle, ext)+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
if _, err := io.Copy(w, f); err != nil {
s.logger.Warn("share file stream interrupted", "share_id", rs.ShareID(), "err", err)
}
}
// recordShareAccess writes the per-attempt access row and mirrors the outcome
// into the audit log (EventShareAccessed). Never blocks the response path.
func (s *Server) recordShareAccess(r *http.Request, rs *storage.ResolvedShare, ip, result string) {
if err := s.store.LogShareAccess(r.Context(), rs.ShareID(), ip, r.UserAgent(), result); err != nil {
s.logger.Error("share access log failed", "share_id", rs.ShareID(), "err", err)
}
tenantID := rs.TenantID()
s.audlog.Log(audit.Entry{
EventType: audit.EventShareAccessed,
Username: "public",
IPAddress: ip,
TenantID: &tenantID,
DocumentID: strconv.FormatInt(rs.DocumentID(), 10),
Success: result == storage.ShareResultSuccess,
Detail: "share:" + strconv.FormatInt(rs.ShareID(), 10) + " result:" + result,
})
}
// shareStateStatus maps a share-state error to an HTTP status.
func shareStateStatus(err error) int {
switch {
case errors.Is(err, storage.ErrShareRevoked):
return http.StatusForbidden
case errors.Is(err, storage.ErrShareExpired):
return http.StatusGone
case errors.Is(err, storage.ErrShareMaxReached):
return http.StatusForbidden
default:
return http.StatusForbidden
}
}
func shareStateMessage(err error) string {
switch {
case errors.Is(err, storage.ErrShareRevoked):
return "share revoked"
case errors.Is(err, storage.ErrShareExpired):
return "share expired"
case errors.Is(err, storage.ErrShareMaxReached):
return "share access limit reached"
default:
return "share not available"
}
}
// safeDownloadName builds a Content-Disposition filename from the document
// title, stripping anything that could break the header or the client's
// filesystem, and appending the stored extension.
func safeDownloadName(title, ext string) string {
title = strings.TrimSpace(title)
if title == "" {
title = "document"
}
var b strings.Builder
for _, ch := range title {
switch {
case ch >= 'a' && ch <= 'z', ch >= 'A' && ch <= 'Z', ch >= '0' && ch <= '9':
b.WriteRune(ch)
case ch == '-', ch == '_', ch == '.', ch == ' ':
b.WriteRune(ch)
default:
b.WriteRune('_')
}
}
name := strings.TrimSpace(b.String())
if name == "" {
name = "document"
}
if ext != "" && !strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) {
name += ext
}
return name
}
// --- per-IP token-bucket rate limiter ---
// ipRateLimiter is a minimal in-memory per-IP token-bucket limiter (no external
// dependency). Each IP gets its own bucket of `burst` tokens, refilled at
// `refillPerSec` tokens per second. Buckets are created lazily and swept when
// they have been idle and full for a while.
type ipRateLimiter struct {
mu sync.Mutex
buckets map[string]*tokenBucket
burst float64
refillPerSec float64
lastSweep time.Time
}
type tokenBucket struct {
tokens float64
last time.Time
}
func newIPRateLimiter(burst, refillPerSec float64) *ipRateLimiter {
return &ipRateLimiter{
buckets: make(map[string]*tokenBucket),
burst: burst,
refillPerSec: refillPerSec,
lastSweep: time.Now(),
}
}
// allow consumes one token for ip, returning false when the bucket is empty.
func (l *ipRateLimiter) allow(ip string) bool {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
l.sweepLocked(now)
b, ok := l.buckets[ip]
if !ok {
b = &tokenBucket{tokens: l.burst, last: now}
l.buckets[ip] = b
}
// Refill based on elapsed time.
elapsed := now.Sub(b.last).Seconds()
b.tokens += elapsed * l.refillPerSec
if b.tokens > l.burst {
b.tokens = l.burst
}
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// sweepLocked drops idle, full buckets roughly once a minute to bound memory.
func (l *ipRateLimiter) sweepLocked(now time.Time) {
if now.Sub(l.lastSweep) < time.Minute {
return
}
l.lastSweep = now
for ip, b := range l.buckets {
if now.Sub(b.last) > 10*time.Minute {
delete(l.buckets, ip)
}
}
}
+171
View File
@@ -0,0 +1,171 @@
// Wiedervorlage (reminder) HTTP handlers:
// POST /api/documents/{id}/reminders
// GET /api/reminders?status=
// PATCH /api/reminders/{id}
// DELETE /api/reminders/{id}
//
// All routes require s.auth(...) (authenticated + tenant context). Ownership
// is enforced in the store layer (id+tenant_id+user_id). Every mutation is
// audit-logged, including failures.
package api
import (
"encoding/json"
"net/http"
"strconv"
"time"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
type createReminderRequest struct {
DueDate string `json:"due_date"` // RFC3339
Note string `json:"note"`
}
// handleCreateReminder handles POST /api/documents/{id}/reminders.
func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
var req createReminderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
dueDate, err := time.Parse(time.RFC3339, req.DueDate)
if err != nil {
writeError(w, http.StatusBadRequest, "due_date must be RFC3339")
return
}
// Verify the document exists and belongs to the caller's tenant before
// attaching a reminder to it (the FK alone would only stop a fully
// nonexistent document_id, not a cross-tenant one).
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
rem, err := s.store.CreateReminder(r.Context(), docID, *sess.TenantID, sess.UserID, dueDate, req.Note)
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventReminderCreate, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: false, Detail: err.Error(),
})
writeError(w, http.StatusInternalServerError, "create reminder failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventReminderCreate, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: true,
Detail: "reminder_id:" + strconv.FormatInt(rem.ID, 10),
})
writeJSON(w, http.StatusCreated, rem)
}
// handleListReminders handles GET /api/reminders?status=.
func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
status := r.URL.Query().Get("status")
if status != "" && status != storage.ReminderStatusOpen && status != storage.ReminderStatusDone && status != storage.ReminderStatusDismissed {
writeError(w, http.StatusBadRequest, "invalid status filter")
return
}
reminders, err := s.store.ListReminders(r.Context(), *sess.TenantID, sess.UserID, status)
if err != nil {
writeError(w, http.StatusInternalServerError, "list reminders failed")
return
}
writeJSON(w, http.StatusOK, reminders)
}
type updateReminderRequest struct {
Status string `json:"status"`
}
// handleUpdateReminder handles PATCH /api/reminders/{id}.
func (s *Server) handleUpdateReminder(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid reminder id")
return
}
var req updateReminderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
switch req.Status {
case storage.ReminderStatusOpen, storage.ReminderStatusDone, storage.ReminderStatusDismissed:
default:
writeError(w, http.StatusBadRequest, "invalid status")
return
}
rem, err := s.store.UpdateReminderStatus(r.Context(), id, *sess.TenantID, sess.UserID, req.Status)
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventReminderStatusChange, Username: sess.Username, TenantID: sess.TenantID,
Detail: "reminder_id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(), Success: false,
})
writeError(w, http.StatusNotFound, "reminder not found")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventReminderStatusChange, Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(rem.DocumentID, 10), Success: true,
Detail: "reminder_id:" + strconv.FormatInt(rem.ID, 10) + " status:" + rem.Status,
})
writeJSON(w, http.StatusOK, rem)
}
// handleDeleteReminder handles DELETE /api/reminders/{id}.
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid reminder id")
return
}
if err := s.store.DeleteReminder(r.Context(), id, *sess.TenantID, sess.UserID); err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventReminderDelete, Username: sess.Username, TenantID: sess.TenantID,
Detail: "reminder_id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(), Success: false,
})
writeError(w, http.StatusNotFound, "reminder not found")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventReminderDelete, Username: sess.Username, TenantID: sess.TenantID,
Detail: "reminder_id:" + strconv.FormatInt(id, 10), Success: true,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
+247
View File
@@ -0,0 +1,247 @@
// GoBD retention-rule ("Aufbewahrungsregeln") HTTP handlers (see
// internal/storage/retention_rules.go):
//
// GET /api/retention-rules list all rules of the tenant
// POST /api/retention-rules create a rule
// PATCH /api/retention-rules/{id} update a rule
// DELETE /api/retention-rules/{id} delete a rule
// GET /api/retention-rules/eligible documents eligible for disposition
// GET /api/retention-rules/preview dry-run of ApplyRetentionRules (no write)
//
// Rules are compliance-critical (they define how long documents must be kept),
// so create/update/delete require domain_admin (s.authAdmin). Reading (list,
// eligible, preview) is a normal tenant action (s.auth). Ownership is enforced
// in the store layer (id+tenant_id). Every mutation is audit-logged, including
// failures.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// retentionRuleRequest is the JSON body for create/update. Pointers where the
// store field is a pointer, so "not set" round-trips correctly.
type retentionRuleRequest struct {
DocTypeID *int64 `json:"doc_type_id"`
Name string `json:"name"`
TriggerType string `json:"trigger_type"`
TriggerReference string `json:"trigger_reference"`
RetentionYears *int `json:"retention_years"`
RetentionDays *int `json:"retention_days"`
LegalBasis string `json:"legal_basis"`
RequiresApprovalForDestroy *bool `json:"requires_approval_for_destroy"`
DSGVOConflict *bool `json:"dsgvo_conflict"`
Active *bool `json:"active"`
}
// toRule maps the request onto a storage.RetentionRule. requires_approval and
// active default to true when omitted (safe GoBD default: keep approval on).
func (req retentionRuleRequest) toRule() storage.RetentionRule {
requiresApproval := true
if req.RequiresApprovalForDestroy != nil {
requiresApproval = *req.RequiresApprovalForDestroy
}
active := true
if req.Active != nil {
active = *req.Active
}
dsgvo := false
if req.DSGVOConflict != nil {
dsgvo = *req.DSGVOConflict
}
return storage.RetentionRule{
DocTypeID: req.DocTypeID,
Name: req.Name,
TriggerType: req.TriggerType,
TriggerReference: req.TriggerReference,
RetentionYears: req.RetentionYears,
RetentionDays: req.RetentionDays,
LegalBasis: req.LegalBasis,
RequiresApprovalForDestroy: requiresApproval,
DSGVOConflict: dsgvo,
Active: active,
}
}
// handleListRetentionRules handles GET /api/retention-rules.
func (s *Server) handleListRetentionRules(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
rules, err := s.store.ListRetentionRules(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list retention rules failed")
return
}
writeJSON(w, http.StatusOK, rules)
}
// handleCreateRetentionRule handles POST /api/retention-rules (domain_admin+).
func (s *Server) handleCreateRetentionRule(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req retentionRuleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
rule := req.toRule()
rule.CreatedBy = &sess.UserID
created, err := s.store.CreateRetentionRule(r.Context(), *sess.TenantID, rule)
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventRetentionRuleCreate, Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "retention_rule_create err:" + err.Error(),
})
writeError(w, retentionRuleErrStatus(err), retentionRuleErrMsg(err, "create retention rule failed"))
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventRetentionRuleCreate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "retention_rule_create id:" + strconv.FormatInt(created.ID, 10) + " name:" + created.Name,
})
writeJSON(w, http.StatusCreated, created)
}
// handleUpdateRetentionRule handles PATCH /api/retention-rules/{id} (domain_admin+).
func (s *Server) handleUpdateRetentionRule(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req retentionRuleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
updated, err := s.store.UpdateRetentionRule(r.Context(), id, *sess.TenantID, req.toRule())
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventRetentionRuleUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "retention_rule_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
})
writeError(w, retentionRuleErrStatus(err), retentionRuleErrMsg(err, "update retention rule failed"))
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventRetentionRuleUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "retention_rule_update id:" + strconv.FormatInt(id, 10),
})
writeJSON(w, http.StatusOK, updated)
}
// handleDeleteRetentionRule handles DELETE /api/retention-rules/{id} (domain_admin+).
func (s *Server) handleDeleteRetentionRule(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.DeleteRetentionRule(r.Context(), id, *sess.TenantID); err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventRetentionRuleDelete, Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "retention_rule_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
})
writeError(w, retentionRuleErrStatus(err), retentionRuleErrMsg(err, "delete retention rule failed"))
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventRetentionRuleDelete, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "retention_rule_delete id:" + strconv.FormatInt(id, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// handleListEligibleForDisposition handles GET /api/retention-rules/eligible:
// documents whose retention has expired but which are not yet in the trash.
func (s *Server) handleListEligibleForDisposition(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docs, err := s.store.ListEligibleForDisposition(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list eligible-for-disposition documents failed")
return
}
writeJSON(w, http.StatusOK, docs)
}
// handlePreviewRetentionRules handles GET /api/retention-rules/preview: a
// dry-run of ApplyRetentionRules for the current tenant. No writes.
func (s *Server) handlePreviewRetentionRules(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
preview, err := s.store.PreviewRetentionRules(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "preview retention rules failed")
return
}
if preview == nil {
preview = []storage.RetentionPreview{}
}
writeJSON(w, http.StatusOK, preview)
}
// retentionRuleErrStatus maps store errors to HTTP status codes. Validation
// errors (bad trigger_type, missing retention period, bad fixed_date) surface as
// 400; not-found as 404; everything else 500.
func retentionRuleErrStatus(err error) int {
if errors.Is(err, storage.ErrRetentionRuleNotFound) {
return http.StatusNotFound
}
if isRetentionValidationErr(err) {
return http.StatusBadRequest
}
return http.StatusInternalServerError
}
// retentionRuleErrMsg returns the validation message verbatim (safe, no PII) so
// the frontend can show it, or a generic fallback otherwise.
func retentionRuleErrMsg(err error, fallback string) string {
if errors.Is(err, storage.ErrRetentionRuleNotFound) {
return "retention rule not found"
}
if isRetentionValidationErr(err) {
return err.Error()
}
return fallback
}
// isRetentionValidationErr reports whether err is a validateRetentionRule
// cross-field error (all prefixed "retention rule:" in the store).
func isRetentionValidationErr(err error) bool {
if err == nil || errors.Is(err, storage.ErrRetentionRuleNotFound) {
return false
}
msg := err.Error()
const prefix = "retention rule: "
return len(msg) >= len(prefix) && msg[:len(prefix)] == prefix
}
+140
View File
@@ -0,0 +1,140 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// handleListSavedViews serves GET /api/saved-views — the caller's own saved
// search views plus every view shared tenant-wide (is_shared). Pure read, not
// audited (consistent with the rest of the project).
func (s *Server) handleListSavedViews(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
views, err := s.store.ListSavedViews(r.Context(), *sess.TenantID, sess.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list saved views failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"views": views})
}
// savedViewRequest is the create/update body. Filters carries the serialized
// search query verbatim (see index.SearchQuery / handleSearchDocuments); it is
// stored as-is in the saved_views.filters JSONB column so the client can
// re-hydrate it 1:1 into a new search request.
type savedViewRequest struct {
Name string `json:"name"`
Filters json.RawMessage `json:"filters"`
IsShared bool `json:"is_shared"`
}
// handleCreateSavedView serves POST /api/saved-views.
func (s *Server) handleCreateSavedView(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req savedViewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
// Default an empty/omitted filters payload to an empty JSON object so the
// NOT NULL JSONB column always receives valid JSON.
if len(req.Filters) == 0 {
req.Filters = json.RawMessage("{}")
}
view, err := s.store.CreateSavedView(r.Context(), *sess.TenantID, sess.UserID, name, req.Filters, req.IsShared)
if err != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: err.Error()})
writeError(w, http.StatusInternalServerError, "create saved view failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventSavedViewCreate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "saved_view_id=" + strconv.FormatInt(view.ID, 10),
})
writeJSON(w, http.StatusCreated, view)
}
// handleUpdateSavedView serves PATCH /api/saved-views/{id}. Only the view's
// creator may update it (403 otherwise).
func (s *Server) handleUpdateSavedView(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid saved view id")
return
}
var req savedViewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if len(req.Filters) == 0 {
req.Filters = json.RawMessage("{}")
}
if err := s.store.UpdateSavedView(r.Context(), id, *sess.TenantID, sess.UserID, name, req.Filters, req.IsShared); err != nil {
status, msg := savedViewErrStatus(err, "update saved view failed")
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
// handleDeleteSavedView serves DELETE /api/saved-views/{id}. Only the view's
// creator may delete it (403 otherwise).
func (s *Server) handleDeleteSavedView(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "invalid saved view id")
return
}
if err := s.store.DeleteSavedView(r.Context(), id, *sess.TenantID, sess.UserID); err != nil {
status, msg := savedViewErrStatus(err, "delete saved view failed")
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventSavedViewDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// savedViewErrStatus maps store errors to an HTTP status + message: not-found
// -> 404, forbidden (exists but owned by another user) -> 403, else 500.
func savedViewErrStatus(err error, defaultMsg string) (int, string) {
switch {
case errors.Is(err, storage.ErrSavedViewNotFound):
return http.StatusNotFound, "saved view not found"
case errors.Is(err, storage.ErrSavedViewForbidden):
return http.StatusForbidden, "not allowed to modify this saved view"
default:
return http.StatusInternalServerError, defaultMsg
}
}
+122
View File
@@ -0,0 +1,122 @@
package api
import (
"errors"
"net/http"
"strconv"
"strings"
"archivdms/internal/auth"
"archivdms/internal/index"
"archivdms/internal/storage"
"archivdms/internal/userstore"
)
// handleSearchDocuments serves GET /api/documents/search — full-text +
// attribute search backed by the per-tenant Manticore index.
//
// q full-text term (matched against title/ocr_text/tags/
// correspondent/doc_type). Optional; when empty the query
// degrades to a filter-only listing ordered by recency.
// tag repeatable tag id filter (documents carrying ANY given tag).
// doc_type_id restrict to a single document type.
// page 1-based page number (default 1).
// page_size hits per page (default 20, capped at 100).
//
// ACL: role 'user' is filtered against their permission-group memberships
// (ANY(acl_group_ids)); domain_admin/superadmin bypass the ACL, exactly like
// handleListDocuments. When the search backend is not configured the endpoint
// returns 503 rather than a silent empty result.
func (s *Server) handleSearchDocuments(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
tenantID := *sess.TenantID
q := index.SearchQuery{
Query: strings.TrimSpace(r.URL.Query().Get("q")),
Page: parsePositiveInt(r.URL.Query().Get("page"), 1),
PageSize: clampInt(parsePositiveInt(r.URL.Query().Get("page_size"), 20), 1, 100),
TagIDs: parseInt64List(r.URL.Query()["tag"]),
DocTypeID: parseOptionalInt64(r.URL.Query().Get("doc_type_id")),
}
// Roles are the outer ACL boundary (see handleListDocuments): role 'user'
// is filtered against document_visibility via their group memberships;
// domain_admin/superadmin see every document in the tenant (ACLGroupIDs nil).
if !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
groupIDs, err := s.store.ListGroupIDsForUser(r.Context(), sess.UserID, tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "search failed")
return
}
// Non-nil (possibly empty) slice => ACL filter applies. An empty slice
// means the user is in no group and therefore sees nothing.
q.ACLGroupIDs = groupIDs
}
res, err := s.store.SearchDocuments(r.Context(), tenantID, q)
if err != nil {
if errors.Is(err, storage.ErrSearchUnavailable) {
writeError(w, http.StatusServiceUnavailable, "Suche nicht verfügbar, Manticore nicht konfiguriert")
return
}
writeError(w, http.StatusInternalServerError, "search failed")
return
}
writeJSON(w, http.StatusOK, res)
}
// --- small query-param parsing helpers ---
func parsePositiveInt(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil || n <= 0 {
return def
}
return n
}
func clampInt(n, min, max int) int {
if n < min {
return min
}
if n > max {
return max
}
return n
}
func parseOptionalInt64(s string) *int64 {
if s == "" {
return nil
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return nil
}
return &n
}
// parseInt64List parses a slice of query values (each possibly comma-separated)
// into positive int64 ids, silently dropping anything unparseable.
func parseInt64List(vals []string) []int64 {
var out []int64
for _, v := range vals {
for _, part := range strings.Split(v, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if n, err := strconv.ParseInt(part, 10, 64); err == nil && n > 0 {
out = append(out, n)
}
}
}
return out
}
+554
View File
@@ -0,0 +1,554 @@
// 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-<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)
// 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.
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
}
+116
View File
@@ -0,0 +1,116 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// --- Admin CRUD for per-tenant SFTP credentials (internal/sftpserver) ---
//
// Registered behind s.authAdmin (domain_admin/superadmin), tenant-scoped the
// same way handleListUsers/handleCreateUser are: sess.TenantID (from the
// domain_admin's own session) picks the tenant for domain admins, while a
// tenant-less (superadmin) session must specify tenant_id explicitly on
// create and cannot list/revoke without one — SFTP credentials always
// belong to exactly one tenant's inbox.
type createSFTPCredentialRequest struct {
Username string `json:"username"`
TenantID *int64 `json:"tenant_id,omitempty"` // required only for tenant-less (superadmin) sessions
}
type sftpCredentialCreatedResponse struct {
storage.SFTPCredential
Password string `json:"password"` // returned exactly once, in this response only
}
func (s *Server) handleCreateSFTPCredential(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
var req createSFTPCredentialRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Username == "" {
writeError(w, http.StatusBadRequest, "username is required")
return
}
tenantID := sess.TenantID
if tenantID == nil {
tenantID = req.TenantID
}
if tenantID == nil {
writeError(w, http.StatusBadRequest, "tenant_id is required for tenant-less sessions")
return
}
password, err := randomSFTPPassword()
if err != nil {
writeError(w, http.StatusInternalServerError, "generate password failed")
return
}
cred, err := s.store.CreateSFTPCredential(r.Context(), *tenantID, req.Username, password)
if err != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventSFTPCredentialCreate, Username: sess.Username, TenantID: tenantID, Success: false, Detail: err.Error()})
writeError(w, http.StatusBadRequest, "create sftp credential failed (username may already be taken)")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventSFTPCredentialCreate, Username: sess.Username, TenantID: tenantID,
Success: true, Detail: "sftp_username:" + cred.Username,
})
writeJSON(w, http.StatusCreated, sftpCredentialCreatedResponse{SFTPCredential: *cred, Password: password})
}
func (s *Server) handleListSFTPCredentials(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
creds, err := s.store.ListSFTPCredentials(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list sftp credentials failed")
return
}
writeJSON(w, http.StatusOK, creds)
}
func (s *Server) handleRevokeSFTPCredential(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid credential id")
return
}
if err := s.store.RevokeSFTPCredential(r.Context(), id, *sess.TenantID); err != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventSFTPCredentialRevoke, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, http.StatusNotFound, "sftp credential not found")
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventSFTPCredentialRevoke, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
func randomSFTPPassword() (string, error) {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+159
View File
@@ -0,0 +1,159 @@
// Authenticated share-link handlers (see internal/storage/shares.go and the
// public counterpart in public_share_handlers.go):
//
// POST /api/documents/{id}/shares create a share (expires_at required)
// GET /api/documents/{id}/shares list shares for a document
// DELETE /api/shares/{share_id} revoke a share (soft, never hard-delete)
// GET /api/shares all shares of the tenant (domain_admin+)
//
// Ownership is enforced in the store layer (document/share id + tenant_id), the
// same IDOR guard used by the other document endpoints. Every create/revoke is
// audit-logged (EventShareCreated/EventShareRevoked), including failures. The
// raw token is returned exactly once, in the create response.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// createShareRequest is the POST body for creating a share. ExpiresAt is
// mandatory (no unbounded shares); MaxAccesses and Password are optional.
type createShareRequest struct {
ExpiresAt time.Time `json:"expires_at"`
MaxAccesses *int `json:"max_accesses,omitempty"`
Password string `json:"password,omitempty"`
}
// createShareResponse embeds the stored share plus the one-time plaintext
// token (only ever returned here).
type createShareResponse struct {
storage.DocumentShare
Token string `json:"token"`
}
func (s *Server) logShare(r *http.Request, event string, tenantID *int64, username, detail string, ok bool) {
s.audlog.Log(audit.Entry{
EventType: event, Username: username, TenantID: tenantID,
IPAddress: s.remoteIP(r), Success: ok, Detail: detail,
})
}
// handleCreateShare handles POST /api/documents/{id}/shares.
func (s *Server) handleCreateShare(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
var req createShareRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ExpiresAt.IsZero() {
writeError(w, http.StatusBadRequest, "expires_at is required")
return
}
if !req.ExpiresAt.After(time.Now()) {
writeError(w, http.StatusBadRequest, "expires_at must be in the future")
return
}
if req.MaxAccesses != nil && *req.MaxAccesses < 1 {
writeError(w, http.StatusBadRequest, "max_accesses must be at least 1")
return
}
share, token, err := s.store.CreateShare(r.Context(), storage.CreateShareRequest{
TenantID: *sess.TenantID,
DocumentID: docID,
CreatedBy: sess.UserID,
ExpiresAt: req.ExpiresAt,
MaxAccesses: req.MaxAccesses,
Password: req.Password,
})
if err != nil {
s.logShare(r, audit.EventShareCreated, sess.TenantID, sess.Username, "share_create doc:"+strconv.FormatInt(docID, 10)+" err:"+err.Error(), false)
writeError(w, shareStatus(err), "create share failed")
return
}
s.logShare(r, audit.EventShareCreated, sess.TenantID, sess.Username, "share_create doc:"+strconv.FormatInt(docID, 10)+" share:"+strconv.FormatInt(share.ID, 10), true)
writeJSON(w, http.StatusCreated, createShareResponse{DocumentShare: *share, Token: token})
}
// handleListDocumentShares handles GET /api/documents/{id}/shares.
func (s *Server) handleListDocumentShares(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
shares, err := s.store.ListSharesForDocument(r.Context(), docID, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list shares failed")
return
}
writeJSON(w, http.StatusOK, shares)
}
// handleRevokeShare handles DELETE /api/shares/{share_id}.
func (s *Server) handleRevokeShare(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
shareID, err := strconv.ParseInt(r.PathValue("share_id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid share id")
return
}
if err := s.store.RevokeShare(r.Context(), shareID, *sess.TenantID, sess.UserID); err != nil {
s.logShare(r, audit.EventShareRevoked, sess.TenantID, sess.Username, "share_revoke share:"+strconv.FormatInt(shareID, 10)+" err:"+err.Error(), false)
writeError(w, shareStatus(err), "revoke share failed")
return
}
s.logShare(r, audit.EventShareRevoked, sess.TenantID, sess.Username, "share_revoke share:"+strconv.FormatInt(shareID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// handleListTenantShares handles GET /api/shares (domain_admin+): every share
// of the caller's tenant, document title joined in.
func (s *Server) handleListTenantShares(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
shares, err := s.store.ListSharesForTenant(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list shares failed")
return
}
writeJSON(w, http.StatusOK, shares)
}
// shareStatus maps store errors to an HTTP status for the authenticated
// endpoints.
func shareStatus(err error) int {
if errors.Is(err, storage.ErrShareNotFound) {
return http.StatusNotFound
}
return http.StatusInternalServerError
}
+291
View File
@@ -0,0 +1,291 @@
// Structured-entity HTTP handlers for tags/document_types/correspondents
// (see internal/storage/taxonomy.go) plus manual tag attach/detach:
//
// GET/POST /api/tags PATCH/DELETE /api/tags/{id}
// GET/POST /api/document-types PATCH/DELETE /api/document-types/{id}
// GET/POST /api/correspondents PATCH/DELETE /api/correspondents/{id}
// POST/DELETE /api/documents/{id}/tags/{tagId}
//
// All routes require s.auth(...) (authenticated + tenant context).
// Ownership is enforced in the store layer (id+tenant_id), analogous to
// internal/api/reminder_handlers.go. Every mutation is audit-logged,
// including failures.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
type taxonomyEntityRequest struct {
Name string `json:"name"`
Color string `json:"color"`
MatchAlgorithm string `json:"match_algorithm"`
MatchPattern string `json:"match_pattern"`
CaseSensitive bool `json:"case_sensitive"`
BarcodeValue string `json:"barcode_value"`
}
func taxonomyEventType(kind string) string {
switch kind {
case "tags":
return "tag"
case "document_types":
return "document_type"
case "correspondents":
return "correspondent"
default:
return kind
}
}
// handleListTaxonomy handles GET /api/{tags,document-types,correspondents}.
func (s *Server) handleListTaxonomy(kind string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
entities, err := s.store.ListTaxonomyEntities(r.Context(), kind, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list "+kind+" failed")
return
}
writeJSON(w, http.StatusOK, entities)
}
}
// handleCreateTaxonomy handles POST /api/{tags,document-types,correspondents}.
func (s *Server) handleCreateTaxonomy(kind string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req taxonomyEntityRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
entity, err := s.store.CreateTaxonomyEntity(r.Context(), kind, *sess.TenantID, storage.TaxonomyEntityRequest{
Name: req.Name, Color: req.Color, MatchAlgorithm: req.MatchAlgorithm,
MatchPattern: req.MatchPattern, CaseSensitive: req.CaseSensitive, BarcodeValue: req.BarcodeValue,
})
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrDuplicateTaxonomyName) {
status = http.StatusConflict
}
s.audlog.Log(audit.Entry{EventType: taxonomyEventType(kind) + "_create", Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: err.Error()})
writeError(w, status, "create "+kind+" failed")
return
}
s.audlog.Log(audit.Entry{
EventType: taxonomyEventType(kind) + "_create", Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "id:" + strconv.FormatInt(entity.ID, 10) + " name:" + entity.Name,
})
writeJSON(w, http.StatusCreated, entity)
}
}
// handleUpdateTaxonomy handles PATCH /api/{tags,document-types,correspondents}/{id}.
func (s *Server) handleUpdateTaxonomy(kind string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req taxonomyEntityRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
entity, err := s.store.UpdateTaxonomyEntity(r.Context(), kind, id, *sess.TenantID, storage.TaxonomyEntityRequest{
Name: req.Name, Color: req.Color, MatchAlgorithm: req.MatchAlgorithm,
MatchPattern: req.MatchPattern, CaseSensitive: req.CaseSensitive, BarcodeValue: req.BarcodeValue,
})
if err != nil {
status := http.StatusNotFound
if errors.Is(err, storage.ErrDuplicateTaxonomyName) {
status = http.StatusConflict
}
s.audlog.Log(audit.Entry{
EventType: taxonomyEventType(kind) + "_update", Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
})
writeError(w, status, "update "+kind+" failed")
return
}
s.audlog.Log(audit.Entry{
EventType: taxonomyEventType(kind) + "_update", Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "id:" + strconv.FormatInt(entity.ID, 10),
})
writeJSON(w, http.StatusOK, entity)
}
}
// handleDeleteTaxonomy handles DELETE /api/{tags,document-types,correspondents}/{id}.
func (s *Server) handleDeleteTaxonomy(kind string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.DeleteTaxonomyEntity(r.Context(), kind, id, *sess.TenantID); err != nil {
s.audlog.Log(audit.Entry{
EventType: taxonomyEventType(kind) + "_delete", Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "id:" + strconv.FormatInt(id, 10) + " err:" + err.Error(),
})
writeError(w, http.StatusNotFound, "delete "+kind+" failed")
return
}
s.audlog.Log(audit.Entry{
EventType: taxonomyEventType(kind) + "_delete", Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "id:" + strconv.FormatInt(id, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
}
// handleAttachTag handles POST /api/documents/{id}/tags/{tagId} (manual
// tag attach). Verifies both the document and the tag belong to the
// caller's tenant before inserting the document_tags row.
func (s *Server) handleAttachTag(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
tagID, err := strconv.ParseInt(r.PathValue("tagId"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tag id")
return
}
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
tags, err := s.store.ListTaxonomyEntities(r.Context(), "tags", *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "attach tag failed")
return
}
found := false
for _, t := range tags {
if t.ID == tagID {
found = true
break
}
}
if !found {
writeError(w, http.StatusNotFound, "tag not found")
return
}
if err := s.store.AttachTag(r.Context(), docID, tagID); err != nil {
s.audlog.Log(audit.Entry{
EventType: "tag_attach", Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: false, Detail: err.Error(),
})
writeError(w, http.StatusInternalServerError, "attach tag failed")
return
}
s.audlog.Log(audit.Entry{
EventType: "tag_attach", Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: true, Detail: "tag_id:" + strconv.FormatInt(tagID, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "attached"})
}
// handleDetachTag handles DELETE /api/documents/{id}/tags/{tagId}.
func (s *Server) handleDetachTag(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
tagID, err := strconv.ParseInt(r.PathValue("tagId"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tag id")
return
}
if _, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
if err := s.store.DetachTag(r.Context(), docID, tagID); err != nil {
s.audlog.Log(audit.Entry{
EventType: "tag_detach", Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: false, Detail: err.Error(),
})
writeError(w, http.StatusInternalServerError, "detach tag failed")
return
}
s.audlog.Log(audit.Entry{
EventType: "tag_detach", Username: sess.Username, TenantID: sess.TenantID,
DocumentID: strconv.FormatInt(docID, 10), Success: true, Detail: "tag_id:" + strconv.FormatInt(tagID, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "detached"})
}
// handleListDocumentTags handles GET /api/documents/{id}/tags.
func (s *Server) handleListDocumentTags(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
tags, err := s.store.ListDocumentTags(r.Context(), docID, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list document tags failed")
return
}
writeJSON(w, http.StatusOK, tags)
}
+50
View File
@@ -0,0 +1,50 @@
package api
import (
"encoding/json"
"net/http"
"archivdms/internal/audit"
)
type createTenantRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Domain string `json:"domain"`
}
// handleCreateTenant creates a new tenant. Restricted to superadmin via
// requireRole in server.go's route registration.
func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
var req createTenantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.Slug == "" {
writeError(w, http.StatusBadRequest, "name and slug are required")
return
}
tenant, err := s.tenantStore.Create(r.Context(), req.Name, req.Slug, req.Domain)
if err != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventTenantMgmt, Username: sess.Username, Success: false, Detail: "create_tenant_failed"})
writeError(w, http.StatusBadRequest, "create tenant failed")
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventTenantMgmt, Username: sess.Username, Success: true, Detail: "tenant_created:" + tenant.Slug})
writeJSON(w, http.StatusCreated, tenant)
}
// handleListTenants lists all tenants. Restricted to superadmin via
// requireRole in server.go's route registration.
func (s *Server) handleListTenants(w http.ResponseWriter, r *http.Request) {
tenants, err := s.tenantStore.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "list tenants failed")
return
}
writeJSON(w, http.StatusOK, tenants)
}
+233
View File
@@ -0,0 +1,233 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"archivdms/internal/audit"
"archivdms/internal/dateformat"
"archivdms/internal/storage"
"archivdms/internal/userstore"
)
// resolveTenantSettingsTenant determines which tenant the request targets,
// mirroring resolveLDAPTenant: domain_admin is pinned to its own signed
// session tenant (IDOR-safe, query params ignored); superadmin (no session
// tenant) must pass ?tenant_id=. Returns the tenant ID and false when the
// request is not authorised or the tenant cannot be determined (the caller
// has already written the response).
func (s *Server) resolveTenantSettingsTenant(w http.ResponseWriter, r *http.Request) (int64, bool) {
sess := sessionFromCtx(r.Context())
if sess.Role == userstore.RoleSuperAdmin {
q := r.URL.Query().Get("tenant_id")
if q == "" {
writeError(w, http.StatusBadRequest, "tenant_id query parameter required for superadmin")
return 0, false
}
tid, err := strconv.ParseInt(q, 10, 64)
if err != nil || tid <= 0 {
writeError(w, http.StatusBadRequest, "invalid tenant_id")
return 0, false
}
return tid, true
}
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "no tenant context")
return 0, false
}
return *sess.TenantID, true
}
// availableScanTitleFormats returns the example token patterns the frontend can
// offer as clickable starting points. These are suggestions only — any free
// token pattern is accepted (see dateformat.Translate) — not a validation
// constraint. Returned as a fresh slice so callers never share backing storage.
func availableScanTitleFormats() []string {
out := make([]string, len(exampleScanTitleFormats))
copy(out, exampleScanTitleFormats)
return out
}
type tenantSettingsResponse struct {
ScanTitleDateFormat string `json:"scan_title_date_format"`
ScanTitlePrefix string `json:"scan_title_prefix"`
// DefaultTitleTemplate is the tenant-wide fallback title template (Go
// text/template) used when an applied classification template carries no
// own title_template. Empty string means "no tenant default".
DefaultTitleTemplate string `json:"default_title_template"`
// AvailableFormats is a list of example patterns (suggestions), not an
// allow-list; the field name is kept for frontend compatibility.
AvailableFormats []string `json:"available_formats"`
}
// effectiveScanTitlePrefix returns the tenant's stored prefix, or the default
// for empty/legacy rows, so the response always reflects what would actually be
// used at upload time.
func effectiveScanTitlePrefix(stored string) string {
if p := strings.TrimSpace(stored); p != "" {
return p
}
return defaultScanTitlePrefix
}
// effectiveScanTitleFormat returns the tenant's stored token pattern, or the
// default for empty/invalid rows, so the response always reflects the pattern
// that would actually be used at upload time.
func effectiveScanTitleFormat(stored string) string {
if s := strings.TrimSpace(stored); s != "" {
if _, err := dateformat.Translate(s); err == nil {
return s
}
}
return defaultScanTitleDateFormat
}
// handleGetTenantSettings returns the tenant's own settings (currently the
// placeholder-title date format) plus a list of example format patterns
// (suggestions for the frontend, not an allow-list).
func (s *Server) handleGetTenantSettings(w http.ResponseWriter, r *http.Request) {
if s.tenantStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant store not configured")
return
}
tenantID, ok := s.resolveTenantSettingsTenant(w, r)
if !ok {
return
}
t, err := s.tenantStore.GetByID(r.Context(), tenantID)
if err != nil || t == nil {
writeError(w, http.StatusInternalServerError, "load tenant settings failed")
return
}
writeJSON(w, http.StatusOK, tenantSettingsResponse{
ScanTitleDateFormat: effectiveScanTitleFormat(t.ScanTitleDateFormat),
ScanTitlePrefix: effectiveScanTitlePrefix(t.ScanTitlePrefix),
DefaultTitleTemplate: t.DefaultTitleTemplate,
AvailableFormats: availableScanTitleFormats(),
})
}
// updateTenantSettingsRequest uses pointer fields for PATCH-like semantics:
// only the fields present in the JSON body are updated, so the two settings
// can be changed independently of one another.
type updateTenantSettingsRequest struct {
ScanTitleDateFormat *string `json:"scan_title_date_format"`
ScanTitlePrefix *string `json:"scan_title_prefix"`
DefaultTitleTemplate *string `json:"default_title_template"`
}
// handleUpdateTenantSettings persists the tenant's placeholder-title date
// format. The submitted value is a free token pattern (e.g. "DD.MM.YYYY HH:mm")
// validated via dateformat.Translate; an unparseable pattern yields 400 with
// the translator's error message. Every attempt — success or failure — is
// audit-logged (GoBD).
func (s *Server) handleUpdateTenantSettings(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if s.tenantStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant store not configured")
return
}
tenantID, ok := s.resolveTenantSettingsTenant(w, r)
if !ok {
return
}
logFail := func(detail string) {
tid := tenantID
s.audlog.Log(audit.Entry{
EventType: audit.EventTenantMgmt, Username: sess.Username,
TenantID: &tid, Success: false, Detail: detail,
})
}
var req updateTenantSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logFail("tenant_settings invalid_body")
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ScanTitleDateFormat == nil && req.ScanTitlePrefix == nil && req.DefaultTitleTemplate == nil {
logFail("tenant_settings no_fields")
writeError(w, http.StatusBadRequest, "no settings provided")
return
}
// Validate everything before persisting anything, so a bad prefix can never
// leave a half-applied update.
if req.ScanTitleDateFormat != nil {
if _, err := dateformat.Translate(*req.ScanTitleDateFormat); err != nil {
logFail("tenant_settings invalid_scan_title_date_format:" + *req.ScanTitleDateFormat)
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
if req.ScanTitlePrefix != nil && strings.TrimSpace(*req.ScanTitlePrefix) == "" {
logFail("tenant_settings empty_scan_title_prefix")
writeError(w, http.StatusBadRequest, "scan_title_prefix must not be empty")
return
}
// An empty default_title_template is allowed (clears the tenant default);
// a non-empty one must parse as a valid Go text/template.
if req.DefaultTitleTemplate != nil {
if err := storage.ValidateTitleTemplate(*req.DefaultTitleTemplate); err != nil {
logFail("tenant_settings invalid_default_title_template")
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
var detail string
if req.ScanTitleDateFormat != nil {
if err := s.tenantStore.UpdateScanTitleDateFormat(r.Context(), tenantID, *req.ScanTitleDateFormat); err != nil {
logFail("tenant_settings update_failed")
writeError(w, http.StatusInternalServerError, "save tenant settings failed")
return
}
detail += " scan_title_date_format:" + *req.ScanTitleDateFormat
}
if req.ScanTitlePrefix != nil {
if err := s.tenantStore.UpdateScanTitlePrefix(r.Context(), tenantID, *req.ScanTitlePrefix); err != nil {
// Store-level validation (length) or DB error.
logFail("tenant_settings update_failed")
writeError(w, http.StatusBadRequest, "invalid scan_title_prefix")
return
}
detail += " scan_title_prefix:" + strings.TrimSpace(*req.ScanTitlePrefix)
}
if req.DefaultTitleTemplate != nil {
if err := s.tenantStore.UpdateDefaultTitleTemplate(r.Context(), tenantID, *req.DefaultTitleTemplate); err != nil {
logFail("tenant_settings update_failed")
writeError(w, http.StatusBadRequest, "invalid default_title_template")
return
}
detail += " default_title_template:" + strings.TrimSpace(*req.DefaultTitleTemplate)
}
tid := tenantID
s.audlog.Log(audit.Entry{
EventType: audit.EventTenantMgmt, Username: sess.Username,
TenantID: &tid, Success: true,
Detail: "tenant_settings" + detail,
})
// Reload so the response reflects the effective persisted state for both
// fields, regardless of which one this request changed.
t, err := s.tenantStore.GetByID(r.Context(), tenantID)
if err != nil || t == nil {
writeError(w, http.StatusInternalServerError, "load tenant settings failed")
return
}
writeJSON(w, http.StatusOK, tenantSettingsResponse{
ScanTitleDateFormat: effectiveScanTitleFormat(t.ScanTitleDateFormat),
ScanTitlePrefix: effectiveScanTitlePrefix(t.ScanTitlePrefix),
DefaultTitleTemplate: t.DefaultTitleTemplate,
AvailableFormats: availableScanTitleFormats(),
})
}
+237
View File
@@ -0,0 +1,237 @@
// Trash + staged-deletion HTTP handlers (see internal/storage/trash.go):
//
// GET /api/trash list soft-deleted docs
// POST /api/trash/{id}/restore restore from trash
// POST /api/trash/{id}/delete-requests request final deletion (User A)
// GET /api/trash/{id}/delete-requests request status/history
// POST /api/trash/{id}/delete-requests/{reqId}/confirm confirm + execute (User B, domain_admin)
// DELETE /api/trash/{id}/delete-requests/{reqId} withdraw a pending request
//
// The soft-delete itself lives on DELETE /api/documents/{id}
// (handleDeleteDocument). Ownership is enforced in the store layer via
// id+tenant_id; the two-person rule (requester != confirmer) is enforced in
// ConfirmDeleteRequest. Every phase emits its own append-only audit entry.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// handleListTrash handles GET /api/trash.
func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docs, err := s.store.ListTrash(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list trash failed")
return
}
writeJSON(w, http.StatusOK, docs)
}
// handleRestoreDocument handles POST /api/trash/{id}/restore.
func (s *Server) handleRestoreDocument(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
if err := s.store.RestoreDocument(r.Context(), id, *sess.TenantID, sess.UserID); err != nil {
status := http.StatusInternalServerError
msg := "restore failed"
if errors.Is(err, storage.ErrDocumentNotInTrash) {
status = http.StatusNotFound
msg = "document not found in trash"
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentRestore, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentRestore, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true})
writeJSON(w, http.StatusOK, map[string]string{"status": "restored"})
}
// handleListDeleteRequests handles GET /api/trash/{id}/delete-requests.
func (s *Server) handleListDeleteRequests(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
reqs, err := s.store.ListDeleteRequests(r.Context(), id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list delete requests failed")
return
}
writeJSON(w, http.StatusOK, reqs)
}
// handleCreateDeleteRequest handles POST /api/trash/{id}/delete-requests.
// User A requests final deletion; retention is re-checked, and a blocked
// attempt is still recorded (409).
func (s *Server) handleCreateDeleteRequest(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
req, err := s.store.CreateDeleteRequest(r.Context(), id, *sess.TenantID, sess.UserID)
if err != nil {
if errors.Is(err, storage.ErrRetentionActive) {
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteBlocked, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "delete request blocked by retention"})
writeError(w, http.StatusConflict, "document is under retention and cannot be deleted yet")
return
}
status := http.StatusInternalServerError
msg := "create delete request failed"
if errors.Is(err, storage.ErrDocumentNotInTrash) {
status = http.StatusNotFound
msg = "document not found in trash"
} else if errors.Is(err, storage.ErrDeleteRequestExists) {
status = http.StatusConflict
msg = "a pending delete request already exists"
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "request id:" + strconv.FormatInt(req.ID, 10)})
writeJSON(w, http.StatusCreated, req)
}
// handleCancelDeleteRequest handles DELETE /api/trash/{id}/delete-requests/{reqId}.
func (s *Server) handleCancelDeleteRequest(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
reqID, err2 := strconv.ParseInt(r.PathValue("reqId"), 10, 64)
if err != nil || err2 != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.CancelDeleteRequest(r.Context(), id, reqID, *sess.TenantID, sess.UserID); err != nil {
status := http.StatusInternalServerError
msg := "cancel delete request failed"
if errors.Is(err, storage.ErrDeleteRequestNotFound) {
status = http.StatusNotFound
msg = "pending delete request not found"
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "cancel req:" + r.PathValue("reqId") + " err:" + err.Error()})
writeError(w, status, msg)
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteRequest, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "cancelled req:" + r.PathValue("reqId")})
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
}
// handleConfirmDeleteRequest handles
// POST /api/trash/{id}/delete-requests/{reqId}/confirm (domain_admin, User B).
// The store enforces requester != confirmer and re-checks retention before it
// removes the physical WORM file and tombstones the DB row.
func (s *Server) handleConfirmDeleteRequest(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
reqID, err2 := strconv.ParseInt(r.PathValue("reqId"), 10, 64)
if err != nil || err2 != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
docIDStr := r.PathValue("id")
exec, err := s.store.ConfirmDeleteRequest(r.Context(), id, reqID, *sess.TenantID, sess.UserID)
if err != nil {
if errors.Is(err, storage.ErrRetentionActive) {
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteBlocked, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: false, Detail: "confirm blocked by retention req:" + r.PathValue("reqId")})
writeError(w, http.StatusConflict, "document is under retention and cannot be deleted yet")
return
}
status := http.StatusInternalServerError
msg := "confirm delete request failed"
if errors.Is(err, storage.ErrSelfConfirm) {
status = http.StatusForbidden
msg = "delete request must be confirmed by a different user"
} else if errors.Is(err, storage.ErrDeleteRequestNotFound) {
status = http.StatusNotFound
msg = "pending delete request not found"
} else if errors.Is(err, storage.ErrDocumentNotInTrash) {
status = http.StatusNotFound
msg = "document not found in trash"
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteConfirm, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: false, Detail: "req:" + r.PathValue("reqId") + " err:" + err.Error()})
writeError(w, status, msg)
return
}
// Two-person rule: emit a confirm entry (User B) and an execute entry that
// records the requester (User A) whose request was finally carried out.
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteConfirm, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: true, Detail: "confirmed req:" + strconv.FormatInt(exec.RequestID, 10)})
// GoBD-taugliches Löschprotokoll (ecoDMS-Muster): the execute entry carries
// a structured, self-contained record of the final, irreversible deletion —
// who requested it (User A) and when, who confirmed/executed it (User B, the
// current session) and when, which document (title + content_hash as the
// tamper-evident fingerprint of the removed WORM file), the retention state
// at execution time, and the legal basis (Vier-Augen-Prinzip + elapsed/absent
// retention). Encoded as JSON in the audit Detail field so the append-only
// audit_log (DB + JSON-Lines mirror) remains the single source of truth
// without a dedicated table.
protokoll := map[string]any{
"loeschprotokoll": true,
"document_id": docIDStr,
"title": exec.Title,
"content_hash": exec.ContentHash,
"worm_file_removed": exec.StoragePath,
"request_id": exec.RequestID,
"requested_by_user_id": exec.RequestedBy,
"requested_at": exec.RequestedAt.UTC().Format(time.RFC3339),
"confirmed_by_user_id": sess.UserID,
"confirmed_by_username": sess.Username,
"executed_at": time.Now().UTC().Format(time.RFC3339),
"rechtsgrundlage": "Vier-Augen-Prinzip erfuellt; Aufbewahrungsfrist (retain_until) abgelaufen oder nicht gesetzt",
}
if exec.RetainUntil != nil {
protokoll["retain_until"] = exec.RetainUntil.UTC().Format(time.RFC3339)
} else {
protokoll["retain_until"] = nil
}
detail := "executed req:" + strconv.FormatInt(exec.RequestID, 10) +
" requested_by_user_id:" + strconv.FormatInt(exec.RequestedBy, 10) +
" removed:" + exec.StoragePath
if b, jerr := json.Marshal(protokoll); jerr == nil {
detail = string(b)
}
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentDeleteExecute, Username: sess.Username, TenantID: sess.TenantID, DocumentID: docIDStr, Success: true, Detail: detail})
writeJSON(w, http.StatusOK, map[string]string{"status": "executed"})
}
+129
View File
@@ -0,0 +1,129 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"archivdms/internal/audit"
"archivdms/internal/userstore"
)
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
var users []*userstore.User
var err error
if sess.TenantID != nil {
users, err = s.users.ListByTenant(r.Context(), *sess.TenantID)
} else {
users, err = s.users.List("")
}
if err != nil {
writeError(w, http.StatusInternalServerError, "list users failed")
return
}
writeJSON(w, http.StatusOK, users)
}
type createUserRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
// TenantID is only ever evaluated for a superadmin caller (see
// handleCreateUser). For any other role it is silently ignored and the
// caller's own sess.TenantID is enforced instead — this is a deliberate
// IDOR guard: a domain_admin must never be able to steer a created user
// into a tenant other than their own by sending a different tenant_id.
TenantID *int64 `json:"tenant_id"`
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Role == "" {
req.Role = userstore.RoleUser
}
// Tenant assignment is rollenabhängig (security-critical, see plan):
// - superadmin: may set tenant_id explicitly from the request body,
// including nil for another tenant-less superadmin.
// - everyone else (domain_admin, user): tenant_id is ALWAYS hard-forced
// to the caller's own sess.TenantID; any tenant_id in the request
// body is completely ignored, not merely validated, to close the
// IDOR hole where a domain_admin could otherwise create a user in a
// tenant they don't administer.
tenantID := sess.TenantID
if sess.Role == userstore.RoleSuperAdmin {
tenantID = req.TenantID
}
user, err := s.users.Create(userstore.CreateUserRequest{
Username: req.Username,
Email: req.Email,
Password: req.Password,
Role: req.Role,
TenantID: tenantID,
})
if err != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "create_user_failed"})
writeError(w, http.StatusBadRequest, "create user failed")
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "user_created:" + user.Username})
writeJSON(w, http.StatusCreated, user)
}
type updateUserRequest struct {
Email *string `json:"email"`
Role *string `json:"role"`
Active *bool `json:"active"`
Password *string `json:"password"`
}
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user id")
return
}
var req updateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
user, err := s.users.Update(id, userstore.UpdateUserRequest{
Email: req.Email, Role: req.Role, Active: req.Active, Password: req.Password,
})
if err != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "update_user_failed"})
writeError(w, http.StatusBadRequest, "update user failed")
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "user_updated:" + user.Username})
writeJSON(w, http.StatusOK, user)
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user id")
return
}
if err := s.users.Delete(id); err != nil {
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "delete_user_failed"})
writeError(w, http.StatusBadRequest, "delete user failed")
return
}
s.audlog.Log(audit.Entry{EventType: audit.EventUserMgmt, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "user_deleted"})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
+327
View File
@@ -0,0 +1,327 @@
// Workflow ("Consumption-Regeln") HTTP handlers (see
// internal/storage/workflows.go):
//
// GET/POST /api/workflows GET/PUT/DELETE /api/workflows/{id}
// PUT /api/workflows/{id}/actions
// POST /api/workflows/{id}/test
// GET /api/workflows/{id}/runs
//
// Workflow administration (CRUD + action bulk replace) requires domain_admin
// (s.authAdmin, enforced in server.go). The dry-run test and the runs overview
// are normal authenticated tenant actions (s.auth). Ownership is enforced in
// the store layer (id+tenant_id). Every mutation is audit-logged, incl. failures.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
type workflowRequest struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
TriggerType string `json:"trigger_type"`
ConditionTree json.RawMessage `json:"condition_tree"`
Priority int `json:"priority"`
}
// handleListWorkflows handles GET /api/workflows (without actions).
func (s *Server) handleListWorkflows(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
workflows, err := s.store.ListWorkflows(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list workflows failed")
return
}
writeJSON(w, http.StatusOK, workflows)
}
// handleGetWorkflow handles GET /api/workflows/{id} (resolved with actions).
func (s *Server) handleGetWorkflow(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
wf, err := s.store.GetWorkflow(r.Context(), id, *sess.TenantID)
if err != nil {
if errors.Is(err, storage.ErrWorkflowNotFound) {
writeError(w, http.StatusNotFound, "workflow not found")
return
}
writeError(w, http.StatusInternalServerError, "get workflow failed")
return
}
writeJSON(w, http.StatusOK, wf)
}
// handleCreateWorkflow handles POST /api/workflows (domain_admin+).
func (s *Server) handleCreateWorkflow(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
var req workflowRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
wf, err := s.store.CreateWorkflow(r.Context(), *sess.TenantID, storage.CreateWorkflowRequest{
Name: req.Name, Enabled: req.Enabled, TriggerType: req.TriggerType,
ConditionTree: req.ConditionTree, Priority: req.Priority, CreatedBy: &sess.UserID,
})
if err != nil {
status := workflowErrorStatus(err)
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_create err:" + err.Error()})
writeError(w, status, workflowErrorMessage(err, "create workflow failed"))
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventWorkflowCreate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "workflow_create id:" + strconv.FormatInt(wf.ID, 10) + " name:" + wf.Name,
})
writeJSON(w, http.StatusCreated, wf)
}
// handleUpdateWorkflow handles PUT /api/workflows/{id} (domain_admin+).
func (s *Server) handleUpdateWorkflow(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req workflowRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
err = s.store.UpdateWorkflow(r.Context(), id, *sess.TenantID, storage.UpdateWorkflowRequest{
Name: req.Name, Enabled: req.Enabled, TriggerType: req.TriggerType,
ConditionTree: req.ConditionTree, Priority: req.Priority,
})
if err != nil {
status := workflowErrorStatus(err)
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_update id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, workflowErrorMessage(err, "update workflow failed"))
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "workflow_update id:" + strconv.FormatInt(id, 10),
})
wf, err := s.store.GetWorkflow(r.Context(), id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "reload workflow failed")
return
}
writeJSON(w, http.StatusOK, wf)
}
// handleDeleteWorkflow handles DELETE /api/workflows/{id} (domain_admin+).
func (s *Server) handleDeleteWorkflow(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.DeleteWorkflow(r.Context(), id, *sess.TenantID); err != nil {
status := http.StatusNotFound
if !errors.Is(err, storage.ErrWorkflowNotFound) {
status = http.StatusInternalServerError
}
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowDelete, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_delete id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, "delete workflow failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventWorkflowDelete, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "workflow_delete id:" + strconv.FormatInt(id, 10),
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
type workflowActionsRequest struct {
Actions []struct {
ActionType string `json:"action_type"`
ActionConfig json.RawMessage `json:"action_config"`
} `json:"actions"`
}
// handleSetWorkflowActions handles PUT /api/workflows/{id}/actions (bulk
// replace, domain_admin+).
func (s *Server) handleSetWorkflowActions(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req workflowActionsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
actions := make([]storage.WorkflowActionInput, 0, len(req.Actions))
for _, a := range req.Actions {
actions = append(actions, storage.WorkflowActionInput{ActionType: a.ActionType, ActionConfig: a.ActionConfig})
}
if err := s.store.SetWorkflowActions(r.Context(), id, *sess.TenantID, actions); err != nil {
status := workflowErrorStatus(err)
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_actions_set id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, workflowErrorMessage(err, "set workflow actions failed"))
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventWorkflowUpdate, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "workflow_actions_set id:" + strconv.FormatInt(id, 10) + " count:" + strconv.Itoa(len(actions)),
})
wf, err := s.store.GetWorkflow(r.Context(), id, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "reload workflow failed")
return
}
writeJSON(w, http.StatusOK, wf)
}
type workflowTestRequest struct {
DocumentID *int64 `json:"document_id"`
RawText string `json:"raw_text"`
}
// handleTestWorkflow handles POST /api/workflows/{id}/test. Pure dry-run: it
// reports which leaves matched and which actions WOULD run, never executing
// them. Evaluated against an existing document (document_id) or a synthetic
// document built from raw_text.
func (s *Server) handleTestWorkflow(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req workflowTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
res, err := s.store.TestWorkflow(r.Context(), id, *sess.TenantID, req.DocumentID, req.RawText)
if err != nil {
status := workflowErrorStatus(err)
if errors.Is(err, storage.ErrDocumentNotFound) {
status = http.StatusNotFound
}
s.audlog.Log(audit.Entry{EventType: audit.EventWorkflowRun, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "workflow_test id:" + strconv.FormatInt(id, 10) + " err:" + err.Error()})
writeError(w, status, workflowErrorMessage(err, "test workflow failed"))
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventWorkflowRun, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "workflow_test id:" + strconv.FormatInt(id, 10) + " matched:" + strconv.FormatBool(res.Matched),
})
writeJSON(w, http.StatusOK, res)
}
// handleListWorkflowRuns handles GET /api/workflows/{id}/runs (optional
// ?limit=).
func (s *Server) handleListWorkflowRuns(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
limit := 0
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
limit = n
}
runs, err := s.store.ListWorkflowRuns(r.Context(), id, *sess.TenantID, limit)
if err != nil {
if errors.Is(err, storage.ErrWorkflowNotFound) {
writeError(w, http.StatusNotFound, "workflow not found")
return
}
writeError(w, http.StatusInternalServerError, "list workflow runs failed")
return
}
writeJSON(w, http.StatusOK, runs)
}
// workflowErrorStatus maps store errors from the workflow write path to HTTP
// status codes.
func workflowErrorStatus(err error) int {
switch {
case errors.Is(err, storage.ErrWorkflowNotFound):
return http.StatusNotFound
case errors.Is(err, storage.ErrDuplicateWorkflowName):
return http.StatusConflict
case errors.Is(err, storage.ErrInvalidConditionTree), errors.Is(err, storage.ErrInvalidWorkflowAction):
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}
// workflowErrorMessage returns the store error's message for the client on the
// validation cases (safe, user-actionable), otherwise the generic fallback.
func workflowErrorMessage(err error, fallback string) string {
switch {
case errors.Is(err, storage.ErrInvalidConditionTree), errors.Is(err, storage.ErrInvalidWorkflowAction):
return err.Error()
case errors.Is(err, storage.ErrWorkflowNotFound):
return "workflow not found"
case errors.Is(err, storage.ErrDuplicateWorkflowName):
return "workflow with this name already exists"
default:
return fallback
}
}