The destination is an OIDC subject id, which the app cannot know — it belongs to the identity provider — so this runs deliberately, with Petal stopped and a backup taken, rather than as a startup migration. documents, tags, vocab_words and images carry user_id directly; versions, suggestions and tag assignments hang off their parents and follow, which is why it has to be one transaction with foreign keys off. Sessions for the old identity are deleted rather than moved: a session is proof someone signed in, and nobody ever signed in as 'local'. Dry run by default, VACUUM INTO backup first, and it verifies every row it expected to move actually moved — and that the source is left owning nothing — before committing. The 'is the app stopped?' guard took two attempts. BEGIN EXCLUSIVE, the obvious check, sails past a running-but-idle Petal because in WAL mode it only conflicts with another writer, which is precisely the case worth catching. PRAGMA locking_mode = EXCLUSIVE conflicts with any connection at all, since it locks the shared-memory index every WAL reader maps. Sequencing this also turned up a crash waiting to happen: the image backfill claims unowned files for 'local', which no longer exists after a migration, and the resulting foreign-key error is fatal inside images.New. Petal would have crash-looped the first time it started on a migrated database. It now skips a missing owner, which costs nothing — the migration moves the image rows itself. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
294 lines
9.3 KiB
Go
294 lines
9.3 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
|
|
|
|
// 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)
|
|
|
|
// 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, auth.UserID(r.Context()), 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")
|
|
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)
|
|
}
|
|
|
|
// 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")
|
|
}
|