feat(entry-actions): add owner-scoped photo reorder route

POST /api/v1/entry/{slug}/photos/order renames an entry's image files to
photo-01..NN in the client-supplied order so the feed cover (media.images|first)
follows the drag — no stock endpoint can express this. Same R6 guard chain as the
delete route (site OWNER + direct child of the active trip's dailies), then the
shared PhotoRenumberer does the two-phase rename and the cache is cleared.

Filename safety is layered: unsafe 'order' entries (/, ..) are dropped here and
PhotoRenumberer only renames real image files, so a crafted body can never touch
the entry .md, a .gpx or a .meta.yaml. Registers behind the API route-map cache,
so a deploy cache-clear is required (same as the existing DELETE /entry/{slug}).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 18:58:43 +02:00
co-authored by Claude Opus 4.8
parent fcf52a0e44
commit 4fea522d5c
2 changed files with 77 additions and 8 deletions
+15 -6
View File
@@ -8,12 +8,16 @@ use RocketTheme\Toolbox\Event\Event;
* Entry Actions — a thin, purpose-built API surface for owner-only, active-trip
* scoped journal-entry actions that the stock Grav API cannot express safely.
*
* M1 registers exactly one route: DELETE /api/v1/entry/{slug}. The stock
* DELETE /api/v1/pages<route> only checks write-permission (no trip scope, and
* any admin passes), which violates R6. This route requires the configured site
* OWNER and asserts the target is a direct child of the active trip's dailies
* container — sharing one guard (EntryScopeGuard) with the save path so the two
* R6 enforcement points cannot diverge (KTD5).
* Routes:
* - DELETE /api/v1/entry/{slug} — delete a journal entry folder
* - POST /api/v1/entry/{slug}/photos/order — reorder an entry's photos
*
* The stock DELETE /api/v1/pages<route> only checks write-permission (no trip
* scope, and any admin passes), which violates R6; and no stock endpoint can
* rename media to the photo-NN cover order at all. Both custom routes require the
* configured site OWNER and assert the target is a direct child of the active
* trip's dailies container — sharing one guard (EntryScopeGuard) with the save
* path so the R6 enforcement points cannot diverge (KTD5).
*
* Custom-in-repo (NOT GPM-managed): tracked via a `!` negation in user/.gitignore
* and deployed with the content push, like cache-on-save. Never in plugins.txt.
@@ -56,5 +60,10 @@ class EntryActionsPlugin extends Plugin
{
$routes = $event['routes'];
$routes->delete('/entry/{slug}', [EntryActions\EntryActionsApiController::class, 'deleteEntry']);
// Reorder an entry's photos to a client-supplied order → rename to
// photo-01..NN so the feed cover (media.images|first) follows the drag.
// Nested-static-after-param, same shape as the DELETE above — it only
// registers once the API route-map cache is rebuilt (deploy must clear cache).
$routes->post('/entry/{slug}/photos/order', [EntryActions\EntryActionsApiController::class, 'reorderPhotos']);
}
}