FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user