Table of Contents
Attachments & Storage Backend Design (#38, #31)
Status: research/design, no implementation. Consumers: paste cans (#4).
Part A — Attachments: S3/MinIO vs filesystem-on-volume (#38)
Options
Option 1: Filesystem on the k3s PVC (current 5Gi volume).
Store blobs under <data-dir>/attachments/<paste-id>/<n>-<sha256-8>, metadata in SQLite (paste_id, filename, size, sha256, mime, created_at).
- Pros: zero new infra, zero new credentials, trivial backup (the volume backup job already covers the DB), atomic rename on write, works in dev and prod identically.
- Cons: volume is size-capped (5Gi today; resizable but bounded); serving large files passes through the app process (no ranged-GET offload); multi-replica later would need RWX volume.
Option 2: MinIO via S3 API.
MinIO is already proven in this homelab (Outline). Store at key <paste-id>/<n>; same SQLite metadata row.
- Pros: effectively unbounded capacity, presigned URLs (direct browser download, offloads serving from palette pods), ranged requests free, lifecycle rules could auto-expire orphaned objects.
- Cons: another credential/secret to manage, another failure mode, MultipartForm still terminates at the palette pod (MinIO only helps serving, not uploading, unless we do presigned uploads — which breaks the cans multipart flow and the auth/unlock checks), backup now spans two systems.
Key considerations
- Upload path is the same either way. Palette receives
multipart/form-data(cans need text items + file drops in one request), must enforce auth/password/burn rules server-side. A filesystem backend adds no upload complexity; S3 adds an extra hop (buffer → PUT to MinIO). Streaming straight frommultipart.Readerto the sink works for both (io.Copyto a temp file, or to an S3 PUT withContent-Lengthknown or multipart buffering). - Size limits. Everything is admin-tunable via the settings API (#40 pattern) — add
max_attachment_bytes(default 10 MiB, hard server-side cap checked before reading the body viaContent-Length, plus a counted reader during copy so chunked uploads can't lie). SQLite itself is not a constraint either way; the PVC is the real cap for Option 1. - MIME handling. Security-critical (pentest #34 already fixed a content-type XSS on
/raw). Rules:- Never trust the client-declared Content-Type. Sniff the first 512 bytes (
http.DetectContentType), intersect with an allowlist. - Serve from a dedicated route (
/{id}/a/{n}) withContent-Typefrom the stored sniffed type,X-Content-Type-Options: nosniff,Content-Security-Policy: sandbox,Content-Disposition: attachmentunless the type is on a safe-inline allowlist (text/plain, images, PDF at user opt-in). - Never render user HTML/SVG inline (
image/svg+xmlis XSS-capable — serve asattachmentalways, or store sanitized).
- Never trust the client-declared Content-Type. Sniff the first 512 bytes (
- Streaming & serving.
- Filesystem:
http.ServeContenton the opened file gives ranged GETs, ETag, Last-Modified for free. - MinIO: proxy via
GetObject+io.Copy(simple, keeps auth checks in palette) or presigned GET (faster, but URL embeds credentials-temporarily and bypasses palette's per-request auth — wrong for pastes with passwords/burn semantics). Given cans inherit password/burn parity (#4), proxying is required, which erodes MinIO's main serving advantage.
- Filesystem:
- Lifecycle parity. Attachments must honor soft-delete grace and sweep: sweeper hard-delete also removes blobs (files:
os.Remove; S3:DeleteObject), best-effort with logging; orphan sweep job compares DB rows to store contents.
Recommendation
Filesystem-on-volume first. At current scale (single replica, PVC-based deploy, one user + homelab traffic) it is simpler end-to-end and keeps serving/auth/lifecycle in one place. The internal API should be a narrow blob interface (Put(ctx, key, r io.Reader, size int64) / Open(key) / Delete(key)) — about 60 lines per backend — so MinIO becomes a drop-in later if attachments outgrow the volume. That's the honest middle path: filesystem default, S3-ready seam, no MinIO dependency until it pays for itself.
Part B — SQLite vs Postgres vs Redis (#31)
Assessment of SQLite at pastebin scale
- Driver: modernc.org/sqlite (pure Go, no cgo) — slightly slower than mattn/go-sqlite3 but fine; single-writer semantics are the real constraint, not driver speed.
- Access pattern: paste-heavy, write-rare/read-often; primary keys and small set of indexes (visibility+created, expires, deleted); no joins beyond can items. This is SQLite's best case.
- WAL mode is already on (
journal_mode(WAL), busy_timeout(5000)) — concurrent readers don't block the single writer. - Numbers: SQLite comfortably handles millions of rows and hundreds of reads/sec; WAL write throughput is thousands of small inserts/sec. A pastebin doing even 100k pastes (avg 10 KB = ~1 GB DB) is trivial. Reads: prepared
WHERE id=?lookups at this size are sub-millisecond. - Weak points to watch (document, none urgent):
- Single writer — heavy concurrent create traffic serializes. Mitigation: already rate-limited (#2); fine until that's the bottleneck (unlikely).
LENGTH(content)on every list row — fine now; if it shows up in profiling, storesizeas a column (schema already has aSizefield; list queries could use it).- Sweeper runs a table-wide
UPDATE+DELETEon tick — indexed, fine. - No network access to the DB file — locks palette to single-replica. Acceptable: current deploy is one replica.
- Redis is the wrong tool here: it's a cache/queue, not a system of record. Pastes are durable data with expiry semantics already implemented in SQLite. Redis would only add an optional read-cache layer for hot pastes — pure complexity for zero measured need.
Should we build a backend abstraction (SQLite default, optional Postgres)?
Arguments for: multi-replica scaling later; "docker image env choice" sounds nice; Postgres gives real concurrency and network access.
Arguments against: a Store interface covering the current query surface is a real refactor (sqlite-flavored SQL: INSERT OR IGNORE-style upserts, partial indexes, ? placeholders are compatible but behaviors differ — e.g. sqlite driver pragmas, transaction isolation, AUTOINCREMENT semantics); two backends means two test matrices and two migration paths forever; and there is no current need — single replica, single writer, modest data.
Recommendation: stay SQLite-only. Do not build the abstraction now. Specifically:
- Keep all persistence behind
internal/store(already done in #35 — the package boundary is the abstraction, at zero cost). - Avoid SQLite-specific SQL going forward where free (standard placeholders, no
RETURNINGquirks) — cheap discipline that keeps a future port honest. - Define the trigger conditions for revisiting, and write them down:
- multiple replicas needed (scale-out), or
- sustained WAL write contention (busy timeouts observed in logs), or
- DB file > ~5-10 GB, or
- a concrete user request for a Postgres-backed image.
- When a trigger fires, port
internal/storeto Postgres behind an interface extracted then — the refactor is mechanical against a real need, instead of speculative complexity now. - For the docker image:
PALETTE_DBenv already implies the deployment choice; no extra backend knob needed.
Summary
| Decision | Choice |
|---|---|
| Attachment backend | Filesystem on PVC, behind a ~3-method blob interface; MinIO as a later drop-in, not a dependency |
| Size limits | max_attachment_bytes admin setting, default 10 MiB, enforced pre-read + during stream |
| MIME | Server-side sniff (512 bytes) + allowlist; nosniff, CSP sandbox, Content-Disposition: attachment except safe-inline types; SVG never inline |
| Database | SQLite (WAL, modernc) only; no Postgres/Redis, no backend abstraction until a written trigger fires |