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>
198 lines
6.2 KiB
Go
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()
|
|
}
|