# 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)