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>
14 KiB
title, date
| title | date |
|---|---|
| Photo Editor for Journal Entries (media-API) — Plan | 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/htmlpreviews 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(FormDatafile, 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
- Live, not on-submit. Add / delete / reorder each persist immediately via the API — decoupled from the
/postform's text-field Save. No flash, no submit-time reconcile. This sidestepsadd-page-by-formfor 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.) - 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. - 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: installsortablejsand import it intojs/src/post-form.jsso esbuild bundles it intojs/post. This is a task, not existing foundation. - 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 rendersmedia.images|firstas 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-paddedphoto-Nfiles on first reorder. This helper also runs on create-mode reconcile, so create-mode entries will now emitphoto-01..NNtoo — 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). - Add/delete via stock media API; reorder via one custom scope-guarded route. Stock
POST/DELETE …/mediaare already proven on entry routes, so add + delete use the stock media API (client-side, session-auth). Only the missing reorder (rename tophoto-01..NN) is a custom route in theentry-actionsplugin usingEntryScopeGuard(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 withapi.media.writecan 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: addsvgtosecurity.uploads_dangerous_extensions(or reject.svgin the upload path) so SVGs are blocked, not sanitized — this removes the stored-XSS-via-SVG vector without depending onsecurity.sanitize_svgstaying 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 nextphoto-N— and stock delete leaves a numbering gap without renumbering; without a follow-up renumber,cover = firstbreaks until the next manual drag. The reorder route is the single owner of thephoto-Ninvariant.
Reorder — one custom route (owner + scope guarded):
POST /api/v1/entry/<slug>/photos/order— body: ordered filenames → two-phase rename tophoto-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 FilePondphoto_ordersubmit-handler wiring entirely — do not initialise/populate FilePond. Otherwise the stalephoto_ordermanifest posted on text Save drivescache-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-editorfromGET …/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, sincerenumberPhotosskips files not on disk — or roll back byDELETE-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 breakcover = firstand the numeric sort until the next successful drag. Sortableon the grid → on drop,POST …/photos/orderwith 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 (
/postform +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
svgtosecurity.uploads_dangerous_extensions, butuser/config/security.yamlis gitignored (a Grav 1.7-era rule from when the HMACsaltlived there; obsolete in 2.0.7 where the secret moved to the still-ignoredsecurity-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 inputaccept(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/orderregisters; the existingDELETE /entry/<slug>proves the nested-route pattern works. (Now a deploy note in the Server section.) .meta.yamlsidecars not renamed byrenumberPhotos— 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.yamlsidecar alongside it (and clean up orphans), or per-image metadata will drift on reorder/delete.