Files
paliad/internal/handlers/export.go
mAi 28c7215458 feat(export): t-paliad-214 Slice 1 backend — personal sync export endpoint + xlsx/json/csv writer
Adds GET /api/me/export streaming a deterministic .zip bundle of the
caller's RLS-visible projection (per design §2.3): projects, deadlines,
appointments, parties, notes, documents (metadata), audit events,
approval requests, checklist instances + personal sidecars (me row,
caldav config without ciphertext, views, pins, card layouts, paliadin
turns) + reference data (proceeding_types, event_types, deadline_rules,
courts, countries, holidays …) + restricted users_referenced sheet.

Bundle shape: paliad-export.xlsx + paliad-export.json + per-sheet
CSVs (UTF-8 BOM, RFC 4180) + README.txt + __meta.json. Outer zip is
byte-deterministic — sorted file list, fixed Modified time on every
entry, sorted JSON keys. Two runs at same row-state → identical bytes.

ExportService.WritePersonal owns the SQL recipe + column discovery
+ PII deny-regex (?i)secret|token|password|api[_-]?key|private[_-]?key
+ per-sheet DropColumns belt-and-braces (e.g. user_caldav_config
.password_encrypted explicitly dropped on top of the regex). Audit row
written to paliad.system_audit_log before the run, patched with
row_counts + file_size_bytes after.

Migration 102 creates paliad.system_audit_log (generic event_type +
actor_id/email + scope + scope_root + metadata jsonb). Idempotent
CREATE TABLE IF NOT EXISTS + indexes; RLS enabled with self-read +
admin-read policies. AuditService.ListEntries gains a 6th UNION branch
so the new table surfaces on /admin/audit-log.

excelize/v2 added to go.mod for xlsx generation.

Pure-function tests pin formatCellValue value-coercion, PII regex,
CSV quoting + BOM + umlaut survival, JSON shape, meta key order
stability, filename slugify, and byte-determinism of the bundle
assembly.

Design: docs/design-paliad-data-export-2026-05-19.md §7 Slice 1.
2026-05-19 12:51:52 +02:00

126 lines
4.2 KiB
Go

package handlers
// Data-export handlers (t-paliad-214).
//
// Slice 1 ships the personal scope only:
//
// GET /api/me/export → streams a personal-scope export .zip
//
// Slices 2 + 3 (project + org) layer onto this file when they ship.
//
// Authentication: the existing protected mux middleware (auth.Middleware +
// auth.WithUserID) populates the user UUID in the context. We do not gate
// on global_role here — personal export is available to every authenticated
// user.
import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"strconv"
"time"
"mgit.msbls.de/m/paliad/internal/services"
)
// exportRequestTimeout caps any single export request. Personal-scope
// exports at firm-scale data shape complete in well under this; the
// timeout is the watchdog that surfaces "too large for sync" loudly
// (the user gets a 503 and slice 3's async path becomes the answer).
const exportRequestTimeout = 30 * time.Second
// handleMeExport streams the caller's personal-scope export .zip.
//
// Order of operations:
//
// 1. Validate auth + db wiring.
// 2. Look up the caller's user row for actor_email / actor_label.
// 3. Write an audit row (event_type='data_export', scope='personal').
// 4. Run the export into an in-memory buffer (so we can patch the
// audit row with file_size_bytes before flushing to the client).
// 5. Set headers + flush.
// 6. Patch the audit row with success (row_counts + file_size).
// On any error after step 3, the audit row is patched as failed.
func handleMeExport(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.export == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "export service not configured",
})
return
}
// Apply the per-request watchdog.
ctx, cancel := context.WithTimeout(r.Context(), exportRequestTimeout)
defer cancel()
user, err := dbSvc.users.GetByID(ctx, uid)
if err != nil || user == nil {
log.Printf("export: user lookup failed for %s: %v", uid, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "user lookup failed",
})
return
}
spec := services.ExportSpec{
Scope: services.ExportScopePersonal,
ActorID: uid,
ActorEmail: user.Email,
ActorLabel: user.DisplayName,
GeneratedAt: time.Now().UTC(),
}
auditID, err := dbSvc.export.WriteAuditRow(ctx, spec)
if err != nil {
log.Printf("export: audit insert failed for %s: %v", uid, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "audit write failed",
})
return
}
// Generate into a memory buffer so we can size + audit-patch BEFORE
// writing to the response (otherwise headers are committed and we
// can't return a 500 if anything fails). At personal scale this is a
// sub-megabyte buffer.
var buf bytes.Buffer
meta, err := dbSvc.export.WritePersonal(ctx, &buf, spec)
if err != nil {
dbSvc.export.PatchAuditRowFailure(context.Background(), auditID, err.Error())
log.Printf("export: WritePersonal failed for %s (audit=%s): %v", uid, auditID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "export generation failed",
})
return
}
filename := services.ExportFilename(services.ExportScopePersonal, "", spec.GeneratedAt)
size := int64(buf.Len())
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
// Audit-patch failure isn't fatal to the user — they still get
// their export. Log it; the data already left the system.
log.Printf("export: audit patch failed for %s (audit=%s): %v", uid, auditID, err)
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.Header().Set("X-Paliad-Export-Audit-Id", auditID.String())
if _, err := w.Write(buf.Bytes()); err != nil {
// Connection dropped mid-flush — the user didn't get the file.
// We don't patch the audit row a second time; the success patch
// already recorded the row counts. A separate event would be
// noise (the failure is at the network layer, not in our path).
log.Printf("export: response write failed for %s (audit=%s): %v", uid, auditID, err)
}
}