FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled

- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI
- FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs
- FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig
- FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
This commit is contained in:
2026-08-11 22:27:52 +02:00
parent 9a24ea29e1
commit 89de794356
30 changed files with 2688 additions and 113 deletions
+43 -84
View File
@@ -21,6 +21,7 @@ import (
"archivdms/internal/auth"
"archivdms/internal/dateformat"
"archivdms/internal/matching"
"archivdms/internal/objectstore"
"archivdms/internal/ocr"
"archivdms/internal/storage"
"archivdms/internal/userstore"
@@ -138,10 +139,12 @@ func (s *Server) handleGetDocumentFile(w http.ResponseWriter, r *http.Request) {
return
}
f, err := os.Open(doc.StoragePath)
// Read through the storage abstraction: it re-checks that the stored path
// really lies inside this tenant's store subtree before opening.
f, err := s.objects.Open(r.Context(), *sess.TenantID, doc.StoragePath)
if err != nil {
// WORM store should always hold the file, but never trust the disk.
s.logger.Error("document file open failed", "document_id", doc.ID, "tenant_id", *sess.TenantID, "storage_path", doc.StoragePath, "err", err)
s.reqLog(r.Context()).Error("document file open failed", "document_id", doc.ID, "tenant_id", *sess.TenantID, "storage_path", doc.StoragePath, "err", err)
writeError(w, http.StatusInternalServerError, "file unavailable")
return
}
@@ -152,7 +155,7 @@ func (s *Server) handleGetDocumentFile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", "inline; filename=\""+safeDownloadName(doc.Title, ext)+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
if _, err := io.Copy(w, f); err != nil {
s.logger.Warn("document file stream interrupted", "document_id", doc.ID, "err", err)
s.reqLog(r.Context()).Warn("document file stream interrupted", "document_id", doc.ID, "err", err)
}
}
@@ -209,7 +212,7 @@ func (s *Server) handleGetDocumentThumbnail(w http.ResponseWriter, r *http.Reque
w.Header().Set("Cache-Control", "private, max-age=86400")
w.Header().Set("X-Content-Type-Options", "nosniff")
if _, err := io.Copy(w, f); err != nil {
s.logger.Warn("thumbnail stream interrupted", "document_id", doc.ID, "err", err)
s.reqLog(r.Context()).Warn("thumbnail stream interrupted", "document_id", doc.ID, "err", err)
}
}
@@ -453,7 +456,7 @@ func (s *Server) ReprocessDocument(ctx context.Context, tenantID, id int64, acto
mimeType := detectMimeType("", ext, doc.StoragePath)
result, err := s.ocr.Extract(ctx, doc.StoragePath, mimeType)
if err != nil {
s.logger.Warn("reprocess ocr extraction failed", "document_id", doc.ID, "tenant_id", tenantID, "storage_path", doc.StoragePath, "err", err)
s.reqLog(ctx).Warn("reprocess ocr extraction failed", "document_id", doc.ID, "tenant_id", tenantID, "storage_path", doc.StoragePath, "err", err)
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentReprocessed, Username: actor, TenantID: &tenantID, DocumentID: docIDStr, Success: false, Detail: "ocr_failed: " + err.Error()})
return nil, fmt.Errorf("reprocess ocr extract: %w", err)
}
@@ -466,7 +469,7 @@ func (s *Server) ReprocessDocument(ctx context.Context, tenantID, id int64, acto
// alongside the new ones — best-effort, never fails the reprocess since
// ocr_text is already the authoritative persisted result.
if err := s.store.ReplaceOCRWords(ctx, id, ocrWordsFromResult(id, result.Words)); err != nil {
s.logger.Warn("reprocess replace ocr_words failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
s.reqLog(ctx).Warn("reprocess replace ocr_words failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
}
if err := s.store.UpdateDocumentOCRText(ctx, id, tenantID, ocrText); err != nil {
@@ -491,7 +494,7 @@ func (s *Server) ReprocessDocument(ctx context.Context, tenantID, id int64, acto
prefix, dateLayout := s.tenantScanTitleParams(ctx, tenantID)
if newTitle := titleFromOCRText(ocrText, prefix, dateLayout); newTitle != "" && newTitle != doc.Title {
if err := s.store.UpdateDocumentTitleAuto(ctx, id, tenantID, newTitle); err != nil {
s.logger.Warn("reprocess title update failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
s.reqLog(ctx).Warn("reprocess title update failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
} else {
doc.Title = newTitle
}
@@ -513,7 +516,7 @@ func (s *Server) ReprocessDocument(ctx context.Context, tenantID, id int64, acto
}
if !sameDate(datePtr, doc.DocumentDate) {
if err := s.store.UpdateDocumentDate(ctx, id, tenantID, datePtr, scorePtr); err != nil {
s.logger.Warn("reprocess document_date update failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
s.reqLog(ctx).Warn("reprocess document_date update failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
} else {
doc.DocumentDate = datePtr
doc.DocumentDateScore = scorePtr
@@ -524,12 +527,12 @@ func (s *Server) ReprocessDocument(ctx context.Context, tenantID, id int64, acto
// Additive auto-assignment (only fills unset doc_type/correspondent, only
// attaches tags) — best-effort, never fails the request.
if assignWarn := s.autoAssignTaxonomy(ctx, tenantID, doc, barcodes, ocrText); assignWarn != "" {
s.logger.Info("reprocess auto-assignment", "document_id", doc.ID, "tenant_id", tenantID, "detail", assignWarn)
s.reqLog(ctx).Info("reprocess auto-assignment", "document_id", doc.ID, "tenant_id", tenantID, "detail", assignWarn)
}
// Re-evaluate on_upload workflows — best-effort, never fails the request.
if err := s.store.RunWorkflowsForDocument(ctx, tenantID, doc, storage.WorkflowTriggerOnUpload); err != nil {
s.logger.Warn("reprocess workflow execution failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
s.reqLog(ctx).Warn("reprocess workflow execution failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
}
// Re-read so the response reflects any doc_type/correspondent set by
@@ -726,11 +729,11 @@ func (s *Server) generateThumbnailBestEffort(ctx context.Context, tenantID, docu
}
thumbPath := filepath.Join(s.storageCfg.ThumbnailPath(), strconv.FormatInt(tenantID, 10), contentHash+".png")
if err := s.thumbs.Generate(ctx, storagePath, mimeType, thumbPath); err != nil {
s.logger.Warn("eager thumbnail generation failed", "document_id", documentID, "tenant_id", tenantID, "mime_type", mimeType, "err", err)
s.reqLog(ctx).Warn("eager thumbnail generation failed", "document_id", documentID, "tenant_id", tenantID, "mime_type", mimeType, "err", err)
return
}
if err := s.store.SetDocumentHasThumbnail(ctx, documentID, tenantID, true); err != nil {
s.logger.Warn("has_thumbnail flag update failed", "document_id", documentID, "tenant_id", tenantID, "err", err)
s.reqLog(ctx).Warn("has_thumbnail flag update failed", "document_id", documentID, "tenant_id", tenantID, "err", err)
}
}
@@ -753,7 +756,7 @@ func (s *Server) handleUploadDocument(w http.ResponseWriter, r *http.Request) {
tenantID := *sess.TenantID
maxBytes := int64(s.storageCfg.ResolvedMaxUploadSizeMB()) * 1024 * 1024
s.logger.Info("upload request received", "username", sess.Username, "tenant_id", tenantID,
s.reqLog(r.Context()).Info("upload request received", "username", sess.Username, "tenant_id", tenantID,
"content_length", r.ContentLength, "max_bytes", maxBytes, "remote_addr", r.RemoteAddr)
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
@@ -763,7 +766,7 @@ func (s *Server) handleUploadDocument(w http.ResponseWriter, r *http.Request) {
// hitting MaxBytesReader — both close the connection before any
// later log/audit call would otherwise run, which previously left
// zero trace of the failure in the backend logs (see DEVLOG 2026-07-15).
s.logger.Warn("upload parse failed", "username", sess.Username, "tenant_id", tenantID,
s.reqLog(r.Context()).Warn("upload parse failed", "username", sess.Username, "tenant_id", tenantID,
"content_length", r.ContentLength, "max_bytes", maxBytes, "err", err)
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentCreate, Username: sess.Username, TenantID: sess.TenantID,
@@ -956,41 +959,19 @@ func (s *Server) archiveStagedFile(
}
pathDate := time.Now()
// 4. Build the WORM target path store/<tenant>/<yyyy>/<mm>/<hash>.<ext>.
storeDir := filepath.Join(s.storageCfg.StorePath(), strconv.FormatInt(tenantID, 10),
fmt.Sprintf("%04d", pathDate.Year()), fmt.Sprintf("%02d", pathDate.Month()))
if err := os.MkdirAll(storeDir, 0o750); err != nil {
os.Remove(inboxPath)
return nil, "", fmt.Errorf("create store dir: %w", err)
}
storePath := filepath.Join(storeDir, contentHash+ext)
// 5. Collision check: identical hash already stored -> reject as
// duplicate before touching the DB (filesystem-level half of the
// duplicate protection; the DB unique index is the other half).
if _, err := os.Stat(storePath); err == nil {
os.Remove(inboxPath)
return nil, "", storage.ErrDuplicateContentHash
} else if !os.IsNotExist(err) {
os.Remove(inboxPath)
return nil, "", fmt.Errorf("stat store path: %w", err)
}
// 6. Move inbox -> store. Prefer atomic rename; fall back to copy+remove
// if inbox/store ever end up on different filesystems/mounts.
if err := os.Rename(inboxPath, storePath); err != nil {
if copyErr := copyFile(inboxPath, storePath); copyErr != nil {
os.Remove(inboxPath)
return nil, "", fmt.Errorf("move file to store: rename failed (%v), copy fallback failed: %w", err, copyErr)
// 4./5./6./7. WORM archival — delegated unchanged to the storage
// abstraction (internal/objectstore): build the target path
// store/<tenant>/<yyyy>/<mm>/<hash>.<ext>, reject an already-stored hash as
// duplicate (filesystem half of the duplicate protection; the DB unique
// index is the other half), move inbox -> store (rename, copy+remove
// fallback across mounts) and finally chmod 0440. Path scheme and
// semantics are identical to the previous inline implementation.
storePath, err := s.objects.Archive(ctx, tenantID, inboxPath, ext, contentHash, pathDate)
if err != nil {
if errors.Is(err, objectstore.ErrObjectExists) {
return nil, "", storage.ErrDuplicateContentHash
}
os.Remove(inboxPath)
}
// 7. WORM lock: read-only, no write access for anyone once archived. This is
// the ONLY chmod and it happens exactly once, after the file reaches its
// final path — the file is never moved or renamed again afterwards.
if err := os.Chmod(storePath, 0o440); err != nil {
return nil, "", fmt.Errorf("chmod store file: %w", err)
return nil, "", err
}
// 8. Record the document AND its processing job in ONE transaction. If no
@@ -1018,7 +999,7 @@ func (s *Server) archiveStagedFile(
if err != nil {
return nil, "", err
}
s.logger.Info("document staged, processing job queued",
s.reqLog(ctx).Info("document staged, processing job queued",
"document_id", doc.ID, "tenant_id", tenantID, "job_id", job.ID, "derive_title", deriveTitle)
// 7b. Eager thumbnail generation: same pipeline/cache path as the lazy
@@ -1084,7 +1065,7 @@ func (s *Server) trySplitStagedUpload(
res, split, err := s.pagesplitter.Split(ctx, inboxPath)
if err != nil {
s.logger.Warn("separator-page split failed, archiving document unsplit",
s.reqLog(ctx).Warn("separator-page split failed, archiving document unsplit",
"tenant_id", tenantID, "filename", filename, "err", err)
s.audlog.Log(audit.Entry{
EventType: audit.EventDocumentSplit, Username: splitActor, TenantID: &tenantID, Success: false,
@@ -1167,7 +1148,7 @@ func (s *Server) trySplitStagedUpload(
EventType: audit.EventDocumentSplit, Username: splitActor, TenantID: &tenantID,
DocumentID: strconv.FormatInt(docs[0].ID, 10), Success: true, Detail: detail,
})
s.logger.Info("upload split at barcode separator pages",
s.reqLog(ctx).Info("upload split at barcode separator pages",
"tenant_id", tenantID, "filename", filename, "pages", res.PageCount,
"separator_pages", res.SeparatorPages, "parts", len(res.Parts),
"documents_created", len(docs), "duplicates_skipped", dupCount)
@@ -1238,7 +1219,7 @@ func (s *Server) ProcessDocumentJob(ctx context.Context, tenantID, documentID in
mimeType := detectMimeType("", ext, doc.StoragePath)
result, err := s.ocr.Extract(ctx, doc.StoragePath, mimeType)
if err != nil {
s.logger.Warn("jobqueue ocr extraction failed", "document_id", doc.ID, "tenant_id", tenantID,
s.reqLog(ctx).Warn("jobqueue ocr extraction failed", "document_id", doc.ID, "tenant_id", tenantID,
"storage_path", doc.StoragePath, "resolved_mime", mimeType, "err", err)
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentProcessed, Username: actor, TenantID: &tenantID, DocumentID: docIDStr, Success: false, Detail: "ocr_failed: " + err.Error()})
return fmt.Errorf("jobqueue ocr extract: %w", err)
@@ -1251,7 +1232,7 @@ func (s *Server) ProcessDocumentJob(ctx context.Context, tenantID, documentID in
// retry/backoff) never accumulates duplicates. Best-effort, never fails
// the job since ocr_text is already the authoritative persisted result.
if err := s.store.ReplaceOCRWords(ctx, documentID, ocrWordsFromResult(documentID, result.Words)); err != nil {
s.logger.Warn("jobqueue replace ocr_words failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
s.reqLog(ctx).Warn("jobqueue replace ocr_words failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
}
if err := s.store.UpdateDocumentOCRText(ctx, documentID, tenantID, ocrText); err != nil {
@@ -1267,7 +1248,7 @@ func (s *Server) ProcessDocumentJob(ctx context.Context, tenantID, documentID in
prefix, dateLayout := s.tenantScanTitleParams(ctx, tenantID)
if newTitle := titleFromOCRText(ocrText, prefix, dateLayout); newTitle != "" && newTitle != doc.Title {
if err := s.store.UpdateDocumentTitleAuto(ctx, documentID, tenantID, newTitle); err != nil {
s.logger.Warn("jobqueue title update failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
s.reqLog(ctx).Warn("jobqueue title update failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
} else {
doc.Title = newTitle
}
@@ -1286,7 +1267,7 @@ func (s *Server) ProcessDocumentJob(ctx context.Context, tenantID, documentID in
}
if !sameDate(datePtr, doc.DocumentDate) {
if err := s.store.UpdateDocumentDate(ctx, documentID, tenantID, datePtr, scorePtr); err != nil {
s.logger.Warn("jobqueue document_date update failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
s.reqLog(ctx).Warn("jobqueue document_date update failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
} else {
doc.DocumentDate = datePtr
doc.DocumentDateScore = scorePtr
@@ -1295,11 +1276,11 @@ func (s *Server) ProcessDocumentJob(ctx context.Context, tenantID, documentID in
}
if assignWarn := s.autoAssignTaxonomy(ctx, tenantID, doc, barcodes, ocrText); assignWarn != "" {
s.logger.Info("jobqueue auto-assignment", "document_id", doc.ID, "tenant_id", tenantID, "detail", assignWarn)
s.reqLog(ctx).Info("jobqueue auto-assignment", "document_id", doc.ID, "tenant_id", tenantID, "detail", assignWarn)
}
if err := s.store.RunWorkflowsForDocument(ctx, tenantID, doc, storage.WorkflowTriggerOnUpload); err != nil {
s.logger.Warn("jobqueue workflow execution failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
s.reqLog(ctx).Warn("jobqueue workflow execution failed", "document_id", doc.ID, "tenant_id", tenantID, "err", err)
}
// Final index sync: auto-assignment/workflows may have re-indexed already,
@@ -1323,7 +1304,7 @@ func (s *Server) autoAssignTaxonomy(ctx context.Context, tenantID int64, doc *st
// Nachvollziehbarkeit even when nothing matched.
if len(barcodes) > 0 {
if err := s.store.SetDocumentBarcodeValues(ctx, doc.ID, tenantID, barcodes); err != nil {
s.logger.Warn("failed to store barcode values", "document_id", doc.ID, "err", err)
s.reqLog(ctx).Warn("failed to store barcode values", "document_id", doc.ID, "err", err)
}
}
@@ -1371,7 +1352,7 @@ func (s *Server) autoAssignTaxonomy(ctx context.Context, tenantID int64, doc *st
for _, kind := range taxonomyKinds {
entities, err := s.store.ListActiveMatchers(ctx, kind, tenantID)
if err != nil {
s.logger.Warn("failed to list active matchers", "kind", kind, "err", err)
s.reqLog(ctx).Warn("failed to list active matchers", "kind", kind, "err", err)
continue
}
for _, entity := range entities {
@@ -1537,31 +1518,9 @@ func randomUploadID() string {
return hex.EncodeToString(b)
}
// copyFile is the cross-device fallback for os.Rename (EXDEV): copy + fsync
// + remove the source.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(dst)
return err
}
if err := out.Sync(); err != nil {
out.Close()
os.Remove(dst)
return err
}
return out.Close()
}
// The cross-device copy fallback for the WORM move lives in
// internal/objectstore (copyFile there) since FDN-03 moved the archival step
// behind the storage abstraction.
// ocrSupportedMimeTypes is the whitelist of MIME types the OCR pipeline
// (internal/ocr.Extract) actually dispatches on. A declared Content-Type is