summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-04-04-image-block-design.md
blob: 6ab897389b9756945504cfc8c21d2db9fbcb81a4 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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