Editor: font-size presets + image insert/export support

Two editor features that were in flight alongside the sound work:
- FontSize TipTap extension (rides on textStyle) with Small/Normal/Large/
  Title presets in the toolbar; StatusBar + CSS support
- Image handling: internal/images handler, upload route + config, client
  API, EditorCore wiring, and md/html/docx export support for images

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 09:05:40 -07:00
parent 58c326d0cf
commit 6e6e4edce7
16 changed files with 1301 additions and 35 deletions

132
internal/images/handler.go Normal file
View File

@@ -0,0 +1,132 @@
// 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 <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")
}

View File

@@ -0,0 +1,97 @@
package images
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// a 1x1 transparent PNG.
var pngBytes = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
func uploadReq(t *testing.T, field string, data []byte) *http.Request {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile(field, "x.png")
if err != nil {
t.Fatal(err)
}
fw.Write(data)
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
return req
}
func TestUploadAndServe(t *testing.T) {
h, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
r := h.Routes()
// Upload a PNG → expect a JSON url under /api/images/.
rec := httptest.NewRecorder()
r.ServeHTTP(rec, uploadReq(t, "image", pngBytes))
if rec.Code != http.StatusOK {
t.Fatalf("upload code=%d body=%s", rec.Code, rec.Body)
}
var resp struct{ URL string }
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(resp.URL, "/api/images/") || !strings.HasSuffix(resp.URL, ".png") {
t.Fatalf("unexpected url %q", resp.URL)
}
// The same content uploaded again dedupes to the same URL.
rec2 := httptest.NewRecorder()
r.ServeHTTP(rec2, uploadReq(t, "image", pngBytes))
var resp2 struct{ URL string }
json.Unmarshal(rec2.Body.Bytes(), &resp2)
if resp2.URL != resp.URL {
t.Fatalf("expected dedup to same url, got %q vs %q", resp2.URL, resp.URL)
}
// Fetch it back.
name := strings.TrimPrefix(resp.URL, "/api/images/")
rec3 := httptest.NewRecorder()
r.ServeHTTP(rec3, httptest.NewRequest(http.MethodGet, "/"+name, nil))
if rec3.Code != http.StatusOK {
t.Fatalf("serve code=%d", rec3.Code)
}
if !bytes.Equal(rec3.Body.Bytes(), pngBytes) {
t.Fatal("served bytes differ from uploaded")
}
}
func TestUploadRejectsNonImage(t *testing.T) {
h, _ := New(t.TempDir())
r := h.Routes()
rec := httptest.NewRecorder()
r.ServeHTTP(rec, uploadReq(t, "image", []byte("this is plainly not an image at all")))
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("expected 415, got %d", rec.Code)
}
}
func TestServeMissing(t *testing.T) {
h, _ := New(t.TempDir())
r := h.Routes()
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/deadbeef.png", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}