Fix #38 (iteration 1: 1 file per paste) #129

Closed
fen wants to merge 0 commits from fix-38-file-attachments into dev
Collaborator

Fixes #38 (iteration 1: 1 file per paste)

What

  • Storage: internal/store/blob.go — BlobStore interface (Put/Get/Delete/Stat) + filesystem implementation. Blob keys are <paste-id>/<sha256-hex> (content-derived, dedup-friendly, traversal-proof: segment count/../dot segments rejected, covered by tests). Blobs live under <db>.files/ next to the SQLite DB (k3s PVC already covers it).
  • Data model: attachments table (id, paste_id, filename sanitized to 255 bytes w/ directory components stripped, mime sniffed server-side via http.DetectContentType on first 512 bytes, size, sha256, created_at).
  • Create: POST /api/pastes now also accepts multipart/form-data with a file part plus the normal fields (title, custom_slug, password, expires_in, burn_*, visibility). 1 file = 1 paste (user decision): if both text and a file are sent, the file wins and the text is ignored — chosen over rejecting because it makes Ctrl+V-over-existing-text frictionless; the server enforces it, so API clients see the same behavior.
  • Limits: 25 MB per file enforced server-side (413 file_too_large); body cap raised to accommodate multipart overhead.
  • Serving: GET /f/{attachment-id}/{filename} — stored (sniffed) mime, X-Content-Type-Options: nosniff, Content-Disposition: inline only for images/pdf, attachment otherwise. html/svg/xml forced to text/plain; charset=utf-8 (same rule as the /raw #34 fix) so uploads can never execute on this origin. Client-supplied mime is always ignored. Attachment lifecycle follows the paste: expired/burned/deleted paste => 404.
  • UI (/new): dropzone section consistent with existing pill/radius design — click to open file picker, drag-and-drop anywhere on the page, Ctrl+V of a file anywhere; file chip shows name + size + remove. Paste view renders an attachment chip and inline preview for images.

Tests (18 new, all passing; go vet + full test suite green)

  • blob: put/get/stat/delete roundtrip, missing-key, traversal prevention, filename sanitization
  • multipart create: serve roundtrip w/ headers, file-replaces-text, second file rejected, html forced to text/plain, svg forced to text/plain, client mime ignored, oversize 413, exactly-at-limit accepted, empty file 400, missing 404, orphaned-attachment 404, pdf inline, burned-paste attachment 404, password/expiry fields
  • Live-verified locally (built binary + curl matrix + headless chromium): upload, serving headers, 404s, size limit, JSON path regression, /new dropzone render + DataTransfer drop/paste simulation with no console errors

Deferred (later iterations)

  • multiple files per paste / 100 MB per-paste total budget
  • cans integration (file uploads inside cans)
  • admin-configurable limits UI (#40 settings)
  • E2EE file encryption (#39)
  • MinIO/S3 backend behind the BlobStore interface
Fixes #38 (iteration 1: 1 file per paste) ## What - **Storage**: `internal/store/blob.go` — BlobStore interface (Put/Get/Delete/Stat) + filesystem implementation. Blob keys are `<paste-id>/<sha256-hex>` (content-derived, dedup-friendly, traversal-proof: segment count/`..`/dot segments rejected, covered by tests). Blobs live under `<db>.files/` next to the SQLite DB (k3s PVC already covers it). - **Data model**: `attachments` table (id, paste_id, filename sanitized to 255 bytes w/ directory components stripped, mime sniffed server-side via `http.DetectContentType` on first 512 bytes, size, sha256, created_at). - **Create**: `POST /api/pastes` now also accepts `multipart/form-data` with a `file` part plus the normal fields (title, custom_slug, password, expires_in, burn_*, visibility). **1 file = 1 paste (user decision)**: if both text and a file are sent, the file wins and the text is ignored — chosen over rejecting because it makes Ctrl+V-over-existing-text frictionless; the server enforces it, so API clients see the same behavior. - **Limits**: 25 MB per file enforced server-side (413 `file_too_large`); body cap raised to accommodate multipart overhead. - **Serving**: `GET /f/{attachment-id}/{filename}` — stored (sniffed) mime, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline` only for images/pdf, `attachment` otherwise. html/svg/xml forced to `text/plain; charset=utf-8` (same rule as the /raw #34 fix) so uploads can never execute on this origin. Client-supplied mime is always ignored. Attachment lifecycle follows the paste: expired/burned/deleted paste => 404. - **UI** (/new): dropzone section consistent with existing pill/radius design — click to open file picker, drag-and-drop anywhere on the page, Ctrl+V of a file anywhere; file chip shows name + size + remove. Paste view renders an attachment chip and inline preview for images. ## Tests (18 new, all passing; go vet + full test suite green) - blob: put/get/stat/delete roundtrip, missing-key, traversal prevention, filename sanitization - multipart create: serve roundtrip w/ headers, file-replaces-text, second file rejected, html forced to text/plain, svg forced to text/plain, client mime ignored, oversize 413, exactly-at-limit accepted, empty file 400, missing 404, orphaned-attachment 404, pdf inline, burned-paste attachment 404, password/expiry fields - Live-verified locally (built binary + curl matrix + headless chromium): upload, serving headers, 404s, size limit, JSON path regression, /new dropzone render + DataTransfer drop/paste simulation with no console errors ## Deferred (later iterations) - multiple files per paste / 100 MB per-paste total budget - cans integration (file uploads inside cans) - admin-configurable limits UI (#40 settings) - E2EE file encryption (#39) - MinIO/S3 backend behind the BlobStore interface
fen added 2 commits 2026-09-10 03:21:47 +00:00
- Collapse the 10 theme cards into 5 pairs: light swatches top row, dark bottom row
- Dark mode toggle in settings and topbar (sun/moon inline SVG)
- Toggling dark mode switches to the other variant of the selected pair
- Persist pair id in localStorage palette-theme, dark flag in palette-dark
- Head script resolves base+dark to variant; URL ?theme= accepts both ids and wins
- Settings gear highlights like nav tabs on /settings
- PALETTE_DEFAULT_DARK env var sets server default dark state (default on)
- Server-side resolution helpers + tests
#38 iteration 1: file attachments, 1 file per paste
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 26s
4948ef9f1c
- internal/store/blob.go: BlobStore interface + fs implementation with
  traversal-safe keys (<paste-id>/<sha256>), put/get/stat/delete
- attachments table migration (id, paste_id, filename sanitized to 255,
  mime sniffed server-side, size, sha256, created_at)
- POST /api/pastes now accepts multipart/form-data with a 'file' part;
  1 file = 1 paste: file replaces text content when both are sent
- 25 MB per-file limit enforced server-side (413 file_too_large)
- GET /f/{attachment-id}/{filename}: stored sniffed mime, nosniff,
  inline only for images/pdf, html/svg/xml forced to text/plain (#34 rule)
- paste view renders attachment chip + inline image preview
- /new: dropzone with file picker, drag-and-drop, Ctrl+V file paste,
  file chip with name/size/remove, matches pill/radius design
- tests: blob roundtrip/traversal/sanitize; multipart create (mime
  sniffing, client mime ignored, size limit, two-file reject, html/svg
  forcing, 404s, password/expiry fields)
fen added 1 commit 2026-09-10 03:53:57 +00:00
#38 QA fix: render GetAttachmentForPaste in paste view; dedupe dark-toggle CSS
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 27s
0073335875
- renderPaste now looks up the paste's attachment and passes it as
  'Attachment' so the paste.html attachment chip/image preview renders
- remove duplicated .iconbtn.dark-toggle blocks (3x from stacked
  branches #127/#128) and duplicate mobile-media variants

Verified: go vet + go test pass; live render on :18098 shows
attachment-chip + img src=/f/id/name for a png upload (served 200
image/png) and chip-with-filename, no preview, for an html upload.
fen closed this pull request 2026-09-10 04:23:27 +00:00

Pull request closed

This pull request cannot be reopened because the branch was deleted.
Sign in to join this conversation.