Files
pad/internal/server
xarmian 00baf75576 feat(attachments): download/serve API with auth + Range support (TASK-872) (#289)
Adds the GET endpoint that pairs with TASK-871's upload. Streams the
blob from the resolved storage backend with proper headers, Range
support, and cross-workspace defense.

GET /api/v1/workspaces/{slug}/attachments/{attachmentID}
  Optional ?variant=thumb-sm|thumb-md
  - 200 inline render for images / video / audio / PDF / etc.
  - 200 attachment download for HTML / JS / forced-download MIMEs
  - 206 Partial Content on Range requests (video/audio seek)
  - 304 Not Modified on conditional GETs (If-Modified-Since etc.)
  - 400 unknown variant
  - 404 missing attachment OR cross-workspace probe (not 403, to avoid
    leaking existence of attachments in other workspaces)
  - 404 blob_missing if DB row exists but on-disk blob is gone (logs a
    warning since this is a "shouldn't happen" state)
  - 503 if attachments registry not configured

internal/server/handlers_attachments.go
  handleGetAttachment looks up the row, gates cross-workspace via 404,
  optionally swaps to a derived variant via GetAttachmentVariant
  (silent fallback to original when the variant row doesn't exist
  yet — TASK-878 will populate them; this handler shipping today
  doesn't have to wait), resolves the storage backend via Registry,
  and hands off to http.ServeContent when the body satisfies
  io.ReadSeeker. FSStore returns *os.File so that's the common path
  and gets us Range / 206 / conditional GETs for free. Backends
  without Seek (a future S3 streaming reader) fall through to a
  plain io.Copy with no Range support — the contract is "Range works
  when the backend supports it, never breaks correctness".

  Headers:
    Content-Type from att.MimeType (already canonical post-allowlist)
    Content-Disposition: inline | attachment, filename sanitized to
      strip quotes/backslashes/control bytes (header-injection defense
      on top of the upload-time basenaming)
    Cache-Control: private, max-age=3600 (Phase 3 revisits for CDN)
    X-Content-Type-Options: nosniff (browser should never re-sniff;
      we already validated MIME at upload)

  Upload response now includes "url" again — TASK-871 had dropped it
  because the GET handler didn't exist yet. Slug-form path matches
  every other API endpoint.

internal/store/attachments.go
  GetAttachmentVariant(parentID, variant) for the ?variant lookup.

internal/server/server.go
  GET /workspaces/{slug}/attachments/{attachmentID} wired alongside
  the existing POST.

Tests
  Happy-path PNG, HTML force-download, 404 missing, cross-workspace
  404 (NOT 403), Range 206 with bytes 10-29 of an MP4 payload,
  variant fallback to original, unknown variant rejected, derived
  thumb-sm row honored when present, blob-missing 404, and the
  filename sanitizer table.

Verification
  go build ./... — clean
  go vet ./... — clean
  go test ./... — all packages pass
  make install — server restarts on the new binary

Parent: PLAN-866.
2026-04-29 12:34:36 -04:00
..