Files
m038andClaude Opus 5 e873a9cb23 fix(review): land cleanCoordinate server-side guard; surface geocode failures
Two code-review follow-ups.

cleanCoordinate() — the server-side coordinate sanitizer the design doc and
plan both describe as already shipped — had never actually been committed; it
existed only as uncommitted work in another checkout, so this branch had no
server-side validation of lat/lng at all (the blueprint fields are plain
`type: text` with no `validate:` key). Landing it here makes the spec's stated
safety net real. Also corrected its onAdminSave comment, which justified that
hook by saying the public form's lat/lng inputs are CSS-hidden and GPS-filled
— true before this feature, inverted by it. Both hooks are needed: this branch
makes /post the primary hand-entry path, not Admin2.

The geocode lookup swallowed every failure and reset the button, leaving the
DOM byte-identical to the pre-click state — a traveller on flaky mobile data
could not distinguish a failed lookup from a broken button. It now shows a
distinct hint, and checks r.ok first so a 4xx/5xx body no longer parses as
"no results" and tells the traveller their city does not exist. R8's actual
guarantee (fields untouched on failure) is preserved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 21:48:20 +02:00

430 lines
19 KiB
PHP

<?php
namespace Grav\Plugin;
use Grav\Common\Cache;
use Grav\Common\Data\ValidationException;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event;
require_once __DIR__ . '/classes/EntryScopeGuard.php';
require_once __DIR__ . '/classes/PhotoRenumberer.php';
use Grav\Plugin\Shared\EntryScopeGuard;
use Grav\Plugin\Shared\PhotoRenumberer;
class CacheOnSavePlugin extends Plugin
{
/**
* onFormProcessed fires once per `process:` action (add_page, upload, message,
* reset — 4x for the post form). Photo reconciliation must run exactly once:
* the first pass renames the kept photos to photo-01..NN, so a second pass with
* the same manifest would see those renamed files as "unlisted" and delete
* them. This latches after the first run (the plugin instance persists for the
* request); the first fire is the `add_page` action, after add-page-by-form
* (priority 0) has created the page and copied files, so files are present.
*/
private bool $photosReconciled = false;
/**
* Same 4x-per-submit firing as $photosReconciled: onFormProcessed runs once
* per process action. Clearing the page-tree cache is idempotent, but doing it
* four times per post is wasted work (a full deleteAll() + system.yaml touch
* each time). Latch it so the invalidation runs exactly once per submission.
*/
private bool $cacheInvalidated = false;
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],
// Fired by the api plugin (PagesController) on every Admin2-driven
// create/update/translate, right before $page->save().
// onFormValidationProcessed (above) does not fire for Admin2 saves,
// so BOTH hooks are needed to cover every path lat/lng can arrive
// through: this one for Admin2/API, that one for the public /post
// form. (Until the location-override panel shipped, the public
// form's lat/lng inputs were CSS-hidden and GPS-filled, making
// Admin2 the only realistic hand-entry route. They are now visible
// and directly editable, so the /post path is the primary one.)
'onAdminSave' => ['onAdminSave', 0],
];
}
/**
* 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));
$this->sanitizeCoordinates($form);
// 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';
}
/**
* Strip invisible Unicode formatting characters (bidi marks, zero-width
* joiners, etc. — Unicode category "Cf") from a pasted lat/lng value, then
* validate the result is a real coordinate.
*
* Root cause this guards against: coordinates copied from a maps app can
* carry an invisible mark (e.g. U+200E LEFT-TO-RIGHT MARK) that neither
* JS `.trim()` nor PHP's numeric-string coercion strip. Twig's
* `number_format` filter then silently float-casts the corrupted string
* to 0.0 (PHP does not raise a warning), placing the entry at Null Island
* instead of failing loudly.
*
* Coordinates are optional (some entries intentionally have none), so a
* blank value cleans to '' with no error — only a non-blank value that
* still fails to parse as an in-range decimal after cleaning is rejected.
*
* @return string the cleaned value to write back
* @throws ValidationException if non-blank but still invalid after cleaning
*/
private function cleanCoordinate(string $field, string $raw, float $bound): string
{
$clean = trim(preg_replace('/\p{Cf}/u', '', $raw) ?? $raw);
if ($clean === '') {
return $clean;
}
if (!is_numeric($clean) || abs((float) $clean) > $bound) {
throw new ValidationException(sprintf(
'%s "%s" is not a valid coordinate — check for stray characters from pasting.',
$field === 'lat' ? 'Latitude' : 'Longitude',
$raw
));
}
return $clean;
}
/** Post-form entry point (see cleanCoordinate) — 'lat'/'lng' as top-level form fields. */
private function sanitizeCoordinates($form): void
{
foreach (['lat' => 90.0, 'lng' => 180.0] as $field => $bound) {
$raw = $form->value($field);
if (!is_string($raw)) {
continue;
}
$form->setData($field, $this->cleanCoordinate($field, $raw, $bound));
}
}
/**
* Admin2/API entry point (see cleanCoordinate) — 'header.lat'/'header.lng'
* on the Page object the api plugin is about to save. Fires on every
* create/update/translate (PagesController::create/update/translatePage),
* so this also re-validates already-clean values on every subsequent edit
* — harmless, since a clean value round-trips unchanged.
*/
public function onAdminSave(Event $event): void
{
$page = $event['page'] ?? $event['object'] ?? null;
if (!$page instanceof PageInterface) {
return;
}
$header = $page->header();
if (!$header) {
return;
}
foreach (['lat' => 90.0, 'lng' => 180.0] as $field => $bound) {
$raw = $header->{$field} ?? null;
if (!is_string($raw)) {
continue; // unset, null, or already a native number — nothing to clean
}
$header->{$field} = $this->cleanCoordinate($field, $raw, $bound);
}
}
/**
* 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;
}
// Reconcile the entry's photos to the order the owner arranged in the form
// (FilePond drag). On create this only renumbers the just-copied uploads;
// on edit (M2) it also removes any photo the owner dropped and renumbers
// the surviving set so the first file is the cover. Runs ONCE per submit
// (see $photosReconciled) — a second pass would delete the just-renamed
// photo-N files as "unlisted". Best-effort: any failure logs and is
// skipped so a post is never lost over cosmetics.
if (!$this->photosReconciled) {
$this->photosReconciled = true;
try {
$this->reconcilePhotos($form);
} catch (\Throwable $e) {
$this->grav['log']->warning('cache-on-save: photo reconcile skipped — ' . $e->getMessage());
}
}
// Two-part invalidation, latched to run ONCE per submit (see
// $cacheInvalidated) — the 4 process actions would otherwise repeat it.
// deleteAll() drops the Doctrine store (the tracker feed page cache etc.),
// but the page-tree INDEX is keyed on
// md5(dirs + folderHash + config->checksum() + lang) (Pages::buildRegularPages).
// With cache.check.method:folder that index can survive a create — a fresh
// entry then stays invisible to the API (GET /api/v1/pages{route} 404s), so
// opening the just-posted entry for editing shows "this entry no longer
// exists". invalidateCache() touches system.yaml, bumping config->checksum()
// so the index key changes and the tree rebuilds on the next request.
if (!$this->cacheInvalidated) {
$this->cacheInvalidated = true;
$this->grav['cache']->deleteAll();
Cache::invalidateCache();
$this->grav['log']->info('cache-on-save: cleared page cache + invalidated page-tree index after new-entry submit');
}
}
/**
* Reconcile the entry's photo files to the submitted (drag) order.
*
* The published entry lists media in filename order and treats the first as
* the hero/cover (see partials/entry-journal + entry-story), so a deterministic
* photo-N naming is what makes the arranged order stick. post-form.js sends the
* final ordered set via the top-level `photo_order` POST key (orderFromPost):
* on create these are the just-uploaded client filenames; on edit (M2) the mix
* of surviving existing photos (loaded into FilePond as local items) plus any
* new uploads, in the arranged order.
*
* Create: fuzzily locate the fresh folder by its uploaded filenames, then
* renumber. Edit: locate the folder authoritatively through the page tree via
* the shared scope guard (findEntryFolder is unsafe once files are the generic
* photo-N.jpg — many entries share those names), delete any image the owner
* dropped (not in the manifest), then renumber the survivors.
*
* Fail-safe: an empty manifest reconciles nothing (photos are left untouched),
* so a missing/failed `photo_order` on edit never wipes an entry's images.
*/
private function reconcilePhotos($form): void
{
$names = $this->orderFromPost();
if (count($names) < 1) {
return; // nothing submitted — leave the entry's photos untouched
}
$editPath = $this->editPathFromForm($form);
if ($editPath !== '') {
// EDIT — resolve the target folder through the page tree (shared guard),
// then prune dropped photos before renumbering the survivors.
$segment = EntryScopeGuard::segmentFromEditPath($editPath);
$page = EntryScopeGuard::resolveActiveDailyChild($this->grav, $segment);
if ($page === null) {
return; // out of scope / unresolvable — the save guard already ran
}
$dir = $page->path();
$this->deleteUnlistedImages($dir, $names);
} else {
// CREATE — locate the fresh folder by the set of uploaded filenames.
$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
}
}
PhotoRenumberer::renumber($dir, $names);
}
/**
* Delete every image file in $dir whose basename is not in $keep (the manifest
* of photos the owner kept). Only touches known image extensions — never the
* entry .md or any other file — and clears any Grav media sidecar so a stale
* `.meta.yaml` can't resurrect a removed image.
*/
private function deleteUnlistedImages(string $dir, array $keep): void
{
$keepSet = array_flip($keep);
$imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'];
foreach (glob($dir . DIRECTORY_SEPARATOR . '*') ?: [] as $path) {
if (!is_file($path)) {
continue;
}
$base = basename($path);
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
if (!in_array($ext, $imageExts, true)) {
continue; // never touch .md or non-image files
}
if (isset($keepSet[$base])) {
continue; // still in the arranged set — keep it
}
@unlink($path);
if (is_file($path . '.meta.yaml')) {
@unlink($path . '.meta.yaml');
}
}
}
/**
* 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;
}
}