FUTURE: file/image attachments storage backend (S3?) #38

Closed
opened 2026-09-09 02:10:10 +00:00 by poslop · 4 comments
Owner

Research/design task for file and image paste support: should attachments go to S3-compatible storage (MinIO self-hosted works well, already proven on this homelab via Outline) or filesystem-on-volume? Consider size limits, mime type handling, streaming, serving with correct content types, and integration with the paste cans concept. Deliverable: design doc comparing options with a recommendation.

DESIGN (2026-09-09, agreed direction):

STORAGE: filesystem on the data volume behind a storage interface (internal/store/blob.go), not S3/MinIO initially. Reasons: zero extra services, single binary stays single, k3s PVC already exists, and the interface keeps a MinIO backend possible later. Interface: Put(key, reader, size) / Get(key) / Delete(key) / Stat(key). Blob naming: / to dedupe and prevent traversal.

DATA MODEL: attachments table: id ( cuid), paste_id FK, filename (sanitized, 255 max), mime (sniffed server-side, stored), size bytes, sha256, created_at. Paste content stays in pastes.content; files are separate rows. A can (issue #4) bundles pastes; each paste in a can can have attachments, or the can itself holds them - decision during implementation.

LIMITS: 25 MB per file, 100 MB per paste total (both admin-configurable via #40 settings). Server-side mime sniffing (http.DetectContentType on first 512 bytes); extension never trusted.

ENDPOINTS:

  • POST /api/pastes (multipart/form-data alternative to JSON) OR new POST /api/pastes/{id}/files (multipart, multiple files, auth'd by deletion token or viewer cookie for creator).
  • GET /f/{attachment-id}/{filename} serves the file with stored mime + X-Content-Type-Options: nosniff, Content-Disposition inline for images/pdf, attachment otherwise. NEVER trust user mime for html/svg - force text/plain like the #34 fix.
  • DELETE via existing deletion token / viewer check.

UI: file drop zone + file picker on /new (drag-drop and click), thumbnail preview for images, file chips with size + remove. Paste view renders images inline (img tags, width-capped) and other files as download chips.

E2EE INTERACTION (#39): when encrypted, files are encrypted client-side with the same derived key before upload; blob keys stored per attachment; decrypt in browser on view.

MIGRATION/SCALE: none needed initially; the blob interface allows MinIO later without UI changes.

TASKS: storage interface + tests; attachment endpoints + tests; /f serving with hardening tests (traversal, mime spoofing, oversize); /new dropzone UI; paste view rendering; can integration (#4); docs/API.md section.

Research/design task for file and image paste support: should attachments go to S3-compatible storage (MinIO self-hosted works well, already proven on this homelab via Outline) or filesystem-on-volume? Consider size limits, mime type handling, streaming, serving with correct content types, and integration with the paste cans concept. Deliverable: design doc comparing options with a recommendation. DESIGN (2026-09-09, agreed direction): STORAGE: filesystem on the data volume behind a storage interface (internal/store/blob.go), not S3/MinIO initially. Reasons: zero extra services, single binary stays single, k3s PVC already exists, and the interface keeps a MinIO backend possible later. Interface: Put(key, reader, size) / Get(key) / Delete(key) / Stat(key). Blob naming: <paste-id>/<sha256> to dedupe and prevent traversal. DATA MODEL: attachments table: id ( cuid), paste_id FK, filename (sanitized, 255 max), mime (sniffed server-side, stored), size bytes, sha256, created_at. Paste content stays in pastes.content; files are separate rows. A can (issue #4) bundles pastes; each paste in a can can have attachments, or the can itself holds them - decision during implementation. LIMITS: 25 MB per file, 100 MB per paste total (both admin-configurable via #40 settings). Server-side mime sniffing (http.DetectContentType on first 512 bytes); extension never trusted. ENDPOINTS: - POST /api/pastes (multipart/form-data alternative to JSON) OR new POST /api/pastes/{id}/files (multipart, multiple files, auth'd by deletion token or viewer cookie for creator). - GET /f/{attachment-id}/{filename} serves the file with stored mime + X-Content-Type-Options: nosniff, Content-Disposition inline for images/pdf, attachment otherwise. NEVER trust user mime for html/svg - force text/plain like the #34 fix. - DELETE via existing deletion token / viewer check. UI: file drop zone + file picker on /new (drag-drop and click), thumbnail preview for images, file chips with size + remove. Paste view renders images inline (img tags, width-capped) and other files as download chips. E2EE INTERACTION (#39): when encrypted, files are encrypted client-side with the same derived key before upload; blob keys stored per attachment; decrypt in browser on view. MIGRATION/SCALE: none needed initially; the blob interface allows MinIO later without UI changes. TASKS: storage interface + tests; attachment endpoints + tests; /f serving with hardening tests (traversal, mime spoofing, oversize); /new dropzone UI; paste view rendering; can integration (#4); docs/API.md section.
poslop added the In Progress label 2026-09-09 14:10:29 +00:00
Author
Owner

Design doc: #69 (docs/design/attachments-storage.md)

Design doc: https://git.archfox.org/poslop/palette/pulls/69 (docs/design/attachments-storage.md)
Author
Owner

Design phase complete: #69 merged (docs/design/). Implementation remains open.

Design phase complete: #69 merged (docs/design/). Implementation remains open.
poslop removed the In Progress label 2026-09-09 23:33:43 +00:00
fen added spent time 25 seconds 2026-09-10 02:53:45 +00:00
fen added the In Progress label 2026-09-10 02:53:45 +00:00
fen added QA and removed In Progress labels 2026-09-10 03:22:26 +00:00
fen added spent time 1 minute 2026-09-10 03:24:31 +00:00
Collaborator

QA verdict — PR #129 (file attachments, fix-38-file-attachments)

Abuse matrix: PASS (all security checks)

  • XSS: html/svg uploads served with safe content type; <script> in filename/content escaped in chip render
  • Path traversal: filenames sanitized (../, absolute paths, ..%2f all neutralized); stored under attachment ID, never raw filename
  • Size limits: oversized upload rejected (413/400), body correctly cut off by MaxBytesReader
  • Expired paste: attachment inaccessible (404) after expiry
  • Burned paste: attachment inaccessible after burn
  • Deleted paste: attachment inaccessible after deletion

Blocker found (1):

  • renderPaste never passed an Attachment key into the template data map; GetAttachmentForPaste had zero callers, so the paste.html attachment chip/image-preview block never rendered (confirmed live: no attachment-chip in a file paste's HTML view).

Fix pushed (0073335 on fix-38-file-attachments):

  • renderPaste now looks up GetAttachmentForPaste(row.ID) and adds it as Attachment, so the chip/image preview renders.
  • Cosmetic: deduped the .iconbtn.dark-toggle CSS (3x duplicate blocks from stacked branches #127/#128) to a single occurrence.

Verified locally: go vet + go test pass; live render shows attachment-chip + <img src="/f/{id}/{name}"> (served 200 image/png) for a png upload, and chip-with-filename/no-preview for an html upload.

Status: no merge yet — back in QA label for re-check of the chip render.

## QA verdict — PR #129 (file attachments, fix-38-file-attachments) **Abuse matrix: PASS** (all security checks) - XSS: html/svg uploads served with safe content type; `<script>` in filename/content escaped in chip render - Path traversal: filenames sanitized (`../`, absolute paths, `..%2f` all neutralized); stored under attachment ID, never raw filename - Size limits: oversized upload rejected (413/400), body correctly cut off by MaxBytesReader - Expired paste: attachment inaccessible (404) after expiry - Burned paste: attachment inaccessible after burn - Deleted paste: attachment inaccessible after deletion **Blocker found (1):** - `renderPaste` never passed an `Attachment` key into the template data map; `GetAttachmentForPaste` had zero callers, so the paste.html attachment chip/image-preview block never rendered (confirmed live: no `attachment-chip` in a file paste's HTML view). **Fix pushed** (0073335 on `fix-38-file-attachments`): - `renderPaste` now looks up `GetAttachmentForPaste(row.ID)` and adds it as `Attachment`, so the chip/image preview renders. - Cosmetic: deduped the `.iconbtn.dark-toggle` CSS (3x duplicate blocks from stacked branches #127/#128) to a single occurrence. **Verified locally:** go vet + go test pass; live render shows `attachment-chip` + `<img src="/f/{id}/{name}">` (served 200 image/png) for a png upload, and chip-with-filename/no-preview for an html upload. **Status:** no merge yet — back in QA label for re-check of the chip render.
fen added spent time 25 seconds 2026-09-10 03:55:50 +00:00
Collaborator

QA re-run passed (iteration 1, renderPaste fix verified).

  • Local: go vet + go test clean on 0073335; chip + img preview render on paste view (dump-dom verified), src -> /f/; html upload = chip only, no preview.
  • Abuse matrix spot-checks after fix commit: html forced text/plain + attachment disposition with nosniff, 26MB -> 413, traversal filename sanitized to evil.png. Full matrix passed in prior QA run.
  • Merged to dev (merge API 405-persisted; local --no-ff 22c6624 pushed), CI test+docker success, deployed to palette-dev.
  • Live on palette-dev: png paste shows chip + preview, /f/ serves image/png inline + nosniff; html upload served text/plain attachment.

Issue #38 closed — iteration 1 complete. Deferred: multi-file, S3 backend, E2EE (#39).

QA re-run passed (iteration 1, renderPaste fix verified). - Local: go vet + go test clean on 0073335; chip + img preview render on paste view (dump-dom verified), src -> /f/; html upload = chip only, no preview. - Abuse matrix spot-checks after fix commit: html forced text/plain + attachment disposition with nosniff, 26MB -> 413, traversal filename sanitized to evil.png. Full matrix passed in prior QA run. - Merged to dev (merge API 405-persisted; local --no-ff 22c6624 pushed), CI test+docker success, deployed to palette-dev. - Live on palette-dev: png paste shows chip + preview, /f/ serves image/png inline + nosniff; html upload served text/plain attachment. Issue #38 closed — iteration 1 complete. Deferred: multi-file, S3 backend, E2EE (#39).
fen closed this issue 2026-09-10 04:23:27 +00:00
fen added spent time 1 hour 48 minutes 2026-09-10 13:57:37 +00:00
Sign in to join this conversation.
2 Participants
Notifications
Total Time Spent: 1 hour 50 minutes
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: poslop/palette#38