From 1fdf7dffa8b3be0eac61796a39a0e4af61713d7a Mon Sep 17 00:00:00 2001 From: ivar Date: Sat, 4 Apr 2026 16:15:23 +0200 Subject: docs: add image block implementation plan Co-Authored-By: Claude Sonnet 4.6 --- docs/superpowers/plans/2026-04-04-image-block.md | 902 +++++++++++++++++++++++ 1 file changed, 902 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-04-image-block.md 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=` resize. Processed variants are cached in `assets/media/` alongside originals using the naming scheme `_w.`. + +**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/.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= 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: ".webp" / ".jpg" +// - With width: "_w.webp" / "_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 `` with the UUID from Step 3: +```bash +curl -I "http://localhost:8080/media/.webp" +``` +Expected: `HTTP/1.1 200 OK`, `Content-Type: image/webp` + +- [ ] **Step 5: Request a resized variant** + +```bash +curl -I "http://localhost:8080/media/.webp?w=400" +``` +Expected: `HTTP/1.1 200 OK`, `Content-Type: image/webp` + +Verify the cached variant was written: +```bash +ls assets/media/ +``` +Expected: `.jpg` (original) + `.webp` (format-only cache) + `_400w.webp` (resize cache) + +- [ ] **Step 6: Request JPEG** + +```bash +curl -I "http://localhost:8080/media/.jpg" +``` +Expected: `HTTP/1.1 200 OK`, `Content-Type: image/jpeg` (served directly from original, no cache write) -- cgit v1.3