feat(trips): owner publish/unpublish toggle on /trips listing
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
This commit is contained in:
@@ -141,4 +141,84 @@ class EntryActionsApiController extends AbstractApiController
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user