summaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-04-04 16:06:06 +0200
committerivar <i@oiee.no>2026-04-04 16:06:06 +0200
commit41c0f3e592c2140c8ad26ce5d91010399679ee8d (patch)
tree9f79ce0a469669af6de77d28a10269b50812e480 /docs
parent59429d9d696b4c6c8d59b48769b286f65e7b1163 (diff)
downloadnebbet.no-41c0f3e592c2140c8ad26ce5d91010399679ee8d.tar.xz
nebbet.no-41c0f3e592c2140c8ad26ce5d91010399679ee8d.zip
docs: add image block upload and serving design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/specs/2026-04-04-image-block-design.md114
1 files changed, 114 insertions, 0 deletions
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/<filename>.<ext>` with optional `?w=<width>` 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: `<uuid>_<width>w.<ext>` (e.g. `abc123_800w.webp`)
+- Format only (no width): `<uuid>.<ext>` — served directly if original matches, else cached as `<uuid>_orig.<ext>`
+
+## 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>.<original-ext>` (UUID v4, no user-controlled paths)
+5. Write original to `assets/media/<uuid>.<ext>`
+6. Return EditorJS-compatible JSON:
+ ```json
+ { "success": 1, "file": { "url": "/media/<uuid>.webp" } }
+ ```
+ The returned URL points at the WebP variant so the editor preview shows a converted image immediately on next serve.
+
+### Serve (`GET /media/<uuid>.<ext>?w=800`)
+
+1. Parse filename → base (`<uuid>`), 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/<uuid>_800w.webp` (or `<uuid>.webp` if no width)
+4. Cache hit → serve file directly with correct `Content-Type`
+5. Cache miss:
+ a. Locate original by globbing `assets/media/<uuid>.*`
+ 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