From 41c0f3e592c2140c8ad26ce5d91010399679ee8d Mon Sep 17 00:00:00 2001 From: ivar Date: Sat, 4 Apr 2026 16:06:06 +0200 Subject: docs: add image block upload and serving design spec Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-04-04-image-block-design.md | 114 +++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-04-image-block-design.md diff --git a/docs/superpowers/specs/2026-04-04-image-block-design.md b/docs/superpowers/specs/2026-04-04-image-block-design.md new file mode 100644 index 0000000..6ab8973 --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-image-block-design.md @@ -0,0 +1,114 @@ +# Image Block: Upload, Processing & Serving + +**Date:** 2026-04-04 +**Status:** Approved + +## Overview + +Add image upload support to the EditorJS admin editor, with on-the-fly format conversion and resizing via govips (libvips Go bindings). Uploaded images are stored as originals; processed variants are cached alongside them. Images are served publicly at `/media/.` with optional `?w=` resizing. + +## Architecture + +A new `internal/media` package owns all image logic: + +- `handler.go` — `MediaHandler` struct; upload endpoint; serve endpoint; cache key logic +- `process.go` — govips wrapper: resize + format conversion, lazy init guard + +`MediaHandler` is wired into the Gin engine in `main.go` (or equivalent startup): + +| Route | Auth | Purpose | +|---|---|---| +| `POST /admin/upload/image` | admin Basic Auth | EditorJS ImageTool upload endpoint | +| `GET /media/*filepath` | public | Serve originals or processed variants | + +Storage root: `assets/media/` + +Cache key scheme: +- With width: `_w.` (e.g. `abc123_800w.webp`) +- Format only (no width): `.` — served directly if original matches, else cached as `_orig.` + +## Data Flow + +### Upload (`POST /admin/upload/image`) + +1. Enforce 20 MB body limit via `http.MaxBytesReader` +2. Parse `multipart/form-data`, field name `image` +3. Validate MIME type — accept `image/jpeg`, `image/png`, `image/gif`, `image/webp`; reject others with 400 +4. Generate filename: `.` (UUID v4, no user-controlled paths) +5. Write original to `assets/media/.` +6. Return EditorJS-compatible JSON: + ```json + { "success": 1, "file": { "url": "/media/.webp" } } + ``` + The returned URL points at the WebP variant so the editor preview shows a converted image immediately on next serve. + +### Serve (`GET /media/.?w=800`) + +1. Parse filename → base (``), requested format (`.webp` or `.jpg`/`.jpeg`) +2. Parse optional `?w` query param: + - Must be a positive integer ≤ 3000; return 400 otherwise + - Absent means no resize, format conversion only +3. Build cache path: `assets/media/_800w.webp` (or `.webp` if no width) +4. Cache hit → serve file directly with correct `Content-Type` +5. Cache miss: + a. Locate original by globbing `assets/media/.*` + b. Open with govips + c. Resize to requested width (maintain aspect ratio) if `?w` given + d. Export to requested format (WebP or JPEG) + e. Write to cache path atomically (write temp, rename) + f. Serve result +6. Original not found → 404 + +## Error Handling & Edge Cases + +| Scenario | Behaviour | +|---|---| +| Upload > 20 MB | 400 before reading body | +| Invalid MIME type | 400 with message | +| `?w` non-integer or ≤ 0 | 400 | +| `?w` > 3000 | Clamped to 3000 (no error) | +| Concurrent cache miss for same variant | Write-to-temp-then-rename; last writer wins; both requests served correctly | +| govips decode failure | 500 | +| Original not found during serve | 404 | +| Requested format matches original, no resize | Serve original directly, skip cache write | +| govips not started | Handler panics at startup (fail fast) | + +## govips Lifecycle + +- `vips.Startup(nil)` called once in `main.go` before routes are registered +- `defer vips.Shutdown()` in `main.go` for clean teardown +- `process.go` exposes `ConvertAndResize(src string, width int, format string) ([]byte, error)` — takes absolute path to original, returns processed bytes + +## EditorJS Integration + +In `internal/admin/templates/form.html`, update the image tool registration: + +```js +image: { + class: ImageTool, + config: { + endpoints: { byFile: '/admin/upload/image' } + } +} +``` + +`renderImage` in `internal/builder/editorjs.go` already reads `file.URL` from the block data — no changes needed. The stored URL is whatever the upload endpoint returns (defaults to `.webp`). + +## Files Changed / Created + +| File | Change | +|---|---| +| `internal/media/handler.go` | New — `MediaHandler`, upload + serve handlers | +| `internal/media/process.go` | New — govips resize/convert wrapper | +| `cmd/nebbet/main.go` | Wire `MediaHandler` routes, add govips init/shutdown | +| `internal/admin/templates/form.html` | Add `endpoints` config to ImageTool | +| `go.mod` / `go.sum` | Add `github.com/davidbyttow/govips/v2` | + +`internal/builder/editorjs.go` — no changes required. + +## Out of Scope + +- Image deletion (originals and cached variants remain until manually removed) +- Alt text editing beyond what EditorJS ImageTool provides +- Animated GIF preservation through resize +- AVIF or other formats beyond WebP/JPEG -- cgit v1.3