POST /api/v1/entry/{slug}/photos/order renames an entry's image files to
photo-01..NN in the client-supplied order so the feed cover (media.images|first)
follows the drag — no stock endpoint can express this. Same R6 guard chain as the
delete route (site OWNER + direct child of the active trip's dailies), then the
shared PhotoRenumberer does the two-phase rename and the cache is cleared.
Filename safety is layered: unsafe 'order' entries (/, ..) are dropped here and
PhotoRenumberer only renames real image files, so a crafted body can never touch
the entry .md, a .gpx or a .meta.yaml. Registers behind the API route-map cache,
so a deploy cache-clear is required (same as the existing DELETE /entry/{slug}).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
122 lines
5.1 KiB
PHP
122 lines
5.1 KiB
PHP
<?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 Grav\Plugin\Shared\PhotoRenumberer;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
|
|
// Shared R6 guard + the photo-NN renumber helper both live in cache-on-save (the
|
|
// always-present custom plugin); require them so save, delete and reorder enforce
|
|
// scope identically (KTD5) and share one numbering invariant.
|
|
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/EntryScopeGuard.php';
|
|
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/PhotoRenumberer.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();
|
|
}
|
|
|
|
/**
|
|
* POST /api/v1/entry/{slug}/photos/order
|
|
*
|
|
* Body: { "order": ["photo-x.jpg", "photo-y.jpg", …] } — the entry's image
|
|
* files in the display order the owner arranged. Renames them to photo-01..NN
|
|
* so the feed cover (media.images|first) and numeric client sort follow the
|
|
* drag. Same guard chain as deleteEntry: OWNER + direct-child-of-active-dailies.
|
|
*
|
|
* Filename safety is defence in depth: unsafe segments (containing '/' or '..')
|
|
* are dropped here, and PhotoRenumberer only ever renames files that already
|
|
* exist as image media in the folder — so a crafted order body can never touch
|
|
* the entry .md, a .gpx or a .meta.yaml sidecar.
|
|
*/
|
|
public function reorderPhotos(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 reorder entry photos.');
|
|
}
|
|
|
|
$slug = $this->getRouteParam($request, 'slug');
|
|
if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) {
|
|
throw new ApiException(400, 'Bad Request', 'Invalid entry slug.');
|
|
}
|
|
|
|
$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.');
|
|
}
|
|
|
|
// Reduce the body's `order` to a clean list of safe basenames. Anything
|
|
// unsafe or non-string is dropped; PhotoRenumberer then keeps only the
|
|
// entries that are real image files on disk.
|
|
$body = $this->getRequestBody($request);
|
|
$order = $body['order'] ?? null;
|
|
if (!is_array($order)) {
|
|
throw new ApiException(400, 'Bad Request', 'Body must include an "order" array of filenames.');
|
|
}
|
|
$names = [];
|
|
foreach ($order as $name) {
|
|
if (is_string($name) && EntryScopeGuard::isSafeSegment($name)) {
|
|
$names[] = $name;
|
|
}
|
|
}
|
|
|
|
PhotoRenumberer::renumber($path, $names);
|
|
$this->grav['cache']->deleteAll();
|
|
|
|
return ApiResponse::noContent();
|
|
}
|
|
}
|