summaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-04-04 16:15:23 +0200
committerivar <i@oiee.no>2026-04-04 16:15:23 +0200
commit1fdf7dffa8b3be0eac61796a39a0e4af61713d7a (patch)
treebe7e1f6a0cdbac1c2ac79c6e45ed4999a0fd5c6d /docs/superpowers
parentac4d54db511f0776252bc9bf3001fcb349c62cf0 (diff)
downloadnebbet.no-1fdf7dffa8b3be0eac61796a39a0e4af61713d7a.tar.xz
nebbet.no-1fdf7dffa8b3be0eac61796a39a0e4af61713d7a.zip
docs: add image block implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-04-04-image-block.md902
1 files changed, 902 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-04-04-image-block.md b/docs/superpowers/plans/2026-04-04-image-block.md
new file mode 100644
index 0000000..80566ba
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-04-image-block.md
@@ -0,0 +1,902 @@
+# Image Block: Upload, Processing & Serving Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add EditorJS image upload with govips-powered on-the-fly resize/format conversion and public image serving.
+
+**Architecture:** A new `internal/media` package provides `MediaHandler` (upload + serve) and a `ConvertAndResize` govips wrapper. Upload is auth-protected at `POST /admin/upload/image`; images are served publicly at `GET /media/*filepath` with extension-based format selection (`.webp` / `.jpg`) and optional `?w=<pixels>` resize. Processed variants are cached in `assets/media/` alongside originals using the naming scheme `<uuid>_<width>w.<ext>`.
+
+**Tech Stack:** Go, govips (`github.com/davidbyttow/govips/v2`), Gin, libvips (system dependency), `github.com/google/uuid` (already in go.mod as indirect dep)
+
+---
+
+## File Map
+
+| File | Action | Responsibility |
+|---|---|---|
+| `internal/media/process.go` | Create | `ConvertAndResize` — govips resize + format conversion |
+| `internal/media/process_test.go` | Create | Tests for `ConvertAndResize`; `TestMain` for govips init |
+| `internal/media/handler.go` | Create | `MediaHandler`: upload, serve, `cacheKey`, `findOriginal` |
+| `internal/media/handler_test.go` | Create | Tests for upload and serve handlers |
+| `internal/admin/server.go` | Modify | Add `RegisterUploadRoute(gin.HandlerFunc)` method |
+| `cmd/nebbet/main.go` | Modify | govips lifecycle, replace `/media/` static route, add upload route |
+| `internal/admin/templates/form.html` | Modify | Add `endpoints` config to `ImageTool` |
+| `go.mod` / `go.sum` | Modified by go get | govips dependency |
+
+---
+
+### Task 1: Install libvips and add govips dependency
+
+**Files:**
+- Modify: `go.mod`, `go.sum`
+
+- [ ] **Step 1: Install libvips system dependency**
+
+On macOS:
+```bash
+brew install vips
+```
+On Ubuntu/Debian:
+```bash
+apt-get install -y libvips-dev
+```
+
+- [ ] **Step 2: Add govips module**
+
+```bash
+CGO_ENABLED=1 go get github.com/davidbyttow/govips/v2@latest
+```
+
+- [ ] **Step 3: Verify build**
+
+```bash
+CGO_ENABLED=1 go build ./...
+```
+Expected: no errors.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add go.mod go.sum
+git commit -m "chore: add govips dependency"
+```
+
+---
+
+### Task 2: Create process.go with tests (TDD)
+
+**Files:**
+- Create: `internal/media/process_test.go`
+- Create: `internal/media/process.go`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/media/process_test.go`:
+
+```go
+package media
+
+import (
+ "image"
+ "image/png"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/davidbyttow/govips/v2/vips"
+)
+
+func TestMain(m *testing.M) {
+ vips.Startup(nil)
+ code := m.Run()
+ vips.Shutdown()
+ os.Exit(code)
+}
+
+// testPNG writes a 100×60 PNG to a temp file and returns its path.
+func testPNG(t *testing.T) string {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, 100, 60))
+ path := filepath.Join(t.TempDir(), "src.png")
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatalf("create test png: %v", err)
+ }
+ if err := png.Encode(f, img); err != nil {
+ t.Fatalf("encode test png: %v", err)
+ }
+ f.Close()
+ return path
+}
+
+func TestConvertToWebP(t *testing.T) {
+ out, err := ConvertAndResize(testPNG(t), 0, "webp")
+ if err != nil {
+ t.Fatalf("ConvertAndResize: %v", err)
+ }
+ if len(out) == 0 {
+ t.Fatal("expected non-empty output")
+ }
+}
+
+func TestConvertToJPEG(t *testing.T) {
+ out, err := ConvertAndResize(testPNG(t), 0, "jpeg")
+ if err != nil {
+ t.Fatalf("ConvertAndResize: %v", err)
+ }
+ if len(out) == 0 {
+ t.Fatal("expected non-empty output")
+ }
+}
+
+func TestResizeAndConvert(t *testing.T) {
+ out, err := ConvertAndResize(testPNG(t), 50, "webp")
+ if err != nil {
+ t.Fatalf("ConvertAndResize resize: %v", err)
+ }
+ if len(out) == 0 {
+ t.Fatal("expected non-empty output")
+ }
+}
+
+func TestConvertUnsupportedFormat(t *testing.T) {
+ _, err := ConvertAndResize(testPNG(t), 0, "avif")
+ if err == nil {
+ t.Fatal("expected error for unsupported format")
+ }
+}
+
+func TestConvertMissingFile(t *testing.T) {
+ _, err := ConvertAndResize("/nonexistent/file.png", 0, "webp")
+ if err == nil {
+ t.Fatal("expected error for missing file")
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to confirm they fail**
+
+```bash
+CGO_ENABLED=1 go test ./internal/media/ -v -run TestConvert
+```
+Expected: `FAIL` — package and `ConvertAndResize` do not exist yet.
+
+- [ ] **Step 3: Implement process.go**
+
+Create `internal/media/process.go`:
+
+```go
+package media
+
+import (
+ "fmt"
+
+ "github.com/davidbyttow/govips/v2/vips"
+)
+
+// ConvertAndResize loads the image at src, optionally resizes it to width pixels
+// wide (maintaining aspect ratio), and exports it in the requested format.
+//
+// width <= 0 means no resize. format must be "webp" or "jpeg".
+func ConvertAndResize(src string, width int, format string) ([]byte, error) {
+ if format != "webp" && format != "jpeg" {
+ return nil, fmt.Errorf("unsupported format %q: must be webp or jpeg", format)
+ }
+
+ img, err := vips.NewImageFromFile(src)
+ if err != nil {
+ return nil, fmt.Errorf("load image %s: %w", src, err)
+ }
+ defer img.Close()
+
+ if width > 0 && width < img.Width() {
+ scale := float64(width) / float64(img.Width())
+ if err := img.Resize(scale, vips.KernelLanczos3); err != nil {
+ return nil, fmt.Errorf("resize: %w", err)
+ }
+ }
+
+ switch format {
+ case "webp":
+ buf, _, err := img.ExportWebp(vips.NewWebpExportParams())
+ if err != nil {
+ return nil, fmt.Errorf("export webp: %w", err)
+ }
+ return buf, nil
+ case "jpeg":
+ buf, _, err := img.ExportJpeg(vips.NewJpegExportParams())
+ if err != nil {
+ return nil, fmt.Errorf("export jpeg: %w", err)
+ }
+ return buf, nil
+ }
+ return nil, fmt.Errorf("unsupported format %q", format)
+}
+```
+
+- [ ] **Step 4: Run tests to confirm they pass**
+
+```bash
+CGO_ENABLED=1 go test ./internal/media/ -v -run TestConvert
+```
+Expected: all 5 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/media/process.go internal/media/process_test.go
+git commit -m "feat: add govips ConvertAndResize wrapper"
+```
+
+---
+
+### Task 3: Create handler.go with tests (TDD)
+
+**Files:**
+- Create: `internal/media/handler_test.go`
+- Create: `internal/media/handler.go`
+
+Note: `TestMain` in `process_test.go` already initialises govips for the whole package — handler tests benefit automatically.
+
+- [ ] **Step 1: Write the failing handler tests**
+
+Create `internal/media/handler_test.go`:
+
+```go
+package media
+
+import (
+ "bytes"
+ "encoding/json"
+ "image"
+ "image/jpeg"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func init() {
+ gin.SetMode(gin.TestMode)
+}
+
+func newTestHandler(t *testing.T) (*MediaHandler, string) {
+ t.Helper()
+ dir := t.TempDir()
+ return NewMediaHandler(dir), dir
+}
+
+func testJPEGBytes(t *testing.T) []byte {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, 100, 60))
+ var buf bytes.Buffer
+ if err := jpeg.Encode(&buf, img, nil); err != nil {
+ t.Fatalf("encode jpeg: %v", err)
+ }
+ return buf.Bytes()
+}
+
+func multipartUpload(t *testing.T, field, filename string, data []byte) *http.Request {
+ t.Helper()
+ var body bytes.Buffer
+ w := multipart.NewWriter(&body)
+ fw, err := w.CreateFormFile(field, filename)
+ if err != nil {
+ t.Fatalf("create form file: %v", err)
+ }
+ fw.Write(data)
+ w.Close()
+ req, _ := http.NewRequest(http.MethodPost, "/admin/upload/image", &body)
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ return req
+}
+
+// TestUploadValid verifies a valid JPEG upload returns success with a /media/ URL.
+func TestUploadValid(t *testing.T) {
+ h, dir := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = multipartUpload(t, "image", "photo.jpg", testJPEGBytes(t))
+
+ h.HandleUpload(c)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
+ }
+ var resp map[string]interface{}
+ if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("parse response: %v", err)
+ }
+ if resp["success"] != float64(1) {
+ t.Fatalf("expected success=1, got %v", resp)
+ }
+ file, ok := resp["file"].(map[string]interface{})
+ if !ok {
+ t.Fatal("expected file object in response")
+ }
+ url, _ := file["url"].(string)
+ if len(url) < 7 || url[:7] != "/media/" {
+ t.Fatalf("expected /media/ URL, got %q", url)
+ }
+ entries, _ := os.ReadDir(dir)
+ if len(entries) == 0 {
+ t.Fatal("expected original to be written to storage dir")
+ }
+}
+
+// TestUploadInvalidMIME rejects non-image content.
+func TestUploadInvalidMIME(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = multipartUpload(t, "image", "evil.txt", []byte("not an image at all"))
+
+ h.HandleUpload(c)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rr.Code)
+ }
+}
+
+// TestUploadWrongField rejects request missing the "image" field.
+func TestUploadWrongField(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = multipartUpload(t, "file", "photo.jpg", testJPEGBytes(t))
+
+ h.HandleUpload(c)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rr.Code)
+ }
+}
+
+// TestServeCacheHit serves a pre-existing cache file directly.
+func TestServeCacheHit(t *testing.T) {
+ h, dir := newTestHandler(t)
+ if err := os.WriteFile(filepath.Join(dir, "abc123.webp"), []byte("FAKE_WEBP"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/abc123.webp", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/abc123.webp"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rr.Code)
+ }
+ if ct := rr.Header().Get("Content-Type"); ct != "image/webp" {
+ t.Fatalf("expected image/webp, got %q", ct)
+ }
+}
+
+// TestServeNotFound returns 404 when no original exists.
+func TestServeNotFound(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/missing.webp", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/missing.webp"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", rr.Code)
+ }
+}
+
+// TestServeInvalidWidth returns 400 for non-positive width.
+func TestServeInvalidWidth(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/abc.webp?w=0", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/abc.webp"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rr.Code)
+ }
+}
+
+// TestServeUnknownFormat returns 404 for unrecognised extensions.
+func TestServeUnknownFormat(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/abc.png", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/abc.png"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", rr.Code)
+ }
+}
+
+// TestCacheKey verifies the cache filename scheme.
+func TestCacheKey(t *testing.T) {
+ cases := []struct {
+ base, format string
+ width int
+ want string
+ }{
+ {"uuid1", "webp", 0, "uuid1.webp"},
+ {"uuid1", "webp", 800, "uuid1_800w.webp"},
+ {"uuid1", "jpeg", 0, "uuid1.jpg"},
+ {"uuid1", "jpeg", 400, "uuid1_400w.jpg"},
+ }
+ for _, tc := range cases {
+ got := cacheKey(tc.base, tc.width, tc.format)
+ if got != tc.want {
+ t.Errorf("cacheKey(%q, %d, %q) = %q, want %q", tc.base, tc.width, tc.format, got, tc.want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to confirm they fail**
+
+```bash
+CGO_ENABLED=1 go test ./internal/media/ -v -run "TestUpload|TestServe|TestCacheKey"
+```
+Expected: `FAIL` — `MediaHandler`, `NewMediaHandler`, `HandleUpload`, `HandleServe`, `cacheKey` undefined.
+
+- [ ] **Step 3: Implement handler.go**
+
+Create `internal/media/handler.go`:
+
+```go
+package media
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+const (
+ maxUploadSize = 20 << 20 // 20 MB
+ maxWidth = 3000
+)
+
+// allowedMIMEs maps detected content types to canonical file extensions.
+var allowedMIMEs = map[string]string{
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+}
+
+// MediaHandler handles image uploads and on-the-fly image serving.
+type MediaHandler struct {
+ StorageDir string
+}
+
+// NewMediaHandler returns a MediaHandler that stores files in storageDir.
+func NewMediaHandler(storageDir string) *MediaHandler {
+ return &MediaHandler{StorageDir: storageDir}
+}
+
+// HandleUpload handles POST /admin/upload/image.
+// Expects multipart/form-data with an "image" field.
+// Returns EditorJS-compatible JSON: {"success":1,"file":{"url":"/media/<uuid>.webp"}}
+func (h *MediaHandler) HandleUpload(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize)
+
+ file, _, err := c.Request.FormFile("image")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": 0, "error": err.Error()})
+ return
+ }
+ defer file.Close()
+
+ // Sniff MIME type from first 512 bytes
+ sniff := make([]byte, 512)
+ n, _ := file.Read(sniff)
+ mimeType := http.DetectContentType(sniff[:n])
+ ext, ok := allowedMIMEs[mimeType]
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"success": 0, "error": "unsupported image type: " + mimeType})
+ return
+ }
+
+ // Rewind so the full file is copied to disk
+ if seeker, ok := file.(io.Seeker); ok {
+ if _, err := seeker.Seek(0, io.SeekStart); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "seek error"})
+ return
+ }
+ }
+
+ if err := os.MkdirAll(h.StorageDir, 0755); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "storage error"})
+ return
+ }
+
+ id := uuid.New().String()
+ destPath := filepath.Join(h.StorageDir, id+ext)
+
+ out, err := os.Create(destPath)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "storage error"})
+ return
+ }
+ defer out.Close()
+
+ if _, err := io.Copy(out, file); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "write error"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": 1,
+ "file": gin.H{"url": "/media/" + id + ".webp"},
+ })
+}
+
+// HandleServe handles GET /media/*filepath.
+// Format is determined by the file extension (.webp or .jpg/.jpeg).
+// The optional ?w=<pixels> query param resizes the image (clamped to 3000; error if ≤ 0).
+func (h *MediaHandler) HandleServe(c *gin.Context) {
+ filename := strings.TrimPrefix(c.Param("filepath"), "/")
+ ext := strings.ToLower(filepath.Ext(filename))
+ base := strings.TrimSuffix(filename, filepath.Ext(filename))
+
+ var format, contentType string
+ switch ext {
+ case ".webp":
+ format, contentType = "webp", "image/webp"
+ case ".jpg", ".jpeg":
+ format, contentType = "jpeg", "image/jpeg"
+ default:
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ width := 0
+ if wStr := c.Query("w"); wStr != "" {
+ w, err := strconv.Atoi(wStr)
+ if err != nil || w <= 0 {
+ c.AbortWithStatus(http.StatusBadRequest)
+ return
+ }
+ if w > maxWidth {
+ w = maxWidth
+ }
+ width = w
+ }
+
+ cacheName := cacheKey(base, width, format)
+ cachePath := filepath.Join(h.StorageDir, cacheName)
+
+ // Cache hit: serve the existing variant directly
+ if f, err := os.Open(cachePath); err == nil {
+ defer f.Close()
+ info, _ := os.Stat(cachePath)
+ c.Header("Content-Type", contentType)
+ http.ServeContent(c.Writer, c.Request, cacheName, info.ModTime(), f)
+ return
+ }
+
+ origPath, err := findOriginal(h.StorageDir, base)
+ if err != nil {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ // Skip processing if format matches original and no resize is requested
+ origExt := strings.ToLower(filepath.Ext(origPath))
+ sameFormat := origExt == ext ||
+ (origExt == ".jpg" && ext == ".jpeg") ||
+ (origExt == ".jpeg" && ext == ".jpg")
+ if width == 0 && sameFormat {
+ f, err := os.Open(origPath)
+ if err != nil {
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+ defer f.Close()
+ info, _ := os.Stat(origPath)
+ c.Header("Content-Type", contentType)
+ http.ServeContent(c.Writer, c.Request, filename, info.ModTime(), f)
+ return
+ }
+
+ result, err := ConvertAndResize(origPath, width, format)
+ if err != nil {
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+
+ // Atomic cache write — concurrent writers are harmless (last rename wins)
+ tmp := cachePath + ".tmp"
+ if err := os.WriteFile(tmp, result, 0644); err == nil {
+ _ = os.Rename(tmp, cachePath)
+ }
+
+ c.Header("Content-Type", contentType)
+ c.Data(http.StatusOK, contentType, result)
+}
+
+// cacheKey returns the filename for a processed image variant.
+// - No width: "<base>.webp" / "<base>.jpg"
+// - With width: "<base>_<width>w.webp" / "<base>_<width>w.jpg"
+func cacheKey(base string, width int, format string) string {
+ ext := ".webp"
+ if format == "jpeg" {
+ ext = ".jpg"
+ }
+ if width > 0 {
+ return fmt.Sprintf("%s_%dw%s", base, width, ext)
+ }
+ return base + ext
+}
+
+// findOriginal finds the original upload file for a given UUID base name.
+// Cache variants (which contain '_' before the extension) are excluded.
+func findOriginal(dir, base string) (string, error) {
+ matches, err := filepath.Glob(filepath.Join(dir, base+".*"))
+ if err != nil {
+ return "", err
+ }
+ for _, m := range matches {
+ name := filepath.Base(m)
+ nameBase := strings.TrimSuffix(name, filepath.Ext(name))
+ if !strings.Contains(nameBase, "_") {
+ return m, nil
+ }
+ }
+ return "", fmt.Errorf("original not found for %q in %s", base, dir)
+}
+```
+
+- [ ] **Step 4: Run handler tests**
+
+```bash
+CGO_ENABLED=1 go test ./internal/media/ -v -run "TestUpload|TestServe|TestCacheKey"
+```
+Expected: all tests pass.
+
+- [ ] **Step 5: Run all media package tests**
+
+```bash
+CGO_ENABLED=1 go test ./internal/media/ -v
+```
+Expected: all tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/media/handler.go internal/media/handler_test.go
+git commit -m "feat: add MediaHandler for image upload and on-the-fly serving"
+```
+
+---
+
+### Task 4: Wire MediaHandler into main.go and admin server
+
+**Files:**
+- Modify: `internal/admin/server.go` — add `RegisterUploadRoute`
+- Modify: `cmd/nebbet/main.go` — govips lifecycle, new routes
+
+- [ ] **Step 1: Add RegisterUploadRoute to admin Server**
+
+In `internal/admin/server.go`, add this method after `Engine()` (line 106):
+
+```go
+// RegisterUploadRoute registers handler under POST /admin/upload/image
+// behind the admin Basic Auth middleware.
+func (s *Server) RegisterUploadRoute(handler gin.HandlerFunc) {
+ admin := s.engine.Group("/admin")
+ admin.Use(s.authMiddleware())
+ admin.POST("/upload/image", handler)
+}
+```
+
+- [ ] **Step 2: Update imports in main.go**
+
+In `cmd/nebbet/main.go`, add to the import block:
+
+```go
+"github.com/davidbyttow/govips/v2/vips"
+"nebbet.no/internal/media"
+```
+
+- [ ] **Step 3: Update cmdServe in main.go**
+
+Replace the full `cmdServe` function with:
+
+```go
+func cmdServe(args []string) {
+ fs := flag.NewFlagSet("serve", flag.ExitOnError)
+ port := fs.String("port", "8080", "port to listen on")
+ _ = fs.Parse(args)
+
+ vips.Startup(nil)
+ defer vips.Shutdown()
+
+ meta, search := mustOpenDBs()
+ defer meta.Close()
+ defer search.Close()
+
+ adminSrv := admin.NewServer(passwordFile, meta, search, outputDir)
+ engine := adminSrv.Engine()
+
+ tmpl, err := template.ParseGlob(filepath.Join("templates", "*.html"))
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "template load error:", err)
+ os.Exit(1)
+ }
+
+ libMIMEs := server.AllowedMIMEs{
+ ".js": "application/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".wasm": "application/wasm",
+ }
+ publicMIMEs := server.AllowedMIMEs{
+ ".html": "text/html; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".js": "application/javascript; charset=utf-8",
+ ".json": "application/json",
+ ".xml": "application/xml",
+ ".txt": "text/plain; charset=utf-8",
+ ".ico": "image/x-icon",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".webp": "image/webp",
+ }
+ stylesMIMEs := server.AllowedMIMEs{
+ ".css": "text/css; charset=utf-8",
+ }
+
+ engine.GET("/assets/styles/*filepath", server.FileHandler("assets/styles", stylesMIMEs))
+ engine.GET("/lib/*filepath", server.FileHandler("assets/lib", libMIMEs))
+ engine.GET("/assets/components/*filepath", server.FileHandler("assets/components", libMIMEs))
+
+ // Image upload (admin-protected) + public image serving with on-the-fly conversion
+ mediaSrv := media.NewMediaHandler("assets/media")
+ adminSrv.RegisterUploadRoute(mediaSrv.HandleUpload)
+ engine.GET("/media/*filepath", mediaSrv.HandleServe)
+
+ frontpage := server.NewFrontpageHandler(meta, search, tmpl)
+ engine.GET("/", frontpage.Serve)
+
+ postHandler := server.NewPostHandler(meta, tmpl, outputDir)
+ engine.GET("/:slug", postHandler.Serve)
+
+ engine.NoRoute(server.PublicFileHandler(outputDir, publicMIMEs))
+
+ addr := ":" + *port
+ fmt.Printf("listening on http://localhost%s\n", addr)
+ fmt.Printf(" admin UI: http://localhost%s/admin/\n", addr)
+ if err := engine.Run(addr); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+```
+
+- [ ] **Step 4: Verify build**
+
+```bash
+CGO_ENABLED=1 go build ./...
+```
+Expected: no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cmd/nebbet/main.go internal/admin/server.go
+git commit -m "feat: wire MediaHandler routes and govips lifecycle"
+```
+
+---
+
+### Task 5: Update ImageTool config in form.html
+
+**Files:**
+- Modify: `internal/admin/templates/form.html`
+
+- [ ] **Step 1: Add endpoints config to ImageTool**
+
+In `internal/admin/templates/form.html`, replace line 53:
+
+```js
+ image: ImageTool,
+```
+
+with:
+
+```js
+ image: {
+ class: ImageTool,
+ config: {
+ endpoints: { byFile: '/admin/upload/image' },
+ },
+ },
+```
+
+- [ ] **Step 2: Verify build**
+
+```bash
+CGO_ENABLED=1 go build ./...
+```
+Expected: no errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/admin/templates/form.html
+git commit -m "feat: configure EditorJS ImageTool upload endpoint"
+```
+
+---
+
+### Task 6: Smoke test end-to-end
+
+- [ ] **Step 1: Start the server**
+
+```bash
+CGO_ENABLED=1 go run ./cmd/nebbet serve --port 8080
+```
+Expected output:
+```
+listening on http://localhost:8080
+ admin UI: http://localhost:8080/admin/
+```
+
+- [ ] **Step 2: Upload an image via the editor**
+
+Visit `http://localhost:8080/admin/` — log in, open any post (or create one). Click the image block tool in EditorJS and upload a JPEG or PNG. Expected: image preview appears in the editor.
+
+- [ ] **Step 3: Verify the original was stored**
+
+```bash
+ls -la assets/media/
+```
+Expected: one file like `550e8400-e29b-41d4-a716-446655440000.jpg`
+
+- [ ] **Step 4: Request the WebP URL**
+
+Replace `<uuid>` with the UUID from Step 3:
+```bash
+curl -I "http://localhost:8080/media/<uuid>.webp"
+```
+Expected: `HTTP/1.1 200 OK`, `Content-Type: image/webp`
+
+- [ ] **Step 5: Request a resized variant**
+
+```bash
+curl -I "http://localhost:8080/media/<uuid>.webp?w=400"
+```
+Expected: `HTTP/1.1 200 OK`, `Content-Type: image/webp`
+
+Verify the cached variant was written:
+```bash
+ls assets/media/
+```
+Expected: `<uuid>.jpg` (original) + `<uuid>.webp` (format-only cache) + `<uuid>_400w.webp` (resize cache)
+
+- [ ] **Step 6: Request JPEG**
+
+```bash
+curl -I "http://localhost:8080/media/<uuid>.jpg"
+```
+Expected: `HTTP/1.1 200 OK`, `Content-Type: image/jpeg` (served directly from original, no cache write)