Enforce R6 on the save path (KTD6): in cache-on-save's onFormValidationProcessed, when a hidden edit_path is present, require the site owner (not merely any login — the super-admin tester also authenticates) AND that the target resolves through the page tree to a direct child of the active trip's dailies container. Fail closed with a ValidationException so add_page never runs. Create (empty edit_path) is left untouched. New shared EntryScopeGuard (classes/EntryScopeGuard.php) is the single source of truth for both R6 enforcement points — this save guard and U6's delete route call the same isOwner()/resolveActiveDailyChild()/segment helpers, so they cannot diverge (KTD5). Resolution is via $pages->find() + a parent-route assertion, never raw path concatenation, closing the traversal hole (basename(dirname()) yields the same target add-page-by-form writes to). Verified on the 2.0.4 container: non-owner edit, out-of-scope edit_path, unsafe '..' segment, and non-dailies-child targets are all rejected; owner in-place edit succeeds (V3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
273 lines
10 KiB
PHP
273 lines
10 KiB
PHP
<?php
|
|
namespace Grav\Plugin;
|
|
|
|
use Grav\Common\Data\ValidationException;
|
|
use Grav\Common\Plugin;
|
|
use RocketTheme\Toolbox\Event\Event;
|
|
|
|
require_once __DIR__ . '/classes/EntryScopeGuard.php';
|
|
|
|
use Grav\Plugin\Shared\EntryScopeGuard;
|
|
|
|
class CacheOnSavePlugin extends Plugin
|
|
{
|
|
public static function getSubscribedEvents(): array
|
|
{
|
|
return [
|
|
// Runs before add-page-by-form's onFormProcessed (page write), so it
|
|
// can inject the write target and abort the submit by failing validation.
|
|
'onFormValidationProcessed' => ['onFormValidationProcessed', 0],
|
|
// Priority -100 so this runs AFTER add-page-by-form's onFormProcessed
|
|
// (priority 0) has created the page and copied the uploaded files —
|
|
// we reorder those files, then clear the page-tree cache.
|
|
'onFormProcessed' => ['onFormProcessed', -100],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Server-authoritative active-trip parent injection.
|
|
*
|
|
* The post form no longer hardcodes `pageconfig.parent`; instead the write
|
|
* target is derived from `site.active_trip` at submit time and injected into
|
|
* the form data. add-page-by-form reads `$form->value()->toArray()['parent']`
|
|
* (add-page-by-form.php:521) and honours it over any pageconfig/header parent.
|
|
*
|
|
* Fail closed: if `active_trip` is unset/empty we throw a ValidationException
|
|
* so the `add_page` action never runs. Merely leaving `parent` unset is unsafe —
|
|
* with `pageconfig.parent` removed, add-page-by-form's `getParentPage('')`
|
|
* resolves to the /post page itself and the entry would silently land there.
|
|
*/
|
|
public function onFormValidationProcessed(Event $event): void
|
|
{
|
|
$form = $event['form'];
|
|
if (!$form || $form->getName() !== 'new-entry') {
|
|
return;
|
|
}
|
|
|
|
$activeTrip = $this->grav['config']->get('site.active_trip');
|
|
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
|
|
|
|
if ($activeTrip === '') {
|
|
throw new ValidationException('No active trip is set — cannot post an entry. Set site.active_trip first.');
|
|
}
|
|
|
|
$form->setData('parent', $this->resolveDailiesParent($activeTrip));
|
|
|
|
// One shared /post form drives both create and edit (KTD1). add-page-by-form
|
|
// reads overwrite_mode from the /post page header's pageconfig (not form
|
|
// data), so we toggle it here per submit.
|
|
$editPath = $this->editPathFromForm($form);
|
|
|
|
if ($editPath === '') {
|
|
// CREATE — left untouched (any site.login user): overwrite_mode:false
|
|
// so stock add-page-by-form falls through to slug_field (date,title)
|
|
// and writes a fresh dated folder.
|
|
$this->setOverwriteMode('false');
|
|
return;
|
|
}
|
|
|
|
// EDIT — enforce R6 server-side (KTD6) BEFORE allowing an in-place write.
|
|
// Fail closed (ValidationException) so the add_page action never runs.
|
|
// The UI only renders Edit for the owner on the active trip, but that gate
|
|
// is cosmetic; this is the authoritative check.
|
|
if (!EntryScopeGuard::isOwner($this->grav)) {
|
|
throw new ValidationException('You are not allowed to edit journal entries.');
|
|
}
|
|
$segment = EntryScopeGuard::segmentFromEditPath($editPath);
|
|
if (EntryScopeGuard::resolveActiveDailyChild($this->grav, $segment) === null) {
|
|
// Unsafe/traversal segment, no active trip, missing page, or a target
|
|
// outside the active trip's dailies — all rejected identically.
|
|
throw new ValidationException('That entry is not editable here — it is not in the active trip.');
|
|
}
|
|
$this->setOverwriteMode('edit');
|
|
}
|
|
|
|
/**
|
|
* The hidden edit_path form field, trimmed. Empty string when creating a new
|
|
* entry; the entry's `<route>/entry.md` path when editing (set by post-form.js).
|
|
*/
|
|
private function editPathFromForm($form): string
|
|
{
|
|
$value = $form->value('edit_path');
|
|
return is_string($value) ? trim($value) : '';
|
|
}
|
|
|
|
/**
|
|
* Override add-page-by-form's overwrite_mode by mutating the current (/post)
|
|
* page header's pageconfig. add-page-by-form reads
|
|
* `$grav['page']->header()->pageconfig['overwrite_mode']` (add-page-by-form.php
|
|
* :385) from the same page singleton, and Page::header() returns a cached
|
|
* instance, so this write is visible when its onFormProcessed runs afterwards.
|
|
*/
|
|
private function setOverwriteMode(string $mode): void
|
|
{
|
|
$page = $this->grav['page'] ?? null;
|
|
if (!$page) {
|
|
return;
|
|
}
|
|
$header = $page->header();
|
|
$pageconfig = (isset($header->pageconfig) && is_array($header->pageconfig)) ? $header->pageconfig : [];
|
|
$pageconfig['overwrite_mode'] = $mode;
|
|
$header->pageconfig = $pageconfig;
|
|
}
|
|
|
|
/**
|
|
* Normalise `active_trip` to its dailies container route.
|
|
*
|
|
* Accepts either a full route ("/trips/italy-2026-demo") or a bare slug
|
|
* ("italy-2026-demo") and returns "/trips/<slug>/dailies".
|
|
*/
|
|
private function resolveDailiesParent(string $activeTrip): string
|
|
{
|
|
$trip = trim($activeTrip, '/');
|
|
if (strpos($trip, 'trips/') !== 0) {
|
|
$trip = 'trips/' . $trip;
|
|
}
|
|
|
|
return '/' . $trip . '/dailies';
|
|
}
|
|
|
|
/**
|
|
* The photo order the user arranged in the form, sent explicitly by
|
|
* post-form.js as a JSON array of filenames in the dedicated
|
|
* data[photo_order] input. FilePond does not re-sequence its own submitted
|
|
* inputs on reorder, so this is the only reliable source of the drag order.
|
|
* Read straight from $_POST as a top-level key (not under data[]), so Grav's
|
|
* form never captures it and it never lands in the entry frontmatter.
|
|
*/
|
|
private function orderFromPost(): array
|
|
{
|
|
$raw = $_POST['photo_order'] ?? null;
|
|
if (!is_string($raw) || $raw === '') {
|
|
return [];
|
|
}
|
|
$decoded = json_decode($raw, true);
|
|
if (!is_array($decoded)) {
|
|
return [];
|
|
}
|
|
$names = [];
|
|
foreach ($decoded as $name) {
|
|
if (is_string($name) && $name !== '') {
|
|
$names[] = basename(str_replace('\\', '/', $name));
|
|
}
|
|
}
|
|
return $names;
|
|
}
|
|
|
|
public function onFormProcessed(Event $event): void
|
|
{
|
|
$form = $event['form'];
|
|
if (!$form || $form->getName() !== 'new-entry') {
|
|
return;
|
|
}
|
|
|
|
// Reorder the just-copied photos to match the order the user arranged in
|
|
// the form (FilePond drag). Best-effort: any failure logs and is skipped
|
|
// so a post is never lost over cosmetics.
|
|
try {
|
|
$this->reorderPhotos();
|
|
} catch (\Throwable $e) {
|
|
$this->grav['log']->warning('cache-on-save: photo reorder skipped — ' . $e->getMessage());
|
|
}
|
|
|
|
$this->grav['cache']->deleteAll();
|
|
}
|
|
|
|
/**
|
|
* Rename the uploaded photos to photo-1..N in the submitted (drag) order.
|
|
*
|
|
* The published entry lists media in filename order and treats the first as
|
|
* the hero (see partials/entry-journal + entry-story), so a deterministic
|
|
* photo-N naming is what makes the arranged order stick. copyFiles() writes
|
|
* each file under its unsanitised client filename, and post-form.js sends the
|
|
* drag order via the top-level `photo_order` POST key (orderFromPost) — so we
|
|
* can map each on-disk file to its final photo-N slot.
|
|
*/
|
|
private function reorderPhotos(): void
|
|
{
|
|
$names = $this->orderFromPost();
|
|
if (count($names) < 1) {
|
|
return; // nothing uploaded
|
|
}
|
|
|
|
$activeTrip = $this->grav['config']->get('site.active_trip');
|
|
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
|
|
if ($activeTrip === '') {
|
|
return;
|
|
}
|
|
$slug = preg_replace('#^/?trips/#', '', trim($activeTrip, '/'));
|
|
$slug = preg_replace('#/.*$#', '', $slug);
|
|
|
|
$dir = $this->findEntryFolder($slug, $names);
|
|
if ($dir === null) {
|
|
return; // couldn't confidently locate the new entry folder
|
|
}
|
|
|
|
// Two-phase rename via temp names so a target (photo-2.jpg) can't clobber
|
|
// a not-yet-moved source of the same name.
|
|
$planned = [];
|
|
$i = 1;
|
|
foreach ($names as $name) {
|
|
$src = $dir . DIRECTORY_SEPARATOR . $name;
|
|
if (!is_file($src)) {
|
|
continue; // skip anything not actually on disk
|
|
}
|
|
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)) ?: 'jpg';
|
|
$tmp = $dir . DIRECTORY_SEPARATOR . '.reorder-tmp-' . $i . '.' . $ext;
|
|
$final = $dir . DIRECTORY_SEPARATOR . 'photo-' . $i . '.' . $ext;
|
|
if ($src === $final) {
|
|
$i++;
|
|
continue; // already correctly named
|
|
}
|
|
@rename($src, $tmp);
|
|
$planned[] = [$tmp, $final];
|
|
$i++;
|
|
}
|
|
foreach ($planned as [$tmp, $final]) {
|
|
if (is_file($tmp)) {
|
|
@rename($tmp, $final);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Locate the freshly-created entry folder: the child of the active trip's
|
|
* dailies directory that contains all of the uploaded files. Matching by the
|
|
* exact uploaded filenames avoids re-deriving add-page-by-form's slug logic.
|
|
*/
|
|
private function findEntryFolder(string $slug, array $names): ?string
|
|
{
|
|
$pagesRoot = rtrim(USER_DIR, '/\\') . '/pages';
|
|
$dailies = null;
|
|
foreach (glob($pagesRoot . '/*trips*', GLOB_ONLYDIR) ?: [] as $tripsDir) {
|
|
foreach (glob($tripsDir . '/*', GLOB_ONLYDIR) ?: [] as $tripDir) {
|
|
$base = basename($tripDir);
|
|
if ($base === $slug || preg_match('/(^|\.)' . preg_quote($slug, '/') . '$/', $base)) {
|
|
$found = glob($tripDir . '/*dailies*', GLOB_ONLYDIR) ?: [];
|
|
if ($found) {
|
|
$dailies = $found[0];
|
|
break 2;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ($dailies === null) {
|
|
return null;
|
|
}
|
|
|
|
foreach (glob($dailies . '/*', GLOB_ONLYDIR) ?: [] as $child) {
|
|
$allPresent = true;
|
|
foreach ($names as $name) {
|
|
if (!is_file($child . DIRECTORY_SEPARATOR . $name)) {
|
|
$allPresent = false;
|
|
break;
|
|
}
|
|
}
|
|
if ($allPresent) {
|
|
return $child;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|