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.
126 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|