getUser($request); // Enforce the API-key scope cap (GHSA-x7hm) with the SAME permission the // stock media/page-write endpoints require. The owner already holds it // (their add/delete media uploads pass it), so this only caps a scoped // key — it never blocks the legitimate owner. $this->requirePermission($request, 'api.pages.write'); if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) { throw new ForbiddenException('Only the site owner can delete journal entries.'); } $slug = $this->getRouteParam($request, 'slug'); if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) { throw new ApiException(400, 'Bad Request', 'Invalid entry slug.'); } // Resolve via $pages->find() + parent-route assertion (never raw path // concatenation) — same shared check the save path uses. $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.'); } Folder::delete($path); // deleteAll() drops the cache stores but does NOT rebuild Grav's page-tree // index (keyed on folderHash, which doesn't change on child removal under // cache.check.method: folder). Without invalidateCache() the deleted entry // lingers in the index and the feed re-renders it — now image-less — on the // next load. Mirrors the create-path fix in the cache-on-save plugin. See // docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md $this->grav['cache']->deleteAll(); Cache::invalidateCache(); // Audit trail: entry deletion is destructive and owner-only — record who // did it and to what, so an unexpected disappearance is traceable. $this->grav['log']->info(sprintf('entry-actions: owner "%s" deleted entry "%s"', $user->username, $slug)); 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. An incomplete `order` (e.g. a * stale second tab) is safe too: PhotoRenumberer renumbers every on-disk image, * appending any the manifest omits, so no photo is lost — `order` only sorts. */ public function reorderPhotos(ServerRequestInterface $request): ResponseInterface { // Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous. $user = $this->getUser($request); // Enforce the API-key scope cap (GHSA-x7hm) — see deleteEntry above. $this->requirePermission($request, 'api.pages.write'); 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(); // Audit trail: mirror deleteEntry — record the owner mutating an entry's // photo order (and how many files the manifest listed). $this->grav['log']->info(sprintf('entry-actions: owner "%s" reordered %d photo(s) for entry "%s"', $user->username, count($names), $slug)); return ApiResponse::noContent(); } }