Files
pad/internal/server/handlers_export_bundle.go
T
xarmian 828b656e92 fix(export): an export reads one snapshot, so a concurrent write cannot leave an item without its collection (BUG-3654) (#2122)
* fix(export): an export reads one snapshot, so a concurrent write cannot leave an item without its collection (BUG-3654)

Both HTTP export doors read each section on the pool, so a collection and
item created between the collections scan and the items scan put the item in
the bundle without its collection, and import dropped it as an orphan. The
bundle's attachment manifest was a separate pooled read with the same gap.

ExportWorkspaceSnapshot reads the export and, for the bundle, the manifest in
one read-only transaction. On SQLite a ReadOnly BeginTx is a plain deferred
BEGIN in modernc, so it does not take the write lock that _txlock=immediate
gives db.Begin(). On Postgres it is REPEATABLE READ, READ ONLY. ExportWorkspace
goes through it too, and the pooled manifest reader is gone, so the bundle
door cannot read the manifest outside the snapshot.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* test(attachments): the BUG-3652 test lists live rows with WorkspaceAttachments, not the export query (BUG-3654)

#2118 added a test calling WorkspaceAttachmentsForExport. This PR makes that
query snapshot-only (workspaceAttachmentsForExportQ, called only inside the
export transaction), so the rebased tree did not compile (CI Go (PostgreSQL)
on #2122 at 21ddabc7f). The test wants the live, non-derived rows, which
WorkspaceAttachments returns; restoring a pooled export query would reopen
the door the fix closes.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-14 00:25:14 -04:00

335 lines
12 KiB
Go

package server
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// exportBundleVersion pins the on-disk bundle layout. Bumped if the
// internal structure changes in a way the import path can't handle
// transparently. Independent of WorkspaceExport.Version so we can
// evolve the JSON schema and the bundle format on different cadences.
const exportBundleVersion = 1
// requireUnrestrictedExportAccess denies workspace export (both the JSON
// and tar.gz bundle forms) to a caller whose workspace access is scoped to
// specific collections (BUG-1922). Dave's ruling: workspace export is an
// owner affordance for backup/portability, not a visibility-scoped view —
// so rather than filtering the export contents to what the caller can see
// (which would silently produce incomplete backups), a restricted caller
// is denied the export outright. member_collection_access has no role
// exclusion (see BUG-1920), so a workspace-role "owner" can still be
// restricted; requireMinRole("owner") alone doesn't catch that.
//
// Must be called after requireMinRole(w, r, "owner") has already passed,
// and before any export content is read from the store or written to the
// response — a 403 here must never follow partial output.
func (s *Server) requireUnrestrictedExportAccess(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
return s.requireUnrestrictedAccess(w, r, workspaceID, "Workspace export requires unrestricted workspace access")
}
// requireUnrestrictedAccess denies, with 403 and message, a caller whose
// workspace access is scoped to specific collections. It gates the
// capabilities that hand out the WHOLE workspace regardless of the caller's
// own visibility: full export (BUG-1922) and webhooks (BUG-3340), which carry
// item snapshots from every collection.
func (s *Server) requireUnrestrictedAccess(w http.ResponseWriter, r *http.Request, workspaceID, message string) bool {
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return false
}
if visibleIDs != nil {
writeError(w, http.StatusForbidden, "forbidden", message)
return false
}
return true
}
// handleExportWorkspaceBundle streams a tar.gz containing the
// workspace JSON export plus every original (non-thumbnail)
// attachment blob and a manifest. Bundle layout:
//
// pad-export.json
// attachments/manifest.json
// attachments/<uuid>.<ext>
//
// Tar entries are written in a deterministic order so byte-identical
// workspaces produce byte-identical bundles (modulo `exported_at`):
//
// 1. pad-export.json
// 2. attachments/manifest.json
// 3. attachments/<uuid>.<ext> — sorted by (created_at, id) via the
// store's ORDER BY in workspaceAttachmentsForExportQ.
//
// We stream chunks straight to the response writer rather than
// buffering — a workspace with multi-GB of attachments would otherwise
// pin that much memory for the duration of the download.
//
// On error mid-stream the connection is dropped (the client sees a
// truncated tar that gunzip will fail to decompress); that's the
// least-bad option since headers + early bytes are already on the
// wire. The error is logged with attachment_id context so operators
// can diagnose without re-running the export.
//
// Auth: owner, and requireUnrestrictedExportAccess further denies a
// restricted owner (BUG-1922). The plain JSON path is owner-only too; the
// bundle has the same access scope plus the user-uploaded attachment
// blobs, so don't loosen.
func (s *Server) handleExportWorkspaceBundle(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
ws, ok := s.getWorkspace(w, r)
if !ok {
return
}
if !s.requireUnrestrictedExportAccess(w, r, ws.ID) {
return
}
// The export and the manifest rows come from one snapshot (BUG-3654), and
// both are read before anything is written to the response so a store
// error doesn't strand half a tar header. Streaming the blobs themselves
// still happens after we commit to the response.
export, attachments, err := s.store.ExportWorkspaceSnapshot(r.Context(), ws.Slug, true)
if errors.Is(err, store.ErrExportManifest) {
writeInternalError(w, err)
return
}
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
// If attachments exist, the registry must be wired — otherwise
// the blobs aren't reachable and the bundle would be a lie.
if len(attachments) > 0 && s.attachments == nil {
writeError(w, http.StatusServiceUnavailable, "attachments_disabled",
"Attachment storage is not configured on this server")
return
}
manifest := models.AttachmentManifest{
Version: exportBundleVersion,
Entries: make([]models.AttachmentManifestEntry, 0, len(attachments)),
}
for _, a := range attachments {
entry := models.AttachmentManifestEntry{
ID: a.ID,
Filename: a.Filename,
// Carried so an import does not re-label a substituted
// "upload.bin" as the caller's own name (BUG-2819).
FilenameSource: a.FilenameSource,
MIME: a.MimeType,
SizeBytes: a.SizeBytes,
ContentHash: a.ContentHash,
Width: a.Width,
Height: a.Height,
UploadedBy: a.UploadedBy,
CreatedAt: a.CreatedAt.UTC().Format(time.RFC3339),
}
if a.ItemID != nil {
entry.ItemID = *a.ItemID
}
// ParentID + Variant stay empty for shipped entries — derived
// rows are filtered out in workspaceAttachmentsForExportQ.
manifest.Entries = append(manifest.Entries, entry)
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", contentDisposition("attachment", ws.Slug+"-export.tar.gz"))
// Bundles are streamed; we don't know the final size up front. No
// Content-Length header — http.Server falls through to chunked
// transfer-encoding, which the gzip+tar pair handles fine.
//
// X-Bundle-Status is an HTTP trailer that CLI clients check after
// streaming — without it, mid-stream errors are invisible because
// headers + the first tar entries are already on the wire. The
// trailer is set to "ok" only when every entry wrote cleanly;
// otherwise the client treats the file as corrupt and discards it.
// Codex caught the silent-corruption gap on PR #305 round 3.
w.Header().Set("Trailer", BundleStatusTrailer)
gzw := gzip.NewWriter(w)
tw := tar.NewWriter(gzw)
// Track streaming success. On error we want the gzip stream to
// terminate WITHOUT a clean trailer so a client that ignores the
// HTTP trailer still detects corruption via gunzip failure.
// Closing tw/gzw flushes the gzip trailer (CRC + size); skipping
// those calls leaves the gzip footer unwritten and the client's
// gzip reader returns ErrUnexpectedEOF.
streamOK := false
defer func() {
if !streamOK {
// Mid-stream failure path. Don't write a clean gzip
// trailer — CLI clients will see an io error reading
// the gzip stream AND a missing X-Bundle-Status:ok
// trailer. Either signal is sufficient on its own.
return
}
if err := tw.Close(); err != nil {
s.logBundleStreamError(r.Context(), "tar close", err)
return
}
if err := gzw.Close(); err != nil {
s.logBundleStreamError(r.Context(), "gzip close", err)
return
}
// Only mark the bundle ok in the trailer once every byte is
// flushed and both close calls returned without error.
w.Header().Set(BundleStatusTrailer, BundleStatusOK)
}()
// 1. pad-export.json
exportJSON, err := json.MarshalIndent(export, "", " ")
if err != nil {
s.logBundleStreamError(r.Context(), "marshal export", err)
return
}
if err := writeTarFile(tw, "pad-export.json", exportJSON); err != nil {
s.logBundleStreamError(r.Context(), "write pad-export.json", err)
return
}
// 2. attachments/manifest.json
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
s.logBundleStreamError(r.Context(), "marshal manifest", err)
return
}
if err := writeTarFile(tw, "attachments/manifest.json", manifestJSON); err != nil {
s.logBundleStreamError(r.Context(), "write manifest", err)
return
}
// 3. attachment blobs
for _, a := range attachments {
if err := s.streamAttachmentToTar(r.Context(), tw, &a); err != nil {
s.logBundleStreamError(r.Context(), "stream attachment", err,
"attachment_id", a.ID, "storage_key", a.StorageKey)
return
}
}
// All entries wrote cleanly. The deferred close + trailer set
// run after this returns; nothing else to do here.
streamOK = true
}
// BundleStatusTrailer is the HTTP response trailer that signals
// whether the export bundle stream completed without errors. A
// successful response sets it to BundleStatusOK; mid-stream errors
// leave it absent. Exported so the CLI can check after streaming.
const (
BundleStatusTrailer = "X-Bundle-Status"
BundleStatusOK = "ok"
)
// streamAttachmentToTar resolves the storage backend for one
// attachment row, writes a tar header sized to size_bytes, and copies
// the blob from the backend into the tar writer in 32 KiB chunks.
//
// Filename inside the tar is `attachments/<uuid><ext>` where ext
// comes from the original filename. Falls back to .bin when the
// upload had no extension. Using <uuid> avoids name collisions when
// two distinct attachments share the same display filename, which
// happens routinely with screenshots ("Screenshot 2025-...png").
func (s *Server) streamAttachmentToTar(ctx context.Context, tw *tar.Writer, a *models.Attachment) error {
store, err := s.attachments.Resolve(a.StorageKey)
if err != nil {
return fmt.Errorf("resolve storage backend: %w", err)
}
body, err := store.Get(ctx, a.StorageKey)
if err != nil {
return fmt.Errorf("get blob: %w", err)
}
defer body.Close()
hdr := &tar.Header{
Name: bundleAttachmentPath(a.ID, a.Filename),
Mode: 0o644,
Size: a.SizeBytes,
ModTime: a.CreatedAt.UTC(),
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("tar header: %w", err)
}
n, err := io.Copy(tw, body)
if err != nil {
return fmt.Errorf("copy blob: %w", err)
}
// io.Copy on a backend that returns fewer bytes than expected
// would otherwise return nil and the tar writer would surface a
// "missed N bytes" error only at Close. Catch the truncation
// here so the per-blob log carries the attachment id +
// storage_key; the deferred tw.Close() then trips its own
// missed-bytes error which we already log.
if n != a.SizeBytes {
return fmt.Errorf("blob truncated: copied %d bytes, expected %d (size_bytes column out of sync with backend?)",
n, a.SizeBytes)
}
return nil
}
// bundleAttachmentPath builds the tar entry name for an attachment.
//
// CROSS-VERSION CONTRACT: import does not read this path from the manifest; it
// RECOMPUTES it from (id, filename) with this same function
// (handlers_import_bundle.go), and a blob whose name matches no entry is
// skipped silently. So changing the output for any input makes an older
// server drop those attachments' bytes from a newer bundle without a word.
// That is why a reserved character in the extension ("x.p|ng") is left as is
// here, rather than made portable (BUG-3186, lead ruling: the bundle is a
// machine format consumed by import). Do not change this casually.
// Exported as a function (not a const helper) so the import path can
// import it and resolve manifest entries without duplicating the
// filename logic.
func bundleAttachmentPath(id, filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
if ext == "" {
ext = ".bin"
}
return "attachments/" + id + ext
}
// writeTarFile writes a single buffered file into the tar archive.
// Used for the small JSON entries (pad-export.json + manifest.json);
// blob entries stream through streamAttachmentToTar instead.
func writeTarFile(tw *tar.Writer, name string, data []byte) error {
hdr := &tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(data)),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := tw.Write(data); err != nil {
return err
}
return nil
}
// logBundleStreamError logs at warn level with structured context.
// Mid-stream failures can't be turned into a clean HTTP error response
// (we've already started the response body), so the operator-facing
// log is the best we can do for diagnostics.
func (s *Server) logBundleStreamError(_ context.Context, op string, err error, kv ...any) {
args := append([]any{"op", op, "error", err}, kv...)
slog.Warn("export bundle stream failed", args...)
}