diff --git a/plugins/entry-actions/classes/EntryActionsApiController.php b/plugins/entry-actions/classes/EntryActionsApiController.php index 8e36c20..2d8497a 100644 --- a/plugins/entry-actions/classes/EntryActionsApiController.php +++ b/plugins/entry-actions/classes/EntryActionsApiController.php @@ -8,12 +8,15 @@ use Grav\Plugin\Api\Exceptions\ForbiddenException; use Grav\Plugin\Api\Exceptions\NotFoundException; use Grav\Plugin\Api\Response\ApiResponse; use Grav\Plugin\Shared\EntryScopeGuard; +use Grav\Plugin\Shared\PhotoRenumberer; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -// Shared R6 guard lives in cache-on-save (the always-present custom plugin); -// require it so save and delete enforce scope identically (KTD5). +// Shared R6 guard + the photo-NN renumber helper both live in cache-on-save (the +// 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/PhotoRenumberer.php'; /** * DELETE /api/v1/entry/{slug} @@ -58,4 +61,61 @@ class EntryActionsApiController extends AbstractApiController 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(); + } } diff --git a/plugins/entry-actions/entry-actions.php b/plugins/entry-actions/entry-actions.php index 9d82d05..0f3c9eb 100644 --- a/plugins/entry-actions/entry-actions.php +++ b/plugins/entry-actions/entry-actions.php @@ -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 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 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']); } }