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)
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
- 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
- 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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #38 (iteration 1: 1 file per paste)
What
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).attachmentstable (id, paste_id, filename sanitized to 255 bytes w/ directory components stripped, mime sniffed server-side viahttp.DetectContentTypeon first 512 bytes, size, sha256, created_at).POST /api/pastesnow also acceptsmultipart/form-datawith afilepart 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.file_too_large); body cap raised to accommodate multipart overhead.GET /f/{attachment-id}/{filename}— stored (sniffed) mime,X-Content-Type-Options: nosniff,Content-Disposition: inlineonly for images/pdf,attachmentotherwise. html/svg/xml forced totext/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.Tests (18 new, all passing; go vet + full test suite green)
Deferred (later iterations)
- 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)Pull request closed