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//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.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; } }