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,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
|
||||
}
|
||||
Reference in New Issue
Block a user