Files
petal/internal/images/handler.go
prosolis 69bf3ffde1 Close the door the edge gate used to hold
A security review of the whole repo. The queries were already scoped, the
OIDC flow already did state and nonce and PKCE, the session tokens were
already stored as hashes. What it found was mostly the seam between the
code and the deployment — and one place where the deployment quietly
undid the code.

The one that matters: with any AUTHENTIK_* variable missing, Petal fell
back to resolving every request to the single `local` user. That is right
on a laptop and a catastrophe on a public host, and Phase 16 removed the
Traefik basic-auth gate that used to stand behind the mistake. A typo in
the client secret would have served her journals to the open internet and
said so only in a log line nobody reads. It now refuses to start, guarded
by default for any BASE_URL that isn't loopback.

Then the one that would have been fixed and wasn't: stored images now
serve under `default-src 'none'; sandbox`, so an SVG pasted into a
document can't run as a page on Petal's own origin. Traefik's
customresponseheaders *overwrites*, so the CSP declared in the compose
labels would have silently replaced that per-route policy in production.
The whole header block moved into the binary, where a route can tighten
its own and a test can prove it; only HSTS stays at the edge, where TLS
actually terminates.

The rest, smaller:

  - PETAL_ALLOWED_SUBS empty means everyone authentik authenticates, and
    authentik here fronts half a dozen applications. Still legal, now
    said out loud every boot, and set in both env examples.
  - LLM failures relayed err.Error() to the browser, which carries the
    address of the inference box on the far side of the VPN. Logged
    instead; the client only ever rendered "the helper is resting".
  - Exports scheme-check their links. Escaping makes a URL safe to sit
    in an attribute and says nothing about following it, and an export
    is the one artifact here meant to leave. Writing the test found the
    markdown image src, which I'd missed reading it.
  - The draft rescue is namespaced per account and cleared on sign-out.
    Everything else in localStorage is a preference; this is her unsaved
    writing, sitting in a profile two people share.
  - /auth/logout is POST-only. With SameSite=Lax a GET route lets any
    page on the internet sign her out mid-draft.
  - Image uploads get a per-account allowance and the TTS cache a size
    cap. Both share the encrypted volume the database is on, and a full
    disk is SQLite failing to write, not a feature degrading.
  - The session cookie takes the __Host- prefix over https, so nothing
    else under parodia.dev can plant one. Old cookies still resolve;
    nobody is signed out to get there.
  - npm audit: linkify-it and postcss.

Verified: go build, go vet, the full Go suite, tsc, 195 frontend tests,
npm audit clean. The startup guard and both CSPs checked against a
running server rather than only asserted.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 18:24:47 -07:00

355 lines
12 KiB
Go

