feat(post-form): U6 — owner-scoped delete API route + card wiring
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
This commit is contained in:
@@ -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 }
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
namespace Grav\Plugin\EntryActions;
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Plugin\Api\Controllers\AbstractApiController;
|
||||
use Grav\Plugin\Api\Exceptions\ApiException;
|
||||
use Grav\Plugin\Api\Exceptions\ForbiddenException;
|
||||
use Grav\Plugin\Api\Exceptions\NotFoundException;
|
||||
use Grav\Plugin\Api\Response\ApiResponse;
|
||||
use Grav\Plugin\Shared\EntryScopeGuard;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
// Shared R6 guard lives in cache-on-save (the always-present custom plugin);
|
||||
// require it so save and delete enforce scope identically (KTD5).
|
||||
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/EntryScopeGuard.php';
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/entry/{slug}
|
||||
*
|
||||
* Deletes a journal entry folder, but only when ALL hold:
|
||||
* - the request is the authenticated site OWNER (not merely any login/admin);
|
||||
* - {slug} is a safe single segment (no '/', no '..');
|
||||
* - it resolves through the page tree to a DIRECT child of the ACTIVE trip's
|
||||
* dailies container.
|
||||
* Otherwise: 401 (anon), 403 (non-owner), 400 (bad slug), 404 (out of scope /
|
||||
* not found). On success the folder is removed and the page-tree cache cleared.
|
||||
*/
|
||||
class EntryActionsApiController extends AbstractApiController
|
||||
{
|
||||
public function deleteEntry(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
// Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous.
|
||||
$user = $this->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
namespace Grav\Plugin;
|
||||
|
||||
use Grav\Common\Plugin;
|
||||
use RocketTheme\Toolbox\Event\Event;
|
||||
|
||||
/**
|
||||
* Entry Actions — a thin, purpose-built API surface for owner-only, active-trip
|
||||
* scoped journal-entry actions that the stock Grav API cannot express safely.
|
||||
*
|
||||
* M1 registers exactly one route: DELETE /api/v1/entry/{slug}. The stock
|
||||
* DELETE /api/v1/pages<route> 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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
enabled: true
|
||||
Reference in New Issue
Block a user