// 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. package images import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "net/http" "os" "path/filepath" "strings" "github.com/go-chi/chi/v5" ) // 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. type Handler struct { dir string } // New constructs a Handler, ensuring the storage directory exists. func New(dir string) (*Handler, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } return &Handler{dir: dir}, nil } // Routes mounts the image endpoints. Mount under "/images" so the full paths are // POST /api/images (upload) and GET /api/images/{name} (fetch). func (h *Handler) Routes() chi.Router { r := chi.NewRouter() r.Post("/", h.upload) r.Get("/{name}", h.serve) 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) { ext, ok = ".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 } } 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. 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. func (h *Handler) serve(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) { http.NotFound(w, r) return } path := filepath.Join(h.dir, filepath.Base(name)) if _, err := os.Stat(path); err != nil { http.NotFound(w, r) return } w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") http.ServeFile(w, r, path) } // looksLikeSVG does a cheap check for an 512 { head = head[:512] } return strings.Contains(strings.ToLower(string(head)), "