// 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 } 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 512 { head = head[:512] } return strings.Contains(strings.ToLower(string(head)), "