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
163 lines
5.9 KiB
PHP
163 lines
5.9 KiB
PHP
<?php
|
|
namespace Grav\Plugin\Shared;
|
|
|
|
use Grav\Common\Grav;
|
|
use Grav\Common\Page\Interfaces\PageInterface;
|
|
|
|
/**
|
|
* Single source of truth for the R6 server-side scope guard, shared by BOTH
|
|
* enforcement points so they cannot diverge (KTD5):
|
|
* - the save/publish path (cache-on-save onFormValidationProcessed), and
|
|
* - the delete path (entry-actions API route).
|
|
*
|
|
* Two independent checks, both required:
|
|
* 1. isOwner() — the acting user is the configured site owner, NOT merely any
|
|
* authenticated account (the super-admin `tester` also authenticates).
|
|
* 2. resolveActiveDailyChild() — the target resolves, through the page tree,
|
|
* to a real DIRECT child of the active trip's `dailies` container. Resolving
|
|
* via $pages->find() + a parent-route assertion (never raw path concatenation)
|
|
* closes the traversal hole where a string like `/…/dailies/../other/entry.md`
|
|
* prefix-matches the active dailies but points elsewhere.
|
|
*/
|
|
class EntryScopeGuard
|
|
{
|
|
/**
|
|
* Active trip's dailies container route ("/trips/<slug>/dailies"), or null
|
|
* when site.active_trip is unset. Accepts a full route or a bare slug.
|
|
*/
|
|
public static function dailiesRoute(Grav $grav): ?string
|
|
{
|
|
$active = $grav['config']->get('site.active_trip');
|
|
$active = is_string($active) ? trim($active) : '';
|
|
if ($active === '') {
|
|
return null;
|
|
}
|
|
$trip = trim($active, '/');
|
|
if (strpos($trip, 'trips/') !== 0) {
|
|
$trip = 'trips/' . $trip;
|
|
}
|
|
return '/' . $trip . '/dailies';
|
|
}
|
|
|
|
/**
|
|
* True only when the current user is authenticated AND their username equals
|
|
* site.owner_username. Gating on authentication alone would grant rights to
|
|
* every account, including the super-admin `tester` (KTD8).
|
|
*/
|
|
public static function isOwner(Grav $grav): bool
|
|
{
|
|
$user = $grav['user'] ?? null;
|
|
if (!$user || empty($user->authenticated)) {
|
|
return false;
|
|
}
|
|
return self::isOwnerUser($grav, $user);
|
|
}
|
|
|
|
/**
|
|
* Owner check for an explicit user object — used by the API delete route,
|
|
* whose authenticated user comes from the request (api_user attribute), not
|
|
* $grav['user']. Same rule: username must equal site.owner_username.
|
|
*/
|
|
public static function isOwnerUser(Grav $grav, $user): bool
|
|
{
|
|
if (!$user || !isset($user->username)) {
|
|
return false;
|
|
}
|
|
$owner = $grav['config']->get('site.owner_username');
|
|
return is_string($owner) && $owner !== '' && $user->username === $owner;
|
|
}
|
|
|
|
/**
|
|
* A safe single path segment: non-empty, no separators, no dot-traversal.
|
|
*/
|
|
public static function isSafeSegment(string $segment): bool
|
|
{
|
|
if ($segment === '' || $segment === '.' || $segment === '..') {
|
|
return false;
|
|
}
|
|
if (strpbrk($segment, '/\\') !== false) {
|
|
return false;
|
|
}
|
|
return strpos($segment, '..') === false;
|
|
}
|
|
|
|
/**
|
|
* The folder segment carried by a hidden edit_path value. post-form.js sets
|
|
* edit_path to "<entry-route>/entry.md", so basename(dirname()) is the entry's
|
|
* own folder name (its route's last segment) — the same value stock
|
|
* add-page-by-form derives for the in-place write.
|
|
*/
|
|
public static function segmentFromEditPath(string $editPath): string
|
|
{
|
|
$editPath = trim($editPath);
|
|
if ($editPath === '') {
|
|
return '';
|
|
}
|
|
return basename(dirname($editPath));
|
|
}
|
|
|
|
/**
|
|
* Resolve a folder segment to the page that is a DIRECT child of the active
|
|
* trip's dailies container, or null when the segment is unsafe, no active trip
|
|
* is set, the page does not exist, or its parent is not the active dailies.
|
|
*/
|
|
public static function resolveActiveDailyChild(Grav $grav, string $segment): ?PageInterface
|
|
{
|
|
if (!self::isSafeSegment($segment)) {
|
|
return null;
|
|
}
|
|
$dailies = self::dailiesRoute($grav);
|
|
if ($dailies === null) {
|
|
return null;
|
|
}
|
|
$pages = $grav['pages'];
|
|
// In the API request context the page tree is lazily disabled; enable it
|
|
// so find() can resolve (mirrors the api plugin's own resolvePageByRoute).
|
|
// Idempotent — a no-op in the frontend save-path context.
|
|
if (method_exists($pages, 'enablePages')) {
|
|
$pages->enablePages();
|
|
}
|
|
$page = $pages->find($dailies . '/' . $segment);
|
|
if ($page === null) {
|
|
return null;
|
|
}
|
|
$parent = $page->parent();
|
|
if ($parent === null || $parent->route() !== $dailies) {
|
|
return null;
|
|
}
|
|
return $page;
|
|
}
|
|
|
|
/**
|
|
* Resolve a slug to the trip page that is a DIRECT child of /trips, or null
|
|
* when the segment is unsafe, the page does not exist, or its parent is not
|
|
* /trips. The trip-scoped analogue of resolveActiveDailyChild, used by the
|
|
* publish/unpublish route (KTD4).
|
|
*
|
|
* Unlike the front-end listing collections, this does NOT filter on published
|
|
* state: find() must return drafts so the owner can republish an unpublished
|
|
* trip from the listing (R7).
|
|
*/
|
|
public static function resolveTripChild(Grav $grav, string $slug): ?PageInterface
|
|
{
|
|
if (!self::isSafeSegment($slug)) {
|
|
return null;
|
|
}
|
|
$pages = $grav['pages'];
|
|
// In the API request context the page tree is lazily disabled; enable it
|
|
// so find() can resolve (mirrors resolveActiveDailyChild). Idempotent.
|
|
if (method_exists($pages, 'enablePages')) {
|
|
$pages->enablePages();
|
|
}
|
|
$page = $pages->find('/trips/' . $slug);
|
|
if ($page === null) {
|
|
return null;
|
|
}
|
|
$parent = $page->parent();
|
|
if ($parent === null || $parent->route() !== '/trips') {
|
|
return null;
|
|
}
|
|
return $page;
|
|
}
|
|
}
|