Files
intotheeast-com-content/plugins/entry-actions/classes/EntryActionsApiController.php
T
m038andClaude Opus 4.8 c46bbd952f fix(entry-actions): invalidate page-tree index on delete
deleteEntry did cache.deleteAll() but not Cache::invalidateCache(), so under
cache.check.method: folder the deleted entry lingered in the pages index and the
feed re-rendered it (image-less) on reload. Mirror the create-path fix. See
docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 10:29:05 +02:00

145 lines
6.9 KiB
PHP

<?php
namespace Grav\Plugin\EntryActions;
use Grav\Common\Cache;
use Grav\Common\Filesystem\Folder;
use Grav\Plugin\Api\Controllers\AbstractApiController;
use Grav\Plugin\Api\Exceptions\ApiException;
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 + 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}
*
* Deletes a journal entry folder, but only when ALL hold:
* - the request is the authenticated site OWNER (not merely any login/admin);
* - {slug} is a safe single segment (no '/', no '..');
* - it resolves through the page tree to a DIRECT child of the ACTIVE trip's
* dailies container.
* Otherwise: 401 (anon), 403 (non-owner), 400 (bad slug), 404 (out of scope /
* not found). On success the folder is removed and the page-tree cache cleared.
*/
class EntryActionsApiController extends AbstractApiController
{
public function deleteEntry(ServerRequestInterface $request): ResponseInterface
{
// Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous.
$user = $this->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();
}
}