Files
intotheeast-com/docs/working/plans/2026-07-05-photo-editor-media-api.md
T
m038andClaude Opus 4.8 8441ce392d docs(plan): photo-editor implemented + server-logic verified; status update
Record implementation completion and the verification split: PhotoRenumberer
unit-verified, build/lint clean, /post + assets serve locally; owner-session UI
verification and on-device touch-drag reserved for the user. Note the server-side
SVG block deferred to the R6 add/delete fast-follow (config/security.yaml is
gitignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:25:04 +02:00

113 lines
14 KiB
Markdown

---
title: Photo Editor for Journal Entries (media-API) — Plan
date: 2026-07-05
---
# Photo Editor for Journal Entries (media-API) — Plan
**Status:** 🔄 In progress — implemented & server-logic verified (2026-07-05). Server (shared `PhotoRenumberer`, reorder route, guards) + client (own grid, SortableJS, FilePond decommission) landed; `PhotoRenumberer` unit-verified (pad/normalise/swap/gap/crafted-name-safety/10+/idempotent/ext), PHP lints clean, JS/CSS build clean, `/post` + assets serve on :8091. Server-side SVG block deferred to the R6 add/delete fast-follow (see Deferred). **Pending owner-session UI verification** (add incl. HEIC, inline-confirm delete, mouse reorder, combined; feed cover=first; regressions a/b/c) and **on-device touch-drag** — both need the owner login the harness can't obtain.
## Why this exists (the honest reason)
M2 tried to edit an entry's photos by reusing the `/post` **create** form + FilePond + the abandoned `add-page-by-form` plugin. Two distinct failure classes came out of that, and it matters not to blur them into one root cause:
- **FilePond-widget bugs** — `text/html` previews and broken touch-drag. FilePond is built to upload new files to a fresh entry, not to load/preview/reorder existing server files; these are the widget used against its grain.
- **PHP-side bugs** — the header-cast fatal and the rename-reconcile gymnastics live in `add-page-by-form` / `cache-on-save`, **not** in FilePond. This plan **reuses that same rename-reconcile logic** (see the reorder route below), so it must be validated on its own merits — a "different foundation" does not make the carried-forward reconcile code automatically safe.
This plan replaces the **photo UI** with the **proven `gpx-manager` pattern**: our own UI talking straight to the Grav media API.
## Foundation status (what's proven vs still assumed)
**Proven on 2026-07-05 (not assumed):**
- `POST /api/v1/pages<entry-route>/media` (FormData `file`, owner session) → **201**, file on disk ✓
- `DELETE /api/v1/pages<entry-route>/media/<filename>`**204**, removed ✓
- Owner session auth works on entry routes ✓
**Still assumed (novel, load-bearing, NOT yet proven — this is where the 4-day risk lives):**
- The custom reorder route (rename to `photo-01..NN`) — no stock endpoint exists.
- Live reorder-rename behaviour under real add/delete ops.
- Three independent live mutations interacting cleanly with the edit session's text-field Save.
- HEIC→JPEG conversion at real photo sizes/counts on a phone.
## Design decisions
1. **Live, not on-submit.** Add / delete / reorder each persist **immediately** via the API — decoupled from the `/post` form's text-field Save. No flash, no submit-time reconcile. This sidesteps `add-page-by-form` for the photo path entirely (the text-field save still uses it + our committed patch). *(Edit-then-leave / no-undo behaviour for the destructive delete path is unresolved — see Open Questions.)*
2. **Inline on `/post?edit`.** In edit mode, hide the FilePond section and render the photo-editor component from the media list. **Create mode keeps FilePond, untouched** (out of scope). Hiding the section alone is **not** enough — see the FilePond decommission step in the Client section.
3. **Own thumbnail grid, SortableJS for drag.** Square `<img>` thumbnails in a grid. Reorder via **SortableJS** — exactly what FilePond couldn't do reliably here. SortableJS is **not yet a theme dependency**: install `sortablejs` and import it into `js/src/post-form.js` so esbuild bundles it into `js/post`. This is a task, not existing foundation.
4. **Cover = first.** After any add/delete/reorder, files are renumbered **`photo-01..NN`** (zero-padded, wide enough for the expected max) in display order; the client sorts thumbnails **numerically**, and the feed renders `media.images|first` as cover. Zero-padding is required so lexicographic media order equals numeric order past 10 photos (otherwise photo-1, photo-10, photo-2…). The shared renumber helper must also normalise any pre-existing un-padded `photo-N` files on first reorder. **This helper also runs on create-mode reconcile**, so create-mode entries will now emit `photo-01..NN` too — an intentional, accepted change (see Scope boundaries). Existing published entries keep their un-padded names harmlessly (they have <10 photos and the client sorts numerically).
5. **Add/delete via stock media API; reorder via one custom scope-guarded route.** Stock `POST`/`DELETE …/media` are already proven on entry routes, so add + delete use the **stock media API** (client-side, session-auth). Only the missing **reorder** (rename to `photo-01..NN`) is a custom route in the **`entry-actions`** plugin using `EntryScopeGuard` (owner-username + direct-child-of-active-dailies, the R6 guard). **Accepted tradeoff:** server-side scope enforcement on photo **add/delete** is a **known R6 gap** — any account with `api.media.write` can reach the un-scoped stock endpoint directly, and the client UI gate is **not** an access-control boundary. For a solo-owner blog this is accepted for launch and tracked as a **documented fast-follow** (promote add/delete onto scope-guarded custom routes later). HEIC→JPEG happens client-side before upload (reuse the existing converter).
## Server — `entry-actions` plugin, 1 custom route (+ stock media API for add/delete)
**Add / delete — stock media API (client-side, session-auth):**
- `POST /api/v1/pages<entry-route>/media` — upload (stock endpoint). **No SVG support for now:** add `svg` to `security.uploads_dangerous_extensions` (or reject `.svg` in the upload path) so SVGs are **blocked, not sanitized** — this removes the stored-XSS-via-SVG vector without depending on `security.sanitize_svg` staying enabled. Other executable types (html/js/php) are already blocked by Grav's default dangerous-extension denylist, which is the **actual** control on this stock path — there is no positive MIME allowlist or on-disk extension rewrite here. Allowed image types: **jpg/jpeg/png/webp**; the client file input accepts those **plus HEIC** (converted client-side to JPEG before upload) and excludes SVG. If stronger positive-MIME validation is ever wanted, it moves add onto the scope-guarded custom route (the same place the R6 add/delete fast-follow lands).
- `DELETE /api/v1/pages<entry-route>/media/<filename>` — remove.
- **After every stock add and every stock delete, immediately call the reorder route (below) to re-establish `photo-01..NN`.** Stock upload keeps the file's original (slugified) name — not the next `photo-N` — and stock delete leaves a numbering gap without renumbering; without a follow-up renumber, `cover = first` breaks until the next manual drag. The reorder route is the single owner of the `photo-N` invariant.
**Reorder — one custom route (owner + scope guarded):**
- `POST /api/v1/entry/<slug>/photos/order` — body: ordered filenames → two-phase rename to `photo-01..NN` (reuse the proven cache-on-save rename logic; factor it into a shared helper — and validate that helper on its own, per "Why this exists").
Handler: `EntryScopeGuard::isOwnerUser` + `resolveActiveDailyChild` (reject 403/400 otherwise), then filesystem op, then `cache->deleteAll()`. Reject filenames containing `/` or `..`. **Operate only on filenames that already exist as image media** in the entry folder — any name in the ordered list that isn't a current image file is ignored, so the entry `.md`, a `.gpx`, or a `.meta.yaml` can never be renamed or clobbered by a crafted order body.
**Deploy note:** the new `/entry/<slug>/photos/order` route only registers after the API route-map cache is rebuilt, so a cache clear must run on deploy. The existing `DELETE /entry/<slug>` route confirms the nested-static-after-param pattern registers fine.
## Client — new `photo-editor.js` (bundled into the post-form entry)
In edit mode only:
- **Decommission the FilePond photo path (hiding it is not enough).** Skip `editLoadPhotos()` and the FilePond `photo_order` submit-handler wiring entirely — do not initialise/populate FilePond. Otherwise the stale `photo_order` manifest posted on text Save drives `cache-on-save.reconcilePhotos()``deleteUnlistedImages()`, which **silently deletes any photo added live after page-open**. With an empty manifest the reconcile leaves the live-managed folder untouched.
- Hide the FilePond `.photos-collapse`; render `.photo-editor` from `GET …/media` (image files, numeric-sorted). Show a **loading placeholder** during the fetch and an **empty state** for zero-photo entries that keeps the "Add photos" button visible ("No photos yet — add some").
- Each cell: `<img>` thumbnail + ✕ delete. **Inline confirm:** ✕ swaps the cell to "Delete? [Confirm] [Cancel]" (Confirm disabled while the DELETE is in flight) → `DELETE …/media/<file>` → renumber → re-render; Cancel reverts.
- "Add photos" button → hidden file input → HEIC→JPEG → `POST …/media` (one per file) → renumber → re-render. **Upload progress:** disable the button while a batch is in flight and show "Uploading N of M…", clearing per file.
- **Add is a two-write op (stock upload, then reorder).** On a multi-file add, upload each file (stock `POST …/media`) and call the reorder route **once after the whole batch** — not per file — so there is one renumber pass and only the final numbering matters. The client passes the stock-uploaded basenames into that reorder manifest. If an upload succeeds (201) but the follow-up reorder fails, auto-retry the reorder — it is idempotent, since `renumberPhotos` skips files not on disk — or roll back by `DELETE`-ing the just-uploaded file(s), and surface a single inline error. Never leave an orphan stock-named file in the folder: it is a real image, so it would break `cover = first` and the numeric sort until the next successful drag.
- `Sortable` on the grid → on drop, `POST …/photos/order` with the new filename order → re-render. First cell = cover.
- **Failure path (every op).** On non-2xx / network error: show an inline error near the affected control (reuse gpx-manager's `.gpx-status.error`), keep the item in place — for reorder, **revert the SortableJS move to the last-known-good order** — re-enable the control for retry, and do **not** silently re-render. Displayed order/cover must never disagree with disk without an error shown.
- All live; independent of the form's Save button (which continues to handle title/date/content/etc.).
## Scope boundaries (non-goals)
- **Create flow (new-entry FilePond) untouched** — *except* that the shared renumber helper is now zero-padded, so create-mode entries also emit `photo-01..NN`. That is the only create-path side effect; the FilePond UI itself is unchanged. Two photo UIs for now (FilePond on create, this on edit); unifying them is a follow-up.
- **Text-field editing unchanged** (`/post` form + `add-page-by-form` + our patch).
- No captions, no crop/rotate, no bulk ops.
## Verification
- **I verify in-harness:** add (incl. HEIC), delete, reorder-by-**mouse**, and combined — each persists to disk + shows in the feed immediately; cover = first after reorder; the reorder route's owner/scope-guard rejects non-owner + out-of-scope.
- **Regression checks:** (a) a text-field Save *after* a live photo add does **not** delete the added photo (FilePond decommission); (b) an entry with **10+ photos** keeps arranged order and the correct cover (zero-padding); (c) each op's failure path shows an inline error and leaves UI and disk consistent.
- **You verify on-device (the one thing I can't simulate):** touch-drag reorder on a phone.
## Estimate
One focused implementation push — 1 custom reorder handler + stock add/delete reuse + one JS component + CSS + the SortableJS dependency (install + import). Not another multi-day cycle. Residual risk concentrated in the "still assumed" list above.
## Deferred / Open Questions
### From 2026-07-05 review
- **No undo / cancel model for destructive live edits (P1).** Add/delete/reorder persist immediately and delete is a destructive `unlink`; the Save button trains the user that leaving without saving discards changes, but live deletes are already gone with no undo and no "permanent" signal. Decide between: (a) accept live-is-permanent + add a "saves immediately" affordance and a real delete confirm (cheapest for the deadline); (b) soft-delete to a trash subfolder purged on Save/leave; (c) stage deletes client-side and commit on Save. Resolve before implementing the delete path.
### Deferred during implementation (2026-07-05)
- **Server-side SVG block deferred to the R6 add/delete fast-follow.** The plan
called for adding `svg` to `security.uploads_dangerous_extensions`, but
`user/config/security.yaml` is **gitignored** (a Grav 1.7-era rule from when the
HMAC `salt` lived there; obsolete in 2.0.7 where the secret moved to the
still-ignored `security-private.php`). Tracking it would mean un-ignoring a
security-namespace file from another work session's era — out of scope for this
push. Instead: **SVG is excluded client-side** in the photo-editor file input
`accept` (jpg/jpeg/png/webp + HEIC only). The **server-side** block is a
documented fast-follow that lands together with promoting photo add/delete onto
the scope-guarded custom route (the same R6 gap already accepted above) — both
concern the un-scoped stock media endpoint, which only the solo owner can reach.
### Resolved at review close (2026-07-05) — recorded for the implementer
- **Reorder-route filename safety** — *resolved:* the handler operates only on filenames already present as image media in the folder, so a crafted order body can't rename/clobber the entry `.md`, a `.gpx`, or a `.meta.yaml`. (Now in the Server reorder-route spec.)
- **Multi-photo add — reorder cadence** — *resolved:* call the reorder route **once after the whole batch** of uploads, not once per file. (Now in the Client "Add is a two-write op" spec.)
- **New route 404 until cache rebuild** — *resolved:* deploy must clear the API route-map cache so `/entry/<slug>/photos/order` registers; the existing `DELETE /entry/<slug>` proves the nested-route pattern works. (Now a deploy note in the Server section.)
- **`.meta.yaml` sidecars not renamed by `renumberPhotos`** — *deferred (genuine future work):* no effect today because per-image captions are deferred. When captions ship, the shared renumber helper must rename each image's `.meta.yaml` sidecar alongside it (and clean up orphans), or per-image metadata will drift on reorder/delete.