// Package images implements a tiny content-addressed image store: writers upload
// images from the editor, they're saved to disk under the configured directory,
// and served back by hashed filename. Content addressing means the same image
// pasted twice is stored once, and URLs are stable and cacheable forever.
//
// Each stored file also has one row per owner in the `images` table, and a fetch
// joins on the caller. Before that, the store was a flat directory with no
// database presence at all: any authenticated user holding a sha256 could fetch
// anyone else's image. Hashes aren't guessable, so it was never an emergency —
// but "unguessable filename" is not access control, and images pasted into a
// private journal are exactly the content that shouldn't depend on it.
//
// One row per owner (rather than one owner per file) is what keeps deduplication:
// the same picture uploaded by two people is stored once and simply has two rows.
// The file is removed only with its last row.
package images
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
)
// maxUploadBytes caps a single image at 10 MiB — generous for a writing tool,
// small enough to keep a careless paste from filling the disk.
const maxUploadBytes = 10 << 20
// maxUserBytes caps what one account may keep stored, at 1 GiB. The per-upload
// limit bounds a single careless paste; nothing bounded ten thousand of them,
// and Petal's data directory is an 8 GiB encrypted volume shared with the
// database, the backups and the TTS cache — the disk filling is the database
// losing writes, not just images failing.
//
// A tenth of the volume per writer is far past any real use: a heavily
// illustrated journal is tens of megabytes. It is a runaway backstop, and it is
// deliberately generous enough that nobody writing normally will ever meet it.
const maxUserBytes = 1 << 30
// extByContentType maps the image types we accept to a canonical extension. The
// allowlist doubles as validation: anything not here is rejected.
var extByContentType = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
// Handler serves the upload + fetch endpoints, backed by a directory on disk and
// an ownership table.
type Handler struct {
dir string
db *sql.DB
}
// New constructs a Handler, ensuring the storage directory exists and that every
// file already in it has an owner.
func New(dir string, database *sql.DB, backfillOwner string) (*Handler, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
h := &Handler{dir: dir, db: database}
if err := h.backfill(backfillOwner); err != nil {
return nil, err
}
return h, nil
}
// backfill claims pre-existing files for one user. Images uploaded before
// ownership existed have no row, and a row is now what makes them fetchable —
// so without this every picture already pasted into a document would 404.
// Attributing them to the account that has been the only one until now is the
// only answer the data supports. Idempotent: files that already have an owner
// are left alone.
func (h *Handler) backfill(owner string) error {
if owner == "" {
return nil
}
// The owner may not exist — after the `local` account has been migrated onto
// a real one, it doesn't. Claiming for a missing user would violate the
// foreign key, and this runs during startup, so the error would take the
// whole app down. There is nothing left to claim in that case anyway: the
// migration moves the image rows along with everything else.
var ownerExists bool
if err := h.db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM users WHERE id = ?)`, owner,
).Scan(&ownerExists); err != nil {
return err
}
if !ownerExists {
return nil
}
entries, err := os.ReadDir(h.dir)
if err != nil {
return err
}
claimed := 0
for _, e := range entries {
if e.IsDir() {
continue
}
var exists bool
if err := h.db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM images WHERE name = ?)`, e.Name(),
).Scan(&exists); err != nil {
return err
}
if exists {
continue
}
var size int64
if info, err := e.Info(); err == nil {
size = info.Size()
}
if _, err := h.db.Exec(
`INSERT INTO images (name, user_id, content_type, size) VALUES (?, ?, '', ?)
ON CONFLICT DO NOTHING`,
e.Name(), owner, size,
); err != nil {
return err
}
claimed++
}
if claimed > 0 {
log.Printf("images: claimed %d pre-existing image(s) for %s", claimed, owner)
}
return nil
}
// Routes mounts the image endpoints. Mount under "/images" so the full paths are
// POST /api/images (upload), GET /api/images/{name} (fetch) and
// DELETE /api/images/{name} (drop your copy).
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Post("/", h.upload)
r.Get("/{name}", h.serve)
r.Delete("/{name}", h.remove)
return r
}
// upload accepts a single multipart "image" field, sniffs and validates its
// type, and writes it under a content hash so identical images dedupe. Responds
// with the served URL the editor inserts.
func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
file, _, err := r.FormFile("image")
if err != nil {
http.Error(w, "expected an 'image' file field", http.StatusBadRequest)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "could not read upload", http.StatusBadRequest)
return
}
// Trust a sniff over the client-declared type. SVG isn't reliably sniffable
// (DetectContentType returns text/plain or text/xml), so fall back to a
// lightweight tag check for it.
ct := http.DetectContentType(data)
ext, ok := extByContentType[ct]
if !ok {
if looksLikeSVG(data) {
ct, ext, ok = "image/svg+xml", ".svg", true
}
}
if !ok {
http.Error(w, "unsupported image type", http.StatusUnsupportedMediaType)
return
}
sum := sha256.Sum256(data)
name := hex.EncodeToString(sum[:])[:32] + ext
path := filepath.Join(h.dir, name)
userID := auth.UserID(r.Context())
within, err := h.withinQuota(userID, name, int64(len(data)))
if err != nil {
log.Printf("images: quota check failed for %s: %v", userID, err)
http.Error(w, "could not store image", http.StatusInternalServerError)
return
}
if !within {
http.Error(w, "you've filled Petal's picture store — delete a few images and try again",
http.StatusInsufficientStorage)
return
}
// Skip the write if this exact content is already stored.
if _, statErr := os.Stat(path); errors.Is(statErr, os.ErrNotExist) {
if err := os.WriteFile(path, data, 0o644); err != nil {
http.Error(w, "could not store image", http.StatusInternalServerError)
return
}
}
// Record the caller as an owner. Re-uploading your own image is a no-op;
// uploading someone else's identical image adds a second row over one file.
if _, err := h.db.Exec(
`INSERT INTO images (name, user_id, content_type, size) VALUES (?, ?, ?, ?)
ON CONFLICT (name, user_id) DO NOTHING`,
name, userID, ct, len(data),
); err != nil {
log.Printf("images: could not record ownership of %s: %v", name, err)
http.Error(w, "could not store image", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": "/api/images/" + name})
}
// serve returns a stored image by its hashed filename, but only to someone who
// owns it. The filename is validated to be a bare name (no path separators) so
// it can't escape the storage dir, and served with a long-lived cache header
// since content-addressed URLs never change.
//
// Someone else's image is a 404, not a 403: whether a hash exists is itself
// information the caller has no business learning.
func (h *Handler) serve(w http.ResponseWriter, r *http.Request) {
name, ok := safeName(chi.URLParam(r, "name"))
if !ok || !h.owns(name, auth.UserID(r.Context())) {
http.NotFound(w, r)
return
}
path := filepath.Join(h.dir, name)
if _, err := os.Stat(path); err != nil {
http.NotFound(w, r)
return
}
// Private: a shared cache must never hand one writer's image to another.
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
// SVG is a document format wearing an image's name: it can carry <script>,
// and this route serves it from Petal's own origin. Rendered through an
// <img> — the only way the editor ever shows one — that script never runs.
// Navigated to directly, which is one "open image in new tab" away, it does,
// and it runs with the API of whoever opened it.
//
// So every stored image answers with a CSP that permits nothing at all
// except the inline styles an illustration legitimately carries. It costs
// pasted SVGs nothing (an <img> was already a script-free context) and
// leaves the direct-navigation case inert. nosniff is set at the edge, but
// repeated here so the guarantee doesn't depend on Traefik's config.
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(w, r, path)
}
// remove drops the caller's claim on an image, and deletes the file itself once
// nobody is left holding it.
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
name, ok := safeName(chi.URLParam(r, "name"))
if !ok {
http.NotFound(w, r)
return
}
res, err := h.db.Exec(`DELETE FROM images WHERE name = ? AND user_id = ?`,
name, auth.UserID(r.Context()))
if err != nil {
http.Error(w, "could not delete image", http.StatusInternalServerError)
return
}
if n, _ := res.RowsAffected(); n == 0 {
http.NotFound(w, r)
return
}
var others bool
if err := h.db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM images WHERE name = ?)`, name,
).Scan(&others); err != nil {
// The row is gone either way; leaving an orphaned file behind is a
// wasted block, not a correctness problem.
log.Printf("images: could not check remaining owners of %s: %v", name, err)
w.WriteHeader(http.StatusNoContent)
return
}
if !others {
if err := os.Remove(filepath.Join(h.dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Printf("images: could not remove %s: %v", name, err)
}
}
w.WriteHeader(http.StatusNoContent)
}
// withinQuota reports whether userID may store one more image of size bytes.
//
// An image the caller already owns is free: content addressing means re-pasting
// the same picture stores nothing new, and charging for it would let a document
// that merely repeats one illustration walk into the limit. Deduplication
// across *accounts* is not credited the same way — two people each keep their
// own claim on a shared file, because either of them deleting it must not
// depend on what the other did.
func (h *Handler) withinQuota(userID, name string, size int64) (bool, error) {
var used, already sql.NullInt64
if err := h.db.QueryRow(
`SELECT (SELECT COALESCE(SUM(size), 0) FROM images WHERE user_id = ?),
(SELECT size FROM images WHERE user_id = ? AND name = ?)`,
userID, userID, name,
).Scan(&used, &already); err != nil {
return false, err
}
if already.Valid {
return true, nil // already stored for this account — costs nothing more
}
return used.Int64+size <= maxUserBytes, nil
}
// owns reports whether userID has a claim on a stored image.
func (h *Handler) owns(name, userID string) bool {
var ok bool
if err := h.db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM images WHERE name = ? AND user_id = ?)`, name, userID,
).Scan(&ok); err != nil {
log.Printf("images: ownership check failed for %s: %v", name, err)
return false
}
return ok
}
// safeName rejects anything that isn't a bare filename, so a request can't walk
// out of the storage directory.
func safeName(name string) (string, bool) {
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
return "", false
}
return name, true
}
// looksLikeSVG does a cheap check for an <svg root tag near the start of the
// file, since DetectContentType doesn't recognize SVG.
func looksLikeSVG(data []byte) bool {
head := data
if len(head) > 512 {
head = head[:512]
}
return strings.Contains(strings.ToLower(string(head)), "<svg")
}