Files
nexarch/dms/internal/export/zip.go
T
patrickandClaude Sonnet 5 df0234d30c DOC-08: massenexport als zip
Export laeuft als jobqueue-Job (blockiert nicht), streamt das ZIP direkt
via io.Pipe in den Objekt-Storage (kein vollstaendiger Aufbau im Speicher),
fuegt eine manifest.csv-Metadaten-Beilage hinzu und ueberspringt Dokumente
ohne Berechtigung statt sie stillschweigend einzuschliessen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 00:53:01 +02:00

198 lines
6.2 KiB
Go

package export
import (
"archive/zip"
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"path"
"strconv"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
)
// PermissionChecker entscheidet, ob requestedBy EIN bestimmtes Dokument
// exportieren darf (Akzeptanzkriterium 3: nur berechtigte Dokumente landen
// im Archiv) — dasselbe Deferred-RBAC-Muster wie in den übrigen HTTP-/Job-
// Paketen (VER-02/DOC-15/VER-06/DOC-07).
type PermissionChecker interface {
CanAccessDocument(ctx context.Context, callerUserID, documentID string) (bool, error)
}
// Processor erzeugt EIN ZIP-Archiv für einen Export-Auftrag.
type Processor struct {
Pool *pgxpool.Pool
Storage *storage.Service
Crypto *dmscrypto.Service
TenantSlug string
Permissions PermissionChecker
}
// Handler ist der jobqueue.Handler für JobType.
func (p *Processor) Handler(ctx context.Context, job *jobqueue.Job) error {
var payload jobPayload
if err := json.Unmarshal(job.Payload, &payload); err != nil {
return fmt.Errorf("export: auftrag dekodieren: %w", err)
}
if err := markProcessing(ctx, p.Pool, payload.ExportID); err != nil {
return fmt.Errorf("export: verarbeitung markieren: %w", err)
}
if err := p.process(ctx, payload.ExportID); err != nil {
if markErr := markFailed(ctx, p.Pool, payload.ExportID, err); markErr != nil {
return fmt.Errorf("export: fehlschlag erfassen (ursprünglicher fehler: %v): %w", err, markErr)
}
return err
}
return nil
}
type manifestRow struct {
DocumentID string
Title string
ChecksumSHA256 string
SizeBytes int64
}
func (p *Processor) process(ctx context.Context, exportID string) error {
exp, err := Get(ctx, p.Pool, exportID)
if err != nil {
return fmt.Errorf("export: auftrag lesen: %w", err)
}
var allowedIDs, skippedIDs []string
for _, documentID := range exp.DocumentIDs {
allowed, err := p.Permissions.CanAccessDocument(ctx, exp.RequestedBy, documentID)
if err != nil {
return fmt.Errorf("export: berechtigung pruefen: %w", err)
}
if allowed {
allowedIDs = append(allowedIDs, documentID)
} else {
skippedIDs = append(skippedIDs, documentID)
}
}
// Streaming statt vollstaendigem Aufbau im Speicher (Pflichtpruefung 1:
// 1000 Dokumente ohne Speicherueberlauf) — ein io.Pipe verbindet den
// zip.Writer (schreibt synchron in der Verarbeitungs-Goroutine) direkt
// mit storage.Service.Put (liest in DIESER Goroutine), es entsteht nie
// ein vollstaendiges ZIP-Byte-Array im Speicher.
pr, pw := io.Pipe()
usedNames := map[string]int{}
manifest := make([]manifestRow, 0, len(allowedIDs))
writeErrCh := make(chan error, 1)
go func() {
zw := zip.NewWriter(pw)
err := func() error {
for _, documentID := range allowedIDs {
row, err := p.addDocumentToZip(ctx, zw, documentID, usedNames)
if err != nil {
return err
}
manifest = append(manifest, row)
}
return writeManifest(zw, manifest)
}()
if closeErr := zw.Close(); err == nil {
err = closeErr
}
writeErrCh <- err
_ = pw.CloseWithError(err)
}()
storageKey := "exports/" + exportID + ".zip"
if _, err := p.Storage.Put(ctx, storageKey, pr, -1, "application/zip"); err != nil {
<-writeErrCh
return fmt.Errorf("export: zip im storage ablegen: %w", err)
}
if err := <-writeErrCh; err != nil {
return fmt.Errorf("export: zip erzeugen: %w", err)
}
if err := markReady(ctx, p.Pool, exportID, storageKey, skippedIDs); err != nil {
return fmt.Errorf("export: bereit-zustand speichern: %w", err)
}
return nil
}
func (p *Processor) addDocumentToZip(ctx context.Context, zw *zip.Writer, documentID string, usedNames map[string]int) (manifestRow, error) {
var title, storageKey, checksumHex string
var sizeBytes int64
var wrappedDEK []byte
if err := p.Pool.QueryRow(ctx, `
SELECT d.title, fr.storage_key, fr.checksum_sha256, fr.size_bytes, fr.wrapped_dek
FROM documents d JOIN file_revisions fr ON fr.id = d.current_revision_id
WHERE d.id = $1
`, documentID).Scan(&title, &storageKey, &checksumHex, &sizeBytes, &wrappedDEK); err != nil {
return manifestRow{}, fmt.Errorf("export: dokument %s lesen: %w", documentID, err)
}
cipherReader, err := p.Storage.Get(ctx, storageKey)
if err != nil {
return manifestRow{}, fmt.Errorf("export: dokument %s aus storage laden: %w", documentID, err)
}
defer func() { _ = cipherReader.Close() }()
plainReader, err := p.Crypto.Open(ctx, p.TenantSlug, wrappedDEK, cipherReader)
if err != nil {
return manifestRow{}, fmt.Errorf("export: dokument %s entschluesseln: %w", documentID, err)
}
entryName := uniqueEntryName(title, usedNames)
w, err := zw.Create(entryName)
if err != nil {
return manifestRow{}, fmt.Errorf("export: zip-eintrag %s anlegen: %w", entryName, err)
}
if _, err := io.Copy(w, plainReader); err != nil {
return manifestRow{}, fmt.Errorf("export: dokument %s in zip schreiben: %w", documentID, err)
}
return manifestRow{DocumentID: documentID, Title: entryName, ChecksumSHA256: checksumHex, SizeBytes: sizeBytes}, nil
}
// uniqueEntryName haengt bei einem Namenskonflikt einen Zaehler an, damit
// gleichnamige Dokumente im selben Export nicht denselben Zip-Eintrag
// ueberschreiben.
func uniqueEntryName(title string, usedNames map[string]int) string {
base := title
if base == "" {
base = "dokument"
}
count := usedNames[base]
usedNames[base] = count + 1
if count == 0 {
return base
}
ext := path.Ext(base)
stem := strings.TrimSuffix(base, ext)
return fmt.Sprintf("%s (%d)%s", stem, count, ext)
}
// writeManifest fuegt die Metadaten-Beilage hinzu (Akzeptanzkriterium 2).
func writeManifest(zw *zip.Writer, rows []manifestRow) error {
w, err := zw.Create("manifest.csv")
if err != nil {
return fmt.Errorf("export: manifest anlegen: %w", err)
}
csvWriter := csv.NewWriter(w)
if err := csvWriter.Write([]string{"document_id", "dateiname", "sha256", "groesse_bytes"}); err != nil {
return err
}
for _, row := range rows {
if err := csvWriter.Write([]string{row.DocumentID, row.Title, row.ChecksumSHA256, strconv.FormatInt(row.SizeBytes, 10)}); err != nil {
return err
}
}
csvWriter.Flush()
return csvWriter.Error()
}