From b8daea217d25d47de51cbc33fb3a0fd88b4283fa Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 5 Jul 2026 00:19:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(post-form):=20U6=20=E2=80=94=20owner-scope?= =?UTF-8?q?d=20delete=20API=20route=20+=20card=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New custom-in-repo plugin entry-actions (un-ignored in .gitignore, NOT in plugins.txt) registers DELETE /api/v1/entry/{slug} via onApiRegisterRoutes (KTD5). The handler requires the authenticated site OWNER (not any login/admin), rejects unsafe slugs (400), resolves the target through the page tree, asserts it is a direct child of the active trip's dailies container, deletes the folder and clears the cache — sharing EntryScopeGuard with the save path so R6 can't diverge. A lazy per-namespace autoloader loads the controller on cached-route requests (the router dispatches from route.cache without re-firing onApiRegisterRoutes). EntryScopeGuard gains isOwnerUser() (API user comes from the request, not $grav['user']) and enablePages() before find() (pages are lazily disabled in the API context). feed-actions.js (new, built via make build-assets; loaded on the trip/home feed only when owner_can_edit) wires the inline Delete → Cancel/Confirm swap: on Confirm it locks both buttons (D2, no double-DELETE), fetches the route (credentials:include), removes the card, moves focus to the next card, and announces via a page-level aria-live region (D4); on failure it restores the control with an inline message (D7). Adds .sr-only + .entry-action[hidden] CSS. Verified on the 2.0.4 container — API matrix 8/8 (anon 401, non-owner 403, bad slug 400, out-of-scope 404, owner 204 + folder removed; V3/V5) and the delete UI in a headless browser (confirm swap, card removal, disk deletion, live announce). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM --- .gitignore | 1 + .../cache-on-save/classes/EntryScopeGuard.php | 22 +++- plugins/entry-actions/blueprints.yaml | 14 ++ .../classes/EntryActionsApiController.php | 61 +++++++++ plugins/entry-actions/entry-actions.php | 60 +++++++++ plugins/entry-actions/entry-actions.yaml | 1 + themes/intotheeast/css/style.css | 16 +++ themes/intotheeast/js/feed-actions.js | 1 + themes/intotheeast/js/src/feed-actions.js | 120 ++++++++++++++++++ themes/intotheeast/package.json | 2 +- themes/intotheeast/templates/home.html.twig | 1 + themes/intotheeast/templates/trip.html.twig | 1 + 12 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 plugins/entry-actions/blueprints.yaml create mode 100644 plugins/entry-actions/classes/EntryActionsApiController.php create mode 100644 plugins/entry-actions/entry-actions.php create mode 100644 plugins/entry-actions/entry-actions.yaml create mode 100644 themes/intotheeast/js/feed-actions.js create mode 100644 themes/intotheeast/js/src/feed-actions.js diff --git a/.gitignore b/.gitignore index 4e7cc00..e46dbd2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ !/plugins/.gitkeep !/plugins/cache-on-save/ !/plugins/story-blocks/ +!/plugins/entry-actions/ /data/ /pages/01.trips/italy-2026-demo/ /pages/02.post/*ui-test*/ diff --git a/plugins/cache-on-save/classes/EntryScopeGuard.php b/plugins/cache-on-save/classes/EntryScopeGuard.php index d61e3b4..7ce5cb8 100644 --- a/plugins/cache-on-save/classes/EntryScopeGuard.php +++ b/plugins/cache-on-save/classes/EntryScopeGuard.php @@ -50,6 +50,19 @@ class EntryScopeGuard 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; } @@ -97,7 +110,14 @@ class EntryScopeGuard if ($dailies === null) { return null; } - $page = $grav['pages']->find($dailies . '/' . $segment); + $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; } diff --git a/plugins/entry-actions/blueprints.yaml b/plugins/entry-actions/blueprints.yaml new file mode 100644 index 0000000..d5cc2d6 --- /dev/null +++ b/plugins/entry-actions/blueprints.yaml @@ -0,0 +1,14 @@ +name: Entry Actions +version: 0.1.0 +description: Owner-only, active-trip-scoped journal entry actions (M1: delete) via the Grav API. +icon: trash +author: + name: Mischa +homepage: https://intotheeast.com +keywords: api, journal, delete +bugs: https://intotheeast.com +license: MIT + +dependencies: + - { name: grav, version: '>=2.0.0' } + - { name: api } diff --git a/plugins/entry-actions/classes/EntryActionsApiController.php b/plugins/entry-actions/classes/EntryActionsApiController.php new file mode 100644 index 0000000..8e36c20 --- /dev/null +++ b/plugins/entry-actions/classes/EntryActionsApiController.php @@ -0,0 +1,61 @@ +getUser($request); + if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) { + throw new ForbiddenException('Only the site owner can delete journal entries.'); + } + + $slug = $this->getRouteParam($request, 'slug'); + if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) { + throw new ApiException(400, 'Bad Request', 'Invalid entry slug.'); + } + + // Resolve via $pages->find() + parent-route assertion (never raw path + // concatenation) — same shared check the save path uses. + $page = EntryScopeGuard::resolveActiveDailyChild($this->grav, $slug); + if ($page === null) { + throw new NotFoundException('Entry not found in the active trip.'); + } + + $path = $page->path(); + if (!is_string($path) || $path === '' || !is_dir($path)) { + throw new NotFoundException('Entry folder not found.'); + } + + Folder::delete($path); + $this->grav['cache']->deleteAll(); + + return ApiResponse::noContent(); + } +} diff --git a/plugins/entry-actions/entry-actions.php b/plugins/entry-actions/entry-actions.php new file mode 100644 index 0000000..9d82d05 --- /dev/null +++ b/plugins/entry-actions/entry-actions.php @@ -0,0 +1,60 @@ + only checks write-permission (no trip scope, and + * any admin passes), which violates R6. This route requires the configured site + * OWNER and asserts the target is a direct child of the active trip's dailies + * container — sharing one guard (EntryScopeGuard) with the save path so the two + * R6 enforcement points cannot diverge (KTD5). + * + * Custom-in-repo (NOT GPM-managed): tracked via a `!` negation in user/.gitignore + * and deployed with the content push, like cache-on-save. Never in plugins.txt. + */ +class EntryActionsPlugin extends Plugin +{ + public static function getSubscribedEvents(): array + { + return [ + 'onPluginsInitialized' => ['onPluginsInitialized', 0], + 'onApiRegisterRoutes' => ['onApiRegisterRoutes', 0], + ]; + } + + /** + * Register a lazy PSR-4 autoloader for this plugin's classes. The api router + * dispatches from a CACHED route map and instantiates the controller directly + * (ApiRouter::handleRoute → `new $controllerClass`) WITHOUT re-firing + * onApiRegisterRoutes, so requiring the class only there would leave it + * unloaded on cached-route requests. Lazy autoloading fires exactly when the + * router constructs the controller — by which point the api plugin's own + * autoloader (for AbstractApiController) is already registered. + */ + public function onPluginsInitialized(): void + { + spl_autoload_register(static function (string $class): void { + $prefix = 'Grav\\Plugin\\EntryActions\\'; + if (strncmp($class, $prefix, strlen($prefix)) !== 0) { + return; + } + $rel = substr($class, strlen($prefix)); + $file = __DIR__ . '/classes/' . str_replace('\\', '/', $rel) . '.php'; + if (is_file($file)) { + require_once $file; + } + }); + } + + public function onApiRegisterRoutes(Event $event): void + { + $routes = $event['routes']; + $routes->delete('/entry/{slug}', [EntryActions\EntryActionsApiController::class, 'deleteEntry']); + } +} diff --git a/plugins/entry-actions/entry-actions.yaml b/plugins/entry-actions/entry-actions.yaml new file mode 100644 index 0000000..d4ca941 --- /dev/null +++ b/plugins/entry-actions/entry-actions.yaml @@ -0,0 +1 @@ +enabled: true diff --git a/themes/intotheeast/css/style.css b/themes/intotheeast/css/style.css index 7174f9b..1873e56 100644 --- a/themes/intotheeast/css/style.css +++ b/themes/intotheeast/css/style.css @@ -329,6 +329,9 @@ body::after { color: var(--color-accent); outline: none; } +/* [hidden] must win over the inline-flex display above (used to swap + Delete <-> Cancel/Confirm from feed-actions.js). */ +.entry-action[hidden] { display: none; } .entry-action--confirm { color: #E5786A; /* soft red — destructive confirm */ border-color: #E5786A; @@ -350,6 +353,19 @@ body::after { } .entry-delete-msg:empty { display: none; } +/* Visually-hidden but screen-reader-available (feed-actions live region). */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .journal-photo-wrap { position: relative; margin-bottom: var(--space-5); diff --git a/themes/intotheeast/js/feed-actions.js b/themes/intotheeast/js/feed-actions.js new file mode 100644 index 0000000..65d2384 --- /dev/null +++ b/themes/intotheeast/js/feed-actions.js @@ -0,0 +1 @@ +(()=>{function m(){var e=document.getElementById("feed-actions-live");return e||(e=document.createElement("div"),e.id="feed-actions-live",e.setAttribute("aria-live","polite"),e.setAttribute("role","status"),e.className="sr-only",document.body.appendChild(e)),e}function s(e){m().textContent=e}function v(e){var n=String(e||"").replace(/\/+$/,"").split("/");return n[n.length-1]||""}function u(e,n){var i=e.querySelector("[data-delete-start]"),r=e.querySelector(".entry-delete-confirm");if(i&&(i.hidden=n),r&&(r.hidden=!n),!n){var t=e.querySelector(".entry-delete-msg");t&&(t.textContent="")}}function g(e){var n=e.getAttribute("data-entry-route"),i=v(n),r=e.querySelector("[data-delete-cancel]"),t=e.querySelector("[data-delete-confirm]"),a=e.querySelector(".entry-delete-msg"),l=e.closest(".journal-post");t&&(t.disabled=!0,t.textContent="Deleting\u2026"),r&&(r.disabled=!0),a&&(a.textContent=""),s("Deleting entry\u2026"),fetch("/api/v1/entry/"+encodeURIComponent(i),{method:"DELETE",credentials:"include",headers:{Accept:"application/json"}}).then(function(d){if(!(d.status===204||d.ok))throw new Error("HTTP "+d.status);for(var o=l?l.nextElementSibling:null;o&&!o.classList.contains("journal-post");)o=o.nextElementSibling;var c=o||(l&&l.previousElementSibling&&l.previousElementSibling.classList&&l.previousElementSibling.classList.contains("journal-post")?l.previousElementSibling:null)||document.querySelector(".home-trip-name, .home-feed-col h1, .feed");if(l&&l.remove(),c){c.hasAttribute("tabindex")||c.setAttribute("tabindex","-1");try{c.focus({preventScroll:!1})}catch{}}s("Entry deleted.")}).catch(function(){t&&(t.disabled=!1,t.textContent="Confirm delete"),r&&(r.disabled=!1),a&&(a.textContent="Could not delete \u2014 please try again."),s("Delete failed.")})}function f(){document.addEventListener("click",function(e){var n=e.target.closest?e.target.closest("[data-delete-start]"):null,i=e.target.closest?e.target.closest("[data-delete-cancel]"):null,r=e.target.closest?e.target.closest("[data-delete-confirm]"):null;if(!(!n&&!i&&!r)){var t=(n||i||r).closest(".journal-post-actions");if(t){if(e.preventDefault(),n){u(t,!0);return}if(i){u(t,!1);return}if(r){var a=t.querySelector("[data-delete-confirm]");if(a&&a.disabled)return;g(t)}}}})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",f):f();})(); diff --git a/themes/intotheeast/js/src/feed-actions.js b/themes/intotheeast/js/src/feed-actions.js new file mode 100644 index 0000000..8a61929 --- /dev/null +++ b/themes/intotheeast/js/src/feed-actions.js @@ -0,0 +1,120 @@ +/* + * feed-actions.js (U6) — owner delete wiring for journal feed cards. + * + * Loaded only on the trip / home active-trip feed for the owner. The Edit link is + * a plain navigation (no JS). Delete is a two-step inline confirm (no browser + * dialog): Delete → Cancel / Confirm delete → DELETE /api/v1/entry/ + * (session cookie), then the card is removed and focus moves to the next card. + * + * Markup (rendered by partials/entry-journal.html.twig): + * article.journal-post[data-entry-route] + * .journal-post-actions[data-entry-route] + * button[data-delete-start] (Delete) + * .entry-delete-confirm[hidden] + * button[data-delete-cancel] (Cancel) + * button[data-delete-confirm] (Confirm delete) + * .entry-delete-msg[aria-live] (inline error slot) + */ + +// One page-level polite live region for cross-card announcements (D4) — the +// per-card .entry-delete-msg vanishes with the card it belongs to. +function liveRegion() { + var el = document.getElementById('feed-actions-live'); + if (!el) { + el = document.createElement('div'); + el.id = 'feed-actions-live'; + el.setAttribute('aria-live', 'polite'); + el.setAttribute('role', 'status'); + el.className = 'sr-only'; + document.body.appendChild(el); + } + return el; +} +function announce(msg) { liveRegion().textContent = msg; } + +function slugFromRoute(route) { + var parts = String(route || '').replace(/\/+$/, '').split('/'); + return parts[parts.length - 1] || ''; +} + +function showConfirm(actions, show) { + var del = actions.querySelector('[data-delete-start]'); + var confirm = actions.querySelector('.entry-delete-confirm'); + if (del) del.hidden = show; + if (confirm) confirm.hidden = !show; + if (!show) { + var msg = actions.querySelector('.entry-delete-msg'); + if (msg) msg.textContent = ''; + } +} + +function performDelete(actions) { + var route = actions.getAttribute('data-entry-route'); + var slug = slugFromRoute(route); + var cancelBtn = actions.querySelector('[data-delete-cancel]'); + var confirmBtn = actions.querySelector('[data-delete-confirm]'); + var msg = actions.querySelector('.entry-delete-msg'); + var card = actions.closest('.journal-post'); + + // D2: lock both buttons immediately so a mobile double-tap can't fire two + // DELETEs (the second would 404 on an already-removed folder). + if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = 'Deleting…'; } + if (cancelBtn) cancelBtn.disabled = true; + if (msg) msg.textContent = ''; + announce('Deleting entry…'); + + fetch('/api/v1/entry/' + encodeURIComponent(slug), { + method: 'DELETE', + credentials: 'include', + headers: { Accept: 'application/json' } + }).then(function (r) { + if (!(r.status === 204 || r.ok)) throw new Error('HTTP ' + r.status); + + // D4: move focus to the next journal card (or the feed heading) BEFORE + // removing this one, then announce. + var next = card ? card.nextElementSibling : null; + while (next && !next.classList.contains('journal-post')) next = next.nextElementSibling; + var focusTarget = next + || (card && card.previousElementSibling && card.previousElementSibling.classList && card.previousElementSibling.classList.contains('journal-post') ? card.previousElementSibling : null) + || document.querySelector('.home-trip-name, .home-feed-col h1, .feed'); + if (card) card.remove(); + if (focusTarget) { + if (!focusTarget.hasAttribute('tabindex')) focusTarget.setAttribute('tabindex', '-1'); + try { focusTarget.focus({ preventScroll: false }); } catch (e) { /* older browsers */ } + } + announce('Entry deleted.'); + }).catch(function () { + // D7: restore the control + inline error, constrained to the card. + if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = 'Confirm delete'; } + if (cancelBtn) cancelBtn.disabled = false; + if (msg) msg.textContent = 'Could not delete — please try again.'; + announce('Delete failed.'); + }); +} + +function initFeedActions() { + document.addEventListener('click', function (e) { + var start = e.target.closest ? e.target.closest('[data-delete-start]') : null; + var cancel = e.target.closest ? e.target.closest('[data-delete-cancel]') : null; + var confirm = e.target.closest ? e.target.closest('[data-delete-confirm]') : null; + if (!start && !cancel && !confirm) return; + + var actions = (start || cancel || confirm).closest('.journal-post-actions'); + if (!actions) return; + e.preventDefault(); + + if (start) { showConfirm(actions, true); return; } + if (cancel) { showConfirm(actions, false); return; } + if (confirm) { + var btn = actions.querySelector('[data-delete-confirm]'); + if (btn && btn.disabled) return; // already in flight (D2) + performDelete(actions); + } + }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initFeedActions); +} else { + initFeedActions(); +} diff --git a/themes/intotheeast/package.json b/themes/intotheeast/package.json index 4002f30..9c856c6 100644 --- a/themes/intotheeast/package.json +++ b/themes/intotheeast/package.json @@ -1,7 +1,7 @@ { "private": true, "scripts": { - "build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && rm -rf js/post && esbuild js/src/post-form.js --bundle --minify --format=esm --splitting --outdir=js/post && mkdir -p css-compiled fonts && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; } && { mv js/post/post-form.css css-compiled/post-form.css 2>/dev/null || true; }" + "build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && esbuild js/src/feed-actions.js --bundle --minify --format=iife --outfile=js/feed-actions.js && rm -rf js/post && esbuild js/src/post-form.js --bundle --minify --format=esm --splitting --outdir=js/post && mkdir -p css-compiled fonts && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; } && { mv js/post/post-form.css css-compiled/post-form.css 2>/dev/null || true; }" }, "dependencies": { "@fontsource-variable/dm-sans": "latest", diff --git a/themes/intotheeast/templates/home.html.twig b/themes/intotheeast/templates/home.html.twig index 5c34b50..0fcb016 100644 --- a/themes/intotheeast/templates/home.html.twig +++ b/themes/intotheeast/templates/home.html.twig @@ -24,6 +24,7 @@ and grav.user.username == grav.config.site.owner_username %} {# Owner-aware feed list: owner sees drafts; everyone else published only #} {% set journal_feed = (owner_can_edit and dailies_page) ? dailies_page.children : journal_entries %} +{% if owner_can_edit %}{% do assets.addJs('theme://js/feed-actions.js', {group: 'bottom'}) %}{% endif %} {% set all_items = [] %} {% for e in journal_feed %} diff --git a/themes/intotheeast/templates/trip.html.twig b/themes/intotheeast/templates/trip.html.twig index 6e24654..435a8ed 100644 --- a/themes/intotheeast/templates/trip.html.twig +++ b/themes/intotheeast/templates/trip.html.twig @@ -24,6 +24,7 @@ {# Feed list is owner-aware: the owner sees drafts (unpublished) too; everyone else (and every non-active-trip view) sees published only (R5, KTD7). #} {% set journal_feed = (owner_can_edit and dailies_page) ? dailies_page.children : journal_entries %} +{% if owner_can_edit %}{% do assets.addJs('theme://js/feed-actions.js', {group: 'bottom'}) %}{% endif %} {% set all_items = [] %} {% for e in journal_feed %}