Files
intotheeast-com-content/plugins/cache-on-save/classes/EntryScopeGuard.php
T
m038andClaude Opus 4.8 5b4e31678e refactor(entry-actions): dedup scope guard + guard publish cache-flush
Address code-review findings on the trip publish/unpublish toggle:

- Extract EntryScopeGuard::resolveChildOf() so resolveActiveDailyChild and
  resolveTripChild share one find() + parent-route-assert body instead of two
  copies that could drift (P1 maintainability).
- Wrap setTripPublished's post-save cache invalidation in try/catch. save() has
  already persisted the published flag to disk, so a flush failure now logs a
  loud reconciliation warning (and still returns success + the audit line)
  rather than bubbling to a bare 500 that reads as "nothing happened"
  (P2 reliability / adversarial).

Behavior-preserving; PHP lint clean; trip-publish Playwright suite 8/8.

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

158 lines
6.0 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 safe segment to the page that is a DIRECT child of $parentRoute,
* or null when the segment is unsafe, the page does not exist, or its parent
* is not exactly $parentRoute. Resolving via $pages->find() + a parent-route
* assertion (never raw path concatenation) is what closes the traversal hole;
* both public resolvers below share this one body so they cannot drift.
*/
private static function resolveChildOf(Grav $grav, string $parentRoute, string $segment): ?PageInterface
{
if (!self::isSafeSegment($segment)) {
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($parentRoute . '/' . $segment);
if ($page === null) {
return null;
}
$parent = $page->parent();
if ($parent === null || $parent->route() !== $parentRoute) {
return null;
}
return $page;
}
/**
* 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
{
$dailies = self::dailiesRoute($grav);
if ($dailies === null) {
return null;
}
return self::resolveChildOf($grav, $dailies, $segment);
}
/**
* 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
{
return self::resolveChildOf($grav, '/trips', $slug);
}
}