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: // // .<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 }