Files
nexarch/dms/internal/export/export.go
T
patrickandClaude Sonnet 5 79c4458618 DOC-18: obergrenze fuer gesamtgroesse/anzahl bei archiv-buendelung
Enqueue prueft konfigurierbare Obergrenzen (Dokumentanzahl, Gesamtgroesse)
VOR dem Anlegen des Auftrags/Einreihen des Jobs und lehnt Ueberschreitungen
mit konkreter Fehlermeldung ab, statt den Job-Worker-Pool zu belasten.

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

149 lines
5.5 KiB
Go

// Package export implementiert DOC-08: Auswahl mehrerer Dokumente als
// Hintergrund-Job zu einem ZIP-Archiv bündeln, inklusive Metadaten-Beilage.
// State-Machine (pending/processing/ready/failed) analog zu
// internal/thumbnail (DOC-04) und internal/malwarescan (DOC-17).
//
// DOC-18: harte, konfigurierbare Obergrenzen für Gesamtgröße und
// Dokumentanzahl je Bündelungs-Anfrage — geprüft VOR dem Anlegen des
// Auftrags/Einreihen des Jobs, damit eine überdimensionierte Anfrage nie
// den gemeinsamen Job-Worker-Pool erreicht (Threat-Model-Finding #9).
package export
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
)
const JobType = "export.zip"
const (
StatusPending = "pending"
StatusProcessing = "processing"
StatusReady = "ready"
StatusFailed = "failed"
)
var ErrNotFound = errors.New("export: nicht gefunden")
// ErrTooManyDocuments/ErrTotalSizeTooLarge werden von Enqueue geliefert,
// wenn eine Anfrage die konfigurierte Obergrenze überschreitet (DOC-18
// Akzeptanzkriterium 1) — als eigener Fehlertyp mit den tatsächlichen
// Zahlen, damit der Aufrufer eine klare, konkrete Fehlermeldung anzeigen
// kann statt nur "abgelehnt".
type ErrTooManyDocuments struct {
Requested, MaxAllowed int
}
func (e *ErrTooManyDocuments) Error() string {
return fmt.Sprintf("export: %d dokumente angefragt, maximal %d je anfrage erlaubt", e.Requested, e.MaxAllowed)
}
type ErrTotalSizeTooLarge struct {
RequestedBytes, MaxAllowedBytes int64
}
func (e *ErrTotalSizeTooLarge) Error() string {
return fmt.Sprintf("export: gesamtgroesse %d bytes angefragt, maximal %d bytes je anfrage erlaubt", e.RequestedBytes, e.MaxAllowedBytes)
}
// Limits sind die DOC-18-Obergrenzen — vom Aufrufer (cmd/app, aus
// Umgebungsvariablen) befüllt, nicht hart im Code verankert
// (Akzeptanzkriterium 3). Ein Feld mit Wert 0 bedeutet "keine Grenze"
// (z. B. für Tests, die die Begrenzung selbst nicht prüfen wollen).
type Limits struct {
MaxDocuments int
MaxTotalSizeBytes int64
}
// Export ist der Zustand EINES Massenexport-Auftrags.
type Export struct {
ID string
RequestedBy string
DocumentIDs []string
Status string
StorageKey *string
SkippedIDs []string
Error *string
}
type jobPayload struct {
ExportID string `json:"export_id"`
}
// Enqueue prüft die DOC-18-Obergrenzen (Akzeptanzkriterium 1: Ablehnung
// VOR Jobstart, mit klarer Fehlermeldung) und legt danach den Export-
// Auftrag im "pending"-Zustand an und reiht den Verarbeitungs-Job ein
// (Akzeptanzkriterium 1 aus DOC-08: läuft als Job, blockiert die
// Oberfläche nicht). Liefert die Export-ID, über die der Fortschritt
// abgefragt werden kann.
func Enqueue(ctx context.Context, pool *pgxpool.Pool, queue *jobqueue.Queue, requestedBy string, documentIDs []string, limits Limits) (string, error) {
if limits.MaxDocuments > 0 && len(documentIDs) > limits.MaxDocuments {
return "", &ErrTooManyDocuments{Requested: len(documentIDs), MaxAllowed: limits.MaxDocuments}
}
if limits.MaxTotalSizeBytes > 0 {
var totalSize int64
if err := pool.QueryRow(ctx, `
SELECT COALESCE(SUM(fr.size_bytes), 0)
FROM documents d JOIN file_revisions fr ON fr.id = d.current_revision_id
WHERE d.id = ANY($1)
`, documentIDs).Scan(&totalSize); err != nil {
return "", fmt.Errorf("export: gesamtgroesse ermitteln: %w", err)
}
if totalSize > limits.MaxTotalSizeBytes {
return "", &ErrTotalSizeTooLarge{RequestedBytes: totalSize, MaxAllowedBytes: limits.MaxTotalSizeBytes}
}
}
var exportID string
if err := pool.QueryRow(ctx, `
INSERT INTO document_exports (requested_by, document_ids, status) VALUES ($1, $2, $3) RETURNING id
`, requestedBy, documentIDs, StatusPending).Scan(&exportID); err != nil {
return "", fmt.Errorf("export: auftrag anlegen: %w", err)
}
if _, err := queue.Enqueue(ctx, JobType, jobPayload{ExportID: exportID}, jobqueue.EnqueueOptions{
IdempotencyKey: "export:" + exportID,
}); err != nil {
return "", fmt.Errorf("export: job einreihen: %w", err)
}
return exportID, nil
}
// Get liefert den aktuellen Zustand eines Export-Auftrags.
func Get(ctx context.Context, pool *pgxpool.Pool, exportID string) (Export, error) {
var e Export
e.ID = exportID
err := pool.QueryRow(ctx, `
SELECT requested_by, document_ids, status, storage_key, skipped_ids, error FROM document_exports WHERE id = $1
`, exportID).Scan(&e.RequestedBy, &e.DocumentIDs, &e.Status, &e.StorageKey, &e.SkippedIDs, &e.Error)
if errors.Is(err, pgx.ErrNoRows) {
return Export{}, ErrNotFound
}
if err != nil {
return Export{}, fmt.Errorf("export: auftrag lesen: %w", err)
}
return e, nil
}
func markProcessing(ctx context.Context, pool *pgxpool.Pool, exportID string) error {
_, err := pool.Exec(ctx, `UPDATE document_exports SET status = $2, updated_at = now() WHERE id = $1`, exportID, StatusProcessing)
return err
}
func markFailed(ctx context.Context, pool *pgxpool.Pool, exportID string, cause error) error {
_, err := pool.Exec(ctx, `UPDATE document_exports SET status = $2, error = $3, updated_at = now() WHERE id = $1`, exportID, StatusFailed, cause.Error())
return err
}
func markReady(ctx context.Context, pool *pgxpool.Pool, exportID, storageKey string, skippedIDs []string) error {
_, err := pool.Exec(ctx, `
UPDATE document_exports SET status = $2, storage_key = $3, skipped_ids = $4, error = NULL, updated_at = now() WHERE id = $1
`, exportID, StatusReady, storageKey, skippedIDs)
return err
}