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
@@ -8,12 +8,15 @@ use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\NotFoundException; use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Response\ApiResponse; use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Shared\EntryScopeGuard; use Grav\Plugin\Shared\EntryScopeGuard;
use Grav\Plugin\Shared\PhotoRenumberer;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
// Shared R6 guard lives in cache-on-save (the always-present custom plugin); // Shared R6 guard + the photo-NN renumber helper both live in cache-on-save (the
// require it so save and delete enforce scope identically (KTD5). // always-present custom plugin); require them so save, delete and reorder enforce
// scope identically (KTD5) and share one numbering invariant.
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/EntryScopeGuard.php'; require_once dirname(__DIR__, 2) . '/cache-on-save/classes/EntryScopeGuard.php';
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/PhotoRenumberer.php';
/** /**
* DELETE /api/v1/entry/{slug} * DELETE /api/v1/entry/{slug}
@@ -58,4 +61,61 @@ class EntryActionsApiController extends AbstractApiController
return ApiResponse::noContent(); return ApiResponse::noContent();
} }
/**
* POST /api/v1/entry/{slug}/photos/order
*
* Body: { "order": ["photo-x.jpg", "photo-y.jpg", …] } — the entry's image
* files in the display order the owner arranged. Renames them to photo-01..NN
* so the feed cover (media.images|first) and numeric client sort follow the
* drag. Same guard chain as deleteEntry: OWNER + direct-child-of-active-dailies.
*
* Filename safety is defence in depth: unsafe segments (containing '/' or '..')
* are dropped here, and PhotoRenumberer only ever renames files that already
* exist as image media in the folder — so a crafted order body can never touch
* the entry .md, a .gpx or a .meta.yaml sidecar.
*/
public function reorderPhotos(ServerRequestInterface $request): ResponseInterface
{
// Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous.
$user = $this->getUser($request);
if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) {
throw new ForbiddenException('Only the site owner can reorder entry photos.');
}
$slug = $this->getRouteParam($request, 'slug');
if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) {
throw new ApiException(400, 'Bad Request', 'Invalid entry slug.');
}
$page = EntryScopeGuard::resolveActiveDailyChild($this->grav, $slug);
if ($page === null) {
throw new NotFoundException('Entry not found in the active trip.');
}
$path = $page->path();
if (!is_string($path) || $path === '' || !is_dir($path)) {
throw new NotFoundException('Entry folder not found.');
}
// Reduce the body's `order` to a clean list of safe basenames. Anything
// unsafe or non-string is dropped; PhotoRenumberer then keeps only the
// entries that are real image files on disk.
$body = $this->getRequestBody($request);
$order = $body['order'] ?? null;
if (!is_array($order)) {
throw new ApiException(400, 'Bad Request', 'Body must include an "order" array of filenames.');
}
$names = [];
foreach ($order as $name) {
if (is_string($name) && EntryScopeGuard::isSafeSegment($name)) {
$names[] = $name;
}
}
PhotoRenumberer::renumber($path, $names);
$this->grav['cache']->deleteAll();
return ApiResponse::noContent();
}
} }
+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 * 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. * scoped journal-entry actions that the stock Grav API cannot express safely.
* *
* M1 registers exactly one route: DELETE /api/v1/entry/{slug}. The stock * Routes:
* DELETE /api/v1/pages<route> only checks write-permission (no trip scope, and * - DELETE /api/v1/entry/{slug} — delete a journal entry folder
* any admin passes), which violates R6. This route requires the configured site * - POST /api/v1/entry/{slug}/photos/order — reorder an entry's photos
* 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 * The stock DELETE /api/v1/pages<route> only checks write-permission (no trip
* R6 enforcement points cannot diverge (KTD5). * 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 * 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. * 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 = $event['routes'];
$routes->delete('/entry/{slug}', [EntryActions\EntryActionsApiController::class, 'deleteEntry']); $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']);
} }
} }