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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user