Add an owner-only publish switch to each /trips card. It POSTs to a new
entry-actions route that mutates trip.md `published` and invalidates the
page-tree cache so the listing, nav and home reflect the change on the
next load. Owner sees drafts (Draft badge); anon/non-owner unchanged.
- U1 EntryScopeGuard::resolveTripChild — resolve a slug to a direct child
of /trips (drafts included, for republish)
- U2 POST /api/v1/trip/{slug}/publish (setTripPublished) — owner-gated
write, strict is_bool body, header-mutation save, audit log
- U3 trip-publish-toggle partial + CSS (role=switch, Draft badge, visible
failure toast, ≥44px target)
- U4 owner-aware /trips listing + card restructure (toggle overlays cover
as a non-anchor sibling; works for coverless drafts)
- U5 home active-trip branch falls back when the active trip is unpublished
- U6 trip-publish.js (confirm/pending/optimistic/revert) + esbuild wiring
Cache note: an in-place frontmatter edit keeps the folder-check cache id,
and driver:auto uses APCu in web memory, so deleteAll()+invalidateCache()
is insufficient — the endpoint also calls apcu_clear_cache().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
225 lines
11 KiB
PHP
225 lines
11 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();
|
|
}
|
|
|
|
/**
|
|
* POST /api/v1/trip/{slug}/publish
|
|
*
|
|
* Body: { "published": true|false } — sets the trip's published state and
|
|
* persists it to trip.md frontmatter, then invalidates the page-tree cache so
|
|
* the /trips listing, nav and home render reflect the change on the next load.
|
|
* Owner-only, but (unlike deleteEntry) NOT active-trip scoped: the owner
|
|
* publishes/unpublishes ANY trip from the listing. 401 (anon), 403 (non-owner),
|
|
* 400 (bad slug / non-boolean body), 404 (slug is not a direct child of /trips).
|
|
*
|
|
* CSRF boundary: this is a session-cookie write with credentials. Its cross-
|
|
* origin protection is the required `Content-Type: application/json`, which
|
|
* (with the api plugin's CORS `origins: []`, i.e. same-origin only) forces a
|
|
* CORS preflight that a cross-site page cannot satisfy — so a forged request
|
|
* from another origin is rejected before it reaches this handler. The strict
|
|
* is_bool guard below backs that up (a form-encoded forgery decodes to no key).
|
|
*/
|
|
public function setTripPublished(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 publish trips.');
|
|
}
|
|
|
|
$slug = $this->getRouteParam($request, 'slug');
|
|
if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) {
|
|
throw new ApiException(400, 'Bad Request', 'Invalid trip slug.');
|
|
}
|
|
|
|
// Resolve via find() + parent-route assertion; drafts resolve too so the
|
|
// owner can republish an unpublished trip (R7, KTD4).
|
|
$page = EntryScopeGuard::resolveTripChild($this->grav, $slug);
|
|
if ($page === null) {
|
|
throw new NotFoundException('Trip not found.');
|
|
}
|
|
|
|
// Strict boolean only — never (bool)-cast (KTD2). A cast would coerce
|
|
// "false"/0/""/a missing key into a valid boolean and silently mis-set
|
|
// the flag, contradicting R5.
|
|
$body = $this->getRequestBody($request);
|
|
if (!is_array($body) || !array_key_exists('published', $body) || !is_bool($body['published'])) {
|
|
throw new ApiException(400, 'Bad Request', 'Body must include a boolean "published".');
|
|
}
|
|
$published = $body['published'];
|
|
|
|
// Persist by mutating the page HEADER before save() (KTD1): in Grav 2.0
|
|
// $page->published($v) sets only the in-memory property, while save()
|
|
// serializes from the header object and the flag is read one-way from the
|
|
// header at init. Mirror cache-on-save's header-mutation pattern.
|
|
$header = $page->header();
|
|
$header->published = $published;
|
|
$page->save();
|
|
|
|
// A published-flag change rewrites trip.md IN PLACE — the trip folder's
|
|
// structure is unchanged, so the pages-index cache id (md5 of the folder
|
|
// checksum under cache.check.method: folder) does NOT change (KTD3). This
|
|
// differs from deleteEntry, where the removed folder IS a structure change
|
|
// that bumps the id, so a fresh id misses cache and rebuilds. With the id
|
|
// unchanged, the stale index survives — and because the cache driver is
|
|
// APCu (driver: auto), it lives in the web server's shared memory, which a
|
|
// CLI `bin/grav clearcache` cannot reach at all. So: flush the runtime
|
|
// store (deleteAll → APCu flushAll) AND apcu_clear_cache() directly to be
|
|
// certain, clear the compiled files, and reset the in-memory tree so the
|
|
// next request rebuilds from disk and re-reads the published flag.
|
|
$this->grav['cache']->deleteAll();
|
|
if (function_exists('apcu_clear_cache')) {
|
|
apcu_clear_cache();
|
|
}
|
|
$this->grav['pages']->reset();
|
|
$this->grav['cache']->clearCache('standard');
|
|
// Audit trail: publish state is owner-only and changes site-wide
|
|
// visibility — record who flipped which trip to what.
|
|
$this->grav['log']->info(sprintf('entry-actions: owner "%s" set trip "%s" published=%s', $user->username, $slug, $published ? 'true' : 'false'));
|
|
|
|
return ApiResponse::noContent();
|
|
}
|
|
}
|