feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
+289
View File
@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api;
use Grav\Common\Data\Blueprints;
use Grav\Common\Grav;
use Grav\Common\Language\Language;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Pages;
use Grav\Common\User\Interfaces\UserInterface;
/**
* Lightweight admin proxy registered as $grav['admin'] during API requests.
*
* Grav core checks `isset($grav['admin'])` in multiple places to alter
* behavior: page routing/visibility, Flex authorization scope, blueprint
* field handling, and event firing. Without this proxy, API-driven changes
* operate in "site" scope rather than "admin" scope, causing subtle bugs:
*
* - Non-routable/hidden pages invisible to API (Pages.php:1047)
* - Flex onAdminSave/AfterSave events don't fire (FlexGravTrait.php:60)
* - Blueprint edit mode not set (Page.php:1261)
* - Flex authorization uses 'site' scope instead of 'admin' (FlexObject.php)
* - Plugins checking isAdmin() return false
*
* This class implements the minimum interface that Grav core actually calls
* on $grav['admin'], without pulling in the full admin plugin dependency.
*/
class AdminProxy
{
/** @var string Admin base route (not applicable for API, but required by getRouteDetails) */
public string $base = '';
/** @var string Current location segment */
public string $location = '';
/** @var string Current route */
public string $route = '';
/** @var UserInterface The authenticated API user */
public UserInterface $user;
/** @var bool Whether multilang is enabled */
public bool $multilang = false;
/** @var string Active language */
public string $language = '';
/** @var string[] Enabled languages */
public array $languages_enabled = [];
/** @var array<int, array{message: string, scope: string}> Queued temp messages */
public array $temp_messages = [];
private Grav $grav;
private ?Blueprints $blueprintsLoader = null;
/** @var array<string, PageInterface|null> Page cache */
private array $pages = [];
public function __construct(Grav $grav, UserInterface $user)
{
$this->grav = $grav;
$this->user = $user;
/** @var Language $language */
$language = $grav['language'];
$this->multilang = $language->enabled();
if ($this->multilang) {
$this->language = $language->getActive() ?? '';
$this->languages_enabled = (array) $grav['config']->get('system.languages.supported', []);
}
}
/**
* Register this proxy as $grav['admin'].
*/
public function register(): void
{
$this->grav['admin'] = $this;
}
/**
* Get the current admin page (used by Pages.php and Plugin.php).
*
* In API context there's no "current admin page" being edited, so this
* returns the page at the current route if set, or null.
*/
public function page($route = false, $path = null): ?PageInterface
{
if (!$path) {
$path = $this->route;
}
if ($route && !$path) {
$path = '/';
}
if (!$path) {
return null;
}
if (!isset($this->pages[$path])) {
$this->pages[$path] = $this->getPage($path);
}
return $this->pages[$path];
}
/**
* Find a page by path (used by Pages.php for parent resolution).
*/
public function getPage(string $path): ?PageInterface
{
$pages = self::enablePages();
if ($path && $path[0] !== '/') {
$path = "/{$path}";
}
$path = urldecode($path);
return $path ? $pages->find($path, true) : $pages->root();
}
/**
* Return route details as [base, location, route] tuple.
*
* Used by Pages.php and AccountsServiceProvider.php to determine
* which admin section is active. For API requests, we return empty
* values since there's no admin page navigation happening.
*/
public function getRouteDetails(): array
{
return [$this->base, $this->location, $this->route];
}
/**
* Load a blueprint by type (used by Flex PageObject).
*/
public function blueprints(string $type)
{
if ($this->blueprintsLoader === null) {
$this->blueprintsLoader = new Blueprints('blueprints://');
}
return $this->blueprintsLoader->get($type);
}
/**
* Translate a string using Grav's language system.
*
* This is a static method in the real Admin class, but core calls it
* on the instance via $grav['admin']->translate().
*
* @param array|string $args
* @param array|string|null $languages
* @return string
*/
public static function translate($args, $languages = null): string
{
$grav = Grav::instance();
if (is_array($args)) {
$lookup = array_shift($args);
} else {
$lookup = $args;
$args = [];
}
if (!$languages) {
if ($grav['config']->get('system.languages.translations_fallback', true)) {
$languages = $grav['language']->getFallbackLanguages();
} else {
$languages = (array) $grav['language']->getDefault();
}
if (isset($grav['user']) && $grav['user']->authenticated) {
$languages = [$grav['user']->language];
}
} else {
$languages = (array) $languages;
}
foreach ((array) $languages as $lang) {
$translation = $grav['language']->getTranslation($lang, $lookup, true);
if (!$translation) {
$language = $grav['language']->getDefault() ?: 'en';
$translation = $grav['language']->getTranslation($language, $lookup, true);
}
if (!$translation) {
$translation = $grav['language']->getTranslation('en', $lookup, true);
}
if ($translation) {
if (count($args) >= 1) {
return vsprintf($translation, $args);
}
return $translation;
}
}
return $lookup;
}
/**
* Add a flash message to the session queue.
*
* Mirrors Admin::setMessage(). Admin-aware plugins routinely call this from
* onAdminSave/onAdminAfterSave handlers (e.g. to report generated image
* derivatives). The core `messages` service always resolves — returning a
* transient Messages instance when there's no active session — so queuing
* here is harmless under the API and simply discarded after the request.
*
* @param string $msg
* @param string $type
* @return void
*/
public function setMessage($msg, $type = 'info'): void
{
$messages = $this->grav['messages'];
$messages->add($msg, $type);
}
/**
* Fetch and clear messages from the session queue.
*
* Mirrors Admin::messages().
*
* @param string|null $type
* @return array
*/
public function messages($type = null): array
{
$messages = $this->grav['messages'];
return $messages->fetch($type);
}
/**
* Queue a temporary message.
*
* Mirrors Admin::addTempMessage().
*
* @param string $msg
* @param string $type
* @return void
*/
public function addTempMessage($msg, $type): void
{
$this->temp_messages[] = ['message' => $msg, 'scope' => $type];
}
/**
* Return queued temporary messages.
*
* Mirrors Admin::getTempMessages().
*
* @return array
*/
public function getTempMessages(): array
{
return $this->temp_messages;
}
/**
* Enable and return the Pages service.
*
* Mirrors Admin::enablePages() — ensures pages are initialized
* (they are disabled by default during API requests for performance).
*/
public static function enablePages(): Pages
{
static $pages;
if ($pages) {
return $pages;
}
$grav = Grav::instance();
/** @var Pages $pages */
$pages = $grav['pages'];
$pages->enablePages();
return $pages;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api;
use FastRoute\RouteCollector;
/**
* Wrapper around FastRoute's RouteCollector that provides a clean API
* for plugins to register their own API routes.
*
* Usage in a plugin:
* public function onApiRegisterRoutes(Event $event) {
* $routes = $event['routes'];
* $routes->get('/comments', [CommentsController::class, 'index']);
* $routes->post('/comments', [CommentsController::class, 'create']);
* $routes->group('/webhooks', function(ApiRouteCollector $group) {
* $group->get('', [WebhookController::class, 'index']);
* $group->post('', [WebhookController::class, 'create']);
* });
* }
*/
class ApiRouteCollector
{
protected string $prefix = '';
public function __construct(
protected readonly RouteCollector $collector,
) {}
public function get(string $route, array $handler): self
{
$this->collector->addRoute('GET', $this->prefix . $route, $handler);
return $this;
}
public function post(string $route, array $handler): self
{
$this->collector->addRoute('POST', $this->prefix . $route, $handler);
return $this;
}
public function patch(string $route, array $handler): self
{
$this->collector->addRoute('PATCH', $this->prefix . $route, $handler);
return $this;
}
public function delete(string $route, array $handler): self
{
$this->collector->addRoute('DELETE', $this->prefix . $route, $handler);
return $this;
}
public function put(string $route, array $handler): self
{
$this->collector->addRoute('PUT', $this->prefix . $route, $handler);
return $this;
}
public function addRoute(string|array $methods, string $route, array $handler): self
{
$this->collector->addRoute($methods, $this->prefix . $route, $handler);
return $this;
}
/**
* Register a group of routes under a shared prefix.
*/
public function group(string $prefix, callable $callback): self
{
$group = new self($this->collector);
$group->prefix = $this->prefix . $prefix;
$callback($group);
return $this;
}
}
+590
View File
@@ -0,0 +1,590 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api;
use FastRoute\Dispatcher;
use FastRoute\RouteCollector;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\Processors\ProcessorBase;
use Grav\Framework\Psr7\Response;
use Grav\Plugin\Api\Controllers\AuthController;
use Grav\Plugin\Api\Controllers\BlueprintController;
use Grav\Plugin\Api\Controllers\BlueprintFilesController;
use Grav\Plugin\Api\Controllers\BlueprintUploadController;
use Grav\Plugin\Api\Controllers\ConfigController;
use Grav\Plugin\Api\Controllers\DashboardController;
use Grav\Plugin\Api\Controllers\DashboardWidgetController;
use Grav\Plugin\Api\Controllers\GpmController;
use Grav\Plugin\Api\Controllers\MediaController;
use Grav\Plugin\Api\Controllers\SchedulerController;
use Grav\Plugin\Api\Controllers\PagesController;
use Grav\Plugin\Api\Controllers\PreferencesController;
use Grav\Plugin\Api\Controllers\ReportsController;
use Grav\Plugin\Api\Controllers\MenubarController;
use Grav\Plugin\Api\Controllers\PasswordPolicyController;
use Grav\Plugin\Api\Controllers\SettingsController;
use Grav\Plugin\Api\Controllers\SetupController;
use Grav\Plugin\Api\Controllers\SidebarController;
use Grav\Plugin\Api\Controllers\FloatingWidgetController;
use Grav\Plugin\Api\Controllers\ContextPanelController;
use Grav\Plugin\Api\Controllers\SystemController;
use Grav\Plugin\Api\Controllers\UsersController;
use Grav\Plugin\Api\Controllers\GroupsController;
use Grav\Plugin\Api\Controllers\InvitationsController;
use Grav\Plugin\Api\Controllers\AccountsConfigController;
use Grav\Plugin\Api\Controllers\WebhookController;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Middleware\AuthMiddleware;
use Grav\Plugin\Api\Middleware\CorsMiddleware;
use Grav\Plugin\Api\Middleware\JsonBodyParserMiddleware;
use Grav\Plugin\Api\Middleware\MethodOverrideMiddleware;
use Grav\Plugin\Api\Middleware\RateLimitMiddleware;
use Grav\Plugin\Api\Response\ErrorResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use RocketTheme\Toolbox\Event\Event;
use Throwable;
use function FastRoute\cachedDispatcher;
class ApiRouter extends ProcessorBase
{
public $id = 'api_router';
public $title = 'API Router';
protected Config $config;
/** @var array<int,string>|null Cached public-route prefixes after plugin contributions. */
protected ?array $publicPrefixes = null;
/** @var array<int,string>|null Cached public-route exact paths after plugin contributions. */
protected ?array $publicExact = null;
public function __construct(Grav $container, Config $config)
{
parent::__construct($container);
$this->config = $config;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
try {
// Run through API middleware chain
$request = (new JsonBodyParserMiddleware())->processRequest($request);
$request = (new CorsMiddleware($this->config))->processRequest($request);
// Must run before routing so dispatch sees the overridden method.
$request = (new MethodOverrideMiddleware())->processRequest($request);
// Handle CORS preflight
if ($request->getMethod() === 'OPTIONS') {
return (new CorsMiddleware($this->config))->createPreflightResponse();
}
// Require and apply Grav environment
$this->applyEnvironment($request);
// Authenticate (skip for public endpoints - use Grav route which is subdirectory-safe)
$route = $request->getAttribute('route');
$routePath = $route ? $route->getRoute() : '';
$base = $this->config->get('plugins.api.route', '/api');
$prefix = $this->config->get('plugins.api.version_prefix', 'v1');
$apiBase = '/' . trim($base, '/') . '/' . $prefix;
$publicPrefixes = [
$apiBase . '/auth/',
$apiBase . '/translations/',
$apiBase . '/thumbnails/',
];
$publicExact = [
$apiBase . '/ping',
];
// Let plugins contribute additional public routes. The event fires once and
// its result is cached on this ApiRouter instance.
if ($this->publicPrefixes === null) {
$event = new Event([
'api_base' => $apiBase,
'prefixes' => $publicPrefixes,
'exact' => $publicExact,
]);
$this->container->fireEvent('onApiCollectPublicRoutes', $event);
$this->publicPrefixes = (array) $event['prefixes'];
$this->publicExact = (array) $event['exact'];
}
$publicPrefixes = $this->publicPrefixes;
$publicExact = $this->publicExact;
// Entries may be method-scoped as "METHOD /path" (e.g. "GET /api/v1/foo/")
// so plugins can expose public reads while writes on the same paths
// still require authentication. Method-less entries match all methods.
$method = $request->getMethod();
$matches = static function (string $entry, bool $prefix) use ($method, $routePath): bool {
$entryMethod = null;
if (str_contains($entry, ' ')) {
[$entryMethod, $entry] = explode(' ', $entry, 2);
}
if ($entryMethod !== null && strcasecmp($entryMethod, $method) !== 0) {
return false;
}
return $prefix ? str_starts_with($routePath, $entry) : $routePath === $entry;
};
$isPublic = false;
foreach ($publicExact as $entry) {
if ($matches($entry, false)) {
$isPublic = true;
break;
}
}
if (!$isPublic) {
foreach ($publicPrefixes as $entry) {
if ($matches($entry, true)) {
$isPublic = true;
break;
}
}
}
if (!$isPublic) {
$request = (new AuthMiddleware($this->container, $this->config))->processRequest($request);
} else {
// Optimistic auth: public endpoints still see the caller when
// credentials are supplied (richer, permission-filtered
// responses); anonymous callers continue as guests.
$request = (new AuthMiddleware($this->container, $this->config))->processOptional($request);
}
// Register admin proxy so Grav core treats API requests as
// admin-scoped (page visibility, Flex auth scope, events, etc.)
$user = $request->getAttribute('api_user');
if ($user && !isset($this->container['admin'])) {
(new AdminProxy($this->container, $user))->register();
}
// Rate limit (after auth so we can rate limit per-user)
$rateLimitResult = (new RateLimitMiddleware($this->config))->check($request);
if ($rateLimitResult['limited']) {
$response = ErrorResponse::create(429, 'Too Many Requests', 'Rate limit exceeded. Try again later.');
return $this->addRateLimitHeaders($response, $rateLimitResult);
}
// Dispatch the route
$response = $this->dispatch($request);
// Add rate limit headers to successful responses
$response = $this->addRateLimitHeaders($response, $rateLimitResult);
// Add CORS headers to response
$response = (new CorsMiddleware($this->config))->addHeaders($request, $response);
} catch (ApiException $e) {
$response = ErrorResponse::fromException($e);
if (isset($rateLimitResult)) {
$response = $this->addRateLimitHeaders($response, $rateLimitResult);
}
// CORS headers on error responses so browsers don't block them
$response = (new CorsMiddleware($this->config))->addHeaders($request, $response);
} catch (Throwable $e) {
$this->container['log']->error('API unhandled exception: ' . $e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
$response = ErrorResponse::create(
500,
'Internal Server Error',
$this->config->get('system.debugger.enabled') ? $e->getMessage() : 'An unexpected error occurred.'
);
// CORS headers on error responses so browsers don't block them
$response = (new CorsMiddleware($this->config))->addHeaders($request, $response);
}
$this->stopTimer();
return $response;
}
protected function dispatch(ServerRequestInterface $request): ResponseInterface
{
$dispatcher = $this->createDispatcher();
$base = $this->config->get('plugins.api.route', '/api');
$prefix = $this->config->get('plugins.api.version_prefix', 'v1');
$basePath = '/' . trim($base, '/') . '/' . $prefix;
// Use Grav's route (base-path-stripped) not the raw URI
$route = $request->getAttribute('route');
$gravPath = $route ? $route->getRoute() : $request->getUri()->getPath();
// Grav's Uri::init strips trailing extensions that match a registered
// page type (e.g. .md, .txt, .html) before the route is built. Without
// this re-attach, `DELETE /api/v1/media/notes.txt` would arrive as
// `/media/notes` and 404.
if ($route) {
$extension = (string)$route->getExtension();
if ($extension !== '' && !str_ends_with($gravPath, '.' . $extension)) {
$gravPath .= '.' . $extension;
}
}
// On subpath installs (e.g. /sync-testing/grav-c) the PSR-7 URI
// path includes Grav's base; strip it so substr below cleanly peels
// off `$basePath` to leave just the route path.
$gravBase = rtrim((string)$this->container['uri']->rootUrl(false), '/');
if ($gravBase !== '' && str_starts_with($gravPath, $gravBase)) {
$gravPath = substr($gravPath, strlen($gravBase)) ?: '/';
}
$routePath = substr($gravPath, strlen($basePath)) ?: '/';
// Ensure leading slash
if (!str_starts_with($routePath, '/')) {
$routePath = '/' . $routePath;
}
$method = $request->getMethod();
$routeInfo = $dispatcher->dispatch($method, $routePath);
return match ($routeInfo[0]) {
Dispatcher::NOT_FOUND => ErrorResponse::create(404, 'Not Found', "No route matches '{$method} {$routePath}'."),
Dispatcher::METHOD_NOT_ALLOWED => ErrorResponse::create(
405,
'Method Not Allowed',
"Method '{$method}' is not allowed. Allowed: " . implode(', ', $routeInfo[1]) . '.',
['Allow' => implode(', ', $routeInfo[1])]
),
Dispatcher::FOUND => $this->handleRoute($request, $routeInfo[1], $routeInfo[2]),
};
}
protected function handleRoute(ServerRequestInterface $request, array $handler, array $vars): ResponseInterface
{
[$controllerClass, $method] = $handler;
$controller = new $controllerClass($this->container, $this->config);
// Grav builds route paths from parse_url() which does not decode
// percent-escaped octets, so captured params still contain raw %xx
// sequences (e.g. "imäge1.png" arrives as "im%C3%A4ge1.png").
// Decode once here so every controller sees real filenames.
$vars = array_map(
static fn($v) => is_string($v) ? rawurldecode($v) : $v,
$vars
);
$request = $request->withAttribute('route_params', $vars);
return $controller->$method($request);
}
protected function createDispatcher(): Dispatcher
{
$cacheFile = $this->container['locator']->findResource('cache://api', true, true) . '/route.cache';
$cacheDisabled = $this->config->get('system.debugger.enabled', false);
return cachedDispatcher(function (RouteCollector $r) {
$this->registerCoreRoutes($r);
$this->registerPluginRoutes($r);
}, [
'cacheFile' => $cacheFile,
'cacheDisabled' => $cacheDisabled,
]);
}
protected function registerCoreRoutes(RouteCollector $r): void
{
// Auth (no auth required for these)
$r->addRoute('POST', '/auth/token', [AuthController::class, 'token']);
$r->addRoute('POST', '/auth/2fa/verify', [AuthController::class, 'verify2fa']);
$r->addRoute('POST', '/auth/refresh', [AuthController::class, 'refresh']);
$r->addRoute('POST', '/auth/revoke', [AuthController::class, 'revoke']);
$r->addRoute('POST', '/auth/forgot-password', [AuthController::class, 'forgotPassword']);
$r->addRoute('POST', '/auth/reset-password', [AuthController::class, 'resetPassword']);
// Invitation acceptance (public — under /auth/ so it inherits the
// public-route prefix; the token is the only credential needed).
$r->addRoute('GET', '/auth/invite/{token}', [InvitationsController::class, 'validate']);
$r->addRoute('POST', '/auth/invite/{token}', [InvitationsController::class, 'accept']);
$r->addRoute('GET', '/auth/setup', [SetupController::class, 'status']);
$r->addRoute('POST', '/auth/setup', [SetupController::class, 'create']);
$r->addRoute('GET', '/auth/password-policy', [PasswordPolicyController::class, 'show']);
// Current user profile + resolved permissions (protected — auth required)
$r->addRoute('GET', '/me', [AuthController::class, 'me']);
// Languages
$r->addRoute('GET', '/languages', [PagesController::class, 'siteLanguages']);
// Pages
$r->addRoute('GET', '/pages', [PagesController::class, 'index']);
$r->addRoute('POST', '/pages', [PagesController::class, 'create']);
$r->addRoute('POST', '/pages/batch', [PagesController::class, 'batch']);
$r->addRoute('POST', '/pages/reorganize', [PagesController::class, 'reorganize']);
$r->addRoute('GET', '/pages/{route:.+}/languages', [PagesController::class, 'languages']);
$r->addRoute('POST', '/pages/{route:.+}/translate', [PagesController::class, 'translate']);
$r->addRoute('POST', '/pages/{route:.+}/adopt-language', [PagesController::class, 'adoptLanguage']);
$r->addRoute('POST', '/pages/{route:.+}/sync', [PagesController::class, 'sync']);
$r->addRoute('GET', '/pages/{route:.+}/compare', [PagesController::class, 'compare']);
$r->addRoute('POST', '/pages/{route:.+}/reorder', [PagesController::class, 'reorder']);
$r->addRoute('GET', '/pages/{route:.+}/media', [MediaController::class, 'pageMedia']);
$r->addRoute('POST', '/pages/{route:.+}/media', [MediaController::class, 'uploadPageMedia']);
$r->addRoute('DELETE', '/pages/{route:.+}/media/{filename}', [MediaController::class, 'deletePageMedia']);
$r->addRoute('POST', '/pages/{route:.+}/move', [PagesController::class, 'move']);
$r->addRoute('POST', '/pages/{route:.+}/copy', [PagesController::class, 'copy']);
$r->addRoute('GET', '/pages/{route:.+}', [PagesController::class, 'show']);
$r->addRoute('PATCH', '/pages/{route:.+}', [PagesController::class, 'update']);
$r->addRoute('DELETE', '/pages/{route:.+}', [PagesController::class, 'delete']);
// Thumbnails
$r->addRoute('GET', '/thumbnails/{file:.+}', [MediaController::class, 'thumbnail']);
// Destination-aware blueprint file-field uploads (theme/plugin/user
// custom file fields that specify `destination:` in their blueprint).
$r->addRoute('POST', '/blueprint-upload', [BlueprintUploadController::class, 'upload']);
$r->addRoute('DELETE', '/blueprint-upload', [BlueprintUploadController::class, 'delete']);
// Read-only browse for blueprint `folder:` fields (filepicker, mediapicker, …)
// — any Grav stream, `self@:` token, or relative path under user/.
$r->addRoute('GET', '/blueprint-files', [BlueprintFilesController::class, 'list']);
// Site-level media
$r->addRoute('GET', '/media', [MediaController::class, 'siteMedia']);
$r->addRoute('POST', '/media', [MediaController::class, 'uploadSiteMedia']);
$r->addRoute('POST', '/media/folders', [MediaController::class, 'createFolder']);
$r->addRoute('POST', '/media/rename', [MediaController::class, 'renameFile']);
$r->addRoute('POST', '/media/folders/rename', [MediaController::class, 'renameFolder']);
$r->addRoute('DELETE', '/media/folders/{path:.+}', [MediaController::class, 'deleteFolder']);
$r->addRoute('DELETE', '/media/{filename:.+}', [MediaController::class, 'deleteSiteMedia']);
// Taxonomy
$r->addRoute('GET', '/taxonomy', [PagesController::class, 'taxonomy']);
// Config
$r->addRoute('GET', '/config', [ConfigController::class, 'index']);
// Static config routes must be registered BEFORE the variable
// /config/{scope:.+} route below — FastRoute rejects statics that
// would be shadowed by an earlier-defined variable on the same path.
$r->addRoute('GET', '/config/accounts', [AccountsConfigController::class, 'show']);
$r->addRoute('PATCH', '/config/accounts', [AccountsConfigController::class, 'update']);
$r->addRoute('POST', '/config/{scope:.+}/revert', [ConfigController::class, 'revert']);
$r->addRoute('GET', '/config/{scope:.+}', [ConfigController::class, 'show']);
$r->addRoute('PATCH', '/config/{scope:.+}', [ConfigController::class, 'update']);
// Users
$r->addRoute('GET', '/users', [UsersController::class, 'index']);
$r->addRoute('POST', '/users', [UsersController::class, 'create']);
$r->addRoute('GET', '/users/{username}', [UsersController::class, 'show']);
$r->addRoute('PATCH', '/users/{username}', [UsersController::class, 'update']);
$r->addRoute('DELETE', '/users/{username}', [UsersController::class, 'delete']);
$r->addRoute('POST', '/users/{username}/avatar', [UsersController::class, 'uploadAvatar']);
$r->addRoute('DELETE', '/users/{username}/avatar', [UsersController::class, 'deleteAvatar']);
$r->addRoute('POST', '/users/{username}/2fa', [UsersController::class, 'generate2fa']);
$r->addRoute('POST', '/users/{username}/2fa/enable', [UsersController::class, 'enable2fa']);
$r->addRoute('POST', '/users/{username}/2fa/disable', [UsersController::class, 'disable2fa']);
$r->addRoute('GET', '/users/{username}/api-keys', [UsersController::class, 'apiKeys']);
$r->addRoute('POST', '/users/{username}/api-keys', [UsersController::class, 'createApiKey']);
$r->addRoute('DELETE', '/users/{username}/api-keys/{keyId}', [UsersController::class, 'deleteApiKey']);
// Groups
$r->addRoute('GET', '/groups', [GroupsController::class, 'index']);
$r->addRoute('POST', '/groups', [GroupsController::class, 'create']);
$r->addRoute('GET', '/groups/{name}', [GroupsController::class, 'show']);
$r->addRoute('PATCH', '/groups/{name}', [GroupsController::class, 'update']);
$r->addRoute('DELETE', '/groups/{name}', [GroupsController::class, 'delete']);
// Invitations (admin). Top-level path (not /users/...) so it never
// collides with the GET /users/{username} catch-all.
$r->addRoute('GET', '/invitations', [InvitationsController::class, 'index']);
$r->addRoute('POST', '/invitations', [InvitationsController::class, 'create']);
$r->addRoute('DELETE', '/invitations/{token}', [InvitationsController::class, 'delete']);
$r->addRoute('POST', '/invitations/{token}/resend', [InvitationsController::class, 'resend']);
// Custom fields discovery (all plugins/themes)
$r->addRoute('GET', '/custom-fields', [GpmController::class, 'allCustomFields']);
// GPM (Package Manager)
$r->addRoute('GET', '/gpm/plugins', [GpmController::class, 'plugins']);
$r->addRoute('GET', '/gpm/plugins/{slug}', [GpmController::class, 'plugin']);
$r->addRoute('GET', '/gpm/plugins/{slug}/readme', [GpmController::class, 'readme']);
$r->addRoute('GET', '/gpm/plugins/{slug}/changelog', [GpmController::class, 'changelog']);
$r->addRoute('GET', '/gpm/plugins/{slug}/field/{type}', [GpmController::class, 'customFieldScript']);
$r->addRoute('GET', '/gpm/plugins/{slug}/page', [GpmController::class, 'pluginPage']);
$r->addRoute('GET', '/gpm/plugins/{slug}/page-script', [GpmController::class, 'customPageScript']);
$r->addRoute('GET', '/gpm/plugins/{slug}/report-script/{reportId}', [GpmController::class, 'reportScript']);
$r->addRoute('GET', '/gpm/themes', [GpmController::class, 'themes']);
$r->addRoute('GET', '/gpm/themes/{slug}', [GpmController::class, 'theme']);
$r->addRoute('GET', '/gpm/themes/{slug}/readme', [GpmController::class, 'readme']);
$r->addRoute('GET', '/gpm/themes/{slug}/changelog', [GpmController::class, 'changelog']);
$r->addRoute('GET', '/gpm/themes/{slug}/field/{type}', [GpmController::class, 'customFieldScript']);
$r->addRoute('GET', '/gpm/updates', [GpmController::class, 'updates']);
$r->addRoute('POST', '/gpm/install', [GpmController::class, 'install']);
$r->addRoute('POST', '/gpm/remove', [GpmController::class, 'remove']);
$r->addRoute('POST', '/gpm/update', [GpmController::class, 'update']);
$r->addRoute('POST', '/gpm/update-all', [GpmController::class, 'updateAll']);
$r->addRoute('POST', '/gpm/upgrade', [GpmController::class, 'upgrade']);
$r->addRoute('POST', '/gpm/direct-install', [GpmController::class, 'directInstall']);
$r->addRoute('GET', '/gpm/search', [GpmController::class, 'search']);
$r->addRoute('GET', '/gpm/repository/plugins', [GpmController::class, 'repositoryPlugins']);
$r->addRoute('GET', '/gpm/repository/themes', [GpmController::class, 'repositoryThemes']);
$r->addRoute('GET', '/gpm/repository/{slug}', [GpmController::class, 'repositoryPackage']);
// Dashboard
$r->addRoute('GET', '/dashboard/notifications', [DashboardController::class, 'notifications']);
$r->addRoute('POST', '/dashboard/notifications/{id}/hide', [DashboardController::class, 'hideNotification']);
$r->addRoute('GET', '/dashboard/feed', [DashboardController::class, 'feed']);
$r->addRoute('GET', '/dashboard/stats', [DashboardController::class, 'stats']);
$r->addRoute('GET', '/dashboard/security/exposure-probe', [DashboardController::class, 'securityProbe']);
$r->addRoute('GET', '/dashboard/popularity', [DashboardController::class, 'popularity']);
$r->addRoute('GET', '/dashboard/widgets', [DashboardWidgetController::class, 'widgets']);
$r->addRoute('PATCH', '/dashboard/layout', [DashboardWidgetController::class, 'saveUserLayout']);
$r->addRoute('PATCH', '/dashboard/site-layout', [DashboardWidgetController::class, 'saveSiteLayout']);
// Admin-next UI preferences (site defaults + per-user overrides + branding)
$r->addRoute('GET', '/admin-next/preferences', [PreferencesController::class, 'show']);
$r->addRoute('PATCH', '/admin-next/preferences/user', [PreferencesController::class, 'saveUser']);
$r->addRoute('DELETE', '/admin-next/preferences/user', [PreferencesController::class, 'resetUser']);
$r->addRoute('PATCH', '/admin-next/preferences/site', [PreferencesController::class, 'saveSite']);
$r->addRoute('PATCH', '/admin-next/branding', [PreferencesController::class, 'saveBranding']);
$r->addRoute('POST', '/admin-next/branding/logo', [PreferencesController::class, 'uploadLogo']);
$r->addRoute('DELETE', '/admin-next/branding/logo', [PreferencesController::class, 'deleteLogo']);
// Scheduler
$r->addRoute('GET', '/scheduler/jobs', [SchedulerController::class, 'jobs']);
$r->addRoute('GET', '/scheduler/status', [SchedulerController::class, 'status']);
$r->addRoute('GET', '/scheduler/history', [SchedulerController::class, 'history']);
$r->addRoute('POST', '/scheduler/run', [SchedulerController::class, 'run']);
// System Info & Reports
$r->addRoute('GET', '/systeminfo', [SchedulerController::class, 'systemInfo']);
$r->addRoute('GET', '/reports', [ReportsController::class, 'index']);
// Webhooks
$r->addRoute('GET', '/webhooks', [WebhookController::class, 'index']);
$r->addRoute('POST', '/webhooks', [WebhookController::class, 'create']);
$r->addRoute('GET', '/webhooks/{id}', [WebhookController::class, 'show']);
$r->addRoute('PATCH', '/webhooks/{id}', [WebhookController::class, 'update']);
$r->addRoute('DELETE', '/webhooks/{id}', [WebhookController::class, 'delete']);
$r->addRoute('GET', '/webhooks/{id}/deliveries', [WebhookController::class, 'deliveries']);
$r->addRoute('POST', '/webhooks/{id}/test', [WebhookController::class, 'test']);
// Data resolver — generic endpoint for data-options@ directives
$r->addRoute('GET', '/data/resolve', [BlueprintController::class, 'resolveData']);
// Blueprints
$r->addRoute('GET', '/blueprints/pages', [BlueprintController::class, 'pageTypes']);
$r->addRoute('GET', '/blueprints/pages/{template:.+}', [BlueprintController::class, 'pageBlueprint']);
$r->addRoute('GET', '/blueprints/plugins/{plugin}', [BlueprintController::class, 'pluginBlueprint']);
$r->addRoute('GET', '/blueprints/plugins/{plugin}/pages/{pageId}', [BlueprintController::class, 'pluginPageBlueprint']);
$r->addRoute('GET', '/blueprints/themes/{theme}', [BlueprintController::class, 'themeBlueprint']);
$r->addRoute('GET', '/blueprints/users', [BlueprintController::class, 'userBlueprint']);
$r->addRoute('GET', '/blueprints/users/permissions', [BlueprintController::class, 'permissionsBlueprint']);
$r->addRoute('GET', '/blueprints/groups', [BlueprintController::class, 'groupBlueprint']);
$r->addRoute('GET', '/blueprints/groups/new', [BlueprintController::class, 'groupNewBlueprint']);
$r->addRoute('GET', '/blueprints/config/accounts', [BlueprintController::class, 'accountsConfigBlueprint']);
$r->addRoute('GET', '/blueprints/config/{scope}', [BlueprintController::class, 'configBlueprint']);
// System
$r->addRoute('GET', '/ping', [SystemController::class, 'ping']);
$r->addRoute('GET', '/system/environments', [SystemController::class, 'environments']);
$r->addRoute('POST', '/system/environments', [SystemController::class, 'createEnvironment']);
$r->addRoute('DELETE', '/system/environments/{name}', [SystemController::class, 'deleteEnvironment']);
$r->addRoute('GET', '/system/info', [SystemController::class, 'info']);
$r->addRoute('DELETE', '/cache', [SystemController::class, 'clearCache']);
$r->addRoute('GET', '/system/logs/files', [SystemController::class, 'logFiles']);
$r->addRoute('GET', '/system/logs', [SystemController::class, 'logs']);
$r->addRoute('POST', '/system/backup', [SystemController::class, 'backup']);
$r->addRoute('GET', '/system/backups', [SystemController::class, 'backups']);
$r->addRoute('DELETE', '/system/backups/{filename}', [SystemController::class, 'deleteBackup']);
$r->addRoute('GET', '/system/backups/{filename}/download', [SystemController::class, 'downloadBackup']);
// Translations
$r->addRoute('GET', '/translations/{lang}', [SystemController::class, 'translations']);
// Admin UI languages (locales the admin itself can be rendered in,
// as opposed to /languages which lists site content languages).
$r->addRoute('GET', '/admin/languages', [SystemController::class, 'adminLanguages']);
// Menubar
$r->addRoute('GET', '/menubar/items', [MenubarController::class, 'items']);
$r->addRoute('POST', '/menubar/actions/{plugin}/{action}', [MenubarController::class, 'executeAction']);
// Sidebar
$r->addRoute('GET', '/sidebar/items', [SidebarController::class, 'items']);
// Admin-next settings panels (plugins register via onApiAdminSettingsPanels)
$r->addRoute('GET', '/settings/panels', [SettingsController::class, 'panels']);
// Floating Widgets
$r->addRoute('GET', '/floating-widgets', [FloatingWidgetController::class, 'items']);
$r->addRoute('GET', '/gpm/plugins/{slug}/widget-script', [GpmController::class, 'widgetScript']);
// Context Panels
$r->addRoute('GET', '/context-panels', [ContextPanelController::class, 'items']);
$r->addRoute('GET', '/gpm/plugins/{slug}/panel-script', [GpmController::class, 'panelScript']);
}
/**
* Fire event to let other plugins register their API routes.
*/
protected function registerPluginRoutes(RouteCollector $r): void
{
$event = new Event(['routes' => new ApiRouteCollector($r)]);
$this->container->fireEvent('onApiRegisterRoutes', $event);
}
/**
* Apply the X-Grav-Environment header if provided.
* Defaults to Grav's auto-detected environment (from hostname) if not set.
*
* NOTE: once Grav has booted, `setup()` is idempotent (it early-returns on
* the `initialized['setup']` guard), so this can only take effect for a
* request that has NOT yet been set up — it does not switch the environment
* of an already-booted request. Per-environment CONFIG reads/writes do not
* rely on this: ConfigController resolves each scope for the requested
* target from YAML files (ConfigDiffer::effective) when the target differs
* from the booted environment, so base/"Default" sees base config even
* though the live Grav instance stays on the hostname overlay.
*/
protected function applyEnvironment(ServerRequestInterface $request): void
{
$environment = $request->getHeaderLine('X-Grav-Environment');
if (!$environment) {
// Default to Grav's auto-detected environment
return;
}
// Sanitize — environment should be a valid hostname-style string
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/', $environment)) {
throw new Exceptions\ApiException(
400,
'Bad Request',
'Invalid environment name. Use a valid hostname (e.g., localhost, mysite.com).'
);
}
$currentEnv = $this->container['uri']->environment();
// Only reinitialize if the requested environment differs from current
if ($environment !== $currentEnv) {
$this->container->setup($environment);
$this->config->reload();
}
}
protected function addRateLimitHeaders(ResponseInterface $response, array $result): ResponseInterface
{
if (!$this->config->get('plugins.api.rate_limit.enabled', true)) {
return $response;
}
return $response
->withHeader('X-RateLimit-Limit', (string) $result['limit'])
->withHeader('X-RateLimit-Remaining', (string) $result['remaining'])
->withHeader('X-RateLimit-Reset', (string) $result['reset']);
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Auth;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Psr\Http\Message\ServerRequestInterface;
class ApiKeyAuthenticator implements AuthenticatorInterface
{
public function __construct(
protected readonly Grav $grav,
) {}
public function authenticate(ServerRequestInterface $request): ?UserInterface
{
$apiKey = $this->extractApiKey($request);
if (!$apiKey || !str_starts_with($apiKey, 'grav_')) {
return null;
}
$manager = new ApiKeyManager();
$match = $manager->findKey($apiKey);
if (!$match) {
return null;
}
$keyData = $match['data'];
$keyId = $match['key_id'];
$username = $match['username'];
// Check if key is active
if (($keyData['active'] ?? true) === false) {
return null;
}
// Check expiry
if (isset($keyData['expires']) && $keyData['expires'] < time()) {
return null;
}
// Load the associated user
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($username);
if (!$user->exists()) {
return null;
}
// Auto-rehash legacy SHA-256 keys to bcrypt
if (!str_starts_with($keyData['hash'], '$2')) {
$manager->rehashKey($keyId, $apiKey);
}
// Update last_used timestamp
$manager->touchKey($keyId);
return $user;
}
protected function extractApiKey(ServerRequestInterface $request): ?string
{
// Check X-API-Key header first
$key = $request->getHeaderLine('X-API-Key');
if ($key) {
return $key;
}
// Fall back to query parameter
$query = $request->getQueryParams();
return $query['api_key'] ?? null;
}
}
@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Auth;
use Grav\Common\Grav;
use Grav\Common\User\Authentication;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Yaml;
/**
* Manages API keys stored centrally in user/data/api-keys.yaml
*/
class ApiKeyManager
{
protected static ?array $keysCache = null;
/**
* Generate a new API key for a user.
*
* @param int|null $expiryDays Number of days until the key expires, or null for no expiry
* @return array{key: string, id: string} The raw key (shown once) and the key ID
*/
public function generateKey(UserInterface $user, string $name = '', array $scopes = [], ?int $expiryDays = null): array
{
$rawKey = 'grav_' . bin2hex(random_bytes(24));
$keyId = bin2hex(random_bytes(8));
$hash = Authentication::create($rawKey);
$expires = $expiryDays !== null ? time() + ($expiryDays * 86400) : null;
$keys = $this->loadKeys();
$keys[$keyId] = [
'id' => $keyId,
'username' => $user->username,
'name' => $name ?: 'API Key',
'hash' => $hash,
'prefix' => substr($rawKey, 0, 12) . '...',
'scopes' => $scopes,
'active' => true,
'created' => time(),
'last_used' => null,
'expires' => $expires,
];
$this->saveKeys($keys);
return [
'key' => $rawKey,
'id' => $keyId,
];
}
/**
* List all API keys for a user (without hashes).
*/
public function listKeys(UserInterface $user): array
{
$keys = $this->loadKeys();
$result = [];
foreach ($keys as $keyData) {
if (!is_array($keyData) || ($keyData['username'] ?? '') !== $user->username) {
continue;
}
$result[] = [
'id' => $keyData['id'] ?? '',
'name' => $keyData['name'] ?? 'API Key',
'prefix' => $keyData['prefix'] ?? '',
'scopes' => $keyData['scopes'] ?? [],
'active' => $keyData['active'] ?? true,
'created' => $keyData['created'] ?? null,
'last_used' => $keyData['last_used'] ?? null,
'expires' => $keyData['expires'] ?? null,
];
}
return $result;
}
/**
* Revoke (delete) an API key.
*/
public function revokeKey(UserInterface $user, string $keyId): bool
{
$keys = $this->loadKeys();
if (!isset($keys[$keyId]) || ($keys[$keyId]['username'] ?? '') !== $user->username) {
return false;
}
unset($keys[$keyId]);
$this->saveKeys($keys);
return true;
}
/**
* Verify a raw API key against a stored hash.
*/
public static function verifyKey(string $rawKey, string $hash): bool
{
// Bcrypt hashes start with $2y$ or $2b$
if (str_starts_with($hash, '$2')) {
return Authentication::verify($rawKey, $hash) > 0;
}
// Legacy SHA-256 fallback
return hash_equals($hash, hash('sha256', $rawKey));
}
/**
* Rehash a legacy SHA-256 key to bcrypt.
*/
public function rehashKey(string $keyId, string $rawKey): void
{
$keys = $this->loadKeys();
if (isset($keys[$keyId]) && is_array($keys[$keyId])) {
$keys[$keyId]['hash'] = Authentication::create($rawKey);
$this->saveKeys($keys);
}
}
/**
* Update last_used timestamp for a key.
*/
public function touchKey(string $keyId): void
{
$keys = $this->loadKeys();
if (isset($keys[$keyId]) && is_array($keys[$keyId])) {
$keys[$keyId]['last_used'] = time();
$this->saveKeys($keys);
}
}
/**
* Find a key entry by raw API key. Returns [keyId, keyData, username] or null.
*/
public function findKey(string $rawKey): ?array
{
$keys = $this->loadKeys();
foreach ($keys as $keyId => $keyData) {
if (!is_array($keyData) || !isset($keyData['hash'])) {
continue;
}
if (self::verifyKey($rawKey, $keyData['hash'])) {
return [
'key_id' => $keyId,
'data' => $keyData,
'username' => $keyData['username'] ?? '',
];
}
}
return null;
}
/**
* Load all API keys from the data file.
*/
public function loadKeys(): array
{
if (static::$keysCache !== null) {
return static::$keysCache;
}
$file = $this->getKeysFile();
if (!file_exists($file)) {
static::$keysCache = [];
return [];
}
$data = Yaml::parse(file_get_contents($file)) ?? [];
static::$keysCache = $data;
return $data;
}
/**
* Save all API keys to the data file.
*/
protected function saveKeys(array $keys): void
{
$file = $this->getKeysFile();
$dir = dirname($file);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
// Write atomically
$tmp = $file . '.tmp';
file_put_contents($tmp, Yaml::dump($keys));
rename($tmp, $file);
static::$keysCache = $keys;
}
/**
* Get the path to the API keys data file.
*/
protected function getKeysFile(): string
{
$locator = Grav::instance()['locator'];
return $locator->findResource('user://data', true, true) . '/api-keys.yaml';
}
/**
* Migrate keys from user account files to centralized storage.
*/
public function migrateFromAccounts(): int
{
$grav = Grav::instance();
$accounts = $grav['accounts'];
$locator = $grav['locator'];
$migrated = 0;
// Scan account files
$accountDir = $locator->findResource('account://', true)
?: $locator->findResource('user://accounts', true);
if (!$accountDir || !is_dir($accountDir)) {
return 0;
}
foreach (new \DirectoryIterator($accountDir) as $file) {
if ($file->isDot() || !$file->isFile() || $file->getExtension() !== 'yaml') {
continue;
}
$username = $file->getBasename('.yaml');
$user = $accounts->load($username);
if (!$user->exists()) {
continue;
}
$userKeys = $user->get('api_keys', []);
if (empty($userKeys)) {
continue;
}
$existingKeys = $this->loadKeys();
foreach ($userKeys as $keyId => $keyData) {
if (!is_array($keyData) || isset($existingKeys[$keyId])) {
continue;
}
$keyData['username'] = $username;
$existingKeys[$keyId] = $keyData;
$migrated++;
}
$this->saveKeys($existingKeys);
static::$keysCache = null; // Clear cache for next loadKeys()
// Remove api_keys from user account
$user->undef('api_keys');
$user->save();
}
return $migrated;
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Auth;
use Grav\Common\User\Interfaces\UserInterface;
use Psr\Http\Message\ServerRequestInterface;
interface AuthenticatorInterface
{
/**
* Attempt to authenticate the request.
* Returns the authenticated user, or null if this authenticator cannot handle the request.
*/
public function authenticate(ServerRequestInterface $request): ?UserInterface;
}
@@ -0,0 +1,323 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Auth;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;
class JwtAuthenticator implements AuthenticatorInterface
{
public function __construct(
protected readonly Grav $grav,
protected readonly Config $config,
) {}
public function authenticate(ServerRequestInterface $request): ?UserInterface
{
$token = $this->extractBearerToken($request);
if (!$token) {
return null;
}
return $this->validateToken($token);
}
/**
* Generate an access token for a user.
*/
public function generateAccessToken(UserInterface $user): string
{
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$expiry = (int) $this->config->get('plugins.api.auth.jwt_expiry', 3600);
$payload = [
'iss' => 'grav-api',
'sub' => $user->username,
'iat' => time(),
'exp' => time() + $expiry,
'type' => 'access',
];
return JWT::encode($payload, $secret, $algorithm);
}
/**
* Generate a refresh token for a user.
*/
public function generateRefreshToken(UserInterface $user): string
{
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$expiry = (int) $this->config->get('plugins.api.auth.jwt_refresh_expiry', 604800);
$payload = [
'iss' => 'grav-api',
'sub' => $user->username,
'iat' => time(),
'exp' => time() + $expiry,
'type' => 'refresh',
'jti' => bin2hex(random_bytes(16)),
];
return JWT::encode($payload, $secret, $algorithm);
}
/**
* Generate a short-lived, single-use challenge token for flows like 2FA
* verification or password reset handoff. The $purpose field is stored in
* the token's `type` claim and must match on validation.
*/
public function generateChallengeToken(UserInterface $user, string $purpose, int $ttl = 300): string
{
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$payload = [
'iss' => 'grav-api',
'sub' => $user->username,
'iat' => time(),
'exp' => time() + $ttl,
'type' => $purpose,
'jti' => bin2hex(random_bytes(16)),
];
return JWT::encode($payload, $secret, $algorithm);
}
/**
* Validate a challenge token and return the associated user. The token must
* carry the expected purpose in its `type` claim and must not have been
* revoked. Returns null if invalid, expired, or revoked.
*/
public function validateChallengeToken(string $token, string $expectedPurpose): ?UserInterface
{
try {
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$decoded = JWT::decode($token, new Key($secret, $algorithm));
if (($decoded->type ?? null) !== $expectedPurpose) {
return null;
}
if ($this->isTokenRevoked($decoded->jti ?? '')) {
return null;
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($decoded->sub);
return $user->exists() ? $user : null;
} catch (Throwable) {
return null;
}
}
/**
* Validate a refresh token and return the associated user.
*/
public function validateRefreshToken(string $token): ?UserInterface
{
try {
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$decoded = JWT::decode($token, new Key($secret, $algorithm));
if (($decoded->type ?? null) !== 'refresh') {
return null;
}
// Check if token has been revoked
if ($this->isTokenRevoked($decoded->jti ?? '')) {
return null;
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($decoded->sub);
return $user->exists() ? $user : null;
} catch (Throwable) {
return null;
}
}
/**
* Revoke a refresh token by its JTI.
*/
public function revokeToken(string $token): bool
{
try {
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$decoded = JWT::decode($token, new Key($secret, $algorithm));
$jti = $decoded->jti ?? null;
if (!$jti) {
return false;
}
$this->addRevokedToken($jti, $decoded->exp ?? time() + 604800);
return true;
} catch (Throwable) {
return false;
}
}
protected function extractBearerToken(ServerRequestInterface $request): ?string
{
// Primary: `X-API-Token` custom header. Preferred because it survives
// FPM / FastCGI / CGI setups that silently strip the `Authorization`
// header (MAMP's mod_fastcgi being the common trigger). Accepts either
// a bare JWT or the traditional `Bearer <jwt>` form.
$custom = trim($request->getHeaderLine('X-API-Token'));
if ($custom !== '') {
return str_starts_with($custom, 'Bearer ') ? substr($custom, 7) : $custom;
}
// Legacy / standards-compliant: `Authorization: Bearer <jwt>`.
// Kept for external clients (curl, Postman, CI) and backward compat.
$header = $request->getHeaderLine('Authorization');
if (str_starts_with($header, 'Bearer ')) {
return substr($header, 7);
}
// Fallback: query parameter for direct links (e.g. file downloads
// where a browser <a download> tag can't attach a header).
$params = $request->getQueryParams();
if (!empty($params['token'])) {
return $params['token'];
}
return null;
}
protected function validateToken(string $token): ?UserInterface
{
try {
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$decoded = JWT::decode($token, new Key($secret, $algorithm));
// Only accept access tokens for API authentication
if (($decoded->type ?? null) !== 'access') {
return null;
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($decoded->sub);
return $user->exists() ? $user : null;
} catch (Throwable) {
return null;
}
}
protected function getSecret(): string
{
$secret = $this->config->get('plugins.api.auth.jwt_secret', '');
// Auto-generate secret if not set
if (!$secret) {
$secret = bin2hex(random_bytes(32));
$this->config->set('plugins.api.auth.jwt_secret', $secret);
// Persist the generated secret so subsequent requests can verify
// tokens signed with it. Without persistence every request re-mints
// a different secret, producing the classic "login succeeds, next
// request 401" reauth loop on a fresh install.
//
// findResource() with defaults (absolute=true, all=false) returns
// either the first existing path or false — the previous third
// `true` flag returned an array and silently broke the fallback.
$locator = $this->grav['locator'];
$file = $locator->findResource('config://plugins/api.yaml');
if (!$file) {
$configDir = $locator->findResource('config://', true);
if (!$configDir) {
if (isset($this->grav['log'])) {
$this->grav['log']->warning('api.auth: could not resolve config:// stream to persist JWT secret; tokens will be single-request only until jwt_secret is configured.');
}
return $secret;
}
$file = $configDir . '/plugins/api.yaml';
}
$dir = dirname($file);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
if (isset($this->grav['log'])) {
$this->grav['log']->warning(sprintf('api.auth: could not create %s to persist JWT secret.', $dir));
}
return $secret;
}
$yaml = \Grav\Common\Yaml::parse(file_exists($file) ? file_get_contents($file) : '') ?? [];
$yaml['auth']['jwt_secret'] = $secret;
if (@file_put_contents($file, \Grav\Common\Yaml::dump($yaml)) === false) {
if (isset($this->grav['log'])) {
$this->grav['log']->warning(sprintf('api.auth: could not write JWT secret to %s — tokens will not survive past this request.', $file));
}
}
}
return $secret;
}
protected function isTokenRevoked(string $jti): bool
{
$file = $this->getRevokedTokensFile();
if (!file_exists($file)) {
return false;
}
$revoked = json_decode(file_get_contents($file), true) ?: [];
$this->cleanExpiredRevocations($revoked, $file);
return isset($revoked[$jti]);
}
protected function addRevokedToken(string $jti, int $expiresAt): void
{
$file = $this->getRevokedTokensFile();
$dir = dirname($file);
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$revoked = [];
if (file_exists($file)) {
$revoked = json_decode(file_get_contents($file), true) ?: [];
}
$revoked[$jti] = $expiresAt;
$this->cleanExpiredRevocations($revoked, $file);
}
protected function cleanExpiredRevocations(array &$revoked, string $file): void
{
$now = time();
$revoked = array_filter($revoked, fn($exp) => $exp > $now);
file_put_contents($file, json_encode($revoked));
}
protected function getRevokedTokensFile(): string
{
$locator = $this->grav['locator'];
return $locator->findResource('cache://api', true, true) . '/revoked_tokens.json';
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Auth;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserInterface;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;
class SessionAuthenticator implements AuthenticatorInterface
{
public function __construct(
protected readonly Grav $grav,
) {}
public function authenticate(ServerRequestInterface $request): ?UserInterface
{
try {
/** @var \Grav\Common\Session $session */
$session = $this->grav['session'];
// Only if session is already started (e.g., from admin browsing)
if (!$session->isStarted()) {
return null;
}
/** @var UserInterface|null $user */
$user = $session->user ?? null;
// Accept any authenticated session user, including one restored via the
// login plugin's "remember me" cookie (which leaves the user
// `authenticated` but not `authorized`). Without this, a remembered user
// shows as signed in in the UI yet every write call is rejected until a
// fresh login. Per-route permission checks (the user's `access` map,
// refreshed below) still gate what they can actually do, and the
// remember-me cookie is itself HttpOnly/Secure/SameSite.
if ($user && $user->exists() && $user->authenticated) {
// Session stores a serialized user snapshot whose `access` map
// is frozen at the moment of login. Admin permission changes
// wouldn't take effect until the session is destroyed. Refresh
// `access` from disk so an operator's grant/revoke is honored
// on the next API request without forcing a re-login.
$username = (string) $user->get('username');
if ($username !== '') {
try {
$fresh = $this->grav['accounts']->load($username);
if ($fresh->exists()) {
$user->set('access', $fresh->get('access'));
$user->set('groups', $fresh->get('groups'));
}
} catch (Throwable) {
// Disk reload failed — fall through with stale access
// rather than denying a legitimately authenticated user.
}
}
return $user;
}
} catch (Throwable) {
// Session not available or errored
}
return null;
}
}
@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\Data\Blueprint;
use Grav\Common\Data\Validation;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\UnauthorizedException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\PermissionResolver;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\UserSerializer;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
abstract class AbstractApiController
{
public function __construct(
protected readonly Grav $grav,
protected readonly Config $config,
) {}
/**
* Get the authenticated user from the request.
*/
protected function getUser(ServerRequestInterface $request): UserInterface
{
$user = $request->getAttribute('api_user');
if (!$user) {
throw new UnauthorizedException();
}
return $user;
}
/**
* Verify the user has the required permission.
*/
protected function requirePermission(ServerRequestInterface $request, string $permission): void
{
$user = $this->getUser($request);
// Super admin can do anything
if ($this->isSuperAdmin($user)) {
return;
}
// Check API access first
if (!$this->hasPermission($user, 'api.access')) {
throw new ForbiddenException('API access is not enabled for this user.');
}
// Check specific permission
if (!$this->hasPermission($user, $permission)) {
throw new ForbiddenException("Missing required permission: {$permission}");
}
}
/**
* Check if user is an API super user via direct access array lookup.
*
* API authority is strictly scoped to access.api.super — admin.super
* (admin-classic's legacy global super) is intentionally NOT honored
* here. Grav 2.0 separates admin-classic and API/Admin-Next authority
* so operators can grant one without implicitly granting the other.
*/
protected function isSuperAdmin(UserInterface $user): bool
{
return (bool) $user->get('access.api.super');
}
/**
* Check user permission with parent-key inheritance.
*
* Granting "api.pages" implicitly covers "api.pages.read" via walk-up
* resolution, matching how Grav's core ACL resolves permissions.
*/
protected function hasPermission(UserInterface $user, string $permission): bool
{
return (bool) $this->getPermissionResolver()->resolve($user, $permission);
}
/**
* Check whether a user satisfies an `authorize` requirement attached to a
* sidebar / menubar / widget item. Mirrors admin-classic's pattern:
*
* - `null` (no requirement) → always allowed.
* - string → user must have that permission.
* - array → user must have at least ONE of the listed permissions.
*
* Super-admins pass regardless of the requirement.
*/
protected function userPassesAuthorize(UserInterface $user, mixed $authorize, bool $isSuperAdmin): bool
{
if ($authorize === null) {
return true;
}
if ($isSuperAdmin) {
return true;
}
if (is_string($authorize)) {
return $this->hasPermission($user, $authorize);
}
if (is_array($authorize)) {
foreach ($authorize as $perm) {
if (is_string($perm) && $this->hasPermission($user, $perm)) {
return true;
}
}
return false;
}
// Unknown shape — fail closed.
return false;
}
private ?PermissionResolver $permissionResolver = null;
protected function getPermissionResolver(): PermissionResolver
{
return $this->permissionResolver ??= new PermissionResolver($this->grav['permissions']);
}
/**
* Get the parsed JSON request body.
*/
protected function getRequestBody(ServerRequestInterface $request): array
{
$body = $request->getAttribute('json_body');
if ($body === null) {
$body = $request->getParsedBody();
}
return is_array($body) ? $body : [];
}
/**
* List-aware recursive merge of an incoming patch into existing data.
*
* Unlike array_replace_recursive, this never merges into list-shaped
* nodes: if either side at a given key is a sequential list, the
* incoming value replaces the existing one wholesale. Prevents the
* "'0','1','2' keys alongside named entries" YAML corruption that
* array_replace_recursive produces when a YAML list on disk is sent
* back as a name-keyed map (or vice versa).
*/
protected function mergePatch(array $existing, array $incoming): array
{
foreach ($incoming as $key => $value) {
if (
is_array($value)
&& isset($existing[$key])
&& is_array($existing[$key])
&& !array_is_list($value)
&& !array_is_list($existing[$key])
) {
$existing[$key] = $this->mergePatch($existing[$key], $value);
} else {
$existing[$key] = $value;
}
}
return $existing;
}
/**
* Validate only the fields present in `$changes` against their blueprint
* definitions, throwing the API's ValidationException (HTTP 422) with
* per-field messages on failure.
*
* We validate the submitted delta — NOT the whole merged object — on
* purpose. Grav's own stock config doesn't pass a whole-object
* `$blueprint->validate()`: `system.errors.display` ships as a bool against
* a `type: int` rule, and the core `list` validator rejects complete
* security/backups/scheduler list items (required per-item sub-fields are
* checked at the wrong nesting level). All of those landmines live in
* fields the request never touches, so validating just the changed fields
* sidesteps them while still rejecting an invalid value or a required field
* submitted empty (getgrav/grav-plugin-admin2#30). Completeness — a required
* field the user never filled — is enforced by the admin UI, which renders
* the whole form.
*
* `$changes` is keyed exactly as the blueprint expects (e.g. `errors.display`
* nested under `errors`, page fields under `header`); it is flattened to the
* blueprint's leaf fields here.
*
* @param array $changes Incoming values (possibly nested), as sent by the client.
*/
protected function validateChangedFields(array $changes, ?Blueprint $blueprint): void
{
if ($blueprint === null || $changes === []) {
return;
}
$schema = $blueprint->schema();
$errors = [];
foreach ($blueprint->flattenData($changes) as $name => $value) {
$field = $schema->getProperty($name);
if (!is_array($field) || !isset($field['type'])) {
// Not a blueprint-defined field (extra/legacy key) — nothing to validate.
continue;
}
$value = $this->coerceForValidation($value, $field);
foreach (Validation::validate($value, $field) as $messages) {
foreach ((array) $messages as $message) {
$errors[] = [
'field' => $name,
'message' => trim(strip_tags((string) $message)),
];
}
}
// XSS safety gate. The full blueprint validator (BlueprintSchema::validate())
// runs checkSafety() per field, but this partial-field path validates the
// submitted delta directly and must enforce the same trust boundary itself —
// otherwise a non-superadmin editor could persist stored XSS (e.g. an
// `onerror=` handler in page Markdown) that fires in an admin or visitor
// session. checkSafety() honors security.xss_whitelist (admin.super) and
// per-field `xss_check: false`, so behaviour matches the classic admin exactly.
foreach (Validation::checkSafety($value, $field) as $messages) {
foreach ((array) $messages as $message) {
$errors[] = [
'field' => $name,
'message' => trim(strip_tags((string) $message)),
];
}
}
}
if ($errors !== []) {
throw new ValidationException(
'The submitted data did not pass blueprint validation.',
$errors,
);
}
}
/**
* Mirror Grav's runtime leniency between ints and booleans for int-typed
* fields. `system.errors.display`, for example, is declared `type: int`
* but Grav's error handler (Errors::resetHandlers) treats `true`/`false`
* as `1`/`0`. Grav's `typeInt` validator is stricter (`is_numeric(true)`
* is false), so without this a legitimate boolean value would be rejected.
*/
private function coerceForValidation(mixed $value, array $field): mixed
{
$type = $field['validate']['type'] ?? $field['type'] ?? null;
if (is_bool($value) && ($type === 'int' || $type === 'number')) {
return (int) $value;
}
return $value;
}
/**
* Get route parameters captured by FastRoute.
*/
protected function getRouteParam(ServerRequestInterface $request, string $name): ?string
{
$params = $request->getAttribute('route_params', []);
return $params[$name] ?? null;
}
/**
* Get pagination parameters from query string.
*/
protected function getPagination(ServerRequestInterface $request): array
{
$query = $request->getQueryParams();
$defaultPerPage = $this->config->get('plugins.api.pagination.default_per_page', 20);
$maxPerPage = $this->config->get('plugins.api.pagination.max_per_page', 1000);
$page = max(1, (int) ($query['page'] ?? 1));
$perPage = min($maxPerPage, max(1, (int) ($query['per_page'] ?? $defaultPerPage)));
return [
'page' => $page,
'per_page' => $perPage,
'offset' => ($page - 1) * $perPage,
'limit' => $perPage,
];
}
/**
* Get sort parameters from query string.
*/
protected function getSorting(ServerRequestInterface $request, array $allowedFields = []): array
{
$query = $request->getQueryParams();
$sort = $query['sort'] ?? null;
$order = strtolower($query['order'] ?? 'asc');
if ($sort && $allowedFields && !in_array($sort, $allowedFields, true)) {
throw new ValidationException("Invalid sort field '{$sort}'. Allowed: " . implode(', ', $allowedFields));
}
if (!in_array($order, ['asc', 'desc'], true)) {
$order = 'asc';
}
return [
'sort' => $sort,
'order' => $order,
];
}
/**
* Get filter parameters from query string.
*/
protected function getFilters(ServerRequestInterface $request, array $allowedFilters = []): array
{
$query = $request->getQueryParams();
$filters = [];
foreach ($allowedFilters as $filter) {
// Support dot notation for nested params (e.g., taxonomy.category)
if (str_contains($filter, '.')) {
$parts = explode('.', $filter);
$value = $query;
foreach ($parts as $part) {
$value = $value[$part] ?? null;
if ($value === null) {
break;
}
}
if ($value !== null) {
$filters[$filter] = $value;
}
} elseif (isset($query[$filter])) {
$filters[$filter] = $query[$filter];
}
}
return $filters;
}
/**
* Validate ETag for optimistic concurrency control.
* Returns true if the client's ETag matches the current resource hash.
*/
protected function validateEtag(ServerRequestInterface $request, string $currentHash): void
{
$ifMatch = $request->getHeaderLine('If-Match');
if ($ifMatch && $this->normalizeEtag($ifMatch) !== $currentHash) {
throw new \Grav\Plugin\Api\Exceptions\ConflictException(
'The resource has been modified since you last retrieved it. Please fetch the latest version and try again.'
);
}
}
/**
* Strip transport-layer noise from an inbound ETag so comparisons survive
* reverse proxies that weaken the header.
*
* Apache mod_deflate and some nginx builds append `-gzip` (or `;gzip`) to
* ETags on compressed responses and leave it in place when the client
* echoes the value back in If-Match. Weak markers (`W/`) and surrounding
* quotes are also normalized here so the raw md5 hash is what gets
* compared against generateEtag()'s output.
*/
private function normalizeEtag(string $etag): string
{
$etag = trim($etag);
if (str_starts_with($etag, 'W/')) {
$etag = substr($etag, 2);
}
$etag = trim($etag, '"');
// Strip known transport suffixes a compressing front-end appends to the
// ETag and leaves in place when the client echoes it back in If-Match:
// mod_deflate `-gzip`/`;gzip`, mod_brotli `-br`, and mod_zstd `-zstd`
// (the last surfaced as a false 409 in getgrav/grav-plugin-admin2#28).
$etag = preg_replace('/[-;](?:gzip|br|deflate|zstd)$/i', '', $etag) ?? $etag;
return $etag;
}
/**
* Generate an ETag hash for a resource.
*/
protected function generateEtag(mixed $data): string
{
return md5(json_encode($data));
}
/**
* Create a response with ETag header, optionally paired with invalidation tags.
*
* By default the ETag is hashed from the response body. Pass an explicit
* $etag when the body and the validator must diverge — e.g. config saves
* return the full merged config as the body but key the ETag off the
* persisted delta so it survives the save→reload round-trip.
*
* @param array<int, string> $invalidates
*/
protected function respondWithEtag(mixed $data, int $status = 200, array $invalidates = [], ?string $etag = null, ?array $meta = null): ResponseInterface
{
$etag ??= $this->generateEtag($data);
$headers = ['ETag' => '"' . $etag . '"'];
if ($invalidates !== []) {
$headers['X-Invalidates'] = implode(', ', $invalidates);
}
return ApiResponse::create($data, $status, $headers, $meta);
}
/**
* Build headers array containing just the X-Invalidates header for a set of tags.
* Useful when composing responses via ApiResponse::created() / noContent() etc.
*
* @param array<int, string> $tags
* @return array<string, string>
*/
protected function invalidationHeaders(array $tags): array
{
$tags = array_values(array_filter($tags, static fn($t) => is_string($t) && $t !== ''));
return $tags === [] ? [] : ['X-Invalidates' => implode(', ', $tags)];
}
/**
* Create a response with an X-Invalidates header declaring which client-side
* caches this mutation should evict. Tags follow `resource:action[:id]` form:
*
* pages:update:/blog/post-1
* pages:list
* users:create
*
* The admin-next client reads this header and emits invalidation events on
* its pub/sub bus, causing list/detail views to refetch automatically.
*
* @param array<int, string> $tags
*/
protected function respondWithInvalidation(
mixed $data,
array $tags,
int $status = 200,
array $extraHeaders = [],
): ResponseInterface {
$headers = $extraHeaders;
if ($tags !== []) {
$headers['X-Invalidates'] = implode(', ', $tags);
}
if ($status === 204) {
// 204 responses have no body — use a bare Response with headers only.
$headers['Cache-Control'] = 'no-store, max-age=0';
return new \Grav\Framework\Psr7\Response(204, $headers);
}
return ApiResponse::create($data, $status, $headers);
}
/**
* Build the API base URL for link generation.
*/
protected function getApiBaseUrl(): string
{
$base = $this->config->get('plugins.api.route', '/api');
$prefix = $this->config->get('plugins.api.version_prefix', 'v1');
return '/' . trim($base, '/') . '/' . $prefix;
}
/**
* Validate required fields are present in the request body.
*/
protected function requireFields(array $body, array $fields): void
{
$missing = [];
foreach ($fields as $field) {
if (!isset($body[$field]) || (is_string($body[$field]) && trim($body[$field]) === '')) {
$missing[] = $field;
}
}
if ($missing) {
throw new ValidationException(
'Missing required fields: ' . implode(', ', $missing),
array_map(fn($f) => ['field' => $f, 'message' => "The '{$f}' field is required."], $missing)
);
}
}
/**
* Fire a Grav event with the given data.
* Returns the event object so callers can check for modifications.
*/
protected function fireEvent(string $name, array $data = []): Event
{
$event = new Event($data);
$this->grav->fireEvent($name, $event);
return $event;
}
/**
* Fire an admin-compatible event alongside the API's own events.
*
* Third-party plugins subscribe to onAdmin* events for critical operations
* (SEO indexing, frontmatter injection, cache busting, etc.). These events
* are normally only fired by the admin plugin's controllers, so API-driven
* changes would silently bypass them. This method ensures compatibility by
* firing the same events with the same data signatures the admin uses.
*/
protected function issueTokenPair(JwtAuthenticator $jwt, UserInterface $user): ResponseInterface
{
$accessToken = $jwt->generateAccessToken($user);
$refreshToken = $jwt->generateRefreshToken($user);
$expiresIn = (int) $this->config->get('plugins.api.auth.jwt_expiry', 3600);
$isSuperAdmin = $this->isSuperAdmin($user);
$resolver = $this->getPermissionResolver();
$resolvedAccess = $resolver->resolvedMap($user, $isSuperAdmin);
return ApiResponse::create([
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => 'Bearer',
'expires_in' => $expiresIn,
'user' => [
'username' => $user->username,
'fullname' => $user->get('fullname'),
'email' => $user->get('email'),
'avatar_url' => UserSerializer::resolveAvatarUrl($user),
'super_admin' => $isSuperAdmin,
'access' => $resolvedAccess,
'content_editor' => $user->get('content_editor', ''),
],
]);
}
protected function fireAdminEvent(string $name, array $data = []): Event
{
// Ensure $grav['page'] is set when firing page-related admin events.
// In admin-classic this is always set; with flex-objects via API it may not be,
// causing plugins that read $grav['page'] (SEO Magic, etc.) to get null.
$page = $data['page'] ?? $data['object'] ?? null;
if ($page instanceof PageInterface) {
// Use offsetUnset first to clear any Pimple frozen state, then set.
unset($this->grav['page']);
$this->grav['page'] = $page;
}
$event = new Event($data);
$this->grav->fireEvent($name, $event);
return $event;
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Grav;
use Grav\Common\Yaml;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Services\ConfigDiffer;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Read/write the Flex accounts configuration at user/config/flex/accounts.yaml.
*
* Classic admin exposes this as the "Configuration" tab under Users — it
* carries the Flex compatibility toggles and any caching options the
* Flex-Objects plugin contributes to user-accounts.
*
* Gated on admin.super, matching the security@ on the underlying blueprint.
*/
class AccountsConfigController extends AbstractApiController
{
private const CONFIG_KEY = 'flex.accounts';
private const CONFIG_FILE = 'flex/accounts.yaml';
public function show(ServerRequestInterface $request): ResponseInterface
{
$this->requireSuperOrAdmin($request);
$data = $this->readConfig();
return $this->respondWithEtag($data);
}
public function update(ServerRequestInterface $request): ResponseInterface
{
$this->requireSuperOrAdmin($request);
$current = $this->readConfig();
$this->validateEtag($request, $this->generateEtag($current));
$body = $this->getRequestBody($request);
if (empty($body)) {
throw new ValidationException('Request body must contain configuration values to update.');
}
$merged = $this->mergePatch($current, $body);
$this->writeConfig($merged);
$this->fireEvent('onApiConfigUpdated', ['scope' => 'flex/accounts', 'data' => $merged]);
return $this->respondWithEtag(
$this->readConfig(),
200,
['config:update:flex/accounts'],
);
}
/**
* @return array<string, mixed>
*/
private function readConfig(): array
{
$data = $this->config->get(self::CONFIG_KEY);
return is_array($data) ? $data : [];
}
/**
* Persist to user/config/flex/accounts.yaml. We always write to base
* user/config — env overlays for this file would be unusual and the
* classic admin doesn't support them either.
*
* @param array<string, mixed> $data
*/
private function writeConfig(array $data): void
{
$grav = Grav::instance();
$locator = $grav['locator'];
$userConfig = $locator->findResource('user://config', true);
if (!$userConfig) {
throw new \RuntimeException('Base user/config directory not found.');
}
$filePath = $userConfig . '/' . self::CONFIG_FILE;
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
// Skip persisting any values injected via GRAV_CONFIG__* env vars (.env);
// they win at runtime and must not be written into the config on disk.
$forFile = (new ConfigDiffer($grav))->stripEnvironmentOverrides($data, 'flex/accounts');
file_put_contents($filePath, Yaml::dump($forFile, 99, 2));
$this->config->set(self::CONFIG_KEY, $data);
$grav['cache']->clearCache('standard');
}
private function requireSuperOrAdmin(ServerRequestInterface $request): void
{
$user = $this->getUser($request);
if ($this->isSuperAdmin($user)) {
return;
}
$this->requirePermission($request, 'admin.super');
}
}
@@ -0,0 +1,525 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\TooManyRequestsException;
use Grav\Plugin\Api\Exceptions\UnauthorizedException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\UserSerializer;
use Grav\Plugin\Login\Login;
use Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class AuthController extends AbstractApiController
{
use ResolvesAdminBaseUrl;
private const CHALLENGE_2FA = '2fa_challenge';
private const CHALLENGE_TTL = 300;
public function token(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['username', 'password']);
$username = (string) $body['username'];
$password = (string) $body['password'];
$this->enforceLoginRateLimit($username);
// Route through the Login plugin when available so the full
// onUserLoginAuthenticate / onUserLoginAuthorize / onUserLogin chain
// fires. This is what lets LDAP (and any other auth plugin that
// subscribes to onUserLoginAuthenticate at higher priority) validate
// the credentials and map groups to access levels.
//
// `authorize` is passed as `[]` rather than `admin.login`: the API
// plugin runs its own permission gate further down that handles both
// legacy and Flex users correctly (admin.super, api.access, etc.).
// Letting the Login plugin gate on `admin.login` here breaks logins
// on regular (non-flex) accounts whose legacy User::authorize() lacks
// an admin.super fallback — even super admins are denied unless they
// also have an explicit access.admin.login: true.
//
// Falls back to the legacy User::authenticate() path on sites without
// the Login plugin.
if (class_exists(Login::class) && isset($this->grav['login'])) {
/** @var Login $login */
$login = $this->grav['login'];
$event = $login->login(
['username' => $username, 'password' => $password],
['admin' => true, 'twofa' => false],
['authorize' => [], 'return_event' => true]
);
$user = $event->getUser();
if (!$user || !$user->authenticated) {
$this->fireEvent('onApiUserLoginFailure', [
'username' => $username,
'reason' => 'password',
'ip' => $this->getRequestIp($request),
]);
throw new UnauthorizedException('Invalid username or password.');
}
} else {
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($username);
// Delegate to User::authenticate() so the core trait's plaintext-password
// fallback fires (auto-hashes a yaml-declared `password:` field on first
// successful login, then saves — same behavior admin-classic and the Login
// plugin have always had).
if (!$user->exists() || !$user->authenticate($password)) {
$this->fireEvent('onApiUserLoginFailure', [
'username' => $username,
'reason' => 'password',
'ip' => $this->getRequestIp($request),
]);
throw new UnauthorizedException('Invalid username or password.');
}
}
// Gate API access AFTER the event chain has run, so any onUserLogin
// handlers (LDAP group→access mapping, etc.) have had a chance to
// populate the user's access matrix. Mirrors admin-classic's
// `admin.login` gate but additionally accepts `api.access` for users
// who are API-only and shouldn't be granted full admin entry.
if (
!$this->isSuperAdmin($user)
&& !$user->authorize('admin.login')
&& !$this->hasPermission($user, 'api.access')
) {
$this->fireEvent('onApiUserLoginFailure', [
'username' => $username,
'reason' => 'no_api_access',
'ip' => $this->getRequestIp($request),
]);
throw new ForbiddenException('API access is not enabled for this user.');
}
if ($user->get('state', 'enabled') === 'disabled') {
$this->fireEvent('onApiUserLoginFailure', [
'username' => $username,
'reason' => 'disabled',
'ip' => $this->getRequestIp($request),
]);
throw new ForbiddenException('This user account is disabled.');
}
$jwt = new JwtAuthenticator($this->grav, $this->config);
if ($this->userRequiresTwoFactor($user)) {
// Password was valid — issue a challenge token. Do NOT reset the
// rate limiter yet: the login only counts as successful after the
// 2FA code verifies in /auth/2fa/verify.
$challengeToken = $jwt->generateChallengeToken($user, self::CHALLENGE_2FA, self::CHALLENGE_TTL);
return ApiResponse::create([
'requires_2fa' => true,
'challenge_token' => $challengeToken,
'expires_in' => self::CHALLENGE_TTL,
'token_type' => 'Challenge',
]);
}
$this->resetLoginRateLimit($username);
$this->fireEvent('onApiUserLogin', [
'user' => $user,
'method' => 'password',
'ip' => $this->getRequestIp($request),
'request' => $request,
]);
return $this->issueTokenPair($jwt, $user);
}
public function verify2fa(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['challenge_token', 'code']);
$jwt = new JwtAuthenticator($this->grav, $this->config);
$user = $jwt->validateChallengeToken($body['challenge_token'], self::CHALLENGE_2FA);
if ($user === null) {
throw new UnauthorizedException('Invalid or expired challenge token.');
}
$username = $user->username;
$this->enforceLoginRateLimit($username);
if ($user->get('state', 'enabled') === 'disabled') {
throw new ForbiddenException('This user account is disabled.');
}
if (!class_exists(TwoFactorAuth::class)) {
throw new ForbiddenException('2FA support is not available.');
}
$secret = (string) $user->get('twofa_secret');
$code = (string) $body['code'];
$twoFa = new TwoFactorAuth();
if (!$secret || !$twoFa->verifyCode($secret, $code)) {
$this->fireEvent('onApiUserLoginFailure', [
'username' => $username,
'reason' => '2fa',
'ip' => $this->getRequestIp($request),
]);
throw new UnauthorizedException('Invalid 2FA code.');
}
// Burn the challenge token so it cannot be replayed.
$jwt->revokeToken($body['challenge_token']);
$this->resetLoginRateLimit($username);
$this->fireEvent('onApiUserLogin', [
'user' => $user,
'method' => '2fa',
'ip' => $this->getRequestIp($request),
'request' => $request,
]);
return $this->issueTokenPair($jwt, $user);
}
public function refresh(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['refresh_token']);
$jwt = new JwtAuthenticator($this->grav, $this->config);
$user = $jwt->validateRefreshToken($body['refresh_token']);
if ($user === null) {
throw new UnauthorizedException('Invalid or expired refresh token.');
}
if ($user->get('state', 'enabled') === 'disabled') {
throw new ForbiddenException('This user account is disabled.');
}
// Revoke the old refresh token (rotation)
$jwt->revokeToken($body['refresh_token']);
return $this->issueTokenPair($jwt, $user);
}
public function revoke(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['refresh_token']);
$jwt = new JwtAuthenticator($this->grav, $this->config);
// Best-effort: decode to capture the subject for the logout event.
$user = $jwt->validateRefreshToken($body['refresh_token']);
$jwt->revokeToken($body['refresh_token']);
if ($user !== null) {
$this->fireEvent('onApiUserLogout', [
'user' => $user,
'ip' => $this->getRequestIp($request),
'request' => $request,
]);
}
return ApiResponse::noContent();
}
/**
* POST /auth/forgot-password
*
* Accepts { email } and sends a password reset email if the address
* matches a user. Always returns a neutral success message to prevent
* account enumeration. Rate limited per-username via the login plugin's
* `pw_resets` bucket so enumeration + flood attacks share the login
* plugin's limits.
*/
public function forgotPassword(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['email']);
$email = htmlspecialchars(strip_tags((string) $body['email']), ENT_QUOTES, 'UTF-8');
$neutralResponse = ApiResponse::create([
'message' => 'If an account exists for that email, a reset link has been sent.',
]);
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $neutralResponse;
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->find($email, ['email']);
if (!$user || !$user->exists()) {
return $neutralResponse;
}
if (!isset($this->grav['Email']) || empty($this->config->get('plugins.email.from'))) {
$this->grav['log']->warning('api.auth: forgot-password skipped — email plugin not configured.');
return $neutralResponse;
}
if (!class_exists(Login::class) || !isset($this->grav['login'])) {
$this->grav['log']->warning('api.auth: forgot-password skipped — login plugin not available.');
return $neutralResponse;
}
/** @var Login $login */
$login = $this->grav['login'];
$rateLimiter = $login->getRateLimiter('pw_resets');
$userKey = (string) $user->username;
$rateLimiter->registerRateLimitedAction($userKey);
if ($rateLimiter->isRateLimited($userKey)) {
throw new TooManyRequestsException(
sprintf('Too many password reset requests. Try again in %d minutes.', $rateLimiter->getInterval()),
$rateLimiter->getInterval() * 60,
);
}
try {
$randomBytes = random_bytes(16);
} catch (\Exception) {
$randomBytes = (string) mt_rand();
}
$token = md5(uniqid($randomBytes, true));
$expire = time() + 86400; // 24 hours
// Same storage format as the login plugin's Controller::taskForgot,
// so the reset token is compatible with either admin or site flows.
$user->set('reset', $token . '::' . $expire);
$user->save();
try {
$this->sendAdminNextResetEmail($user, $token, $body['admin_base_url'] ?? null, $request);
} catch (\Throwable $e) {
$this->grav['log']->error('api.auth: failed to send reset email: ' . $e->getMessage());
// Still return neutral success — do not leak mail infrastructure errors.
}
return $neutralResponse;
}
/**
* Send the admin-next password reset email. Self-contained: builds the
* admin-next reset URL (pointing at its own /reset route, not the Grav
* frontend login plugin's /reset_password page) and renders via the
* API plugin's own template so the reset loop never leaves the admin UI.
*/
private function sendAdminNextResetEmail(
UserInterface $user,
string $token,
mixed $clientBaseUrl,
ServerRequestInterface $request,
): void {
if (!isset($this->grav['Email'])) {
throw new \RuntimeException('Email service not available.');
}
$adminBase = $this->resolveAdminBaseUrl($clientBaseUrl, $request);
$resetLink = rtrim($adminBase, '/')
. '/reset?user=' . rawurlencode((string) $user->username)
. '&token=' . rawurlencode($token);
$cfg = $this->grav['config'];
$siteHost = (string) ($cfg->get('plugins.login.site_host') ?: ($this->grav['uri']->host() ?? ''));
$context = [
'reset_link' => $resetLink,
'user' => $user,
'site_name' => $cfg->get('site.title', 'Website'),
'site_host' => $siteHost,
'author' => $cfg->get('site.author.name', ''),
];
$params = [
'to' => $user->email,
'body' => [
[
'content_type' => 'text/html',
'template' => 'emails/api/reset-password.html.twig',
'body' => '',
],
],
];
/** @var \Grav\Plugin\Email\Email $email */
$email = $this->grav['Email'];
$message = $email->buildMessage($params, $context);
$email->send($message);
}
/**
* POST /auth/reset-password
*
* Accepts { username, token, password } and completes the password reset.
* All failures return a deliberately vague error so token probing cannot
* distinguish "no such user" from "wrong token" from "expired token". IP
* is rate-limited via the login plugin's standard login bucket to cap
* token brute-forcing.
*/
public function resetPassword(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['username', 'token', 'password']);
$username = (string) $body['username'];
$token = (string) $body['token'];
$password = (string) $body['password'];
$this->enforceLoginRateLimit($username);
$invalidMessage = 'Invalid or expired reset link.';
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($username);
if (!$user->exists()) {
throw new ValidationException($invalidMessage);
}
$storedReset = (string) $user->get('reset', '');
if (!str_contains($storedReset, '::')) {
throw new ValidationException($invalidMessage);
}
[$goodToken, $expire] = explode('::', $storedReset, 2);
if (!hash_equals($goodToken, $token) || time() > (int) $expire) {
throw new ValidationException($invalidMessage);
}
// Match the login plugin's reset sequence exactly (Controller::taskReset).
unset($user->hashed_password, $user->reset);
$user->password = $password;
$user->save();
$this->resetLoginRateLimit($username);
$this->fireEvent('onApiPasswordReset', [
'user' => $user,
'ip' => $this->getRequestIp($request),
]);
return ApiResponse::create([
'message' => 'Password reset successfully.',
]);
}
/**
* GET /me — Return the authenticated user's profile and resolved permissions.
*/
public function me(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$isSuperAdmin = $this->isSuperAdmin($user);
$resolver = $this->getPermissionResolver();
$resolvedAccess = $resolver->resolvedMap($user, $isSuperAdmin);
return ApiResponse::create([
'username' => $user->username,
'fullname' => $user->get('fullname'),
'email' => $user->get('email'),
'avatar_url' => UserSerializer::resolveAvatarUrl($user),
'super_admin' => $isSuperAdmin,
'access' => $resolvedAccess,
'content_editor' => $user->get('content_editor', ''),
'grav_version' => GRAV_VERSION,
'admin_version' => $this->getAdminPluginVersion(),
]);
}
private function getAdminPluginVersion(): ?string
{
foreach (['admin2', 'admin'] as $slug) {
if (!$this->config->get("plugins.{$slug}.enabled", false)) {
continue;
}
$blueprintFile = $this->grav['locator']->findResource("plugins://{$slug}/blueprints.yaml");
if (!$blueprintFile || !file_exists($blueprintFile)) {
continue;
}
$data = \Grav\Common\Yaml::parse(file_get_contents($blueprintFile));
$version = $data['version'] ?? null;
if ($version) {
return (string) $version;
}
}
return null;
}
private function userRequiresTwoFactor(UserInterface $user): bool
{
if (!class_exists(TwoFactorAuth::class)) {
return false;
}
if (!$this->config->get('plugins.login.twofa_enabled', false)) {
return false;
}
return (bool) $user->get('twofa_enabled') && (bool) $user->get('twofa_secret');
}
/**
* Call the login plugin's checkLoginRateLimit() which both registers and
* checks attempts against max_login_count / max_login_interval using the
* same cache store the frontend login uses. Throws 429 if the caller is
* currently locked out.
*/
private function enforceLoginRateLimit(string $username): void
{
if (!class_exists(Login::class) || !isset($this->grav['login'])) {
return;
}
/** @var Login $login */
$login = $this->grav['login'];
$interval = $login->checkLoginRateLimit($username);
if ($interval > 0) {
throw new TooManyRequestsException(
sprintf('Too many login attempts. Try again in %d minutes.', $interval),
$interval * 60,
);
}
}
private function resetLoginRateLimit(string $username): void
{
if (!class_exists(Login::class) || !isset($this->grav['login'])) {
return;
}
/** @var Login $login */
$login = $this->grav['login'];
$login->resetLoginRateLimit($username);
}
private function getRequestIp(ServerRequestInterface $request): string
{
$server = $request->getServerParams();
return (string) ($server['REMOTE_ADDR'] ?? '');
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Page\Media;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\MediaSerializer;
use Grav\Plugin\Api\Services\BlueprintPathResolver;
use Grav\Plugin\Api\Services\ThumbnailService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Read-only file browse endpoint for blueprint fields that declare a
* `folder:` option (filepicker, mediapicker, …).
*
* Mirrors admin-classic's `taskGetFilesInFolder` semantics — `folder` can be
* any Grav stream (`user://media`, `theme://images`, `account://`, …), a
* `self@:subpath` token resolved against `scope`, or a plain relative path
* confined under `user/`.
*
* The page-attached media case (`@self` / `self@` / empty) is intentionally
* not handled here. The admin-next client already has the page's media via
* `/pages/{route}/media`; rerouting it through this controller would force
* a round-trip for the most common case. Calls with a `@self` literal get
* a 422 sentinel so the client can fall back.
*/
class BlueprintFilesController extends AbstractApiController
{
private ?BlueprintPathResolver $resolver = null;
private ?MediaSerializer $serializer = null;
/**
* GET /blueprint-files?folder=<stream-or-token>&scope=<scope>&accept=<csv>&preview_images=1
*/
public function list(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.read');
$query = $request->getQueryParams();
$folder = (string)($query['folder'] ?? '');
$scope = (string)($query['scope'] ?? '');
$acceptRaw = (string)($query['accept'] ?? '');
if ($folder === '') {
throw new ValidationException('folder is required.');
}
$resolver = $this->resolver();
$resolver->assertSafe($folder);
// `@self` / `self@` literals are page-media — the client has that already.
if ($resolver->isSelfLiteral($folder)) {
return ApiResponse::create([
'error' => 'PAGE_MEDIA_ONLY',
'message' => 'Use /pages/{route}/media for @self / self@ folders.',
], 422);
}
$abs = $resolver->resolve($folder, $scope, $this->getUser($request));
$logicalFolder = $resolver->logicalParent($folder, $scope);
// Resolve the file list (or empty list when the folder doesn't exist
// yet — common for fresh installs targeting `theme://images` on a
// theme that ships no images).
$items = [];
if (is_dir($abs)) {
$accept = $this->parseAccept($acceptRaw);
foreach ($this->iterateMedia($abs) as $name => $medium) {
if (!$this->matchesAccept((string)$name, (string)($medium->get('mime') ?? ''), $accept)) {
continue;
}
$items[] = $this->serializer()->serialize($medium);
}
}
// Use the paginated envelope (`{ data: [...], meta: { pagination, … } }`)
// even though we don't actually paginate — it matches the shape the
// admin-next client already expects from `/media` and avoids the
// double-wrap that `ApiResponse::create` would impose on a hand-built
// `{ data, meta }` payload.
$total = count($items);
return ApiResponse::paginated(
$items,
$total,
1,
max($total, 1),
$this->getApiBaseUrl() . '/blueprint-files',
200,
[],
[
'folder' => $logicalFolder,
'scope' => $scope !== '' ? $scope : null,
'exists' => is_dir($abs),
],
);
}
/**
* Seam for tests. Yields `filename => Medium` over the given absolute
* directory. Production path delegates to Grav's real Media class.
*/
protected function iterateMedia(string $absoluteDir): iterable
{
return (new Media($absoluteDir))->all();
}
/**
* Parse the comma-separated `accept` query param into an array of
* patterns. Empty input → no filtering.
*/
private function parseAccept(string $raw): array
{
if ($raw === '') return [];
$parts = array_filter(array_map('trim', explode(',', $raw)), static fn($s) => $s !== '');
return array_values($parts);
}
/**
* Mirror admin-classic's accept regex: extension form (`.pdf`, `*.jpg`)
* matches the filename; mime form (`image/png`, `image/*`) matches the
* Grav-detected mime. The `*` / `+` / `.` escaping mirrors
* AdminBaseController::taskFilesUpload.
*
* @param string[] $patterns
*/
private function matchesAccept(string $filename, string $mime, array $patterns): bool
{
if ($patterns === []) return true;
foreach ($patterns as $type) {
if ($type === '*') return true;
$find = str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type);
$isMime = str_contains($type, '/');
if ($isMime) {
if (preg_match('#' . $find . '$#', $mime)) return true;
} else {
if (preg_match('#' . $find . '$#', $filename)) return true;
}
}
return false;
}
private function resolver(): BlueprintPathResolver
{
return $this->resolver ??= new BlueprintPathResolver($this->grav);
}
private function serializer(): MediaSerializer
{
if (!$this->serializer) {
$cacheDir = $this->grav['locator']->findResource('cache://') . '/api/thumbnails';
$thumb = new ThumbnailService($cacheDir);
$this->serializer = new MediaSerializer($thumb, $this->getApiBaseUrl());
}
return $this->serializer;
}
}
@@ -0,0 +1,370 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Filesystem\Folder;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\BlueprintPathResolver;
use Grav\Plugin\Api\Services\UploadFieldSettings;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
/**
* Destination-aware file upload for blueprint-driven `type: file` fields.
*
* Mirrors admin-classic's `taskFilesUpload` semantics: the caller supplies a
* blueprint `destination` (Grav stream, `self@:subpath`, or plain relative
* path) plus the owning `scope` (plugins/<slug>, themes/<slug>, pages/<route>,
* users/<username>) and the controller resolves the target directory using
* Grav's locator, writes the file, and returns the saved path.
*
* Scope is required because `self@:` is relative to the blueprint's owner —
* a theme's favicon field saves under `user/themes/<slug>/`, a plugin's logo
* field under `user/plugins/<slug>/`, and so on. Without it we can't resolve
* `self@:` safely.
*/
class BlueprintUploadController extends AbstractApiController
{
private const MAX_UPLOAD_SIZE = 64 * 1_048_576; // 64 MB
/**
* Image-only allowlist for uploads landing in `user/accounts/` (avatars).
*
* `user/accounts/` doubles as the directory Grav reads as authoritative
* account YAML, so allowing arbitrary extensions there is a privilege
* escalation surface (GHSA-6xx2-m8wv-756h: a YAML file dropped here
* becomes a fully functional account, including `access.api.super`).
* The only legitimate blueprint-upload use case for this directory is
* avatars, so the endpoint hard-restricts it to image extensions.
*/
private const ACCOUNTS_IMAGE_EXTENSIONS = [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico',
];
/**
* Per-endpoint extension denylist on top of `security.uploads_dangerous_extensions`.
*
* Not all of these are "code" in the classic sense, but every one is a
* file Grav (or a sibling tool) parses as authoritative configuration if
* it lands in the right directory. Keeping them out of any blueprint-
* upload target — not just `user/accounts/` — closes a class of bugs
* where a future locator/scope edge case unexpectedly resolves into
* `user/config/`, `user/env/<x>/config/`, or a plugin's own config dir.
*/
private const FORBIDDEN_EXTENSIONS = [
'yaml', 'yml', // Grav account / config / blueprint
'json', // generic config / data
'twig', // template code
'env', // env files
'neon', // alt config format
'lock', // composer/npm lockfiles
];
private ?BlueprintPathResolver $resolver = null;
private function resolver(): BlueprintPathResolver
{
return $this->resolver ??= new BlueprintPathResolver($this->grav);
}
public function upload(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$body = $request->getParsedBody() ?? [];
$destination = is_array($body) ? (string)($body['destination'] ?? '') : '';
$scope = is_array($body) ? (string)($body['scope'] ?? '') : '';
if ($destination === '') {
throw new ValidationException('destination is required.');
}
$this->resolver()->assertSafe($destination);
$targetDir = $this->resolver()->resolve($destination, $scope, $this->getUser($request));
$this->guardConfigBearingTarget($targetDir);
$files = $this->flattenUploadedFiles($request->getUploadedFiles());
if ($files === []) {
throw new ValidationException('No file was uploaded.');
}
if (!is_dir($targetDir)) {
Folder::create($targetDir);
}
$isAccountsDir = $this->resolver()->classifyTargetDir($targetDir) === 'accounts';
// Per-field upload settings (random_name, avoid_overwriting, accept,
// filesize) ride in on the same body as destination/scope.
$settings = is_array($body) ? UploadFieldSettings::fromParams($body) : UploadFieldSettings::none();
$saved = [];
foreach ($files as $file) {
$saved[] = $this->processUploadedFile($file, $targetDir, $isAccountsDir, $settings);
}
// Build a response payload describing each saved file in a Grav
// file-field-compatible shape. `path` is the *logical* user-rooted
// path (e.g. `user/themes/quark2/images/logo/file.png`) — derived
// from the original destination+scope inputs, not the realpath, so
// symlinked theme/plugin folders round-trip through a later delete
// cleanly.
$response = [];
$logicalParent = $this->resolver()->logicalParent($destination, $scope);
foreach ($saved as $filename) {
$absolute = $targetDir . '/' . $filename;
$logical = $logicalParent !== null
? 'user/' . trim($logicalParent, '/') . '/' . $filename
: $this->fallbackRelative($absolute);
$response[] = [
'name' => $filename,
'path' => $logical,
'size' => filesize($absolute) ?: 0,
'type' => mime_content_type($absolute) ?: 'application/octet-stream',
'url' => $this->buildPublicUrl($logical),
];
}
return ApiResponse::create($response, 201);
}
/**
* Last-resort relative path: strip user-root prefix when we can, otherwise
* surface the absolute path so at least the server knows what it wrote.
*/
private function fallbackRelative(string $absolute): string
{
$userRoot = $this->resolver()->userRoot();
if ($userRoot !== null && str_starts_with($absolute, $userRoot . '/')) {
return 'user/' . substr($absolute, strlen($userRoot) + 1);
}
return $absolute;
}
public function delete(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$body = $this->getRequestBody($request);
$path = (string)($body['path'] ?? '');
if ($path === '') {
throw new ValidationException('path is required.');
}
$absolute = $this->resolveDeletePath($path);
$targetDir = dirname($absolute);
$filename = basename($absolute);
$this->guardConfigBearingTarget($targetDir, $filename);
// Symmetric to the upload path: deletes targeting `user/accounts/` may
// only act on image files (avatars). Without this gate, a holder of
// `api.media.write` could `unlink` arbitrary account YAMLs.
if ($this->resolver()->classifyTargetDir($targetDir) === 'accounts') {
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (!in_array($extension, self::ACCOUNTS_IMAGE_EXTENSIONS, true)) {
throw new ForbiddenException(
"Deletes under user/accounts/ are restricted to avatar image files."
);
}
}
$this->assertSafeExtension($filename, false);
// Idempotent: a file that's already gone is indistinguishable from a
// file we just deleted, so don't pollute the client with a 404 that
// forces special-case handling. Anything non-file (directory,
// symlink-to-elsewhere, etc.) still errors — those are genuine
// misuses, not "already gone".
if (!file_exists($absolute)) {
return ApiResponse::noContent();
}
if (!is_file($absolute)) {
throw new ValidationException('Target is not a regular file.');
}
unlink($absolute);
// Clean up adjacent metadata if present.
$meta = $absolute . '.meta.yaml';
if (file_exists($meta)) {
unlink($meta);
}
return ApiResponse::noContent();
}
/**
* Resolve the `path` for a delete request.
*
* Clients send the same logical path we returned on upload (e.g.
* `themes/quark2/images/logo/foo.png`), always relative to the user
* root. No absolute paths and no `..` traversal are permitted on input —
* that's what keeps the endpoint safe. Once the path is validated, we
* join it to the user root and trust the resolved location even if it
* passes through a Grav symlink (a common setup where `user/themes/X`
* points at a dev checkout outside `user/`). The symlink is already part
* of Grav's resource map; pretending it isn't would lock out valid
* deletes on every non-trivial install.
*/
private function resolveDeletePath(string $path): string
{
$path = ltrim($path, '/');
// Allow both "themes/..." and "user/themes/..." inputs — the latter
// is what upload returns when the destination lives under user/
// directly (no symlink), so both forms round-trip.
if (str_starts_with($path, 'user/')) {
$path = substr($path, 5);
}
if (str_contains($path, '..') || str_contains($path, "\0")) {
throw new ValidationException('Traversal or null bytes not allowed in path.');
}
$userRoot = $this->resolver()->userRoot();
if ($userRoot === null) {
throw new ValidationException('User root is not available.');
}
return $userRoot . '/' . $path;
}
private function buildPublicUrl(string $relative): ?string
{
$uri = $this->grav['uri'];
$base = method_exists($uri, 'rootUrl') ? $uri->rootUrl() : '';
return rtrim($base, '/') . '/' . ltrim($relative, '/');
}
private function processUploadedFile(
UploadedFileInterface $file,
string $targetDir,
bool $isAccountsDir,
?UploadFieldSettings $settings = null,
): string {
if ($file->getError() !== UPLOAD_ERR_OK) {
throw new ValidationException('File upload failed.');
}
$size = $file->getSize();
if ($size !== null && $size > self::MAX_UPLOAD_SIZE) {
throw new ValidationException(
sprintf('File exceeds maximum allowed size of %d MB.', self::MAX_UPLOAD_SIZE / 1_048_576)
);
}
$settings?->assertFilesize($size);
$originalName = $file->getClientFilename() ?? 'upload';
$filename = basename($originalName);
$this->assertSafeFilename($filename);
// Extension policy first (the security floor), then the field's accept
// allowlist. Both run against the original name; random_name/
// avoid_overwriting are applied afterwards and preserve the extension.
$this->assertSafeExtension($filename, $isAccountsDir);
$settings?->assertAccepted($filename);
if ($settings !== null) {
$filename = $settings->resolveFilename($filename, $targetDir);
}
$file->moveTo($targetDir . '/' . $filename);
return $filename;
}
/**
* Reject filenames that would escape the target dir or hide as a dotfile.
*/
private function assertSafeFilename(string $filename): void
{
if (
$filename === ''
|| str_contains($filename, '..')
|| str_contains($filename, "\0")
|| str_starts_with($filename, '.')
) {
throw new ValidationException("Invalid filename: '{$filename}'.");
}
}
/**
* Apply layered extension policy:
*
* 1. `security.uploads_dangerous_extensions` (Grav-wide denylist: php, js, exe, ...)
* 2. Per-endpoint denylist for known-config formats (yaml, json, twig, ...)
* 3. If target is `user/accounts/`, restrict to image extensions only —
* the directory doubles as Grav's authoritative account store, so
* anything non-image is a privesc surface (GHSA-6xx2-m8wv-756h).
*
* Returns the lowercased extension for callers that want it.
*/
private function assertSafeExtension(string $filename, bool $isAccountsDir): string
{
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($extension === '') {
throw new ValidationException('Uploaded file must have a file extension.');
}
$dangerous = array_map('strtolower', (array) $this->config->get('security.uploads_dangerous_extensions', []));
if (in_array($extension, $dangerous, true)) {
throw new ValidationException("File extension '.{$extension}' is not allowed for security reasons.");
}
if (in_array($extension, self::FORBIDDEN_EXTENSIONS, true)) {
throw new ValidationException("File extension '.{$extension}' is not allowed for blueprint uploads.");
}
if ($isAccountsDir && !in_array($extension, self::ACCOUNTS_IMAGE_EXTENSIONS, true)) {
throw new ValidationException(
"Only image files (" . implode(', ', self::ACCOUNTS_IMAGE_EXTENSIONS) . ") may be uploaded to user/accounts/."
);
}
return $extension;
}
/**
* Hard-deny writes resolving to directories that Grav reads as
* authoritative configuration: `user/config/` and any `user/env/.../config/`.
* `user/accounts/` is allowed (avatars) but extension-restricted in
* `assertSafeExtension()`.
*
* `$filename` is optional — pass it for delete-path checks (where we
* have the final filename) so the error message can name the target;
* for upload checks the per-file extension policy fires later anyway.
*/
private function guardConfigBearingTarget(string $absoluteDir, ?string $filename = null): void
{
$classification = $this->resolver()->classifyTargetDir($absoluteDir);
if ($classification === 'config' || $classification === 'env') {
$where = $filename !== null ? "'{$filename}' under" : 'into';
throw new ForbiddenException(
"Uploads {$where} the '{$classification}' directory are not allowed via this endpoint."
);
}
}
/**
* @param array<UploadedFileInterface|array> $files
* @return UploadedFileInterface[]
*/
private function flattenUploadedFiles(array $files): array
{
$result = [];
foreach ($files as $file) {
if ($file instanceof UploadedFileInterface) {
$result[] = $file;
} elseif (is_array($file)) {
$result = array_merge($result, $this->flattenUploadedFiles($file));
}
}
return $result;
}
}
@@ -0,0 +1,606 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Data\Blueprints;
use Grav\Common\Data\Data;
use Grav\Common\Yaml;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\ConfigDiffer;
use Grav\Plugin\Api\Services\ConfigScopes;
use Grav\Plugin\Api\Services\EnvironmentService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class ConfigController extends AbstractApiController
{
/**
* Tool-managed scopes that carry execution- or security-sensitive sinks and
* must never be reachable through the generic api.config.read/write
* permissions a non-super "configuration admin" can hold.
*
* `scheduler` is the critical case: scheduler.custom_jobs[].command is fed
* straight into a Symfony Process by Job::run(), so write access to this
* scope is arbitrary command execution. The Scheduler tool is super-only in
* admin-classic, and these scopes are already excluded from index() listing
* because they "belong to tools" — but resolveConfigKey()/scopeFileName()
* still accept them, so without this guard a user holding only
* api.config.write could escalate to RCE (GHSA-wx62). Require API super
* authority for these scopes regardless of the generic config permission.
*/
private const PRIVILEGED_SCOPES = ['scheduler', 'backups'];
/**
* Security-sensitive scopes that any config reader may VIEW but only an API
* super user may WRITE. Unlike PRIVILEGED_SCOPES (tool-managed, fully
* hidden from index() and blocked for read+write), these stay listed and
* readable (a non-super "configuration admin" can still inspect them), but
* must not persist changes, because they steer site-wide execution and
* security behavior: `system` carries `twig.safe_functions` (PHP functions
* callable from trusted templates) and `security` owns the Twig content
* sandbox and XSS/CSP settings. The inheritable `admin.configuration`
* permission would otherwise let a non-super admin weaken these
* (GHSA-9wg2-prc3-vx89). Write-only gate; reads are intentionally left open.
*/
private const SUPER_WRITE_SCOPES = ['system', 'security'];
/**
* GET /config - List available configuration sections.
*/
public function index(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.read');
/** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceIterator $iterator */
$iterator = $this->grav['locator']->getIterator('blueprints://config');
$configurations = [];
foreach ($iterator as $file) {
if ($file->isDir() || !preg_match('/^[^.].*.yaml$/', $file->getFilename())) {
continue;
}
$name = pathinfo($file->getFilename(), PATHINFO_FILENAME);
// Skip scheduler and backups (they belong to tools)
if (in_array($name, ['scheduler', 'backups', 'streams'], true)) {
continue;
}
$configurations[$name] = true;
}
// Sort and enforce canonical ordering: system, site first; info last
ksort($configurations);
$configurations = ['system' => true, 'site' => true] + $configurations + ['info' => true];
return ApiResponse::create(array_keys($configurations));
}
public function show(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.read');
$scope = $this->getRouteParam($request, 'scope');
$this->assertScopeAllowed($request, $scope);
$configKey = $this->resolveConfigKey($scope);
if ($this->config->get($configKey) === null) {
throw new NotFoundException("Configuration scope '{$scope}' not found.");
}
// Body is the full merged config resolved for the requested target, so
// base/"Default" shows base config rather than the active env overlay.
// The ETag keys off the persisted delta for the same write target a
// subsequent PATCH would resolve, so the client's stored ETag still
// validates on the next save.
$targetEnv = $this->resolveTargetEnv($request);
$etag = $this->generateEtag($this->configEtagBasis($scope, $targetEnv));
// meta.overrides / meta.fallback drive the per-field override indicators
// and the revert affordance in admin2 (see docs/config-overrides-revert).
$meta = $this->overrideMeta($scope, $targetEnv);
return $this->respondWithEtag($this->effectiveConfig($scope, $targetEnv), 200, [], $etag, $meta);
}
/**
* POST /config/{scope}/revert — drop one or more overridden keys from the
* active layer's file (or reset the whole scope), letting the value beneath
* take over. Body: `{"keys": ["pages.theme", ...]}` or `{"reset": true}`.
*
* The active layer is the same write target show()/update() resolve from
* X-Config-Environment: base `user/config/<scope>.yaml`, or an environment's
* `user/env/<env>/config/<scope>.yaml`. Reverting a key there falls back to
* the layer beneath (base → core/plugin defaults; env → base).
*/
public function revert(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.write');
$scope = $this->getRouteParam($request, 'scope');
$this->assertScopeAllowed($request, $scope);
$this->assertScopeWritable($request, $scope);
$configKey = $this->resolveConfigKey($scope);
if ($this->config->get($configKey) === null) {
throw new NotFoundException("Configuration scope '{$scope}' not found.");
}
$targetEnv = $this->resolveTargetEnv($request);
// Same ETag basis as show()/update(), so the client's stored If-Match validates.
$this->validateEtag($request, $this->generateEtag($this->configEtagBasis($scope, $targetEnv)));
$body = $this->getRequestBody($request);
$reset = !empty($body['reset']);
$keys = $body['keys'] ?? [];
if (!$reset && (!is_array($keys) || $keys === [])) {
throw new ValidationException('Provide a non-empty "keys" array or "reset": true.');
}
$filePath = $this->resolveConfigFile($scope, $targetEnv);
if ($reset) {
// Nuke the active layer's file entirely → falls back to the parent layer.
if ($filePath && is_file($filePath)) {
unlink($filePath);
}
} elseif ($filePath) {
// The file already IS the persisted delta — drop each requested key,
// prune empties, and rewrite, or remove the file if nothing remains.
$delta = is_file($filePath) ? Yaml::parse((string) file_get_contents($filePath)) : [];
if (!is_array($delta)) {
$delta = [];
}
$differ = new ConfigDiffer($this->grav);
foreach ($keys as $key) {
if (is_string($key) && $key !== '') {
$delta = $differ->unsetDotPath($delta, $key);
}
}
if ($delta === []) {
if (is_file($filePath)) {
unlink($filePath);
}
} else {
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
file_put_contents($filePath, Yaml::dump($delta));
}
}
// Refresh in-memory config + clear cache so the next read is correct.
$effective = $this->effectiveConfig($scope, $targetEnv);
$this->config->set($configKey, $effective);
$this->grav['cache']->clearCache('standard');
$this->fireEvent('onApiConfigUpdated', ['scope' => $scope, 'data' => $effective]);
$tags = ['config:update:' . $scope];
if (str_starts_with($scope, 'plugins/')) {
$pluginName = substr($scope, 8);
$tags[] = 'plugins:update:' . $pluginName;
$tags[] = 'plugins:list';
}
$etag = $this->generateEtag($this->configEtagBasis($scope, $targetEnv));
$meta = $this->overrideMeta($scope, $targetEnv);
return $this->respondWithEtag($effective, 200, $tags, $etag, $meta);
}
/**
* Override metadata for the active layer: which dotted leaf paths the
* target's file actually overrides, and the value each would revert to.
*
* @return array{overrides: list<string>, fallback: array<string, mixed>}
*/
private function overrideMeta(string $scope, ?string $targetEnv): array
{
$differ = new ConfigDiffer($this->grav);
$parent = $differ->parent($scope, $targetEnv);
$delta = $differ->diff($this->effectiveConfig($scope, $targetEnv), $parent);
$overrides = ConfigDiffer::flattenLeaves($delta);
$fallback = [];
foreach ($overrides as $path) {
$fallback[$path] = ConfigDiffer::valueAtPath($parent, $path);
}
return ['overrides' => $overrides, 'fallback' => $fallback];
}
public function update(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.write');
$scope = $this->getRouteParam($request, 'scope');
$this->assertScopeAllowed($request, $scope);
$this->assertScopeWritable($request, $scope);
$configKey = $this->resolveConfigKey($scope);
if ($this->config->get($configKey) === null) {
throw new NotFoundException("Configuration scope '{$scope}' not found.");
}
// Write target: X-Config-Environment selects an existing env folder; empty/default = base.
$targetEnv = $this->resolveTargetEnv($request);
// Edit against the baseline for THIS target, not the live (boot-env)
// config — otherwise a save under base/"Default" would diff the active
// env overlay against defaults and copy the overlay into user/config.
$existing = $this->effectiveConfig($scope, $targetEnv);
// ETag validation — key off the persisted delta, the same basis show()
// and the previous save's response used, so If-Match matches.
$this->validateEtag($request, $this->generateEtag($this->configEtagBasis($scope, $targetEnv)));
$body = $this->getRequestBody($request);
if (empty($body)) {
throw new ValidationException('Request body must contain configuration values to update.');
}
// Load the blueprint and apply field-type filtering (e.g., commalist → array)
$blueprint = $this->loadBlueprint($scope);
// Merge provided values with existing config. Prefer Grav's
// blueprint-aware merge — it REPLACES map values at blueprint-defined
// leaf fields instead of deep-merging them, which is what we want for
// e.g. `type: file` fields whose keys are file paths: when the user
// removes a file the client drops that key, and a blind deep-merge
// would revive it from $existing. Fall back to our list-aware
// mergePatch only when no blueprint is available (rare — mostly test
// fixtures); plain array_replace_recursive would corrupt YAML lists.
if ($blueprint !== null && is_array($existing)) {
$merged = $blueprint->mergeData($existing, $body);
} else {
$merged = is_array($existing) ? $this->mergePatch($existing, $body) : $body;
}
// Validate the submitted fields against the blueprint before persisting
// (getgrav/grav-plugin-admin2#30). A `validate.required` field sent
// empty now returns 422 instead of silently saving. We validate the
// delta, not the merged whole — see validateChangedFields() for why
// (stock Grav config doesn't pass a whole-object validate).
$this->validateChangedFields($body, $blueprint);
$obj = new Data($merged, $blueprint);
$obj->filter(true, true);
// Set the config file on the Data object so plugins (e.g., revisions-pro)
// can read the file path for revision tracking.
$configFile = $this->resolveConfigFile($scope, $targetEnv);
if ($configFile) {
$obj->file(\RocketTheme\Toolbox\File\YamlFile::instance($configFile));
}
// Set the AdminProxy route so plugins that detect context from the admin
// route (e.g., revisions-pro getDataType) work correctly in API context.
$admin = $this->grav['admin'] ?? null;
if ($admin && property_exists($admin, 'route')) {
$admin->route = $this->scopeToAdminRoute($scope);
}
// Allow plugins to modify config before save
$this->fireAdminEvent('onAdminSave', ['object' => &$obj]);
// Extract (potentially modified) data back from the Data object
$merged = $obj->toArray();
// Update in-memory config
$this->config->set($configKey, $merged);
// Persist to the appropriate YAML file
$this->writeConfigFile($scope, $merged, $targetEnv);
// Clear config cache
$this->grav['cache']->clearCache('standard');
$this->fireAdminEvent('onAdminAfterSave', ['object' => $obj]);
$this->fireEvent('onApiConfigUpdated', ['scope' => $scope, 'data' => $merged]);
// Emit invalidations — plugin config changes also invalidate the plugins list.
$tags = ['config:update:' . $scope];
if (str_starts_with($scope, 'plugins/')) {
$pluginName = substr($scope, 8);
$tags[] = 'plugins:update:' . $pluginName;
$tags[] = 'plugins:list';
}
// Response body is the full merged config for the target (re-resolved
// from disk so it matches a subsequent show()); the ETag keys off the
// persisted delta, so the client's stored ETag stays valid for the
// next save even though default-equal values aren't written to disk.
$etag = $this->generateEtag($this->configEtagBasis($scope, $targetEnv));
$meta = $this->overrideMeta($scope, $targetEnv);
return $this->respondWithEtag($this->effectiveConfig($scope, $targetEnv), 200, $tags, $etag, $meta);
}
/**
* Full merged config for a scope, resolved for the requested write target —
* the response body for show()/update() and the baseline a save edits.
*
* The live config->get() snapshot only ever represents the ONE environment
* Grav booted under, and Grav resolves that once at boot and can't switch
* mid-request. Any request can target a different env via X-Config-Environment
* (most importantly base/"Default" while a hostname overlay is active), so we
* always recompute the merge from YAML files (ConfigDiffer::effective). That
* keeps "Default" showing — and saving against — base config, not the env
* overlay, and stays correct for any other named target too.
*/
private function effectiveConfig(string $scope, ?string $targetEnv): array
{
// Always resolve from YAML files for the requested target. We must NOT
// shortcut to the live config->get() snapshot even when the target looks
// like the booted environment: behind a reverse proxy Grav loads its
// config overlay from the REAL connection host (e.g. `localhost` via
// SERVER_NAME), which need not match the requested target. (Note
// EnvironmentService::activeEnvironment() now reports that booted host,
// not the forwarded one — but $targetEnv may still be any other env.)
// ConfigDiffer::effective() is target-exact regardless of which host
// booted the request, and already re-applies GRAV_CONFIG__* env-var
// overrides; blueprint field defaults are filled client-side from the
// blueprint, so the form stays complete.
$data = (new ConfigDiffer($this->grav))->effective($scope, $targetEnv);
return is_array($data) ? $data : ['value' => $data];
}
/**
* Representation the ETag is hashed from: the *persisted delta* (values
* that override the parent), NOT the full merged config.
*
* The delta is the only representation that survives the save→reload round-trip.
* writeConfigFile() stores only the delta, so a value equal to its default
* (e.g. `system.pages.events.twig: true`) is present in the in-memory
* config right after config->set() but absent once the file is reloaded
* from disk on the next request. Hashing the full config therefore yielded
* a different ETag on the following save and broke If-Match with a 409
* (getgrav/grav-plugin-admin2#28). The delta is invariant because it is
* defined relative to the parent: a default-equal value is stripped on
* both sides of the round-trip. Canonicalized so key order can't shift the
* hash either.
*/
private function configEtagBasis(string $scope, ?string $targetEnv): array
{
$current = $this->effectiveConfig($scope, $targetEnv);
$differ = new ConfigDiffer($this->grav);
$delta = $differ->diff($current, $differ->parent($scope, $targetEnv));
return ConfigDiffer::canonicalize($delta);
}
/**
* Resolve the scope route parameter to a Grav config key.
*
* Supported scopes:
* - system -> 'system'
* - site -> 'site'
* - plugins/{name} -> 'plugins.{name}'
* - themes/{name} -> 'themes.{name}'
*/
/**
* Map a config scope to the admin route format that plugins expect.
*/
private function scopeToAdminRoute(string $scope): string
{
return match (true) {
str_starts_with($scope, 'plugins/') => '/' . $scope,
str_starts_with($scope, 'themes/') => '/' . $scope,
default => '/config/' . $scope,
};
}
/**
* Resolve the config file path for a given scope.
*
* Writes land in base user/config/ unless $targetEnv is a non-empty string
* matching an existing user/env/<env>/ folder. We deliberately avoid the
* `config://` stream here because its first resolved path can be an env
* folder Grav auto-inferred from the hostname — that would create an
* unintended user/<host>/ folder on save.
*/
private function resolveConfigFile(string $scope, ?string $targetEnv = null): ?string
{
try {
return $this->resolveWriteDir($targetEnv) . '/' . $this->scopeFileName($scope);
} catch (\Throwable) {
return null;
}
}
/**
* Load the blueprint for the given config scope.
*
* Blueprints define field types (e.g., commalist) that determine how
* values are coerced — without this, arrays may be saved as strings.
*/
private function loadBlueprint(string $scope): ?\Grav\Common\Data\Blueprint
{
try {
$blueprintKey = match (true) {
in_array($scope, ConfigScopes::CORE) => 'config/' . $scope,
str_starts_with($scope, 'plugins/') => 'plugins/' . substr($scope, 8),
str_starts_with($scope, 'themes/') => 'themes/' . substr($scope, 7),
ConfigScopes::isCustom($this->grav, $scope) => 'config/' . $scope,
default => null,
};
if ($blueprintKey === null) {
return null;
}
$blueprints = new Blueprints();
return $blueprints->get($blueprintKey);
} catch (\Exception) {
// If blueprint can't be loaded, save without filtering
return null;
}
}
/**
* Reject access to execution- or security-sensitive, tool-managed scopes
* unless the caller is an API super user. See PRIVILEGED_SCOPES (GHSA-wx62).
*/
private function assertScopeAllowed(ServerRequestInterface $request, ?string $scope): void
{
if ($scope !== null && in_array($scope, self::PRIVILEGED_SCOPES, true)
&& !$this->isSuperAdmin($this->getUser($request))) {
throw new ForbiddenException(
"Configuration scope '{$scope}' is tool-managed and restricted to API super users."
);
}
}
/**
* Reject WRITES to security-sensitive scopes unless the caller is an API
* super user. Reads/listing remain open. See SUPER_WRITE_SCOPES
* (GHSA-9wg2-prc3-vx89).
*/
private function assertScopeWritable(ServerRequestInterface $request, ?string $scope): void
{
if ($scope !== null && in_array($scope, self::SUPER_WRITE_SCOPES, true)
&& !$this->isSuperAdmin($this->getUser($request))) {
throw new ForbiddenException(
"Configuration scope '{$scope}' can only be modified by an API super user."
);
}
}
private function resolveConfigKey(?string $scope): string
{
if ($scope === null || $scope === '') {
throw new ValidationException('Configuration scope is required.');
}
return match (true) {
$scope === 'system' => 'system',
$scope === 'site' => 'site',
$scope === 'media' => 'media',
$scope === 'security' => 'security',
$scope === 'scheduler' => 'scheduler',
$scope === 'backups' => 'backups',
str_starts_with($scope, 'plugins/') => 'plugins.' . substr($scope, 8),
str_starts_with($scope, 'themes/') => 'themes.' . substr($scope, 7),
// Site-authored top-level config (cookbook custom yaml): the scope
// name is its own config key (user/config/<scope>.yaml).
ConfigScopes::isCustom($this->grav, $scope) => $scope,
default => throw new NotFoundException("Unknown configuration scope '{$scope}'."),
};
}
/**
* Resolve the scope to a filesystem path and write the YAML config file.
*
* We persist only the delta vs the parent (defaults for base writes;
* defaults+base for env writes). This mirrors how developers hand-edit
* Grav configs — every file contains only the values that actually
* override something lower in the stack.
*/
private function writeConfigFile(string $scope, mixed $data, ?string $targetEnv = null): void
{
$filePath = $this->resolveWriteDir($targetEnv) . '/' . $this->scopeFileName($scope);
$full = is_array($data) ? $data : ['value' => $data];
$differ = new ConfigDiffer($this->grav);
// Never persist values supplied through GRAV_CONFIG__* env vars (.env);
// they're re-applied at runtime and writing them would leak secrets to disk.
$full = $differ->stripEnvironmentOverrides($full, $scope);
$parent = $differ->parent($scope, $targetEnv);
$delta = $differ->diff($full, $parent);
// No overrides and no pre-existing file → don't create an empty placeholder.
if ($delta === [] && !is_file($filePath)) {
return;
}
// Only ever create plugin/theme sub-dirs inside an existing base or env
// write dir. We never create env roots — those must be opted into
// explicitly via POST /system/environments.
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
file_put_contents($filePath, Yaml::dump($delta));
}
/**
* Where config writes land.
*
* Base user/config/ by default. When $targetEnv is set, the matching
* user/env/<env>/config/ is used — but only if it already exists, we
* never implicitly create env folders.
*/
private function resolveWriteDir(?string $targetEnv = null): string
{
if ($targetEnv !== null && $targetEnv !== '') {
$dir = (new EnvironmentService($this->grav))->envConfigRoot($targetEnv);
if ($dir === null) {
throw new ValidationException("Environment '{$targetEnv}' does not exist. Create it first via POST /system/environments.");
}
return $dir;
}
$userConfig = $this->grav['locator']->findResource('user://config', true);
if (!$userConfig) {
throw new \RuntimeException('Base user/config directory not found.');
}
return $userConfig;
}
/**
* Where a write should land for this request.
*
* header present + env name → that env (validated, must exist on disk)
* header present + `default`/base → explicit base write (the admin-next
* sentinel; non-empty so proxies/FPM
* can't strip it the way empty values
* get dropped)
* header present + empty → explicit base write (legacy opt-out)
* header absent → Grav's currently-active env if it has
* a config dir on disk; otherwise base
*
* The auto-detect branch keeps writes consistent with reads: config is
* loaded with `user/<active-env>/config` overlaid on `user/config`, so
* persisting to base when an env overlay exists lets the env file silently
* shadow the write. (See: enabling a plugin that's pinned `enabled: false`
* in a hostname-derived env folder.)
*/
private function resolveTargetEnv(ServerRequestInterface $request): ?string
{
if (!$request->hasHeader('X-Config-Environment')) {
return (new EnvironmentService($this->grav))->activeEnvironment();
}
$name = trim($request->getHeaderLine('X-Config-Environment'));
if ($name === '' || EnvironmentService::isReservedName($name)) {
return null;
}
if (!EnvironmentService::isValidName($name)) {
throw new ValidationException("Invalid X-Config-Environment header: '{$name}'.");
}
return $name;
}
/**
* Filename for a scope, relative to a config directory.
*/
private function scopeFileName(string $scope): string
{
return match (true) {
in_array($scope, ConfigScopes::CORE, true) => $scope . '.yaml',
str_starts_with($scope, 'plugins/') => 'plugins/' . substr($scope, 8) . '.yaml',
str_starts_with($scope, 'themes/') => 'themes/' . substr($scope, 7) . '.yaml',
ConfigScopes::isCustom($this->grav, $scope) => $scope . '.yaml',
default => throw new NotFoundException("Unknown configuration scope '{$scope}'."),
};
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
/**
* Context Panels API — lets plugins register slide-in panels
* triggered by toolbar buttons in the admin-next page editor.
*
* Plugins listen for `onApiContextPanels` to register panels.
*
* Panel format:
* [
* 'id' => 'revisions-pro', // unique identifier
* 'plugin' => 'revisions-pro', // owning plugin slug
* 'label' => 'Revision History', // tooltip / display name
* 'icon' => 'history', // Lucide icon name
* 'contexts' => ['pages'], // where trigger button appears
* 'priority' => 10, // sort order (higher = earlier)
* 'width' => 900, // panel width in pixels
* 'badgeEndpoint' => '/my-plugin/badge', // optional: returns { count: N }
* ]
*/
class ContextPanelController extends AbstractApiController
{
/**
* GET /context-panels — Collect context panel registrations from plugins.
*/
public function items(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$event = new Event(['panels' => [], 'user' => $this->getUser($request)]);
$this->grav->fireEvent('onApiContextPanels', $event);
return ApiResponse::create($event['panels']);
}
}
@@ -0,0 +1,427 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\HTTP\Response;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\YamlFile;
class DashboardController extends AbstractApiController
{
/**
* GET /dashboard/notifications - Get system notifications.
*/
public function notifications(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$query = $request->getQueryParams();
$force = filter_var($query['force'] ?? false, FILTER_VALIDATE_BOOLEAN);
$user = $this->getUser($request);
$username = $user->get('username');
// Load cached notifications (v2 schema — see notifications2.md on getgrav.org)
$cacheFile = $this->grav['locator']->findResource(
'user://data/notifications/' . md5($username) . '_v2.yaml',
true,
true
);
$userStatusFile = $this->grav['locator']->findResource(
'user://data/notifications/' . $username . '.yaml',
true,
true
);
$notificationsFile = YamlFile::instance($cacheFile);
$notificationsContent = (array) $notificationsFile->content();
$userStatusContent = file_exists($userStatusFile)
? (array) YamlFile::instance($userStatusFile)->content()
: [];
$lastChecked = $notificationsContent['last_checked'] ?? null;
$notifications = $notificationsContent['data'] ?? [];
$timeout = $this->grav['config']->get('system.session.timeout', 1800);
// Refresh from remote if needed
if ($force || !$lastChecked || empty($notifications) || (time() - $lastChecked > $timeout)) {
try {
$body = Response::get('https://getgrav.org/notifications2.json?' . time());
$rawNotifications = json_decode($body, true);
if (is_array($rawNotifications)) {
// Sort by date descending
usort($rawNotifications, fn($a, $b) => strcmp($b['date'] ?? '', $a['date'] ?? ''));
// Group by location
$notifications = [];
foreach ($rawNotifications as $notification) {
foreach ($notification['location'] ?? [] as $location) {
$notifications[$location][] = $notification;
}
}
$notificationsFile->content(['last_checked' => time(), 'data' => $notifications]);
$notificationsFile->save();
}
} catch (\Exception $e) {
// Use cached data on failure
}
}
// Let plugins contribute notifications (grouped by location: `top`,
// `dashboard`, `feed`). Fired after the remote refresh so plugin notices
// are merged fresh every request (never cached) yet still flow through
// the dismiss + reappear_after handling below — a plugin-provided `id`
// is dismissed via the same /notifications/{id}/hide endpoint. This is
// how a plugin can raise a persistent, dismissible admin banner.
$event = new Event([
'notifications' => $notifications,
'user' => $user,
'force' => $force,
]);
$this->grav->fireEvent('onApiDashboardNotifications', $event);
$contributed = $event['notifications'];
if (is_array($contributed)) {
$notifications = $contributed;
}
// Filter out hidden notifications
foreach ($notifications as $location => &$list) {
$list = array_values(array_filter($list, function ($notification) use ($userStatusContent) {
$hidden = $userStatusContent[$notification['id']] ?? null;
if ($hidden === null) {
return true;
}
// Check reappear_after
if (isset($notification['reappear_after'])) {
$now = new \DateTime();
$hiddenOn = new \DateTime($hidden);
$hiddenOn->modify($notification['reappear_after']);
return $now >= $hiddenOn;
}
return false;
}));
}
unset($list);
// Filter by location if requested
$filter = $query['location'] ?? null;
if ($filter) {
$notifications = [$filter => $notifications[$filter] ?? []];
}
return ApiResponse::create([
'notifications' => $notifications,
'last_checked' => $lastChecked ? date('c', $lastChecked) : null,
]);
}
/**
* POST /dashboard/notifications/{id}/hide - Dismiss a notification.
*/
public function hideNotification(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.write');
$id = $this->getRouteParam($request, 'id');
$user = $this->getUser($request);
$username = $user->get('username');
$userStatusFile = $this->grav['locator']->findResource(
'user://data/notifications/' . $username . '.yaml',
true,
true
);
$file = YamlFile::instance($userStatusFile);
$content = (array) $file->content();
$content[$id] = date('Y-m-d H:i:s');
$file->content($content);
$file->save();
return ApiResponse::noContent();
}
/**
* GET /dashboard/feed - Get getgrav.org news feed as JSON.
*/
public function feed(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$query = $request->getQueryParams();
$force = filter_var($query['force'] ?? false, FILTER_VALIDATE_BOOLEAN);
$user = $this->getUser($request);
$username = $user->get('username');
$cacheFile = $this->grav['locator']->findResource(
'user://data/feed/' . md5($username) . '.yaml',
true,
true
);
$feedFile = YamlFile::instance($cacheFile);
$feedContent = (array) $feedFile->content();
$lastChecked = $feedContent['last_checked'] ?? null;
$feed = $feedContent['data'] ?? [];
$timeout = $this->grav['config']->get('system.session.timeout', 1800);
// Refresh from remote if needed
if ($force || !$lastChecked || empty($feed) || (time() - $lastChecked > $timeout)) {
try {
$body = Response::get('https://getgrav.org/blog.atom');
$xml = simplexml_load_string($body);
if ($xml) {
$feed = [];
$count = 0;
foreach ($xml->entry as $entry) {
if ($count >= 10) break;
$feed[] = [
'title' => (string) $entry->title,
'url' => (string) $entry->link['href'],
'date' => (string) $entry->updated,
'summary' => (string) ($entry->summary ?? ''),
];
$count++;
}
$feedFile->content(['last_checked' => time(), 'data' => $feed]);
$feedFile->save();
}
} catch (\Exception $e) {
// Use cached data on failure
}
}
return ApiResponse::create([
'feed' => $feed,
'last_checked' => $lastChecked ? date('c', $lastChecked) : null,
]);
}
/**
* GET /dashboard/stats - Dashboard statistics snapshot.
*/
public function stats(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
// Count pages
$pages = $this->grav['pages'];
$pages->enablePages();
$allPages = $pages->instances();
$totalPages = 0;
$publishedPages = 0;
foreach ($allPages as $page) {
// Skip the virtual pages-root container (no file on disk); the
// home page IS a real file-backed page with route '/'.
if (!$page->route() || !$page->exists()) {
continue;
}
$totalPages++;
if ($page->published()) {
$publishedPages++;
}
}
// Count users
$accountDir = $this->grav['locator']->findResource('account://', true);
$totalUsers = 0;
if ($accountDir && is_dir($accountDir)) {
$totalUsers = count(glob($accountDir . '/*.yaml'));
}
// Count plugins
$plugins = $this->grav['plugins']->all();
$activePlugins = 0;
foreach ($plugins as $name => $plugin) {
if ($this->grav['config']->get("plugins.{$name}.enabled", false)) {
$activePlugins++;
}
}
// Count themes
$themes = $this->grav['themes']->all();
$totalThemes = is_countable($themes) ? count($themes) : 0;
// Active theme
$activeTheme = $this->grav['config']->get('system.pages.theme');
// Count media files
$mediaDir = $this->grav['locator']->findResource('user://media', true)
?: $this->grav['locator']->findResource('user://images', true);
$totalMedia = 0;
if ($mediaDir && is_dir($mediaDir)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($mediaDir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$totalMedia++;
}
}
}
// Last backup
$backupsDir = $this->grav['locator']->findResource('backup://', true);
$lastBackup = null;
if ($backupsDir && is_dir($backupsDir)) {
$backups = glob($backupsDir . '/*.zip');
if (!empty($backups)) {
$latest = max(array_map('filemtime', $backups));
$lastBackup = date('c', $latest);
}
}
$data = [
'pages' => [
'total' => $totalPages,
'published' => $publishedPages,
],
'users' => [
'total' => $totalUsers,
],
'plugins' => [
'total' => count($plugins),
'active' => $activePlugins,
],
'themes' => [
'total' => $totalThemes,
],
'media' => [
'total' => $totalMedia,
],
'theme' => $activeTheme,
'grav_version' => GRAV_VERSION,
'php_version' => PHP_VERSION,
'last_backup' => $lastBackup,
];
return ApiResponse::create($data);
}
/**
* GET /dashboard/security/exposure-probe
*
* Returns the public URL of a sentinel file under user/data plus the
* random token it contains. The dashboard fetches that URL directly from
* the browser: a 200 whose body matches the token means the sensitive
* user/ folders are reachable over the web (a misconfigured webserver),
* while a 403/404 means they are correctly blocked.
*
* The sentinel uses a `.dat` extension on purpose — that extension is not
* in the legacy per-extension blocklist, so it is only refused when the
* folder-wide block (Grav 2.0 / 1.7.53+) is actually in place. A plain
* `.txt`/`.yaml` probe would read as "safe" on installs that still expose
* certificates, keys and databases stored with other extensions.
*/
public function securityProbe(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$dataDir = $this->grav['locator']->findResource('user://data', true, true);
$available = false;
$token = '';
if ($dataDir) {
if (!is_dir($dataDir)) {
@mkdir($dataDir, 0770, true);
}
$probeFile = $dataDir . '/grav-security-probe.dat';
// Reuse a stable token so concurrent dashboards don't race each
// other into writing different tokens.
if (is_file($probeFile)) {
$existing = trim((string) @file_get_contents($probeFile));
if (preg_match('/^[a-f0-9]{32,}$/', $existing)) {
$token = $existing;
}
}
if ($token === '') {
$token = bin2hex(random_bytes(16));
@file_put_contents($probeFile, $token);
}
$available = is_file($probeFile);
}
// Public URL to the sentinel, relative to the site web root (honours a
// custom GRAV_USER_PATH and a subfolder install).
$userPath = defined('GRAV_USER_PATH') ? trim(GRAV_USER_PATH, '/') : 'user';
$rootUrl = rtrim($this->grav['uri']->rootUrl(true), '/');
$url = $rootUrl . '/' . $userPath . '/data/grav-security-probe.dat';
return ApiResponse::create([
'url' => $url,
'token' => $token,
'available' => $available,
]);
}
/**
* GET /dashboard/popularity - Page view statistics.
*
* Reads from PopularityStore (single-file flat JSON, ISO date keys).
* On first read after an upgrade from admin-classic, the store imports
* the legacy four-JSON-file layout transparently.
*/
public function popularity(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$store = new \Grav\Plugin\Api\Popularity\PopularityStore();
$daily = $store->getDaily(365);
$monthly = $store->getMonthly(24);
$todayKey = date('Y-m-d');
$thisMonthKey = date('Y-m');
$todayViews = (int) ($daily[$todayKey] ?? 0);
// Sum last 7 days from ISO-keyed daily map
$weekViews = 0;
for ($i = 0; $i < 7; $i++) {
$day = date('Y-m-d', strtotime("-{$i} days"));
$weekViews += (int) ($daily[$day] ?? 0);
}
$monthViews = (int) ($monthly[$thisMonthKey] ?? 0);
// 14-day chart, oldest first
$chartData = [];
for ($i = 13; $i >= 0; $i--) {
$day = date('Y-m-d', strtotime("-{$i} days"));
$chartData[] = [
'date' => date('M j', strtotime("-{$i} days")),
'views' => (int) ($daily[$day] ?? 0),
];
}
$topPages = [];
foreach ($store->getTopPages(10) as $route => $views) {
$topPages[] = ['route' => $route, 'views' => (int) $views];
}
return ApiResponse::create([
'summary' => [
'today' => $todayViews,
'week' => $weekViews,
'month' => $monthViews,
],
'chart' => $chartData,
'top_pages' => $topPages,
]);
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\PermissionResolver;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\DashboardLayoutResolver;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Dashboard widget customization endpoints.
*
* Backs admin-next's per-user / per-site customizable dashboard. The merged
* widget list combines a built-in core registry, plugin contributions via
* `onApiDashboardWidgets`, the site default layout (super-admin), and the
* current user's overrides. Site-hidden widgets are not exposed to users.
*/
class DashboardWidgetController extends AbstractApiController
{
/**
* GET /dashboard/widgets — Resolved widget list + layouts for the current user.
*/
public function widgets(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$resolver = $this->getResolver();
$isSuperAdmin = $this->isSuperAdmin($user);
return ApiResponse::create($resolver->resolve($user, $isSuperAdmin));
}
/**
* PATCH /dashboard/layout — Save the current user's dashboard layout.
*/
public function saveUserLayout(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$body = $this->getRequestBody($request);
if (!is_array($body)) {
throw new ValidationException('Request body must be a JSON object.');
}
$user = $this->getUser($request);
$resolver = $this->getResolver();
$resolver->saveUserLayout($user, $body);
return ApiResponse::create($resolver->resolve($user, $this->isSuperAdmin($user)));
}
/**
* PATCH /dashboard/site-layout — Save the site-wide default dashboard layout.
*
* Super-admin only. Widgets marked invisible here are hidden for all users
* and cannot be re-enabled per-user.
*/
public function saveSiteLayout(ServerRequestInterface $request): ResponseInterface
{
$user = $this->getUser($request);
if (!$this->isSuperAdmin($user)) {
throw new ForbiddenException('Only super-admins can edit the site dashboard layout.');
}
$body = $this->getRequestBody($request);
if (!is_array($body)) {
throw new ValidationException('Request body must be a JSON object.');
}
$resolver = $this->getResolver();
$resolver->saveSiteLayout($body);
return ApiResponse::create($resolver->resolve($user, true));
}
private function getResolver(): DashboardLayoutResolver
{
return new DashboardLayoutResolver(
$this->grav,
new PermissionResolver($this->grav['permissions']),
);
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
/**
* Floating Widgets API — lets plugins register persistent UI widgets
* (e.g. chat assistants, notification panels) in the admin-next shell.
*
* Plugins listen for `onApiFloatingWidgets` to register widgets.
*
* Widget format:
* [
* 'id' => 'ai-pro-chat', // unique identifier
* 'plugin' => 'ai-pro', // owning plugin slug
* 'label' => 'AI Assistant', // tooltip / display name
* 'icon' => 'bot', // Lucide icon name
* 'priority' => 10, // sort order (higher = earlier)
* 'authorize' => 'api.some.permission', // optional — string or array (any-of)
* ]
*
* `authorize` follows the same string-or-array semantics as the sidebar /
* menubar APIs. Widgets without `authorize` are visible to every authenticated
* user.
*/
class FloatingWidgetController extends AbstractApiController
{
/**
* GET /floating-widgets — Collect floating widget registrations from
* plugins, filtered by the current user's permissions.
*/
public function items(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$event = new Event(['widgets' => [], 'user' => $user]);
$this->grav->fireEvent('onApiFloatingWidgets', $event);
$isSuperAdmin = $this->isSuperAdmin($user);
$filtered = [];
foreach ($event['widgets'] as $widget) {
if (!$this->userPassesAuthorize($user, $widget['authorize'] ?? null, $isSuperAdmin)) {
continue;
}
// Strip the authorize field — it's a server-side annotation, not client data
unset($widget['authorize']);
$filtered[] = $widget;
}
return ApiResponse::create($filtered);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserGroupInterface;
use Grav\Common\Yaml;
use Grav\Framework\Flex\FlexDirectory;
use Grav\Plugin\Api\Exceptions\ConflictException;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\FlexBackend;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\GroupSerializer;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* User Groups CRUD.
*
* Groups are stored in `user/config/groups.yaml` as a keyed map. We prefer the
* Flex `user-groups` directory when it's available (richer search/index), and
* fall back to direct YAML I/O when Flex is disabled or the directory hasn't
* been registered yet.
*
* All write operations require `admin.super` — matching the security@ gate on
* the account blueprint's groups/access sections.
*/
class GroupsController extends AbstractApiController
{
use FlexBackend;
private ?GroupSerializer $serializer = null;
public function index(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.read');
$directory = $this->getFlexDirectory('user-groups');
if ($directory) {
return $this->indexViaFlex($request, $directory);
}
return $this->indexViaYaml($request);
}
private function indexViaFlex(ServerRequestInterface $request, FlexDirectory $directory): ResponseInterface
{
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
$search = $query['search'] ?? null;
$collection = $directory->getCollection();
if ($search && $search !== '') {
$collection = $collection->search((string) $search);
}
$collection = $collection->sort(['groupname' => 'asc']);
$total = $collection->count();
$slice = $collection->slice($pagination['offset'], $pagination['limit']);
$data = [];
foreach ($slice as $group) {
if ($group instanceof UserGroupInterface) {
$data[] = $this->getSerializer()->serialize($group);
}
}
return ApiResponse::paginated(
data: $data,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $this->getApiBaseUrl() . '/groups',
);
}
private function indexViaYaml(ServerRequestInterface $request): ResponseInterface
{
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
$search = strtolower((string) ($query['search'] ?? ''));
$groups = $this->loadGroupsArray();
$rows = [];
foreach ($groups as $name => $entry) {
if (!is_array($entry)) continue;
$row = $this->getSerializer()->serializeArray((string) $name, $entry);
if ($search !== '') {
$haystack = strtolower(($row['groupname'] ?? '') . ' ' . ($row['readableName'] ?? '') . ' ' . ($row['description'] ?? ''));
if (!str_contains($haystack, $search)) {
continue;
}
}
$rows[] = $row;
}
usort($rows, static fn($a, $b) => strcasecmp($a['groupname'], $b['groupname']));
$total = count($rows);
$paged = array_slice($rows, $pagination['offset'], $pagination['limit']);
return ApiResponse::paginated(
data: $paged,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $this->getApiBaseUrl() . '/groups',
);
}
public function show(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.read');
$name = $this->getRouteParam($request, 'name');
$data = $this->loadGroupRow($name);
$etag = $this->generateEtag($data);
return ApiResponse::create($data, 200, ['ETag' => '"' . $etag . '"']);
}
public function create(ServerRequestInterface $request): ResponseInterface
{
$this->requireSuperOrAdmin($request);
$body = $this->getRequestBody($request);
$this->requireFields($body, ['groupname']);
$groupname = (string) $body['groupname'];
if (!preg_match('/^[a-zA-Z0-9_-]{1,200}$/', $groupname)) {
throw new ValidationException(
'Invalid group name.',
[['field' => 'groupname', 'message' => 'Group name must be 1-200 characters of letters, numbers, hyphens or underscores.']],
);
}
$groups = $this->loadGroupsArray();
if (isset($groups[$groupname])) {
throw new ConflictException("Group '{$groupname}' already exists.");
}
$entry = $this->normalizeGroupPayload($body);
$entry['groupname'] = $groupname;
$groups[$groupname] = $entry;
$this->saveGroupsArray($groups);
$this->fireEvent('onApiGroupCreated', ['groupname' => $groupname, 'group' => $entry]);
return ApiResponse::created(
data: $this->getSerializer()->serializeArray($groupname, $entry),
location: $this->getApiBaseUrl() . '/groups/' . $groupname,
headers: $this->invalidationHeaders(['groups:create:' . $groupname, 'groups:list']),
);
}
public function update(ServerRequestInterface $request): ResponseInterface
{
$this->requireSuperOrAdmin($request);
$name = $this->getRouteParam($request, 'name');
$groups = $this->loadGroupsArray();
if (!isset($groups[$name])) {
throw new NotFoundException("Group '{$name}' not found.");
}
$current = $this->getSerializer()->serializeArray((string) $name, $groups[$name]);
$this->validateEtag($request, $this->generateEtag($current));
$body = $this->getRequestBody($request);
if (empty($body)) {
throw new ValidationException('Request body must contain fields to update.');
}
$existing = $groups[$name];
$merged = $existing;
foreach (['readableName', 'description', 'icon'] as $field) {
if (array_key_exists($field, $body)) {
$merged[$field] = (string) $body[$field];
}
}
if (array_key_exists('enabled', $body)) {
$merged['enabled'] = (bool) $body['enabled'];
}
if (array_key_exists('access', $body)) {
$merged['access'] = is_array($body['access']) ? $body['access'] : [];
}
// Renames are out of scope — groupname is the storage key.
$merged['groupname'] = (string) $name;
$groups[$name] = $merged;
$this->saveGroupsArray($groups);
$this->fireEvent('onApiGroupUpdated', ['groupname' => $name, 'group' => $merged]);
$row = $this->getSerializer()->serializeArray((string) $name, $merged);
return $this->respondWithEtag($row, 200, ['groups:update:' . $name, 'groups:list']);
}
public function delete(ServerRequestInterface $request): ResponseInterface
{
$this->requireSuperOrAdmin($request);
$name = $this->getRouteParam($request, 'name');
$groups = $this->loadGroupsArray();
if (!isset($groups[$name])) {
throw new NotFoundException("Group '{$name}' not found.");
}
unset($groups[$name]);
$this->saveGroupsArray($groups);
$this->fireEvent('onApiGroupDeleted', ['groupname' => $name]);
return ApiResponse::noContent(
$this->invalidationHeaders(['groups:delete:' . $name, 'groups:list']),
);
}
private function loadGroupRow(?string $name): array
{
if ($name === null || $name === '') {
throw new ValidationException('Group name is required.');
}
$directory = $this->getFlexDirectory('user-groups');
if ($directory) {
$group = $directory->getObject($name);
if ($group instanceof UserGroupInterface) {
return $this->getSerializer()->serialize($group);
}
}
$groups = $this->loadGroupsArray();
if (!isset($groups[$name]) || !is_array($groups[$name])) {
throw new NotFoundException("Group '{$name}' not found.");
}
return $this->getSerializer()->serializeArray((string) $name, $groups[$name]);
}
/**
* Load groups from in-memory config (which Grav populates from
* user/config/groups.yaml on bootstrap, with env overlays applied).
*
* @return array<string, array<string, mixed>>
*/
private function loadGroupsArray(): array
{
$raw = $this->config->get('groups', []);
return is_array($raw) ? $raw : [];
}
/**
* Persist groups back to user/config/groups.yaml. Writes to the base
* config file (not an env overlay) so saved groups are visible in every
* environment — mirrors how classic admin's groups page writes.
*
* @param array<string, array<string, mixed>> $groups
*/
private function saveGroupsArray(array $groups): void
{
$grav = Grav::instance();
/** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator $locator */
$locator = $grav['locator'];
$userConfig = $locator->findResource('user://config', true);
if (!$userConfig) {
throw new \RuntimeException('Base user/config directory not found.');
}
$filePath = $userConfig . '/groups.yaml';
file_put_contents($filePath, Yaml::dump($groups, 99, 2));
// Reflect in-memory so subsequent reads in the same request see it.
$this->config->set('groups', $groups);
// Clear the standard cache so the next request rebuilds the config
// tree (and any Flex user-groups index cached against the file mtime).
$grav['cache']->clearCache('standard');
}
/**
* @param array<string, mixed> $body
* @return array<string, mixed>
*/
private function normalizeGroupPayload(array $body): array
{
$entry = [];
foreach (['readableName', 'description', 'icon'] as $field) {
if (isset($body[$field])) {
$entry[$field] = (string) $body[$field];
}
}
$entry['enabled'] = array_key_exists('enabled', $body) ? (bool) $body['enabled'] : true;
$entry['access'] = isset($body['access']) && is_array($body['access']) ? $body['access'] : [];
return $entry;
}
private function getSerializer(): GroupSerializer
{
return $this->serializer ??= new GroupSerializer();
}
/**
* Groups are admin-level governance — match the security@: admin.super
* gate that account.yaml places on the groups/access sections.
*/
private function requireSuperOrAdmin(ServerRequestInterface $request): void
{
$user = $this->getUser($request);
if ($this->isSuperAdmin($user)) {
return;
}
// Fall through to permission check so the error response carries the
// standard "missing permission" shape rather than a bare forbidden.
$this->requirePermission($request, 'admin.super');
}
}
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Serializers\MediaSerializer;
use Grav\Plugin\Api\Services\ThumbnailService;
use Grav\Plugin\Api\Services\UploadFieldSettings;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
/**
* Shared file-upload pipeline for media endpoints: validating uploads,
* moving them into a target folder, and serializing the resulting media.
*
* The logic is storage-agnostic — it only needs a resolved filesystem
* directory to write into. That makes it reusable for any object that can
* yield a media folder: a page (its content folder) or a folder-stored Flex
* object (its storage folder, e.g. user-data://flex-objects/contacts/{id}).
*
* Used by MediaController (pages + site media) and by the flex-objects
* plugin's FlexApiController via the shared AbstractApiController base.
*/
trait HandlesMediaUploads
{
/** Maximum upload size: 64 MB */
private const int MAX_UPLOAD_SIZE = 67_108_864;
private ?MediaSerializer $mediaSerializer = null;
protected function getThumbnailService(): ThumbnailService
{
$cacheDir = $this->grav['locator']->findResource('cache://') . '/api/thumbnails';
return new ThumbnailService($cacheDir);
}
protected function getSerializer(): MediaSerializer
{
if (!$this->mediaSerializer) {
$thumbnailService = $this->getThumbnailService();
$baseUrl = $this->getApiBaseUrl();
$this->mediaSerializer = new MediaSerializer($thumbnailService, $baseUrl);
}
return $this->mediaSerializer;
}
/**
* Extract and validate a safe filename from the route parameters.
*/
protected function getSafeFilename(ServerRequestInterface $request): string
{
$filename = $this->getRouteParam($request, 'filename');
if ($filename === null || $filename === '') {
throw new ValidationException('Filename is required.');
}
$filename = basename($filename);
if (
str_contains($filename, '..')
|| str_contains($filename, "\0")
|| str_starts_with($filename, '.')
) {
throw new ValidationException('Invalid filename.');
}
return $filename;
}
/**
* Parse blueprint file-field upload settings (random_name, avoid_overwriting,
* accept, filesize) from a request's form fields. Absent settings yield an
* inert object, so callers without field context keep current behavior.
*/
protected function parseUploadFieldSettings(ServerRequestInterface $request): UploadFieldSettings
{
$body = $request->getParsedBody();
return is_array($body) ? UploadFieldSettings::fromParams($body) : UploadFieldSettings::none();
}
/**
* Process a single uploaded file: validate it and move to the target directory.
*
* Optional per-field $settings (from a blueprint `type: file` field) layer
* filename randomization, overwrite avoidance, an accept allowlist, and a
* per-field size limit *on top of* the immovable security floor enforced
* here (size cap, traversal guard, dangerous-extension denylist).
*
* Returns the safe filename that was written.
*/
protected function processUploadedFile(
UploadedFileInterface $file,
string $targetDir,
?UploadFieldSettings $settings = null,
): string {
// Check for upload errors
if ($file->getError() !== UPLOAD_ERR_OK) {
$message = match ($file->getError()) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File exceeds maximum upload size.',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.',
UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder.',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
default => 'Unknown upload error.',
};
throw new ValidationException($message);
}
// Validate file size against the hard cap, then the per-field limit.
$size = $file->getSize();
if ($size !== null && $size > self::MAX_UPLOAD_SIZE) {
throw new ValidationException(
sprintf('File exceeds maximum allowed size of %d MB.', self::MAX_UPLOAD_SIZE / 1_048_576)
);
}
$settings?->assertFilesize($size);
// Sanitize the filename
$originalName = $file->getClientFilename() ?? 'upload';
$filename = basename($originalName);
if (
str_contains($filename, '..')
|| str_contains($filename, "\0")
|| str_starts_with($filename, '.')
) {
throw new ValidationException("Invalid filename: '{$filename}'.");
}
// Validate extension against dangerous extensions list, then the
// field's accept allowlist (matched on the original name's extension).
$this->validateFileExtension($filename);
$settings?->assertAccepted($filename);
// Apply random_name / avoid_overwriting last — both preserve the
// already-validated extension, so the floor checks above still hold.
if ($settings !== null) {
$filename = $settings->resolveFilename($filename, $targetDir);
}
// Move the file to the target directory
$targetPath = $targetDir . '/' . $filename;
$file->moveTo($targetPath);
return $filename;
}
/**
* Validate that a filename's extension is not on the dangerous list.
*/
protected function validateFileExtension(string $filename): void
{
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($extension === '') {
throw new ValidationException('Uploaded file must have a file extension.');
}
$dangerousExtensions = $this->config->get('security.uploads_dangerous_extensions', []);
// Normalize to lowercase for comparison
$dangerousExtensions = array_map('strtolower', $dangerousExtensions);
if (in_array($extension, $dangerousExtensions, true)) {
throw new ValidationException(
"File extension '.{$extension}' is not allowed for security reasons."
);
}
}
/**
* Flatten a potentially nested array of uploaded files into a flat list.
*
* PSR-7 allows uploaded files to be nested (e.g. files[avatar], files[gallery][]).
*
* @return UploadedFileInterface[]
*/
protected function flattenUploadedFiles(array $files): array
{
$result = [];
foreach ($files as $file) {
if ($file instanceof UploadedFileInterface) {
$result[] = $file;
} elseif (is_array($file)) {
$result = [...$result, ...$this->flattenUploadedFiles($file)];
}
}
return $result;
}
}
@@ -0,0 +1,420 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\User\Authentication;
use Grav\Common\User\DataUser\User as DataUser;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Exceptions\ConflictException;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Invitations\InviteStore;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* User invitations.
*
* An admin pre-configures a new user's permissions/groups and sends a
* time-limited invite link. The recipient opens the link, chooses their own
* username/fullname/title/password, and the account is created with exactly
* the access the admin pre-set — never more. Because the invitee never picks
* their own access, they cannot make themselves a super admin.
*
* Admin endpoints require api.users.write (list requires api.users.read).
* The accept/validate endpoints live under /auth/ so they are public.
*/
class InvitationsController extends AbstractApiController
{
use ResolvesAdminBaseUrl;
private ?InviteStore $store = null;
private function store(): InviteStore
{
return $this->store ??= new InviteStore();
}
/**
* GET /invitations — list pending (non-expired) invites.
*/
public function index(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.read');
$store = $this->store();
$store->purgeExpired();
$data = [];
foreach ($store->all() as $record) {
$data[] = $this->serializeInvite($record);
}
// Most-recent first.
usort($data, static fn($a, $b) => ($b['created'] ?? 0) <=> ($a['created'] ?? 0));
return ApiResponse::create(['invitations' => $data]);
}
/**
* POST /invitations — create an invite and (if email is configured) send it.
*/
public function create(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.write');
$actor = $this->getUser($request);
$body = $this->getRequestBody($request);
$this->requireFields($body, ['email']);
$email = trim((string) $body['email']);
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new ValidationException(
'Invalid email address.',
[['field' => 'email', 'message' => 'A valid email address is required.']],
);
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$existing = $accounts->find($email, ['email']);
if ($existing && $existing->exists()) {
throw new ConflictException('A user with that email already exists.');
}
// Permissions the invitee will receive. Strip super flags unless the
// inviting admin is itself super — an admin cannot grant authority it
// does not hold, and this is the core "can't make yourself super" gate.
$access = is_array($body['access'] ?? null) ? $body['access'] : [];
if (!$this->isSuperAdmin($actor)) {
$access = $this->stripSuperFlags($access);
}
$groups = [];
if (is_array($body['groups'] ?? null)) {
$groups = array_values(array_filter(
$body['groups'],
static fn($g) => is_string($g) && $g !== '',
));
}
// Expiration: clamp to a sane window; default 7 days.
$default = (int) $this->config->get('plugins.api.invitations.expiration', 604800);
$expiration = (int) ($body['expiration'] ?? $default);
if ($expiration < 300) {
$expiration = $default;
}
$store = $this->store();
// One pending invite per email — replace any prior one.
$prior = $store->getByEmail($email);
if ($prior && isset($prior['token'])) {
$store->remove((string) $prior['token']);
}
$token = $store->generateToken();
$record = [
'token' => $token,
'email' => $email,
'fullname' => trim((string) ($body['fullname'] ?? '')),
'access' => $access,
'groups' => $groups,
'created' => time(),
'created_by' => (string) $actor->username,
'created_by_name' => (string) ($actor->get('fullname') ?: $actor->username),
'expires' => time() + $expiration,
];
$store->add($record);
$link = $this->buildInviteLink($body['admin_base_url'] ?? null, $request, $token);
// Email guard mirrors AuthController::forgotPassword. If email isn't
// configured we still create the invite and hand the link back so the
// admin can deliver it manually — never silently fail.
$emailSent = false;
$warning = null;
if (isset($this->grav['Email']) && !empty($this->config->get('plugins.email.from'))) {
try {
$this->sendInviteEmail($record, $link, $actor, (string) ($body['message'] ?? ''));
$emailSent = true;
} catch (\Throwable $e) {
$this->grav['log']->error('api.invitations: failed to send invite email: ' . $e->getMessage());
$warning = 'The invitation was created but the email could not be sent. Share the link manually.';
}
} else {
$warning = 'Email is not configured, so no invitation email was sent. Share the link manually.';
}
$payload = $this->serializeInvite($record);
$payload['link'] = $link;
$payload['email_sent'] = $emailSent;
if ($warning !== null) {
$payload['warning'] = $warning;
}
return ApiResponse::created(
data: $payload,
location: $this->getApiBaseUrl() . '/invitations/' . $token,
headers: $this->invalidationHeaders(['invitations:list']),
);
}
/**
* POST /invitations/{token}/resend — re-send an existing invite's email.
*/
public function resend(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.write');
$token = (string) $this->getRouteParam($request, 'token');
$record = $this->store()->get($token);
if ($record === null || InviteStore::isExpired($record)) {
throw new NotFoundException('Invitation not found or expired.');
}
$body = $this->getRequestBody($request);
$link = $this->buildInviteLink($body['admin_base_url'] ?? null, $request, $token);
if (!isset($this->grav['Email']) || empty($this->config->get('plugins.email.from'))) {
throw new ApiException(422, 'Unprocessable Entity', 'Email is not configured. Share the invite link manually.');
}
$actor = $this->getUser($request);
$this->sendInviteEmail($record, $link, $actor, (string) ($body['message'] ?? ''));
$payload = $this->serializeInvite($record);
$payload['link'] = $link;
$payload['email_sent'] = true;
return ApiResponse::create($payload);
}
/**
* DELETE /invitations/{token} — revoke an invite.
*/
public function delete(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.write');
$token = (string) $this->getRouteParam($request, 'token');
if (!$this->store()->remove($token)) {
throw new NotFoundException('Invitation not found.');
}
return $this->respondWithInvalidation(null, ['invitations:list'], 204);
}
/**
* GET /auth/invite/{token} — PUBLIC. Validate a token for the accept page.
*
* Returns only what the accept form needs (email to lock, optional
* fullname prefill, validity). Never leaks the pre-set access/groups.
*/
public function validate(ServerRequestInterface $request): ResponseInterface
{
$token = (string) $this->getRouteParam($request, 'token');
$record = $this->store()->get($token);
if ($record === null) {
throw new NotFoundException('This invitation is invalid.');
}
if (InviteStore::isExpired($record)) {
return ApiResponse::create([
'valid' => false,
'expired' => true,
'email' => (string) ($record['email'] ?? ''),
]);
}
return ApiResponse::create([
'valid' => true,
'expired' => false,
'email' => (string) ($record['email'] ?? ''),
'fullname' => (string) ($record['fullname'] ?? ''),
]);
}
/**
* POST /auth/invite/{token} — PUBLIC. Accept an invite: create the account
* with the admin-preset access/groups and auto-login.
*/
public function accept(ServerRequestInterface $request): ResponseInterface
{
$token = (string) $this->getRouteParam($request, 'token');
$store = $this->store();
$record = $store->get($token);
if ($record === null) {
throw new NotFoundException('This invitation is invalid.');
}
if (InviteStore::isExpired($record)) {
$store->remove($token);
throw new ApiException(410, 'Gone', 'This invitation has expired.');
}
$body = $this->getRequestBody($request);
$this->requireFields($body, ['username', 'password']);
$username = (string) $body['username'];
$password = (string) $body['password'];
// Username format — identical rules to UsersController::create.
$length = mb_strlen($username);
if ($length < 3 || $length > 64 || !DataUser::isValidUsername($username)) {
throw new ValidationException(
'Invalid username format.',
[['field' => 'username', 'message' => 'Username must be 3-64 characters and contain only letters, numbers, periods, hyphens, and underscores (and cannot start with a period).']],
);
}
// Password policy — mirror SetupController.
$pwdRegex = (string) $this->config->get('system.pwd_regex', '');
if ($pwdRegex !== '' && !@preg_match('#^(?:' . $pwdRegex . ')$#', $password)) {
throw new ValidationException(
'Password does not meet the required policy.',
[['field' => 'password', 'message' => 'Password does not meet the required policy.']],
);
}
if ($pwdRegex === '' && strlen($password) < 8) {
throw new ValidationException(
'Password is too short.',
[['field' => 'password', 'message' => 'Password must be at least 8 characters.']],
);
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
if ($accounts->load($username)->exists()) {
throw new ConflictException("User '{$username}' already exists.");
}
$user = $accounts->load($username);
// Email is locked to the invited address — the token is bound to it.
$user->set('email', (string) ($record['email'] ?? ''));
$user->set('fullname', trim((string) ($body['fullname'] ?? ($record['fullname'] ?? ''))));
$user->set('title', trim((string) ($body['title'] ?? '')));
$user->set('state', 'enabled');
$user->set('hashed_password', Authentication::create($password));
$user->set('created', time());
$user->set('modified', time());
// Access + groups come from the invite, NOT the request body — the
// invitee can never influence their own permissions.
$user->set('access', is_array($record['access'] ?? null) ? $record['access'] : []);
if (!empty($record['groups']) && is_array($record['groups'])) {
$user->set('groups', array_values($record['groups']));
}
// Fresh-account hygiene (matches SetupController).
$user->set('avatar', []);
$user->set('twofa_enabled', false);
$user->set('twofa_secret', '');
// NOTE: unlike the authenticated UsersController::create, this is a
// PUBLIC endpoint, so the router has not registered the admin proxy
// ($grav['admin']) that onAdminSave/onAdminAfterSave subscribers
// (git-sync, SEO, etc.) rely on — firing them here fatals. We follow
// the same convention as the public SetupController: save the account
// and fire only the API-level events.
$user->save();
$this->fireEvent('onApiUserCreated', ['user' => $user]);
$this->fireEvent('onApiInvitationAccepted', ['user' => $user, 'invitation' => $record]);
$store->remove($token);
// Auto-login the new user (same token pair as /auth/setup).
$jwt = new JwtAuthenticator($this->grav, $this->config);
$response = $this->issueTokenPair($jwt, $user);
return $response->withHeader('X-Invalidates', 'users:list');
}
/**
* Strip super-admin flags from an access tree.
*
* @param array<string, mixed> $access
* @return array<string, mixed>
*/
private function stripSuperFlags(array $access): array
{
foreach (['admin', 'api'] as $scope) {
if (isset($access[$scope]) && is_array($access[$scope])) {
unset($access[$scope]['super']);
}
}
return $access;
}
private function buildInviteLink(mixed $clientBaseUrl, ServerRequestInterface $request, string $token): string
{
$adminBase = $this->resolveAdminBaseUrl($clientBaseUrl, $request, ['/users/invite', '/invite']);
return rtrim($adminBase, '/') . '/invite?token=' . rawurlencode($token);
}
/**
* @param array<string, mixed> $record
*/
private function sendInviteEmail(array $record, string $link, UserInterface $actor, string $message = ''): void
{
if (!isset($this->grav['Email'])) {
throw new \RuntimeException('Email service not available.');
}
$cfg = $this->grav['config'];
$siteHost = (string) ($cfg->get('plugins.login.site_host') ?: ($this->grav['uri']->host() ?? ''));
$context = [
'invite_link' => $link,
'actor' => (string) ($record['created_by_name'] ?? $actor->get('fullname') ?: $actor->username),
'message' => $message,
'site_name' => $cfg->get('site.title', 'Website'),
'site_host' => $siteHost,
'author' => $cfg->get('site.author.name', ''),
];
$params = [
'to' => (string) ($record['email'] ?? ''),
'body' => [
[
'content_type' => 'text/html',
'template' => 'emails/api/invite-user.html.twig',
'body' => '',
],
],
];
/** @var \Grav\Plugin\Email\Email $email */
$email = $this->grav['Email'];
$emailMessage = $email->buildMessage($params, $context);
$email->send($emailMessage);
}
/**
* Public-safe invite representation (no access/groups leakage beyond what
* an authenticated admin endpoint returns).
*
* @param array<string, mixed> $record
* @return array<string, mixed>
*/
private function serializeInvite(array $record): array
{
return [
'token' => (string) ($record['token'] ?? ''),
'email' => (string) ($record['email'] ?? ''),
'fullname' => (string) ($record['fullname'] ?? ''),
'groups' => array_values((array) ($record['groups'] ?? [])),
'created' => (int) ($record['created'] ?? 0),
'created_by' => (string) ($record['created_by'] ?? ''),
'created_by_name' => (string) ($record['created_by_name'] ?? ''),
'expires' => (int) ($record['expires'] ?? 0),
'expired' => InviteStore::isExpired($record),
];
}
}
@@ -0,0 +1,890 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Framework\Psr7\Response;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class MediaController extends AbstractApiController
{
use HandlesMediaUploads;
/**
* GET /pages/{route}/media - List all media for a page.
*/
public function pageMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.read');
$page = $this->findPageOrFail($request);
$pagePath = $page->path();
// Create fresh Media object to avoid stale page cache
$media = new \Grav\Common\Page\Media($pagePath);
$serialized = $this->getSerializer()->serializeCollection($media->all());
return ApiResponse::create($serialized);
}
/**
* POST /pages/{route}/media - Upload file(s) to a page.
*/
public function uploadPageMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$page = $this->findPageOrFail($request);
$pagePath = $page->path();
if (!$pagePath || !is_dir($pagePath)) {
throw new NotFoundException('Page directory does not exist on disk.');
}
$uploadedFiles = $this->flattenUploadedFiles($request->getUploadedFiles());
if ($uploadedFiles === []) {
throw new ValidationException('No files were uploaded.');
}
// Honor per-field upload settings (random_name, accept, ...) when the
// file field forwards them; absent, this is an inert no-op.
$settings = $this->parseUploadFieldSettings($request);
$uploadedNames = [];
foreach ($uploadedFiles as $file) {
// Fire before event — plugins can throw to reject specific files
$this->fireEvent('onApiBeforeMediaUpload', [
'page' => $page,
'filename' => $file->getClientFilename(),
'type' => $file->getClientMediaType(),
'size' => $file->getSize(),
]);
$uploadedNames[] = $this->processUploadedFile($file, $pagePath, $settings);
}
// Create fresh Media object to pick up newly uploaded files
$media = new \Grav\Common\Page\Media($pagePath);
$serialized = $this->getSerializer()->serializeCollection($media->all());
$this->fireAdminEvent('onAdminAfterAddMedia', ['object' => $page, 'page' => $page]);
$this->fireEvent('onApiMediaUploaded', [
'page' => $page,
'filenames' => $uploadedNames,
]);
$baseUrl = $this->getApiBaseUrl();
$route = $this->getRouteParam($request, 'route') ?? '';
$location = "{$baseUrl}/pages/{$route}/media";
return ApiResponse::created(
$serialized,
$location,
$this->invalidationHeaders([
'media:update:pages/' . $route,
'pages:update:/' . $route,
]),
);
}
/**
* DELETE /pages/{route}/media/{filename} - Delete a media file from a page.
*/
public function deletePageMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$page = $this->findPageOrFail($request);
$filename = $this->getSafeFilename($request);
$pagePath = $page->path();
if (!$pagePath) {
throw new NotFoundException('Page directory does not exist on disk.');
}
// Verify the file exists on disk
$filePath = $pagePath . '/' . $filename;
if (!file_exists($filePath)) {
throw new NotFoundException("Media file '{$filename}' not found on this page.");
}
$this->fireEvent('onApiBeforeMediaDelete', ['page' => $page, 'filename' => $filename]);
unlink($filePath);
// Also remove any metadata file (.meta.yaml) if it exists
$metaPath = $filePath . '.meta.yaml';
if (file_exists($metaPath)) {
unlink($metaPath);
}
// Build fresh media object for admin event compatibility
$media = new \Grav\Common\Page\Media($pagePath);
$this->fireAdminEvent('onAdminAfterDelMedia', [
'object' => $page, 'page' => $page,
'media' => $media, 'filename' => $filename,
]);
$this->fireEvent('onApiMediaDeleted', ['page' => $page, 'filename' => $filename]);
$route = $this->getRouteParam($request, 'route') ?? '';
return ApiResponse::noContent(
$this->invalidationHeaders([
'media:delete:pages/' . $route . '/' . $filename,
'media:update:pages/' . $route,
'pages:update:/' . $route,
]),
);
}
/**
* GET /media - List site-level media with folder browsing, search, and type filter.
*/
public function siteMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.read');
$mediaPath = $this->getSiteMediaPath();
$queryParams = $request->getQueryParams();
// Validate optional path parameter
$relativePath = '';
if (!empty($queryParams['path'])) {
$relativePath = $this->validateRelativePath($queryParams['path'], $mediaPath);
}
$currentPath = $relativePath !== '' ? $mediaPath . '/' . $relativePath : $mediaPath;
// Handle search mode
if (!empty($queryParams['search'])) {
return $this->handleMediaSearch($request, $mediaPath, $queryParams);
}
// Verify directory exists
if (!is_dir($currentPath)) {
// Return empty result for non-existent paths
$baseUrl = $this->getApiBaseUrl() . '/media';
return ApiResponse::paginated([], 0, 1, 20, $baseUrl, 200, [], [
'path' => $relativePath,
'folders' => [],
]);
}
$result = $this->scanMediaDirectoryWithFolders($currentPath, $relativePath);
$pagination = $this->getPagination($request);
// Apply type filter
$typeFilter = $queryParams['type'] ?? null;
$files = $result['files'];
if ($typeFilter) {
$files = array_values(array_filter($files, function (string $file) use ($currentPath, $typeFilter) {
$mime = mime_content_type($currentPath . '/' . $file) ?: '';
return match ($typeFilter) {
'image' => str_starts_with($mime, 'image/'),
'video' => str_starts_with($mime, 'video/'),
'audio' => str_starts_with($mime, 'audio/'),
'document' => !str_starts_with($mime, 'image/') && !str_starts_with($mime, 'video/') && !str_starts_with($mime, 'audio/'),
default => true,
};
}));
}
$total = count($files);
$pagedFiles = array_slice($files, $pagination['offset'], $pagination['limit']);
$serialized = array_map(
fn(string $file) => $this->serializeSiteFile($currentPath, $file, $relativePath),
$pagedFiles,
);
$baseUrl = $this->getApiBaseUrl() . '/media';
return ApiResponse::paginated(
$serialized,
$total,
$pagination['page'],
$pagination['per_page'],
$baseUrl,
200,
[],
[
'path' => $relativePath,
'folders' => $result['folders'],
],
);
}
/**
* POST /media - Upload file(s) to the site media folder (with optional subfolder path).
*/
public function uploadSiteMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$queryParams = $request->getQueryParams();
// Validate optional subfolder path
$relativePath = '';
if (!empty($queryParams['path'])) {
$relativePath = $this->validateRelativePath($queryParams['path'], $mediaPath);
}
$targetDir = $relativePath !== '' ? $mediaPath . '/' . $relativePath : $mediaPath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new ValidationException('Unable to create upload directory.');
}
$uploadedFiles = $this->flattenUploadedFiles($request->getUploadedFiles());
if ($uploadedFiles === []) {
throw new ValidationException('No files were uploaded.');
}
$settings = $this->parseUploadFieldSettings($request);
$created = [];
foreach ($uploadedFiles as $file) {
$filename = $this->processUploadedFile($file, $targetDir, $settings);
$created[] = $this->serializeSiteFile($targetDir, $filename, $relativePath);
}
$location = $this->getApiBaseUrl() . '/media';
return ApiResponse::created(
$created,
$location,
$this->invalidationHeaders(['media:update:' . ($relativePath !== '' ? $relativePath : '/'), 'media:list']),
);
}
/**
* DELETE /media/{filename} - Delete a site media file (supports subfolder paths).
*/
public function deleteSiteMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$relativePath = $this->getSafeRelativeFilePath($request, $mediaPath);
$filePath = $mediaPath . '/' . $relativePath;
if (!file_exists($filePath)) {
throw new NotFoundException("Media file not found.");
}
unlink($filePath);
// Also remove any metadata file
$metaPath = $filePath . '.meta.yaml';
if (file_exists($metaPath)) {
unlink($metaPath);
}
$parentDir = ltrim(dirname($relativePath), '.');
return ApiResponse::noContent(
$this->invalidationHeaders([
'media:delete:' . $relativePath,
'media:update:' . ($parentDir !== '' ? $parentDir : '/'),
'media:list',
]),
);
}
/**
* POST /media/folders - Create a new folder.
*/
public function createFolder(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$body = json_decode((string) $request->getBody(), true) ?? [];
if (empty($body['path'])) {
throw new ValidationException('Folder path is required.');
}
$relativePath = $this->validateRelativePath($body['path'], $mediaPath);
$absolutePath = $mediaPath . '/' . $relativePath;
if (is_dir($absolutePath)) {
throw new ValidationException('Folder already exists.');
}
if (!mkdir($absolutePath, 0775, true)) {
throw new ValidationException('Unable to create folder.');
}
$name = basename($relativePath);
$data = [
'name' => $name,
'path' => $relativePath,
'children_count' => 0,
'file_count' => 0,
];
return ApiResponse::created(
$data,
$this->getApiBaseUrl() . '/media?path=' . urlencode($relativePath),
$this->invalidationHeaders(['media:create:' . $relativePath, 'media:list']),
);
}
/**
* DELETE /media/folders/{path} - Delete an empty folder.
*/
public function deleteFolder(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$path = $this->getRouteParam($request, 'path');
if ($path === null || $path === '') {
throw new ValidationException('Folder path is required.');
}
$relativePath = $this->validateRelativePath($path, $mediaPath);
$absolutePath = $mediaPath . '/' . $relativePath;
if (!is_dir($absolutePath)) {
throw new NotFoundException('Folder not found.');
}
// Check if folder is empty (only . and ..)
$isEmpty = true;
foreach (new \DirectoryIterator($absolutePath) as $item) {
if (!$item->isDot()) {
$isEmpty = false;
break;
}
}
if (!$isEmpty) {
throw new ValidationException('Folder is not empty. Delete all files first.');
}
if (!rmdir($absolutePath)) {
throw new ValidationException('Unable to delete folder.');
}
return ApiResponse::noContent(
$this->invalidationHeaders(['media:delete:' . $relativePath, 'media:list']),
);
}
/**
* POST /media/rename - Rename or move a media file.
*/
public function renameFile(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$body = json_decode((string) $request->getBody(), true) ?? [];
if (empty($body['from']) || empty($body['to'])) {
throw new ValidationException("Both 'from' and 'to' paths are required.");
}
$from = $this->validateRelativePath($body['from'], $mediaPath);
$to = $this->validateRelativePath($body['to'], $mediaPath);
$fromAbsolute = $mediaPath . '/' . $from;
$toAbsolute = $mediaPath . '/' . $to;
if (!file_exists($fromAbsolute)) {
throw new NotFoundException("Source file not found.");
}
if (file_exists($toAbsolute)) {
throw new ValidationException("A file already exists at the destination.");
}
// Ensure target directory exists
$targetDir = dirname($toAbsolute);
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new ValidationException('Unable to create destination directory.');
}
if (!rename($fromAbsolute, $toAbsolute)) {
throw new ValidationException('Unable to rename file.');
}
// Also rename metadata sidecar if it exists
$fromMeta = $fromAbsolute . '.meta.yaml';
$toMeta = $toAbsolute . '.meta.yaml';
if (file_exists($fromMeta)) {
rename($fromMeta, $toMeta);
}
$toDir = ltrim(dirname($to) === '.' ? '' : dirname($to), '/');
$toFilename = basename($to);
$targetPath = $toDir !== '' ? $mediaPath . '/' . $toDir : $mediaPath;
return ApiResponse::ok(
$this->serializeSiteFile($targetPath, $toFilename, $toDir),
$this->invalidationHeaders([
'media:delete:' . $from,
'media:create:' . $to,
'media:list',
]),
);
}
/**
* POST /media/folders/rename - Rename a folder.
*/
public function renameFolder(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$body = json_decode((string) $request->getBody(), true) ?? [];
if (empty($body['from']) || empty($body['to'])) {
throw new ValidationException("Both 'from' and 'to' paths are required.");
}
$from = $this->validateRelativePath($body['from'], $mediaPath);
$to = $this->validateRelativePath($body['to'], $mediaPath);
$fromAbsolute = $mediaPath . '/' . $from;
$toAbsolute = $mediaPath . '/' . $to;
if (!is_dir($fromAbsolute)) {
throw new NotFoundException("Source folder not found.");
}
if (file_exists($toAbsolute)) {
throw new ValidationException("A folder already exists at the destination.");
}
if (!rename($fromAbsolute, $toAbsolute)) {
throw new ValidationException('Unable to rename folder.');
}
$name = basename($to);
$data = [
'name' => $name,
'path' => $to,
'children_count' => 0,
'file_count' => 0,
];
return ApiResponse::ok(
$data,
$this->invalidationHeaders([
'media:delete:' . $from,
'media:create:' . $to,
'media:list',
]),
);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* GET /thumbnails/{hash}.{ext} - Serve a cached thumbnail image.
*/
public function thumbnail(ServerRequestInterface $request): ResponseInterface
{
$file = $this->getRouteParam($request, 'file');
if (!$file) {
throw new NotFoundException('Thumbnail not found.');
}
$cacheDir = $this->grav['locator']->findResource('cache://') . '/api/thumbnails';
$cachePath = $cacheDir . '/' . basename($file);
if (!file_exists($cachePath)) {
throw new NotFoundException('Thumbnail not found.');
}
$mime = mime_content_type($cachePath) ?: 'application/octet-stream';
$content = file_get_contents($cachePath);
return new Response(
200,
[
'Content-Type' => $mime,
'Content-Length' => (string) strlen($content),
'Cache-Control' => 'public, max-age=31536000, immutable',
],
$content
);
}
/**
* Resolve a page from the route parameter or throw a 404.
*/
private function findPageOrFail(ServerRequestInterface $request): PageInterface
{
$route = $this->getRouteParam($request, 'route');
if ($route === null || $route === '') {
throw new NotFoundException('Page route is required.');
}
$pages = $this->grav['pages'];
// Enable pages if they were disabled (e.g. in admin context)
if (method_exists($pages, 'enablePages')) {
$pages->enablePages();
}
$page = $pages->find('/' . ltrim($route, '/'));
if (!$page) {
throw new NotFoundException("Page '/{$route}' not found.");
}
return $page;
}
/**
* Validate a relative path is safe and within the media directory.
* Returns the sanitized relative path.
*/
private function validateRelativePath(string $path, string $basePath): string
{
// Normalize separators
$path = str_replace('\\', '/', $path);
$path = trim($path, '/');
if ($path === '') {
return '';
}
// Check each segment
foreach (explode('/', $path) as $segment) {
if (
$segment === '' ||
$segment === '.' ||
$segment === '..' ||
str_contains($segment, "\0") ||
str_starts_with($segment, '.')
) {
throw new ValidationException("Invalid path: '{$path}'.");
}
}
// Verify resolved path is within base
$absolute = $basePath . '/' . $path;
// For existing paths, use realpath
if (file_exists($absolute)) {
$real = realpath($absolute);
$realBase = realpath($basePath);
if ($real === false || $realBase === false || !str_starts_with($real, $realBase . '/')) {
throw new ValidationException("Invalid path: '{$path}'.");
}
}
return $path;
}
/**
* Extract and validate a relative file path from route parameters.
* Unlike getSafeFilename() which strips directories with basename(),
* this preserves path components for subfolder support.
*/
private function getSafeRelativeFilePath(ServerRequestInterface $request, string $basePath): string
{
$filename = $this->getRouteParam($request, 'filename');
if ($filename === null || $filename === '') {
throw new ValidationException('Filename is required.');
}
// Normalize
$filename = str_replace('\\', '/', $filename);
$filename = trim($filename, '/');
// Validate each path segment
foreach (explode('/', $filename) as $segment) {
if (
$segment === '' ||
$segment === '.' ||
$segment === '..' ||
str_contains($segment, "\0") ||
str_starts_with($segment, '.')
) {
throw new ValidationException('Invalid filename.');
}
}
// Verify resolved path is within base
$absolute = $basePath . '/' . $filename;
if (file_exists($absolute)) {
$real = realpath($absolute);
$realBase = realpath($basePath);
if ($real === false || $realBase === false || !str_starts_with($real, $realBase . '/')) {
throw new ValidationException('Invalid filename.');
}
}
return $filename;
}
/**
* Resolve the absolute path to the site-level media directory.
*/
private function getSiteMediaPath(): string
{
/** @var \Grav\Common\Locator $locator */
$locator = $this->grav['locator'];
$path = $locator->findResource('user://media', true, true);
if (!$path) {
throw new NotFoundException('Site media directory could not be resolved.');
}
return $path;
}
/**
* Handle recursive media search across all subfolders.
*/
private function handleMediaSearch(
ServerRequestInterface $request,
string $mediaPath,
array $queryParams
): ResponseInterface {
$search = strtolower($queryParams['search']);
$typeFilter = $queryParams['type'] ?? null;
$pagination = $this->getPagination($request);
$matches = [];
if (is_dir($mediaPath)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($mediaPath, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir()) {
continue;
}
$name = $item->getFilename();
// Skip hidden and metadata files
if (str_starts_with($name, '.') || str_ends_with($name, '.meta.yaml')) {
continue;
}
// Match filename
if (!str_contains(strtolower($name), $search)) {
continue;
}
// Apply type filter
if ($typeFilter) {
$mime = mime_content_type($item->getPathname()) ?: '';
$passesFilter = match ($typeFilter) {
'image' => str_starts_with($mime, 'image/'),
'video' => str_starts_with($mime, 'video/'),
'audio' => str_starts_with($mime, 'audio/'),
'document' => !str_starts_with($mime, 'image/') && !str_starts_with($mime, 'video/') && !str_starts_with($mime, 'audio/'),
default => true,
};
if (!$passesFilter) {
continue;
}
}
// Calculate relative path
$fullPath = $item->getPathname();
$relDir = ltrim(str_replace($mediaPath, '', dirname($fullPath)), '/');
$matches[] = ['filename' => $name, 'dir' => $relDir, 'fullPath' => $fullPath];
}
}
// Sort matches
usort($matches, fn($a, $b) => strnatcasecmp($a['filename'], $b['filename']));
$total = count($matches);
$paged = array_slice($matches, $pagination['offset'], $pagination['limit']);
$serialized = array_map(function (array $match) {
return $this->serializeSiteFile(dirname($match['fullPath']), $match['filename'], $match['dir']);
}, $paged);
$baseUrl = $this->getApiBaseUrl() . '/media';
return ApiResponse::paginated(
$serialized,
$total,
$pagination['page'],
$pagination['per_page'],
$baseUrl,
200,
[],
[
'path' => '',
'folders' => [],
'search' => $queryParams['search'],
],
);
}
/**
* Scan a directory for media files, returning just the filenames sorted alphabetically.
*
* @return string[]
*/
private function scanMediaDirectory(string $path): array
{
if (!is_dir($path)) {
return [];
}
$files = [];
/** @var \SplFileInfo $item */
foreach (new \DirectoryIterator($path) as $item) {
if ($item->isDot() || $item->isDir()) {
continue;
}
// Skip hidden files and metadata files
$name = $item->getFilename();
if (str_starts_with($name, '.') || str_ends_with($name, '.meta.yaml')) {
continue;
}
$files[] = $name;
}
sort($files, SORT_NATURAL | SORT_FLAG_CASE);
return $files;
}
/**
* Scan a directory for media files and subdirectories.
*
* @return array{files: string[], folders: array<array{name: string, path: string, children_count: int, file_count: int}>}
*/
private function scanMediaDirectoryWithFolders(string $absolutePath, string $relativePath = ''): array
{
$files = [];
$folders = [];
if (!is_dir($absolutePath)) {
return ['files' => $files, 'folders' => $folders];
}
foreach (new \DirectoryIterator($absolutePath) as $item) {
if ($item->isDot()) {
continue;
}
$name = $item->getFilename();
// Skip hidden files/dirs
if (str_starts_with($name, '.')) {
continue;
}
if ($item->isDir()) {
$folderPath = $relativePath !== '' ? $relativePath . '/' . $name : $name;
$childPath = $absolutePath . '/' . $name;
// Count immediate children
$childrenCount = 0;
$fileCount = 0;
if (is_dir($childPath)) {
foreach (new \DirectoryIterator($childPath) as $child) {
if ($child->isDot() || str_starts_with($child->getFilename(), '.')) {
continue;
}
if ($child->isDir()) {
$childrenCount++;
} elseif (!str_ends_with($child->getFilename(), '.meta.yaml')) {
$fileCount++;
}
}
}
$folders[] = [
'name' => $name,
'path' => $folderPath,
'children_count' => $childrenCount,
'file_count' => $fileCount,
];
} else {
// Skip metadata files
if (str_ends_with($name, '.meta.yaml')) {
continue;
}
$files[] = $name;
}
}
sort($files, SORT_NATURAL | SORT_FLAG_CASE);
usort($folders, fn(array $a, array $b) => strnatcasecmp($a['name'], $b['name']));
return ['files' => $files, 'folders' => $folders];
}
/**
* Build a serialized array for a raw file in the site media directory.
* Used when we don't have Grav Medium objects available.
*/
private function serializeSiteFile(string $basePath, string $filename, string $relativePath = ''): array
{
$filePath = $basePath . '/' . $filename;
$mime = mime_content_type($filePath) ?: 'application/octet-stream';
$fullRelativePath = $relativePath !== '' ? $relativePath . '/' . $filename : $filename;
$data = [
'filename' => $filename,
'path' => $relativePath,
'url' => '/user/media/' . $fullRelativePath,
'type' => $mime,
'size' => (int) filesize($filePath),
];
if (str_starts_with($mime, 'image/') && $mime !== 'image/svg+xml') {
if ($imageSize = @getimagesize($filePath)) {
$data['dimensions'] = [
'width' => $imageSize[0],
'height' => $imageSize[1],
];
}
// Generate thumbnail
try {
$thumbnailService = $this->getThumbnailService();
$hash = $thumbnailService->getOrCreate($filePath);
if ($hash) {
$data['thumbnail_url'] = $this->getApiBaseUrl() . '/thumbnails/' . $hash;
}
} catch (\Throwable) {
// Thumbnail generation failed — skip it
}
}
$mtime = filemtime($filePath);
$data['modified'] = date(\DateTimeInterface::ATOM, $mtime ?: time());
return $data;
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
/**
* Menubar API — lets plugins register toolbar items with executable actions.
*
* Plugins listen for `onApiMenubarItems` to register items and
* `onApiMenubarAction` to handle action execution.
*
* Item format:
* [
* 'id' => 'warm-cache', // unique identifier
* 'plugin' => 'warm-cache', // owning plugin slug
* 'label' => 'Warm Cache', // tooltip / display name
* 'icon' => 'fa-tachometer', // FA icon class
* 'action' => 'warm', // action key for POST
* 'confirm' => 'Warm the cache?', // optional confirmation prompt
* 'authorize' => 'api.some.permission', // optional — string or array (any-of)
* ]
*
* `authorize` follows the same string-or-array semantics as the sidebar API.
* Items without `authorize` are visible to every authenticated user.
*/
class MenubarController extends AbstractApiController
{
/**
* GET /menubar/items — Collect menu items from plugins, filtered by the
* current user's permissions.
*/
public function items(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$event = new Event(['items' => [], 'user' => $user]);
$this->grav->fireEvent('onApiMenubarItems', $event);
$isSuperAdmin = $this->isSuperAdmin($user);
$filtered = [];
foreach ($event['items'] as $item) {
if (!$this->userPassesAuthorize($user, $item['authorize'] ?? null, $isSuperAdmin)) {
continue;
}
// Strip the authorize field — it's a server-side annotation, not client data
unset($item['authorize']);
$filtered[] = $item;
}
return ApiResponse::create($filtered);
}
/**
* POST /menubar/actions/{plugin}/{action} — Execute a plugin action.
*/
public function executeAction(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$plugin = $this->getRouteParam($request, 'plugin');
$action = $this->getRouteParam($request, 'action');
$body = $this->getRequestBody($request);
$sentinel = "__no_handler_{$plugin}_{$action}__";
$event = new Event([
'plugin' => $plugin,
'action' => $action,
'body' => $body,
'user' => $this->getUser($request),
'result' => [
'status' => 'error',
'message' => $sentinel,
],
]);
$this->grav->fireEvent('onApiMenubarAction', $event);
$result = $event['result'];
// Distinguish "no plugin registered for this action" from a handler
// that ran and reported a domain-level failure (e.g. auth error from
// Cloudflare). The former is a 404; the latter is a successful API
// call that the client will toast as an error based on result.status.
if (($result['message'] ?? null) === $sentinel) {
throw new NotFoundException("No handler registered for action '{$plugin}/{$action}'.");
}
return ApiResponse::create($result, 200);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\PasswordPolicyService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Public endpoint that exposes the configured password policy so the
* setup, password-reset and user-creation flows can render matching
* client-side validation and a strength meter.
*/
class PasswordPolicyController extends AbstractApiController
{
public function show(ServerRequestInterface $request): ResponseInterface
{
return ApiResponse::create(PasswordPolicyService::build($this->config));
}
}
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\PreferencesResolver;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Admin-next UI preferences endpoints.
*
* GET /admin-next/preferences — full resolved payload
* PATCH /admin-next/preferences/user — patch current user overrides
* DELETE /admin-next/preferences/user — clear all current-user overrides
* PATCH /admin-next/preferences/site — super-admin: write site defaults
* PATCH /admin-next/branding — super-admin: write logo mode + text
* POST /admin-next/branding/logo — super-admin: upload logo file
* DELETE /admin-next/branding/logo — super-admin: delete a logo file
*
* The SPA fetches once on boot, then PATCHes deltas as the user changes
* preferences. See PreferencesResolver for storage layout (Tier A/B/C).
*/
class PreferencesController extends AbstractApiController
{
/** Whitelist of MIME types accepted for logo uploads. */
private const LOGO_MIMES = [
'image/svg+xml' => 'svg',
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
];
/** 4 MB cap — logos shouldn't be anywhere near this. */
private const LOGO_MAX_SIZE = 4_194_304;
public function show(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$resolver = $this->getResolver();
$payload = $resolver->resolve($user, $this->canEditSite($user));
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return $this->respondWithEtag($payload);
}
public function saveUser(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$body = $this->getRequestBody($request);
$user = $this->getUser($request);
$resolver = $this->getResolver();
$resolver->saveUserPreferences($user, $body);
$payload = $resolver->resolve($user, $this->canEditSite($user));
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload);
}
public function resetUser(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$resolver = $this->getResolver();
$resolver->clearUserPreferences($user);
$payload = $resolver->resolve($user, $this->canEditSite($user));
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload);
}
public function saveSite(ServerRequestInterface $request): ResponseInterface
{
$this->requireSiteEditor($request);
$body = $this->getRequestBody($request);
$resolver = $this->getResolver();
// Route a flat payload into the two yaml destinations: Tier B keys
// go to `ui.defaults` (overridable per-user), Tier A2 keys go to
// `ui.settings` (site-only behavioral). Anything else is ignored.
$tierB = array_intersect_key($body, $resolver->defaultPreferences());
$tierA2 = array_intersect_key($body, $resolver->defaultSiteSettings());
if ($tierB !== []) {
$resolver->saveSitePreferences($tierB);
}
if ($tierA2 !== []) {
$resolver->saveSiteSettings($tierA2);
}
$user = $this->getUser($request);
$payload = $resolver->resolve($user, true);
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload);
}
public function saveBranding(ServerRequestInterface $request): ResponseInterface
{
$this->requireSiteEditor($request);
$body = $this->getRequestBody($request);
$resolver = $this->getResolver();
// Branding is replace-all: merge with current so callers can PATCH
// just `mode` or just `text` without wiping the saved file paths.
$merged = array_replace($resolver->siteBranding(), $body);
$resolver->saveSiteBranding($merged);
$user = $this->getUser($request);
$payload = $resolver->resolve($user, true);
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload);
}
public function uploadLogo(ServerRequestInterface $request): ResponseInterface
{
$this->requireSiteEditor($request);
$variant = $this->getLogoVariant($request);
$uploaded = $request->getUploadedFiles();
$file = $uploaded['file'] ?? $uploaded['logo'] ?? null;
if ($file === null || $file->getError() !== UPLOAD_ERR_OK) {
throw new ValidationException('No logo file uploaded.');
}
$size = $file->getSize();
if ($size !== null && $size > self::LOGO_MAX_SIZE) {
throw new ValidationException(
sprintf('Logo exceeds maximum size of %d MB.', self::LOGO_MAX_SIZE / 1_048_576)
);
}
$mime = strtolower((string) ($file->getClientMediaType() ?? ''));
if (!isset(self::LOGO_MIMES[$mime])) {
throw new ValidationException(
'Logo must be SVG, PNG, JPEG, or WebP. Received: ' . ($mime === '' ? '(unknown)' : $mime)
);
}
$ext = self::LOGO_MIMES[$mime];
$resolver = $this->getResolver();
$dir = $resolver->brandingMediaDir(createDir: true);
if ($dir === null) {
throw new \RuntimeException('Unable to resolve user://media/admin-next/.');
}
// Timestamp+rand keeps writes idempotent on filesystems with second-resolution mtime.
$stamp = substr(md5(uniqid('logo', true)), 0, 10);
$filename = "logo-{$variant}-{$stamp}.{$ext}";
$filepath = $dir . '/' . $filename;
$file->moveTo($filepath);
// Replace the path for this variant; preserve everything else.
$branding = $resolver->siteBranding();
$previous = $branding[$variant === 'light' ? 'logoLight' : 'logoDark'] ?? '';
$branding[$variant === 'light' ? 'logoLight' : 'logoDark'] = $filename;
// If a custom logo file was uploaded, auto-flip mode to `custom` unless the
// operator has explicitly set `text` mode (text trumps both default + custom).
if (($branding['mode'] ?? 'default') !== 'text') {
$branding['mode'] = 'custom';
}
$resolver->saveSiteBranding($branding);
// Clean up the previous file for this variant if it's different.
if ($previous && $previous !== $filename) {
$oldPath = $dir . '/' . basename($previous);
if (is_file($oldPath)) {
@unlink($oldPath);
}
}
$user = $this->getUser($request);
$payload = $resolver->resolve($user, true);
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload, 201);
}
public function deleteLogo(ServerRequestInterface $request): ResponseInterface
{
$this->requireSiteEditor($request);
$variant = $this->getLogoVariant($request);
$resolver = $this->getResolver();
$branding = $resolver->siteBranding();
$key = $variant === 'light' ? 'logoLight' : 'logoDark';
$existing = $branding[$key] ?? '';
if ($existing) {
$dir = $resolver->brandingMediaDir();
if ($dir && is_file($dir . '/' . basename($existing))) {
@unlink($dir . '/' . basename($existing));
}
}
$branding[$key] = '';
// If both variants are now empty, revert to default mode so the SPA
// falls back to the built-in Grav logo rather than rendering nothing.
if ($branding['logoLight'] === '' && $branding['logoDark'] === '' && ($branding['mode'] ?? '') === 'custom') {
$branding['mode'] = 'default';
}
$resolver->saveSiteBranding($branding);
$user = $this->getUser($request);
$payload = $resolver->resolve($user, true);
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload);
}
private function getLogoVariant(ServerRequestInterface $request): string
{
$variant = $request->getQueryParams()['variant'] ?? null;
if ($variant === null) {
$body = $request->getParsedBody();
if (is_array($body)) {
$variant = $body['variant'] ?? null;
}
}
$variant = is_string($variant) ? strtolower($variant) : '';
if ($variant !== 'light' && $variant !== 'dark') {
throw new ValidationException("Query parameter 'variant' must be 'light' or 'dark'.");
}
return $variant;
}
private function requireSiteEditor(ServerRequestInterface $request): void
{
$user = $this->getUser($request);
if (!$this->canEditSite($user)) {
throw new ForbiddenException('Only super-admins can edit site-wide admin preferences.');
}
}
private function canEditSite(\Grav\Common\User\Interfaces\UserInterface $user): bool
{
return $this->isSuperAdmin($user);
}
/**
* Project filename-only branding paths into URL fragments the SPA can use directly.
*
* @param array<string, mixed> $branding
* @return array{light: string, dark: string}
*/
private function resolveBrandingUrls(array $branding, PreferencesResolver $resolver): array
{
return [
'light' => $resolver->brandingMediaUrl((string) ($branding['logoLight'] ?? '')),
'dark' => $resolver->brandingMediaUrl((string) ($branding['logoDark'] ?? '')),
];
}
private function getResolver(): PreferencesResolver
{
return new PreferencesResolver($this->grav);
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Helpers\YamlLinter;
use Grav\Common\Page\Pages;
use Grav\Common\Security;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
class ReportsController extends AbstractApiController
{
private const PERMISSION_READ = 'api.reports.read';
/**
* GET /reports - Generate plugin-extensible reports.
*
* Built-in reports: Security Check, YAML Linter.
* Plugins can add reports via the onApiGenerateReports event.
*/
public function index(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$reports = [];
// Built-in: Grav Security Check (XSS scan)
$reports[] = $this->securityReport();
// Built-in: YAML Linter
$reports[] = $this->yamlLinterReport();
// Fire event for plugins to add their own reports
$event = new Event(['reports' => $reports]);
$this->grav->fireEvent('onApiGenerateReports', $event);
$reports = $event['reports'];
return ApiResponse::create($reports);
}
/**
* Scan all pages for potential XSS vulnerabilities.
*/
private function securityReport(): array
{
/** @var Pages $pages */
$pages = $this->grav['pages'];
$pages->enablePages();
$result = Security::detectXssFromPages($pages, true);
$items = [];
foreach ($result as $route => $fields) {
foreach ($fields as $field) {
$items[] = [
'route' => $route,
'field' => $field,
];
}
}
$issueCount = count($items);
return [
'id' => 'security-check',
'title' => 'Grav Security Check',
'provider' => 'core',
'component' => null,
'status' => $issueCount === 0 ? 'success' : 'warning',
'message' => $issueCount === 0
? 'Security Scan complete: No issues found.'
: "Security Scan complete: {$issueCount} potential XSS issue" . ($issueCount > 1 ? 's' : '') . ' found...',
'items' => $items,
];
}
/**
* Lint all YAML files for syntax errors.
*/
private function yamlLinterReport(): array
{
$result = YamlLinter::lint();
$items = [];
foreach ($result as $file => $error) {
$items[] = [
'file' => $file,
'error' => $error,
];
}
$errorCount = count($items);
return [
'id' => 'yaml-linter',
'title' => 'Grav Yaml Linter',
'provider' => 'core',
'component' => null,
'status' => $errorCount === 0 ? 'success' : 'error',
'message' => $errorCount === 0
? 'YAML Linting: No errors found.'
: "YAML Linting: {$errorCount} error" . ($errorCount > 1 ? 's' : '') . ' found.',
'items' => $items,
];
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Psr\Http\Message\ServerRequestInterface;
/**
* Resolves the admin-next frontend base URL (scheme + host + port + any base
* path) for building self-referential links inside emails (password reset,
* invitations, …). Shared by AuthController and InvitationsController.
*
* Resolution priority:
* 1. Explicit admin_base_url from the request body — the admin-next client
* sends `window.location.origin + base`, always correct for browsers.
* 2. Referer header — fallback when the body field is missing.
* 3. Origin header + Grav base path — last resort.
*
* Any accepted value is sanity-checked: only http(s) URLs are allowed so a
* link can't be coerced into producing something like a javascript: or
* data: URL.
*/
trait ResolvesAdminBaseUrl
{
/**
* @param string[] $stripSuffixes path suffixes to trim off the Referer
* path (e.g. ['/forgot', '/invite']) so we
* land at the admin-next root.
*/
protected function resolveAdminBaseUrl(
mixed $clientBaseUrl,
ServerRequestInterface $request,
array $stripSuffixes = ['/forgot'],
): string {
if (is_string($clientBaseUrl) && $clientBaseUrl !== '') {
$normalized = $this->sanitizeHttpUrl($clientBaseUrl);
if ($normalized !== null) {
return $normalized;
}
}
$referer = $request->getHeaderLine('Referer');
if ($referer !== '') {
$parts = parse_url($referer);
if (!empty($parts['scheme']) && !empty($parts['host'])) {
$origin = $parts['scheme'] . '://' . $parts['host'];
if (!empty($parts['port'])) {
$origin .= ':' . $parts['port'];
}
$path = $parts['path'] ?? '';
foreach ($stripSuffixes as $suffix) {
if ($suffix !== '' && str_ends_with($path, $suffix)) {
$path = substr($path, 0, -\strlen($suffix));
break;
}
}
$normalized = $this->sanitizeHttpUrl($origin . rtrim($path, '/'));
if ($normalized !== null) {
return $normalized;
}
}
}
$origin = $request->getHeaderLine('Origin');
if ($origin !== '') {
$basePath = (string) $this->grav['uri']->rootUrl(false);
$normalized = $this->sanitizeHttpUrl(rtrim($origin, '/') . $basePath);
if ($normalized !== null) {
return $normalized;
}
}
// Last resort: Grav's own root URL. Wrong in dev when admin-next runs
// on a separate origin, but at least a valid URL.
return rtrim((string) $this->grav['uri']->rootUrl(true), '/');
}
protected function sanitizeHttpUrl(string $url): ?string
{
$url = trim($url);
if ($url === '') {
return null;
}
$parts = parse_url($url);
if (empty($parts['scheme']) || empty($parts['host'])) {
return null;
}
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
return null;
}
return rtrim($url, '/');
}
}
@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Scheduler\Scheduler;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
class SchedulerController extends AbstractApiController
{
private const PERMISSION_READ = 'api.scheduler.read';
private const PERMISSION_WRITE = 'api.scheduler.write';
/**
* GET /scheduler/jobs - List all registered scheduler jobs with status.
*/
public function jobs(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
/** @var Scheduler $scheduler */
$scheduler = $this->grav['scheduler'];
// Fire onSchedulerInitialized so plugins register their system jobs
// (cache-purge, cache-clear, backups, etc.)
$this->grav->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler]));
$allJobs = $scheduler->getAllJobs();
$states = $scheduler->getJobStates()->content();
$data = [];
foreach ($allJobs as $job) {
$id = $job->getId();
$command = $job->getCommand();
$state = $states[$id] ?? null;
$data[] = [
'id' => $id,
'command' => is_string($command) ? $command : '(closure)',
'expression' => $job->getAt(),
'enabled' => $job->getEnabled(),
'status' => $state['state'] ?? 'pending',
'last_run' => isset($state['last-run']) ? date('c', $state['last-run']) : null,
'error' => $state['error'] ?? null,
];
}
return ApiResponse::create($data);
}
/**
* GET /scheduler/status - Get scheduler cron status.
*/
public function status(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
/** @var Scheduler $scheduler */
$scheduler = $this->grav['scheduler'];
// Fire onSchedulerInitialized so health status sees system jobs
$this->grav->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler]));
$crontabStatus = $scheduler->isCrontabSetup();
$statusMap = [0 => 'not_installed', 1 => 'installed', 2 => 'error'];
// Health status and active triggers
$health = method_exists($scheduler, 'getHealthStatus') ? $scheduler->getHealthStatus() : [];
$triggers = method_exists($scheduler, 'getActiveTriggers') ? $scheduler->getActiveTriggers() : [];
// Webhook plugin status
$webhookInstalled = class_exists('Grav\\Plugin\\SchedulerWebhookPlugin')
|| is_dir($this->grav['locator']->findResource('plugin://scheduler-webhook') ?: '');
$webhookEnabled = method_exists($scheduler, 'isWebhookEnabled') && $scheduler->isWebhookEnabled();
$data = [
'crontab_status' => $statusMap[$crontabStatus] ?? 'unknown',
'cron_command' => $scheduler->getCronCommand(),
'scheduler_command' => $scheduler->getSchedulerCommand(),
'whoami' => $scheduler->whoami(),
'health' => $health,
'triggers' => $triggers,
'webhook_installed' => $webhookInstalled,
'webhook_enabled' => $webhookEnabled,
];
return ApiResponse::create($data);
}
/**
* GET /scheduler/history - Job execution history (paginated).
*/
public function history(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$pagination = $this->getPagination($request);
/** @var Scheduler $scheduler */
$scheduler = $this->grav['scheduler'];
$states = $scheduler->getJobStates()->content();
// Convert states to array sorted by last-run desc
$history = [];
foreach ($states as $jobId => $state) {
$history[] = [
'job_id' => $jobId,
'status' => $state['state'] ?? 'unknown',
'last_run' => isset($state['last-run']) ? date('c', $state['last-run']) : null,
'last_run_timestamp' => $state['last-run'] ?? 0,
'error' => $state['error'] ?? null,
];
}
// Sort by last_run descending
usort($history, fn($a, $b) => ($b['last_run_timestamp'] ?? 0) <=> ($a['last_run_timestamp'] ?? 0));
// Remove the timestamp helper field
$history = array_map(function ($item) {
unset($item['last_run_timestamp']);
return $item;
}, $history);
$total = count($history);
$slice = array_slice($history, $pagination['offset'], $pagination['limit']);
$baseUrl = $this->getApiBaseUrl() . '/scheduler/history';
return ApiResponse::paginated(
data: $slice,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $baseUrl,
);
}
/**
* POST /scheduler/run - Trigger scheduler run manually.
*/
public function run(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
/** @var Scheduler $scheduler */
$scheduler = $this->grav['scheduler'];
$body = $this->getRequestBody($request);
$force = filter_var($body['force'] ?? false, FILTER_VALIDATE_BOOLEAN);
$scheduler->run(null, $force);
// Collect results
$states = $scheduler->getJobStates()->content();
return ApiResponse::create([
'message' => 'Scheduler run completed.',
'forced' => $force,
'job_states' => $states,
]);
}
/**
* GET /systeminfo - Generate system info overview.
*/
public function systemInfo(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$reports = [];
// PHP info
$reports['php'] = [
'version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'extensions' => get_loaded_extensions(),
'memory_limit' => ini_get('memory_limit'),
'max_execution_time' => ini_get('max_execution_time'),
'upload_max_filesize' => ini_get('upload_max_filesize'),
'post_max_size' => ini_get('post_max_size'),
];
// Grav info
$reports['grav'] = [
'version' => GRAV_VERSION,
'php_version' => PHP_VERSION,
];
// Disk usage
$rootPath = GRAV_ROOT;
$reports['disk'] = [
'free_space' => disk_free_space($rootPath),
'total_space' => disk_total_space($rootPath),
];
// Plugin status
$plugins = $this->grav['plugins']->all();
$enabledPlugins = 0;
$disabledPlugins = 0;
foreach ($plugins as $name => $plugin) {
if ($this->grav['config']->get("plugins.{$name}.enabled", false)) {
$enabledPlugins++;
} else {
$disabledPlugins++;
}
}
$reports['plugins'] = [
'total' => count($plugins),
'enabled' => $enabledPlugins,
'disabled' => $disabledPlugins,
];
// Cache status
$cacheDriver = $this->grav['config']->get('system.cache.driver', 'auto');
$cacheEnabled = $this->grav['config']->get('system.cache.enabled', true);
$reports['cache'] = [
'enabled' => $cacheEnabled,
'driver' => $cacheDriver,
];
return ApiResponse::create($reports);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
/**
* Settings API — lets plugins register admin-next settings panels that
* render as sections inside the Settings page, instead of as standalone
* sidebar entries via the plugin-page mechanism.
*
* Plugins listen for `onApiAdminSettingsPanels` to register panels.
*
* Panel format (same shape as plugin-page definitions, blueprint mode only):
* [
* 'id' => 'login-settings', // unique identifier
* 'plugin' => 'api', // plugin owning the blueprint
* 'label' => 'Login & Security', // card title
* 'description' => 'Authentication …', // optional sub-label
* 'icon' => 'fa-shield-alt', // optional FA icon
* 'blueprint' => 'login-settings', // blueprint file name
* 'data_endpoint' => '/login-settings/data', // GET endpoint
* 'save_endpoint' => '/login-settings/save', // PATCH endpoint
* 'priority' => 0, // sort order (higher = earlier)
* ]
*
* Panels are gated by the registering plugin — the user is passed in the
* event so listeners can skip adding the panel when permissions aren't met.
*/
class SettingsController extends AbstractApiController
{
/**
* GET /settings/panels — Collect admin-next settings panels from plugins.
*/
public function panels(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$event = new Event(['panels' => [], 'user' => $this->getUser($request)]);
$this->grav->fireEvent('onApiAdminSettingsPanels', $event);
$panels = $event['panels'] ?? [];
// Sort by priority descending (higher priority first), preserving
// insertion order among equal-priority panels.
usort($panels, fn($a, $b) => ($b['priority'] ?? 0) <=> ($a['priority'] ?? 0));
return ApiResponse::create($panels);
}
}
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\User\Authentication;
use Grav\Common\User\DataUser\User as DataUser;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Exceptions\ConflictException;
use Grav\Plugin\Api\Exceptions\TooManyRequestsException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\PasswordPolicyService;
use Grav\Plugin\Login\Login;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Handles the one-time first-run setup for fresh Grav 2.0 installs that use
* Admin-Next + API. Active only while user/accounts/ is empty; once any user
* is created the endpoints 409.
*/
class SetupController extends AbstractApiController
{
public function status(ServerRequestInterface $request): ResponseInterface
{
return ApiResponse::create([
'setup_required' => $this->noAccountsExist(),
'password_policy' => PasswordPolicyService::build($this->config),
]);
}
public function create(ServerRequestInterface $request): ResponseInterface
{
$this->enforceSetupRateLimit($request);
if (!$this->noAccountsExist()) {
throw new ConflictException('Setup has already been completed.');
}
$body = $this->getRequestBody($request);
$this->requireFields($body, ['username', 'password', 'email']);
$username = (string) $body['username'];
$password = (string) $body['password'];
$email = (string) $body['email'];
// Validate username format. Delegate the character rules to the core
// helper (Grav\Common\User\DataUser\User::isValidUsername) so setup
// accepts exactly what admin-classic does: letters, numbers, periods,
// hyphens and underscores, while still blocking path traversal,
// leading dots and filesystem-dangerous characters. Keep a 3-64 length
// bound for a friendlier message and to match the admin-next UI hint.
$length = mb_strlen($username);
if ($length < 3 || $length > 64 || !DataUser::isValidUsername($username)) {
throw new ValidationException(
'Invalid username format.',
[['field' => 'username', 'message' => 'Username must be 3-64 characters and contain only letters, numbers, periods, hyphens, and underscores (and cannot start with a period).']],
);
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new ValidationException(
'Invalid email address.',
[['field' => 'email', 'message' => 'A valid email address is required.']],
);
}
$pwdRegex = (string) $this->config->get('system.pwd_regex', '');
if ($pwdRegex !== '' && !@preg_match('#^(?:' . $pwdRegex . ')$#', $password)) {
throw new ValidationException(
'Password does not meet the required policy.',
[['field' => 'password', 'message' => 'Password does not meet the required policy.']],
);
} elseif ($pwdRegex === '' && strlen($password) < 8) {
throw new ValidationException(
'Password is too short.',
[['field' => 'password', 'message' => 'Password must be at least 8 characters.']],
);
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
// Second race-guard check after acquiring accounts: another concurrent
// setup call may have completed between the first check and now.
if ($accounts->count() > 0) {
throw new ConflictException('Setup has already been completed.');
}
$user = $accounts->load($username);
$user->set('email', $email);
$user->set('fullname', $body['fullname'] ?? $username);
$user->set('title', $body['title'] ?? 'Administrator');
$user->set('state', 'enabled');
$user->set('access', [
'site' => ['login' => true],
'api' => ['super' => true],
]);
$user->set('hashed_password', Authentication::create($password));
$user->set('created', time());
$user->set('modified', time());
// Flex user-accounts storage may still hold cached state for this
// username from a previous account (avatar, 2FA, content editor, …).
// Zero them out so the new super-admin is genuinely fresh.
$user->set('avatar', []);
$user->set('twofa_enabled', false);
$user->set('twofa_secret', '');
$user->save();
$this->fireEvent('onApiUserCreated', ['user' => $user]);
$this->fireEvent('onApiSetupComplete', ['user' => $user]);
$jwt = new JwtAuthenticator($this->grav, $this->config);
return $this->issueTokenPair($jwt, $user);
}
private function noAccountsExist(): bool
{
/** @var UserCollectionInterface|null $accounts */
$accounts = $this->grav['accounts'] ?? null;
return $accounts !== null && $accounts->count() === 0;
}
/**
* Defense-in-depth: even though this endpoint is self-disabling once any
* user exists, rate-limit by IP to blunt rapid brute-force probing during
* the eligible window. Reuses the login plugin's rate limiter keyed by a
* synthetic "__api_setup__:{ip}" string.
*/
private function enforceSetupRateLimit(ServerRequestInterface $request): void
{
if (!class_exists(Login::class) || !isset($this->grav['login'])) {
return;
}
$server = $request->getServerParams();
$ip = (string) ($server['REMOTE_ADDR'] ?? 'unknown');
$key = '__api_setup__:' . $ip;
/** @var Login $login */
$login = $this->grav['login'];
$interval = $login->checkLoginRateLimit($key);
if ($interval > 0) {
throw new TooManyRequestsException(
sprintf('Too many setup attempts. Try again in %d minutes.', $interval),
$interval * 60,
);
}
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
/**
* Sidebar API — lets plugins register navigation items in the admin sidebar.
*
* Plugins listen for `onApiSidebarItems` to register items.
*
* Item format:
* [
* 'id' => 'license-manager', // unique identifier
* 'plugin' => 'license-manager', // owning plugin slug
* 'label' => 'License Manager', // display name
* 'icon' => 'fa-key', // FA icon class
* 'route' => '/plugin/license-manager', // admin-next route
* 'priority' => 0, // sort order (higher = earlier)
* 'badge' => null, // optional static badge text/count
* 'badgeEndpoint' => '/my-plugin/badge', // optional — API path returning { count: N }, refreshed live
* 'authorize' => 'api.some.permission', // optional — single permission, or array for any-of
* ]
*
* When `badgeEndpoint` is set, admin-next fetches it on load and re-fetches on
* content/config/plugin/theme changes; a plugin can also push an update live by
* dispatching `grav:sidebar:badge` ({ id, count }). The live count overrides the
* static `badge`.
*
* `authorize` accepts either a string or an array of permissions. An array is
* treated as an any-of test, matching admin-classic's nav-quick-tray template.
* Items without `authorize` are shown to every authenticated user (anyone past
* the api.access gate).
*/
class SidebarController extends AbstractApiController
{
/**
* GET /sidebar/items — Collect sidebar items from plugins, filtered by
* the current user's permissions.
*/
public function items(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$event = new Event(['items' => [], 'user' => $user]);
$this->grav->fireEvent('onApiSidebarItems', $event);
$isSuperAdmin = $this->isSuperAdmin($user);
$filtered = [];
foreach ($event['items'] as $item) {
if (!$this->userPassesAuthorize($user, $item['authorize'] ?? null, $isSuperAdmin)) {
continue;
}
// Strip the authorize field — it's a server-side annotation, not client data
unset($item['authorize']);
$filtered[] = $item;
}
usort($filtered, fn($a, $b) => ($b['priority'] ?? 0) <=> ($a['priority'] ?? 0));
return ApiResponse::create($filtered);
}
}
@@ -0,0 +1,767 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Backup\Backups;
use Grav\Common\Language\LanguageCodes;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\DisabledPluginLangIndex;
use Grav\Plugin\Api\Services\EnvironmentService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class SystemController extends AbstractApiController
{
/**
* GET /system/environments — list writable environment targets.
*
* Response shape:
* {
* detected: "host.example", // what Grav inferred from the URL
* environments: [
* { name: "", label: "Default", exists: true, hasOverrides: true|false },
* { name: "staging", exists: true, hasOverrides: true }
* ]
* }
*
* `name: ""` represents the base user/config target. Any other entry is an
* existing user/env/<name>/ folder that can be selected as a write target.
* Legacy user/<host>/config/ layouts (Grav 1.6 fallback) are included too.
*/
public function environments(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$envService = new EnvironmentService($this->grav);
$list = [[
'name' => '',
'label' => 'Default',
'exists' => true,
'hasOverrides' => false,
]];
foreach ($envService->listEnvironments() as $name) {
$list[] = [
'name' => $name,
'label' => $name,
'exists' => true,
'hasOverrides' => $envService->envHasOverrides($name),
];
}
return ApiResponse::create([
'detected' => $this->grav['uri']->environment(),
'environments' => $list,
]);
}
/**
* POST /system/environments — create a new env folder.
*
* Body: { "name": "staging.foo.com" }
* Creates user/env/<name>/config/ (and user/env/ if missing).
*/
public function createEnvironment(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.write');
$body = $this->getRequestBody($request);
$name = trim((string)($body['name'] ?? ''));
$envService = new EnvironmentService($this->grav);
try {
$envService->createEnvironment($name);
} catch (\InvalidArgumentException $e) {
throw new ValidationException($e->getMessage());
}
return ApiResponse::create([
'name' => $name,
'label' => $name,
'exists' => true,
'hasOverrides' => false,
], 201, ['X-Invalidates' => 'system:environments']);
}
/**
* DELETE /system/environments/{name} — remove a user/env/<name>/ folder.
*
* Refuses to delete the env that Grav resolved for the current request, and
* refuses to act on legacy user/<name>/ layouts. See EnvironmentService for
* the full safety rules.
*/
public function deleteEnvironment(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.write');
$name = (string) $this->getRouteParam($request, 'name');
$envService = new EnvironmentService($this->grav);
try {
$envService->deleteEnvironment($name);
} catch (\InvalidArgumentException $e) {
throw new ValidationException($e->getMessage());
}
return ApiResponse::noContent(['X-Invalidates' => 'system:environments']);
}
public function info(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$plugins = $this->getPluginsInfo();
$themes = $this->getThemesInfo();
$data = [
'grav_version' => GRAV_VERSION,
'php_version' => PHP_VERSION,
'php_extensions' => get_loaded_extensions(),
'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown',
'environment' => $this->config->get('system.environment') ?? $this->grav['uri']->environment(),
'plugins' => $plugins,
'themes' => $themes,
'php_config' => $this->getPhpConfig(),
];
return ApiResponse::create($data);
}
private function getPhpConfig(): array
{
$ini = function (string $key): string {
return (string) ini_get($key);
};
return [
'Upload & POST' => [
'file_uploads' => $ini('file_uploads'),
'upload_max_filesize' => $ini('upload_max_filesize'),
'max_file_uploads' => $ini('max_file_uploads'),
'post_max_size' => $ini('post_max_size'),
],
'Memory & Execution' => [
'memory_limit' => $ini('memory_limit'),
'max_execution_time' => $ini('max_execution_time') . 's',
'max_input_time' => $ini('max_input_time') . 's',
'max_input_vars' => $ini('max_input_vars'),
],
'Error Handling' => [
'display_errors' => $ini('display_errors'),
'error_reporting' => (string) error_reporting(),
'log_errors' => $ini('log_errors'),
'error_log' => $ini('error_log') ?: '(none)',
],
'Paths & Environment' => [
'open_basedir' => $ini('open_basedir') ?: '(none)',
'sys_temp_dir' => sys_get_temp_dir(),
'doc_root' => $_SERVER['DOCUMENT_ROOT'] ?? '(unknown)',
'include_path' => $ini('include_path'),
],
'Session' => [
'session.save_handler' => $ini('session.save_handler'),
'session.save_path' => $ini('session.save_path') ?: '(default)',
'session.gc_maxlifetime' => $ini('session.gc_maxlifetime') . 's',
'session.cookie_lifetime' => $ini('session.cookie_lifetime') . 's',
'session.cookie_secure' => $ini('session.cookie_secure'),
'session.cookie_httponly' => $ini('session.cookie_httponly'),
],
'OPcache' => function_exists('opcache_get_status') ? [
'opcache.enable' => $ini('opcache.enable'),
'opcache.memory_consumption' => $ini('opcache.memory_consumption') . 'MB',
'opcache.max_accelerated_files' => $ini('opcache.max_accelerated_files'),
'opcache.validate_timestamps' => $ini('opcache.validate_timestamps'),
'opcache.revalidate_freq' => $ini('opcache.revalidate_freq') . 's',
] : ['opcache.enable' => '0'],
'Security' => [
'allow_url_fopen' => $ini('allow_url_fopen'),
'allow_url_include' => $ini('allow_url_include'),
'disable_functions' => $ini('disable_functions') ?: '(none)',
'expose_php' => $ini('expose_php'),
],
'Date & Locale' => [
'date.timezone' => $ini('date.timezone') ?: date_default_timezone_get(),
'default_charset' => $ini('default_charset'),
'mbstring.internal_encoding' => $ini('mbstring.internal_encoding') ?: '(default)',
],
];
}
/**
* GET /ping - Lightweight keep-alive endpoint.
* Health/connectivity check. No auth required — session keep-alive
* is handled by proactive token refresh on the client side.
*/
public function ping(ServerRequestInterface $request): ResponseInterface
{
return ApiResponse::create(['pong' => true]);
}
public function clearCache(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.write');
$query = $request->getQueryParams();
$scope = $query['scope'] ?? 'standard';
$allowedScopes = ['all', 'standard', 'images', 'assets', 'tmp'];
if (!in_array($scope, $allowedScopes, true)) {
throw new ValidationException(
"Invalid cache scope '{$scope}'. Allowed: " . implode(', ', $allowedScopes),
);
}
$results = $this->grav['cache']->clearCache($scope);
return ApiResponse::create([
'scope' => $scope,
'message' => "Cache cleared successfully (scope: {$scope}).",
'details' => $results,
]);
}
/**
* GET /system/logs/files — list log files registered for the admin viewer.
*
* Seeds with grav.log / email.log / scheduler.log, then fires
* onApiLogFiles so plugins can append their own. The file names returned
* here are the only values accepted by GET /system/logs?file=...
*/
public function logFiles(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$files = $this->getRegisteredLogFiles();
return ApiResponse::create([
'files' => array_values($files),
'default' => 'grav.log',
]);
}
public function logs(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
$levelFilter = $query['level'] ?? null;
$search = $query['search'] ?? null;
// Validate ?file= against the registered whitelist. Without this an
// attacker could read any file the locator can resolve.
$registered = $this->getRegisteredLogFiles();
$allowed = array_column($registered, 'file');
$requested = $query['file'] ?? 'grav.log';
if (!in_array($requested, $allowed, true)) {
throw new ValidationException('Unknown log file: ' . $requested, [
['field' => 'file', 'message' => 'Must be one of: ' . implode(', ', $allowed)],
]);
}
$logFile = $this->grav['locator']->findResource('log://' . $requested);
if (!$logFile || !file_exists($logFile)) {
return ApiResponse::paginated([], 0, $pagination['page'], $pagination['per_page'], $this->getApiBaseUrl() . '/system/logs');
}
$content = file_get_contents($logFile);
$lines = explode("\n", $content);
$entries = [];
foreach ($lines as $line) {
if ($line === '' || $line[0] !== '[') {
continue;
}
// Extract date
$closeBracket = strpos($line, ']');
if ($closeBracket === false) {
continue;
}
$date = substr($line, 1, $closeBracket - 1);
// Extract logger.LEVEL: message
$rest = ltrim(substr($line, $closeBracket + 1));
$colonPos = strpos($rest, ':');
if ($colonPos === false) {
continue;
}
$loggerLevel = substr($rest, 0, $colonPos);
$dotPos = strpos($loggerLevel, '.');
if ($dotPos === false) {
continue;
}
$logger = substr($loggerLevel, 0, $dotPos);
$level = strtoupper(substr($loggerLevel, $dotPos + 1));
$message = trim(substr($rest, $colonPos + 1));
// Strip trailing [] []
$message = preg_replace('/\s*\[\]\s*\[\]\s*$/', '', $message);
if ($levelFilter !== null && $level !== strtoupper($levelFilter)) {
continue;
}
if ($search !== null && $search !== '' && stripos($message, $search) === false) {
continue;
}
$entries[] = [
'date' => $date,
'logger' => $logger,
'level' => $level,
'message' => $message,
];
}
$entries = array_reverse($entries);
$total = count($entries);
$paged = array_slice($entries, $pagination['offset'], $pagination['limit']);
return ApiResponse::paginated($paged, $total, $pagination['page'], $pagination['per_page'], $this->getApiBaseUrl() . '/system/logs');
}
/**
* Build the list of log files available to the admin viewer.
*
* Seeded with the core logs Grav writes itself, then plugins can append
* via onApiLogFiles. Result is deduped by `file` (first wins) so plugins
* cannot shadow core log labels.
*
* @return array<int, array{file: string, label: string}>
*/
private function getRegisteredLogFiles(): array
{
$files = [
['file' => 'grav.log', 'label' => 'Grav System Log'],
['file' => 'email.log', 'label' => 'Email Log'],
['file' => 'scheduler.log', 'label' => 'Scheduler Log'],
];
$event = $this->fireEvent('onApiLogFiles', ['files' => $files]);
$merged = $event['files'] ?? $files;
// Dedupe by file name; first occurrence wins so core entries above
// are preserved even if a plugin tries to re-register the same name.
$seen = [];
$result = [];
foreach ($merged as $entry) {
if (!is_array($entry) || empty($entry['file'])) {
continue;
}
$name = (string) $entry['file'];
if (isset($seen[$name])) {
continue;
}
// Strip path components defensively — log names must be simple
// basenames so they resolve through the log:// stream.
if ($name !== basename($name)) {
continue;
}
$seen[$name] = true;
$result[] = [
'file' => $name,
'label' => (string) ($entry['label'] ?? $name),
];
}
return $result;
}
public function backup(ServerRequestInterface $request): ResponseInterface
{
// Backups archive the full Grav root, including user/accounts (admin
// password hashes) and user/config secrets. Gate creation, listing,
// download and deletion behind a dedicated api.system.backup permission
// (or api.super) rather than the broader read/write tiers, so only
// operators explicitly trusted with the credential-bearing archive can
// touch it (GHSA-2f86-9cp8-6hcf).
$this->requirePermission($request, 'api.system.backup');
// Ensure backup directory is initialized
$backups = $this->grav['backups'] ?? new Backups();
if (method_exists($backups, 'init')) {
$backups->init();
}
$result = Backups::backup();
$filename = basename($result);
$size = file_exists($result) ? filesize($result) : 0;
return ApiResponse::created(
data: [
'filename' => $filename,
'path' => $result,
'size' => $size,
'date' => date('c'),
],
location: $this->getApiBaseUrl() . '/system/backups',
);
}
public function backups(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.backup');
// Ensure backup directory is initialized before listing
$backups = $this->grav['backups'] ?? new Backups();
if (method_exists($backups, 'init')) {
$backups->init();
}
$list = Backups::getAvailableBackups(true);
$items = [];
foreach ($list as $backup) {
// getAvailableBackups returns stdClass objects, not arrays
$b = is_object($backup) ? $backup : (object) $backup;
$items[] = [
'filename' => $b->filename ?? basename($b->path ?? ''),
'title' => $b->title ?? null,
'date' => $b->date ?? null,
'size' => $b->size ?? 0,
];
}
// Include purge config for storage usage display
$purge = Backups::getPurgeConfig();
return ApiResponse::create([
'backups' => $items,
'purge' => $purge,
'profiles_count' => count(Backups::getBackupProfiles() ?? []),
]);
}
/**
* DELETE /system/backups/{filename} - Delete a backup file.
*/
public function deleteBackup(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.backup');
$b = $this->grav['backups'] ?? new Backups();
if (method_exists($b, 'init')) { $b->init(); }
$filename = $this->getRouteParam($request, 'filename');
// Validate filename (no path traversal)
if (!$filename || $filename !== basename($filename) || !str_ends_with($filename, '.zip')) {
throw new ValidationException(['filename' => ['Invalid backup filename.']]);
}
$backupDir = $this->grav['locator']->findResource('backup://', true);
$filepath = $backupDir . '/' . $filename;
if (!file_exists($filepath)) {
throw new NotFoundException("Backup '{$filename}' not found.");
}
unlink($filepath);
return ApiResponse::noContent();
}
/**
* GET /system/backups/{filename}/download - Download a backup file.
*/
public function downloadBackup(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.backup');
$b = $this->grav['backups'] ?? new Backups();
if (method_exists($b, 'init')) { $b->init(); }
$filename = $this->getRouteParam($request, 'filename');
if (!$filename || $filename !== basename($filename) || !str_ends_with($filename, '.zip')) {
throw new ValidationException(['filename' => ['Invalid backup filename.']]);
}
$backupDir = $this->grav['locator']->findResource('backup://', true);
$filepath = $backupDir . '/' . $filename;
if (!file_exists($filepath)) {
throw new NotFoundException("Backup '{$filename}' not found.");
}
$stream = fopen($filepath, 'rb');
return new \Grav\Framework\Psr7\Response(
200,
[
'Content-Type' => 'application/zip',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => (string) filesize($filepath),
],
$stream,
);
}
/**
* GET /translations/{lang} - Get all translation strings for a language.
*
* Returns a flat key-value object of all translation strings for efficient
* client-side caching. Optionally filter by prefix (e.g., ?prefix=PLUGIN_ADMIN).
*/
public function translations(ServerRequestInterface $request): ResponseInterface
{
// No auth required — translation strings are not sensitive
$lang = $this->getRouteParam($request, 'lang');
$prefix = $request->getQueryParams()['prefix'] ?? null;
/** @var \Grav\Common\Language\Language $language */
$language = $this->grav['language'];
// Validate language code shape only — admin UI languages are a
// different concept from site content languages, so we DO NOT gate
// on $language->getLanguages() (which lists languages configured in
// system.yaml for site content). Any plugin shipping a `languages/
// <lang>.yaml` should be loadable here, even if the site itself only
// serves English content.
if (!is_string($lang) || !preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z]{2,4})?$/', $lang)) {
$lang = $language->getDefault() ?: 'en-US';
}
// Coerce legacy short codes to their BCP 47 canonical form so a request
// for `/translations/en` resolves to admin2's `en-US.yaml`.
$lang = self::normalizeLangCode($lang);
/** @var \Grav\Common\Config\Languages $languages */
$languages = $this->grav['languages'];
try {
$translations = $languages->flattenByLang($lang);
} catch (\Throwable) {
$translations = [];
}
// Strip strings contributed only by disabled plugins. Grav core's
// `flattenByLang()` reads every plugin's lang yaml regardless of enabled
// state — fine for the legacy admin, broken for admin2: a disabled plugin
// would still influence what admin2 renders. The service walks each
// plugin's lang yaml to determine provenance and returns keys unique to
// disabled plugins. Keys also shipped by enabled sources stay.
if (is_array($translations)) {
$disabledIndex = new DisabledPluginLangIndex($this->grav);
foreach ($disabledIndex->disabledOnlyKeys($lang) as $key) {
unset($translations[$key]);
}
}
// Drop flat `<key>` entries when an `ICU.<key>` shadow exists. Admin2 ships
// the canonical PLUGIN_ADMIN.* vocabulary under ICU; if a 3rd-party plugin
// still using the Grav 1 flat convention is also installed, its values
// would otherwise leak into the dictionary served to the client. Keeping
// only the ICU side guarantees admin2 is the source of truth.
if (is_array($translations)) {
foreach (array_keys($translations) as $key) {
if (is_string($key) && !str_starts_with($key, 'ICU.') && isset($translations['ICU.' . $key])) {
unset($translations[$key]);
}
}
}
// Filter by prefix if requested
if ($prefix && is_array($translations)) {
$prefixLower = strtolower($prefix) . '.';
$translations = array_filter(
$translations,
fn($key) => str_starts_with(strtolower($key), $prefixLower),
ARRAY_FILTER_USE_KEY
);
}
// Include a checksum for cache invalidation
$checksum = md5(json_encode($translations));
return ApiResponse::create([
'lang' => $lang,
'dir' => LanguageCodes::getOrientation(self::primarySubtag($lang)),
'count' => count($translations),
'checksum' => $checksum,
'strings' => $translations,
]);
}
/**
* GET /admin/languages - Locales the admin UI itself can be rendered in.
*
* Distinct from GET /languages, which returns *site content* languages
* configured in system.yaml. This endpoint returns locales for which a
* translation file exists in the admin2 plugin's languages directory —
* i.e. languages a user can pick for their admin interface.
*/
public function adminLanguages(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$dir = GRAV_ROOT . '/user/plugins/admin2/languages';
$languages = [];
if (is_dir($dir)) {
foreach (glob($dir . '/*.yaml') ?: [] as $file) {
$code = basename($file, '.yaml');
$languages[] = [
'code' => $code,
'name' => LanguageCodes::getName($code) ?: $code,
'native_name' => LanguageCodes::getNativeName($code) ?: $code,
'rtl' => LanguageCodes::isRtl(self::primarySubtag($code)),
];
}
}
// Stable sort by native name so the dropdown order doesn't depend on
// filesystem readdir order.
usort($languages, fn($a, $b) => strcmp($a['native_name'], $b['native_name']));
return ApiResponse::create([
'languages' => $languages,
]);
}
private function getPluginsInfo(): array
{
$plugins = [];
$gpm = $this->grav['plugins'];
foreach ($gpm as $plugin) {
$name = $plugin->name;
// Plugin::getBlueprint() asserts the plugin's metadata is in
// the Plugins manager. On Grav 2.0-rc.2 a number of registered
// plugin instances have no companion entry there (login, form,
// error, several first-party + side-car plugins), and the
// assert blows up for the whole /system/info request. Fall
// back to a read-from-disk path so partial info still ships.
$bpName = null;
$bpVersion = null;
if ($gpm->get($name) !== null) {
try {
$blueprint = $plugin->getBlueprint();
$bpName = $blueprint->get('name');
$bpVersion = $blueprint->get('version');
} catch (\Throwable $e) {
// Defensive: even past the null check, blueprint
// hydration can throw on malformed yaml. Treat as
// metadata-unavailable.
}
} else {
// Direct file read — bypasses Plugin::loadBlueprint() entirely.
$file = GRAV_ROOT . "/user/plugins/{$name}/blueprints.yaml";
if (is_file($file)) {
try {
$raw = \Symfony\Component\Yaml\Yaml::parseFile($file);
if (is_array($raw)) {
$bpName = $raw['name'] ?? null;
$bpVersion = $raw['version'] ?? null;
}
} catch (\Throwable $e) {
// ignore — leave metadata blank
}
}
}
$plugins[] = [
'name' => $bpName ?? $name,
'version' => $bpVersion ?? '0.0.0',
'enabled' => $this->config->get("plugins.{$name}.enabled", false),
];
}
return $plugins;
}
private function getThemesInfo(): array
{
$themes = [];
$activeTheme = $this->config->get('system.pages.theme');
$themesDir = $this->grav['locator']->findResource('themes://');
if (!$themesDir || !is_dir($themesDir)) {
return $themes;
}
$iterator = new \DirectoryIterator($themesDir);
foreach ($iterator as $item) {
if ($item->isDot() || !$item->isDir()) {
continue;
}
$blueprintFile = $item->getPathname() . '/blueprints.yaml';
if (!file_exists($blueprintFile)) {
continue;
}
$blueprint = \Grav\Common\Yaml::parse(file_get_contents($blueprintFile));
$themeName = $item->getFilename();
$themes[] = [
'name' => $blueprint['name'] ?? $themeName,
'version' => $blueprint['version'] ?? '0.0.0',
'active' => $themeName === $activeTheme,
];
}
return $themes;
}
/**
* Map a raw lang code (`en`, `fr`, `zh-hans`) to its BCP 47 canonical form
* (`en-US`, `fr-FR`, `zh-Hans`). Admin2 + admin-next standardize on BCP 47
* for their UI surfaces, so any short or lowercase variant arriving on the
* wire is coerced here before disk lookup. Anything not in the alias map
* (or already in canonical region/script casing) passes through.
*/
/**
* Primary language subtag of a BCP 47 code. `he-IL` → `he`, `zh-Hans` →
* `zh`. Grav core's `LanguageCodes` table is keyed by short codes only,
* so any lookup against it has to go through here when the input might
* be region/script-qualified.
*/
private static function primarySubtag(string $code): string
{
return strtolower(explode('-', $code, 2)[0]);
}
private static function normalizeLangCode(string $code): string
{
static $aliases = [
'en' => 'en-US',
'ar' => 'ar-SA',
'cs' => 'cs-CZ',
'de' => 'de-DE',
'es' => 'es-ES',
'es-mx' => 'es-MX',
'fi' => 'fi-FI',
'fr' => 'fr-FR',
'fr-ca' => 'fr-CA',
'he' => 'he-IL',
'it' => 'it-IT',
'nl' => 'nl-NL',
'pt' => 'pt-PT',
'ru' => 'ru-RU',
'sv' => 'sv-SE',
'uk' => 'uk-UA',
'zh-hans' => 'zh-Hans',
'zh-hant' => 'zh-Hant',
];
$key = strtolower(str_replace('_', '-', trim($code)));
if (isset($aliases[$key])) {
return $aliases[$key];
}
if (preg_match('/^([a-z]{2,3})-([a-z0-9]{2,4})$/i', $code, $m)) {
$tag = strlen($m[2]) === 4
? ucfirst(strtolower($m[2]))
: strtoupper($m[2]);
return strtolower($m[1]) . '-' . $tag;
}
return $code;
}
}
@@ -0,0 +1,933 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\User\Authentication;
use Grav\Common\User\DataUser\User as DataUser;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Framework\Flex\FlexDirectory;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Grav\Plugin\Api\Exceptions\ConflictException;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\FlexBackend;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\UserSerializer;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class UsersController extends AbstractApiController
{
use FlexBackend;
private ?UserSerializer $serializer = null;
public function index(ServerRequestInterface $request): ResponseInterface
{
// Without api.users.read a caller can still see *their own* row —
// we auto-filter the listing to self rather than 403 the request.
// Anything beyond that requires api.users.read.
$currentUser = $this->getUser($request);
$canSeeAll = $this->isSuperAdmin($currentUser)
|| $this->hasPermission($currentUser, 'api.users.read');
if (!$canSeeAll) {
return $this->indexSelfOnly($request, $currentUser);
}
$directory = $this->getFlexDirectory('user-accounts');
if ($directory) {
return $this->indexViaFlex($request, $directory);
}
return $this->indexViaAccounts($request);
}
/**
* Single-row "listing" for callers without api.users.read. Matches the
* paginated envelope of the full listing so the client doesn't need a
* special-case branch.
*/
private function indexSelfOnly(ServerRequestInterface $request, UserInterface $currentUser): ResponseInterface
{
$pagination = $this->getPagination($request);
$data = [$this->serializeUser($currentUser)];
return ApiResponse::paginated(
data: $data,
total: 1,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $this->getApiBaseUrl() . '/users',
);
}
/**
* List users using the Flex-Objects backend (indexed, searchable).
*/
private function indexViaFlex(ServerRequestInterface $request, FlexDirectory $directory): ResponseInterface
{
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
$search = $query['search'] ?? null;
$filters = $this->getListFilters($request);
// Grav's Flex FileStorage indexes every file in user/accounts/ without
// filtering by extension — any stray file left there by another plugin
// (e.g. revisions-pro's `name.yaml.<timestamp>.rev` snapshots) surfaces
// as a phantom user. Constrain to keys that look like actual usernames
// before the collection is built so downstream search/sort/pagination
// operate on real accounts only.
//
// Usernames may legitimately contain periods (DataUser::isValidUsername
// allows them, and so does POST /users), so we can't simply reject dots
// — that hid accounts like `bill.bailey`. Instead accept anything that
// is a valid username but drop keys that embed a stored-file extension
// (`.yaml`/`.json`), which is the tell-tale of a revision/backup stray.
$index = $directory->getIndex();
$validKeys = array_values(array_filter(
$index->getKeys(),
static fn($k) => is_string($k)
&& DataUser::isValidUsername($k)
&& !preg_match('/\.(ya?ml|json)(\.|$)/i', $k),
));
$collection = $directory->getCollection($validKeys);
// Apply search (searches username, email, fullname per blueprint config)
if ($search && $search !== '') {
$collection = $collection->search($search);
}
// Sort by username by default
$collection = $collection->sort(['username' => 'asc']);
if ($filters['access'] === '' && $filters['group'] === '') {
// No permission/group filter — keep the lazy, indexed fast path that
// only materializes the requested page.
$total = $collection->count();
$slice = $collection->slice($pagination['offset'], $pagination['limit']);
$data = [];
foreach ($slice as $flexUser) {
if ($flexUser instanceof UserInterface) {
$data[] = $this->serializeUser($flexUser);
}
}
} else {
// Permission/group filtering can't be expressed as an indexed query
// (it depends on effective access, including group inheritance and
// the superuser fallback), so materialize the ordered users and
// filter in PHP before paginating. Search above already narrowed
// the set.
$users = [];
foreach ($collection as $flexUser) {
if ($flexUser instanceof UserInterface && $this->userMatchesFilters($flexUser, $filters)) {
$users[] = $flexUser;
}
}
$total = count($users);
$data = [];
foreach (array_slice($users, $pagination['offset'], $pagination['limit']) as $flexUser) {
$data[] = $this->serializeUser($flexUser);
}
}
return ApiResponse::paginated(
data: $data,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $this->getApiBaseUrl() . '/users',
);
}
/**
* List users using filesystem scan (fallback).
*/
private function indexViaAccounts(ServerRequestInterface $request): ResponseInterface
{
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
$search = isset($query['search']) ? trim((string) $query['search']) : '';
$filters = $this->getListFilters($request);
$allUsers = [];
foreach ($this->getAllUsernames() as $username) {
$user = $this->grav['accounts']->load($username);
if (!$user->exists()) {
continue;
}
if ($search !== '' && !$this->userMatchesSearch($user, $search)) {
continue;
}
if (!$this->userMatchesFilters($user, $filters)) {
continue;
}
$allUsers[] = $this->serializeUser($user);
}
$total = count($allUsers);
$paged = array_slice($allUsers, $pagination['offset'], $pagination['limit']);
return ApiResponse::paginated(
data: $paged,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $this->getApiBaseUrl() . '/users',
);
}
public function show(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
// Self-access mirrors update(): a user can fetch their own record
// with just api.access. Otherwise api.users.read is required to see
// someone else's account.
$currentUser = $this->getUser($request);
if ($currentUser->username !== $username) {
$this->requirePermission($request, 'api.users.read');
} else {
$this->requirePermission($request, 'api.access');
}
$user = $this->loadUserOrFail($username);
$data = $this->serializeUser($user);
// ETag is computed from the user data only — system capability flags
// like twofa_global_enabled are not part of the resource state and
// shouldn't cause spurious 409s on PATCH when the admin flips the
// global setting between fetch and save.
$etag = $this->generateEtag($data);
$data['twofa_global_enabled'] = (bool) $this->config->get('plugins.login.twofa_enabled', false);
return ApiResponse::create($data, 200, ['ETag' => '"' . $etag . '"']);
}
public function create(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.write');
$body = $this->getRequestBody($request);
$this->requireFields($body, ['username', 'password', 'email']);
$username = $body['username'];
// Validate username format. Delegate the character rules to the core
// helper (Grav\Common\User\DataUser\User::isValidUsername) so the API
// accepts exactly what admin-classic does: letters, numbers, periods,
// hyphens and underscores, while still blocking path traversal,
// leading dots and filesystem-dangerous characters. Keep a 3-64 length
// bound for a friendlier message and to match the admin-next UI hint.
$length = mb_strlen((string) $username);
if ($length < 3 || $length > 64 || !DataUser::isValidUsername((string) $username)) {
throw new ValidationException(
'Invalid username format.',
[['field' => 'username', 'message' => 'Username must be 3-64 characters and contain only letters, numbers, periods, hyphens, and underscores (and cannot start with a period).']],
);
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$existing = $accounts->load($username);
if ($existing->exists()) {
throw new ConflictException("User '{$username}' already exists.");
}
// Create new user
$user = $accounts->load($username);
$user->set('email', $body['email']);
$user->set('fullname', $body['fullname'] ?? '');
$user->set('title', $body['title'] ?? '');
$user->set('state', $body['state'] ?? 'enabled');
$user->set('hashed_password', Authentication::create($body['password']));
$user->set('created', time());
$user->set('modified', time());
if (isset($body['access'])) {
$user->set('access', $body['access']);
}
// `groups` is super-admin-only (see update()): group membership can grant
// access, so a non-super creator must not seed group assignments.
if (isset($body['groups']) && $this->isSuperAdmin($this->getUser($request))) {
$user->set('groups', $body['groups']);
}
// Allow plugins to modify the user before save
$this->fireAdminEvent('onAdminSave', ['object' => &$user]);
// Validate the submitted fields against the account blueprint before
// writing to disk (admin2#30) — e.g. a password that fails the
// configured pwd_regex, or a required field sent empty, now returns 422.
$this->validateChangedFields($body, method_exists($user, 'getBlueprint') ? $user->getBlueprint() : null);
$user->save();
$this->fireAdminEvent('onAdminAfterSave', ['object' => $user]);
$this->fireEvent('onApiUserCreated', ['user' => $user]);
return ApiResponse::created(
data: $this->serializeUser($user),
location: $this->getApiBaseUrl() . '/users/' . $username,
headers: $this->invalidationHeaders(['users:create:' . $username, 'users:list']),
);
}
public function update(ServerRequestInterface $request): ResponseInterface
{
$currentUser = $this->getUser($request);
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
// Users can update themselves with just api.access, otherwise need api.users.write
$isSelf = $currentUser->username === $username;
$canManageUsers = $this->isSuperAdmin($currentUser)
|| $this->hasPermission($currentUser, 'api.users.write');
if (!$isSelf) {
$this->requirePermission($request, 'api.users.write');
} else {
// Self-edit only requires api.access (already checked by auth middleware)
$this->requirePermission($request, 'api.access');
}
// ETag validation
$currentHash = $this->generateEtag($this->serializeUser($user));
$this->validateEtag($request, $currentHash);
$body = $this->getRequestBody($request);
if (empty($body)) {
throw new ValidationException('Request body must contain fields to update.');
}
// Privilege-sensitive fields are gated on api.users.write. Without this
// split a self-edit (api.access only) could PATCH `access` and grant
// itself api.super / admin.super — see GHSA-r945-h4vm-h736.
$selfFields = ['email', 'fullname', 'title', 'language', 'content_editor', 'twofa_enabled'];
$adminFields = ['state', 'access'];
// `groups` is marked `security@: admin.super` in the account blueprint:
// group membership can confer access, so only super admins may change it
// — a plain api.users.write manager must not assign users into groups.
$superFields = ['groups'];
$isSuper = $this->isSuperAdmin($currentUser);
if (!$canManageUsers) {
foreach ($adminFields as $field) {
if (array_key_exists($field, $body)) {
throw new ForbiddenException(
"Modifying '{$field}' requires the 'api.users.write' permission."
);
}
}
}
if (!$isSuper) {
foreach ($superFields as $field) {
if (array_key_exists($field, $body)) {
throw new ForbiddenException(
"Modifying '{$field}' requires super-admin privileges."
);
}
}
}
$allowedFields = $selfFields;
if ($canManageUsers) {
$allowedFields = array_merge($allowedFields, $adminFields);
}
if ($isSuper) {
$allowedFields = array_merge($allowedFields, $superFields);
}
foreach ($allowedFields as $field) {
if (array_key_exists($field, $body)) {
$user->set($field, $body[$field]);
}
}
// Hash password if provided
if (isset($body['password']) && $body['password'] !== '') {
$user->set('hashed_password', Authentication::create($body['password']));
}
$user->set('modified', time());
// Allow plugins to modify the user before save
$this->fireAdminEvent('onAdminSave', ['object' => &$user]);
// Validate the submitted fields against the account blueprint before
// writing to disk (admin2#30).
$this->validateChangedFields($body, method_exists($user, 'getBlueprint') ? $user->getBlueprint() : null);
$user->save();
$this->fireAdminEvent('onAdminAfterSave', ['object' => $user]);
$this->fireEvent('onApiUserUpdated', ['user' => $user]);
return $this->respondWithEtag(
$this->serializeUser($user),
200,
['users:update:' . $username, 'users:list'],
);
}
public function delete(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.write');
$currentUser = $this->getUser($request);
$username = $this->getRouteParam($request, 'username');
if ($currentUser->username === $username) {
throw new ForbiddenException('You cannot delete your own account.');
}
$user = $this->loadUserOrFail($username);
$this->fireEvent('onApiBeforeUserDelete', ['user' => $user]);
// Remove user file
$file = $user->file();
if ($file) {
$file->delete();
}
$this->fireEvent('onApiUserDeleted', ['username' => $username]);
return ApiResponse::noContent(
$this->invalidationHeaders(['users:delete:' . $username, 'users:list']),
);
}
/**
* POST /users/{username}/avatar - Upload a custom avatar image.
*/
public function uploadAvatar(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$currentUser = $this->getUser($request);
if ($currentUser->username !== $username) {
$this->requirePermission($request, 'api.users.write');
}
$uploadedFiles = $request->getUploadedFiles();
$file = $uploadedFiles['avatar'] ?? $uploadedFiles['file'] ?? null;
if (!$file || $file->getError() !== UPLOAD_ERR_OK) {
throw new ValidationException('No avatar file uploaded.');
}
$mime = $file->getClientMediaType() ?? '';
if (!str_starts_with($mime, 'image/')) {
throw new ValidationException('Avatar must be an image file.');
}
// Save to account://avatars/
$locator = $this->grav['locator'];
$avatarDir = $locator->findResource('account://', true) . '/avatars';
if (!is_dir($avatarDir)) {
mkdir($avatarDir, 0755, true);
}
$ext = match ($mime) {
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => 'jpg',
};
$filename = $username . '-' . substr(md5((string) time()), 0, 8) . '.' . $ext;
$filepath = $avatarDir . '/' . $filename;
$file->moveTo($filepath);
// Build path relative to Grav root (e.g. user/accounts/avatars/filename.jpg)
// to match the format used by the old admin plugin.
$relativeBase = $locator->findResource('account://', false);
$relativePath = $relativeBase . '/avatars/' . $filename;
// Update user's avatar reference
$user->set('avatar', [
$relativePath => [
'name' => $filename,
'type' => $mime,
'size' => filesize($filepath),
'path' => $relativePath,
],
]);
$user->save();
return ApiResponse::create(
$this->serializeUser($user),
201,
$this->invalidationHeaders(['users:update:' . $username]),
);
}
/**
* DELETE /users/{username}/avatar - Remove the custom avatar.
*/
public function deleteAvatar(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$currentUser = $this->getUser($request);
if ($currentUser->username !== $username) {
$this->requirePermission($request, 'api.users.write');
}
// Delete avatar file(s)
$avatar = $user->get('avatar');
if (is_array($avatar)) {
foreach ($avatar as $entry) {
if (is_array($entry) && isset($entry['path'])) {
// path is relative to Grav root (e.g. user/accounts/avatars/file.jpg)
$filePath = GRAV_ROOT . '/' . $entry['path'];
if (file_exists($filePath)) {
@unlink($filePath);
}
}
}
}
$user->set('avatar', []);
$user->save();
return ApiResponse::create(
$this->serializeUser($user),
200,
$this->invalidationHeaders(['users:update:' . $username]),
);
}
/**
* POST /users/{username}/2fa - Generate or regenerate 2FA secret and return QR code.
*/
public function generate2fa(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
// Self or admin
$currentUser = $this->getUser($request);
if ($currentUser->username !== $username) {
$this->requirePermission($request, 'api.users.write');
}
if (!class_exists(\Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth::class)) {
throw new \Grav\Plugin\Api\Exceptions\ApiException(
500,
'2FA Not Available',
'The Login plugin with 2FA support must be installed.'
);
}
$twoFa = new \Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth();
$secret = $twoFa->createSecret();
// Format secret with spaces for readability
$formattedSecret = trim(chunk_split($secret, 4, ' '));
// Save to user
$user->set('twofa_secret', $formattedSecret);
// Generating/regenerating a secret resets the enabled flag — the user
// must verify a code against the new secret to re-enable.
$user->set('twofa_enabled', false);
$user->save();
// Generate QR code data URI
$qrImage = $twoFa->getQrImageData($username, $secret);
return ApiResponse::create([
'secret' => $formattedSecret,
'qr_code' => $qrImage,
]);
}
/**
* POST /users/{username}/2fa/enable - Verify a code against the stored
* secret and set twofa_enabled=true. Self-only: only the account owner
* can enable their own 2FA, because enabling requires proving you hold
* the secret (otherwise an attacker could lock a user out by enabling
* 2FA with a secret they don't control).
*/
public function enable2fa(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$currentUser = $this->getUser($request);
if ($currentUser->username !== $username) {
throw new ForbiddenException('Only the account owner can enable 2FA.');
}
$body = $this->getRequestBody($request);
$this->requireFields($body, ['code']);
if (!class_exists(\Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth::class)) {
throw new \Grav\Plugin\Api\Exceptions\ApiException(
500,
'2FA Not Available',
'The Login plugin with 2FA support must be installed.',
);
}
$secret = (string) $user->get('twofa_secret');
if ($secret === '') {
throw new ValidationException('2FA secret has not been generated. POST /users/{username}/2fa first.');
}
$twoFa = new \Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth();
if (!$twoFa->verifyCode($secret, (string) $body['code'])) {
throw new ValidationException('Invalid 2FA code.');
}
$user->set('twofa_enabled', true);
$user->save();
$this->fireEvent('onApiUser2faEnabled', ['user' => $user]);
return ApiResponse::create(['twofa_enabled' => true]);
}
/**
* POST /users/{username}/2fa/disable - Disable 2FA for a user.
*
* Self-disable requires a valid current TOTP code so that a stolen
* session cannot unilaterally remove 2FA. Admins with api.users.write
* (or superadmin) can force-disable without a code — used for lost-
* device recovery. Both paths clear twofa_secret.
*/
public function disable2fa(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$currentUser = $this->getUser($request);
$isSelf = $currentUser->username === $username;
$isAdmin = $this->isSuperAdmin($currentUser) || $this->hasPermission($currentUser, 'api.users.write');
if (!$isSelf && !$isAdmin) {
throw new ForbiddenException('You do not have permission to disable 2FA for this user.');
}
if ($isSelf && !$isAdmin) {
// Self-disable without admin privilege requires code verification.
$body = $this->getRequestBody($request);
$this->requireFields($body, ['code']);
if (!class_exists(\Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth::class)) {
throw new \Grav\Plugin\Api\Exceptions\ApiException(
500,
'2FA Not Available',
'The Login plugin with 2FA support must be installed.',
);
}
$secret = (string) $user->get('twofa_secret');
$twoFa = new \Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth();
if (!$secret || !$twoFa->verifyCode($secret, (string) $body['code'])) {
throw new ValidationException('Invalid 2FA code.');
}
}
$user->set('twofa_enabled', false);
$user->set('twofa_secret', '');
$user->save();
$this->fireEvent('onApiUser2faDisabled', [
'user' => $user,
'forced_by_admin' => !$isSelf,
]);
return ApiResponse::create(['twofa_enabled' => false]);
}
public function apiKeys(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$this->requireApiKeyPermission($request, $username);
$manager = new ApiKeyManager();
$keys = $manager->listKeys($user);
return ApiResponse::create($keys);
}
public function createApiKey(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$this->requireApiKeyPermission($request, $username, write: true);
$body = $this->getRequestBody($request);
$name = $body['name'] ?? '';
$scopes = $body['scopes'] ?? [];
$expiryDays = isset($body['expiry_days']) ? (int) $body['expiry_days'] : null;
$manager = new ApiKeyManager();
$result = $manager->generateKey($user, $name, $scopes, $expiryDays);
// Return the raw key (shown ONCE only) along with key metadata
$keys = $manager->listKeys($user);
$keyMeta = null;
foreach ($keys as $key) {
if ($key['id'] === $result['id']) {
$keyMeta = $key;
break;
}
}
$data = array_merge($keyMeta ?? [], ['api_key' => $result['key']]);
return ApiResponse::created(
data: $data,
location: $this->getApiBaseUrl() . '/users/' . $username . '/api-keys',
headers: $this->invalidationHeaders(['users:update:' . $username . ':api-keys']),
);
}
public function deleteApiKey(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$this->requireApiKeyPermission($request, $username, write: true);
$keyId = $this->getRouteParam($request, 'keyId');
$manager = new ApiKeyManager();
$revoked = $manager->revokeKey($user, $keyId);
if (!$revoked) {
throw new NotFoundException("API key '{$keyId}' not found for user '{$username}'.");
}
return ApiResponse::noContent(
$this->invalidationHeaders(['users:update:' . $username . ':api-keys']),
);
}
/**
* Check permission for API key operations. Own user with api.access is sufficient,
* otherwise require api.users.read (or api.users.write for mutations).
*/
private function requireApiKeyPermission(
ServerRequestInterface $request,
string $targetUsername,
bool $write = false,
): void {
$currentUser = $this->getUser($request);
$isSelf = $currentUser->username === $targetUsername;
if ($isSelf) {
// Self-access only requires api.access
$this->requirePermission($request, 'api.access');
} else {
$this->requirePermission($request, $write ? 'api.users.write' : 'api.users.read');
}
}
private function loadUserOrFail(?string $username): UserInterface
{
if ($username === null || $username === '') {
throw new ValidationException('Username is required.');
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($username);
if (!$user->exists()) {
throw new NotFoundException("User '{$username}' not found.");
}
return $user;
}
private function serializeUser(UserInterface $user): array
{
return $this->getSerializer()->serialize($user);
}
/**
* Extract the access/group list filters from the request query string.
*
* `access` is the canonical permission filter (e.g. `admin.login`,
* `api.super`); `permission` is accepted as an alias. `group` filters by
* group membership.
*
* @return array{access: string, group: string}
*/
private function getListFilters(ServerRequestInterface $request): array
{
$query = $request->getQueryParams();
$access = $query['access'] ?? $query['permission'] ?? '';
$group = $query['group'] ?? '';
return [
'access' => is_string($access) ? trim($access) : '',
'group' => is_string($group) ? trim($group) : '',
];
}
/**
* @param array{access: string, group: string} $filters
*/
private function userMatchesFilters(UserInterface $user, array $filters): bool
{
if ($filters['group'] !== '') {
$groups = array_map('strval', (array) $user->get('groups', []));
if (!in_array($filters['group'], $groups, true)) {
return false;
}
}
if ($filters['access'] !== '' && !$this->userHasEffectiveAccess($user, $filters['access'])) {
return false;
}
return true;
}
/**
* Test whether a user is effectively granted a permission, independent of
* login state (so it works against accounts loaded from storage).
*
* Resolves the action against the merged access map (group access overlaid
* by the user's own access) with parent-key inheritance — `api.pages`
* covers `api.pages.read` — and treats super admins (api.super or the
* legacy admin.super) as authorized for everything, so "find all admins"
* catches either authority.
*/
private function userHasEffectiveAccess(UserInterface $user, string $action): bool
{
if ($action === '') {
return true;
}
$flat = $this->effectiveAccessMap($user);
if ($action !== 'admin.super' && $action !== 'api.super') {
if ($this->isPositiveFlat($flat, 'api.super') || $this->isPositiveFlat($flat, 'admin.super')) {
return true;
}
}
// Walk up the dot-path; the closest explicitly-set key wins.
$key = $action;
while ($key !== '') {
if (array_key_exists($key, $flat)) {
return Utils::isPositive($flat[$key]);
}
$pos = strrpos($key, '.');
$key = $pos !== false ? substr($key, 0, $pos) : '';
}
return false;
}
/**
* Build a flattened (dot-notation) access map for the user: each group's
* access first, then the user's own access on top so direct grants
* override inherited ones.
*
* @return array<string, mixed>
*/
private function effectiveAccessMap(UserInterface $user): array
{
$map = [];
foreach ((array) $user->get('groups', []) as $group) {
if (!is_string($group)) {
continue;
}
$groupAccess = $this->config->get("groups.{$group}.access");
if (is_array($groupAccess)) {
$map = array_merge($map, Utils::arrayFlattenDotNotation($groupAccess));
}
}
$own = $user->get('access');
if (is_array($own)) {
$map = array_merge($map, Utils::arrayFlattenDotNotation($own));
}
return $map;
}
/**
* @param array<string, mixed> $flat
*/
private function isPositiveFlat(array $flat, string $key): bool
{
return array_key_exists($key, $flat) && Utils::isPositive($flat[$key]);
}
/**
* Case-insensitive substring match across the searchable user fields,
* mirroring the Flex backend's blueprint-configured search.
*/
private function userMatchesSearch(UserInterface $user, string $search): bool
{
$needle = mb_strtolower($search);
$haystacks = [
(string) $user->username,
(string) $user->get('email', ''),
(string) $user->get('fullname', ''),
(string) $user->get('title', ''),
];
foreach ($haystacks as $value) {
if ($value !== '' && str_contains(mb_strtolower($value), $needle)) {
return true;
}
}
return false;
}
private function getSerializer(): UserSerializer
{
return $this->serializer ??= new UserSerializer();
}
/**
* Get all usernames by scanning account files.
*/
private function getAllUsernames(): array
{
$locator = $this->grav['locator'];
$accountDir = $locator->findResource('account://', true)
?: $locator->findResource('user://accounts', true);
if (!$accountDir || !is_dir($accountDir)) {
return [];
}
$usernames = [];
foreach (new \DirectoryIterator($accountDir) as $file) {
if ($file->isDot() || !$file->isFile() || $file->getExtension() !== 'yaml') {
continue;
}
$usernames[] = $file->getBasename('.yaml');
}
sort($usernames);
return $usernames;
}
}
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Webhooks\WebhookDispatcher;
use Grav\Plugin\Api\Webhooks\WebhookManager;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class WebhookController extends AbstractApiController
{
private const PERMISSION_READ = 'api.webhooks.read';
private const PERMISSION_WRITE = 'api.webhooks.write';
private const VALID_EVENTS = [
'*',
'page.created', 'page.updated', 'page.deleted', 'page.moved', 'page.translated',
'pages.reordered',
'media.uploaded', 'media.deleted',
'user.created', 'user.updated', 'user.deleted',
'config.updated',
'gpm.installed', 'gpm.removed', 'grav.upgraded',
];
private readonly WebhookManager $manager;
public function __construct(\Grav\Common\Grav $grav, \Grav\Common\Config\Config $config)
{
parent::__construct($grav, $config);
$this->manager = new WebhookManager();
}
/**
* GET /webhooks - List all configured webhooks.
*/
public function index(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$webhooks = $this->manager->getAll();
// Redact secrets in listing
$data = array_map(function ($webhook) {
$webhook['secret'] = $this->redactSecret($webhook['secret'] ?? '');
return $webhook;
}, $webhooks);
return ApiResponse::create($data);
}
/**
* POST /webhooks - Create a new webhook.
*/
public function create(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$body = $this->getRequestBody($request);
$this->requireFields($body, ['url']);
$url = $body['url'];
if (!filter_var($url, FILTER_VALIDATE_URL)) {
throw new ValidationException("Invalid webhook URL: {$url}");
}
// Validate events if provided
if (isset($body['events'])) {
$this->validateEvents($body['events']);
}
$webhook = $this->manager->create($body);
$location = $this->getApiBaseUrl() . '/webhooks/' . $webhook['id'];
return ApiResponse::created($webhook, $location);
}
/**
* GET /webhooks/{id} - Get webhook details.
*/
public function show(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$id = $this->getRouteParam($request, 'id');
$webhook = $this->manager->get($id);
if (!$webhook) {
throw new NotFoundException("Webhook '{$id}' not found.");
}
// Redact secret
$webhook['secret'] = $this->redactSecret($webhook['secret'] ?? '');
return $this->respondWithEtag($webhook);
}
/**
* PATCH /webhooks/{id} - Update a webhook.
*/
public function update(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$id = $this->getRouteParam($request, 'id');
$body = $this->getRequestBody($request);
if (isset($body['url']) && !filter_var($body['url'], FILTER_VALIDATE_URL)) {
throw new ValidationException("Invalid webhook URL: {$body['url']}");
}
if (isset($body['events'])) {
$this->validateEvents($body['events']);
}
$webhook = $this->manager->update($id, $body);
if (!$webhook) {
throw new NotFoundException("Webhook '{$id}' not found.");
}
// Redact secret
$webhook['secret'] = $this->redactSecret($webhook['secret'] ?? '');
return $this->respondWithEtag($webhook);
}
/**
* DELETE /webhooks/{id} - Delete a webhook.
*/
public function delete(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$id = $this->getRouteParam($request, 'id');
$deleted = $this->manager->delete($id);
if (!$deleted) {
throw new NotFoundException("Webhook '{$id}' not found.");
}
return ApiResponse::noContent();
}
/**
* GET /webhooks/{id}/deliveries - Get delivery log for a webhook.
*/
public function deliveries(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$id = $this->getRouteParam($request, 'id');
if (!$this->manager->get($id)) {
throw new NotFoundException("Webhook '{$id}' not found.");
}
$pagination = $this->getPagination($request);
$result = $this->manager->getDeliveries($id, $pagination['limit'], $pagination['offset']);
$baseUrl = $this->getApiBaseUrl() . '/webhooks/' . $id . '/deliveries';
return ApiResponse::paginated(
data: $result['deliveries'],
total: $result['total'],
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $baseUrl,
);
}
/**
* POST /webhooks/{id}/test - Send a test payload.
*/
public function test(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$id = $this->getRouteParam($request, 'id');
$webhook = $this->manager->get($id);
if (!$webhook) {
throw new NotFoundException("Webhook '{$id}' not found.");
}
$dispatcher = new WebhookDispatcher($this->manager);
$delivery = $dispatcher->sendTest($webhook);
return ApiResponse::create($delivery, $delivery['success'] ? 200 : 502);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private function validateEvents(array $events): void
{
foreach ($events as $event) {
if (!in_array($event, self::VALID_EVENTS, true)) {
$valid = implode(', ', self::VALID_EVENTS);
throw new ValidationException("Invalid event '{$event}'. Valid events: {$valid}");
}
}
}
private function redactSecret(string $secret): string
{
if (strlen($secret) <= 10) {
return str_repeat('*', strlen($secret));
}
return substr($secret, 0, 6) . str_repeat('*', strlen($secret) - 10) . substr($secret, -4);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
use RuntimeException;
class ApiException extends RuntimeException
{
public function __construct(
protected readonly int $statusCode,
protected readonly string $errorTitle,
string $detail = '',
protected readonly array $headers = [],
?\Throwable $previous = null,
) {
parent::__construct($detail, $statusCode, $previous);
}
public function getStatusCode(): int
{
return $this->statusCode;
}
public function getErrorTitle(): string
{
return $this->errorTitle;
}
public function getHeaders(): array
{
return $this->headers;
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class ConflictException extends ApiException
{
public function __construct(string $detail = 'The resource has been modified. Refresh and try again.', ?\Throwable $previous = null)
{
parent::__construct(409, 'Conflict', $detail, [], $previous);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class ForbiddenException extends ApiException
{
public function __construct(string $detail = 'You do not have permission to perform this action.', ?\Throwable $previous = null)
{
parent::__construct(403, 'Forbidden', $detail, [], $previous);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class NotFoundException extends ApiException
{
public function __construct(string $detail = 'The requested resource was not found.', ?\Throwable $previous = null)
{
parent::__construct(404, 'Not Found', $detail, [], $previous);
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class TooManyRequestsException extends ApiException
{
public function __construct(string $detail = 'Too many requests.', int $retryAfter = 0, ?\Throwable $previous = null)
{
$headers = [];
if ($retryAfter > 0) {
$headers['Retry-After'] = (string) $retryAfter;
}
parent::__construct(429, 'Too Many Requests', $detail, $headers, $previous);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
/**
* 403 forbidden, dedicated to the `security.twig_content.*` gate. The
* `errorTitle` field carries a stable machine-readable reason code that
* Admin Next can switch on to render the right toast.
*/
class TwigContentForbiddenException extends ApiException
{
/** Site-wide gate is off; nobody can enable Twig in content. */
public const REASON_DISABLED = 'TWIG_CONTENT_DISABLED';
/** Gate is on, but the current user is not allowed to toggle Twig on pages. */
public const REASON_FORBIDDEN = 'TWIG_CONTENT_FORBIDDEN';
/** Page already has process.twig:true; the current user cannot edit it. */
public const REASON_PAGE_FORBIDDEN = 'TWIG_CONTENT_PAGE_FORBIDDEN';
public function __construct(string $reason, string $detail = '', ?\Throwable $previous = null)
{
if ($detail === '') {
$detail = match ($reason) {
self::REASON_DISABLED => 'Twig processing in page content is disabled site-wide. An administrator can enable it under Configuration > Security > Twig in Content.',
self::REASON_FORBIDDEN => "You don't have permission to enable Twig processing on pages.",
self::REASON_PAGE_FORBIDDEN => "This page has Twig processing enabled in its content. You don't have permission to edit pages with Twig enabled.",
default => 'Twig in content is not allowed.',
};
}
parent::__construct(403, $reason, $detail, [], $previous);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class UnauthorizedException extends ApiException
{
public function __construct(string $detail = 'Authentication is required.', ?\Throwable $previous = null)
{
parent::__construct(401, 'Unauthorized', $detail, ['WWW-Authenticate' => 'Bearer'], $previous);
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class ValidationException extends ApiException
{
public function __construct(
string $detail = 'The request data is invalid.',
protected readonly array $errors = [],
?\Throwable $previous = null,
) {
parent::__construct(422, 'Unprocessable Entity', $detail, [], $previous);
}
public function getValidationErrors(): array
{
return $this->errors;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api;
use Grav\Framework\Flex\FlexDirectory;
/**
* Trait for controllers that optionally use Flex-Objects backend
* for listing/search operations.
*
* When enabled (default), listing endpoints use flex directories for
* indexed search, filtering, sorting, and pagination. When disabled
* or unavailable, controllers fall back to regular Grav services.
*
* Config keys: plugins.api.flex_backend.pages, plugins.api.flex_backend.accounts
*/
trait FlexBackend
{
/**
* Map flex directory types to their config keys.
*/
private const FLEX_CONFIG_MAP = [
'pages' => 'pages',
'user-accounts' => 'accounts',
];
protected function getFlexDirectory(string $type): ?FlexDirectory
{
$configKey = self::FLEX_CONFIG_MAP[$type] ?? $type;
if (!$this->config->get('plugins.api.flex_backend.' . $configKey, true)) {
return null;
}
if (!isset($this->grav['flex_objects'])) {
return null;
}
$flex = $this->grav['flex_objects'];
$directory = $flex->getDirectory($type);
return ($directory && $directory->isEnabled()) ? $directory : null;
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Invitations;
use Grav\Common\File\CompiledYamlFile;
/**
* Self-contained store for user invitations.
*
* Invites are persisted to user-data://accounts/invites.yaml, keyed by token.
* Each record carries the recipient email plus the access/groups the inviting
* admin pre-configured — those are applied verbatim when the invite is
* accepted, so the invitee never gets to choose their own permissions.
*
* Deliberately independent of the Login plugin's own Invitations classes so
* the API plugin has no hard dependency on Login being installed.
*
* Record shape:
* token string the secret token (also the array key)
* email string recipient address (locked at acceptance)
* fullname string optional pre-fill for the accept form
* access array permission tree applied on acceptance
* groups array group names applied on acceptance
* created int unix timestamp
* created_by string inviting user's username
* created_by_name string inviting user's fullname (email "actor")
* expires int unix timestamp after which the token is invalid
*/
class InviteStore
{
private const FILE = 'user-data://accounts/invites.yaml';
/** @var array<string, array<string, mixed>>|null */
private ?array $items = null;
private function getFile(): CompiledYamlFile
{
return CompiledYamlFile::instance(self::FILE);
}
/**
* @return array<string, array<string, mixed>>
*/
private function load(): array
{
if ($this->items === null) {
$content = $this->getFile()->content();
$this->items = is_array($content) ? $content : [];
}
return $this->items;
}
private function persist(): void
{
$file = $this->getFile();
$file->save($this->items ?? []);
$file->free();
}
/**
* Generate a unique, URL-safe token (40 hex chars).
*/
public function generateToken(): string
{
$items = $this->load();
do {
try {
$token = bin2hex(random_bytes(20));
} catch (\Exception) {
$token = md5(uniqid((string) mt_rand(), true)) . md5(uniqid((string) mt_rand(), true));
}
} while (isset($items[$token]));
return $token;
}
/**
* @return array<string, array<string, mixed>> token => record
*/
public function all(): array
{
return $this->load();
}
/**
* @return array<string, mixed>|null
*/
public function get(string $token): ?array
{
$items = $this->load();
return $items[$token] ?? null;
}
public function getByEmail(string $email): ?array
{
$email = strtolower(trim($email));
foreach ($this->load() as $record) {
if (strtolower((string) ($record['email'] ?? '')) === $email) {
return $record;
}
}
return null;
}
/**
* @param array<string, mixed> $record must contain a 'token' key
*/
public function add(array $record): void
{
$token = (string) ($record['token'] ?? '');
if ($token === '') {
throw new \InvalidArgumentException('Invite record requires a token.');
}
$this->load();
$this->items[$token] = $record;
$this->persist();
}
public function remove(string $token): bool
{
$this->load();
if (!isset($this->items[$token])) {
return false;
}
unset($this->items[$token]);
$this->persist();
return true;
}
/**
* Drop any invites whose expiry has passed.
*
* @return int number of invites removed
*/
public function purgeExpired(): int
{
$this->load();
$removed = 0;
foreach ($this->items as $token => $record) {
if (self::isExpired($record)) {
unset($this->items[$token]);
$removed++;
}
}
if ($removed > 0) {
$this->persist();
}
return $removed;
}
/**
* @param array<string, mixed> $record
*/
public static function isExpired(array $record): bool
{
$expires = (int) ($record['expires'] ?? 0);
return $expires > 0 && time() > $expires;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Middleware;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Plugin\Api\Auth\ApiKeyAuthenticator;
use Grav\Plugin\Api\Auth\AuthenticatorInterface;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Auth\SessionAuthenticator;
use Grav\Plugin\Api\Exceptions\UnauthorizedException;
use Psr\Http\Message\ServerRequestInterface;
class AuthMiddleware
{
/** @var AuthenticatorInterface[] */
protected array $authenticators = [];
public function __construct(
protected readonly Grav $grav,
protected readonly Config $config,
) {
$this->buildAuthenticatorChain();
}
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
// Try each authenticator in order
foreach ($this->authenticators as $authenticator) {
$user = $authenticator->authenticate($request);
if ($user !== null) {
return $request->withAttribute('api_user', $user);
}
}
throw new UnauthorizedException(
'No valid authentication credentials provided. Use an API key, JWT token, or active session.'
);
}
/**
* Optimistic authentication for public routes: attach api_user when valid
* credentials are supplied, continue as guest otherwise. Lets public
* endpoints return richer, permission-filtered responses to logged-in
* callers without requiring auth from anonymous ones.
*/
public function processOptional(ServerRequestInterface $request): ServerRequestInterface
{
foreach ($this->authenticators as $authenticator) {
$user = $authenticator->authenticate($request);
if ($user !== null) {
return $request->withAttribute('api_user', $user);
}
}
return $request;
}
protected function buildAuthenticatorChain(): void
{
// API Key is fastest to check - try first
if ($this->config->get('plugins.api.auth.api_keys_enabled', true)) {
$this->authenticators[] = new ApiKeyAuthenticator($this->grav);
}
// JWT is next
if ($this->config->get('plugins.api.auth.jwt_enabled', true)) {
$this->authenticators[] = new JwtAuthenticator($this->grav, $this->config);
}
// Session passthrough is last (requires existing session)
if ($this->config->get('plugins.api.auth.session_enabled', true)) {
$this->authenticators[] = new SessionAuthenticator($this->grav);
}
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Middleware;
use Grav\Common\Config\Config;
use Grav\Framework\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class CorsMiddleware
{
public function __construct(
protected readonly Config $config,
) {}
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
// Nothing to modify on the request, CORS is response-side
return $request;
}
public function addHeaders(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
if (!$this->config->get('plugins.api.cors.enabled', true)) {
return $response;
}
$origin = $request->getHeaderLine('Origin');
if (!$origin) {
return $response;
}
$allowedOrigins = (array) $this->config->get('plugins.api.cors.origins', ['*']);
if (in_array('*', $allowedOrigins, true)) {
$response = $response->withHeader('Access-Control-Allow-Origin', '*');
} elseif (in_array($origin, $allowedOrigins, true)) {
$response = $response
->withHeader('Access-Control-Allow-Origin', $origin)
->withHeader('Vary', 'Origin');
} else {
return $response;
}
$credentials = $this->config->get('plugins.api.cors.credentials', false);
if ($credentials) {
$response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
}
$exposeHeaders = (array) $this->config->get('plugins.api.cors.expose_headers', []);
// Always expose X-Invalidates so the client can read cache invalidation tags
if (!in_array('X-Invalidates', $exposeHeaders)) {
$exposeHeaders[] = 'X-Invalidates';
}
$response = $response->withHeader('Access-Control-Expose-Headers', implode(', ', $exposeHeaders));
return $response;
}
public function createPreflightResponse(): ResponseInterface
{
$headers = [];
$allowedOrigins = (array) $this->config->get('plugins.api.cors.origins', ['*']);
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
if (in_array('*', $allowedOrigins, true)) {
$headers['Access-Control-Allow-Origin'] = '*';
} elseif (in_array($origin, $allowedOrigins, true)) {
$headers['Access-Control-Allow-Origin'] = $origin;
$headers['Vary'] = 'Origin';
}
$methods = (array) $this->config->get('plugins.api.cors.methods', ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS']);
$headers['Access-Control-Allow-Methods'] = implode(', ', $methods);
$allowHeaders = (array) $this->config->get('plugins.api.cors.headers', []);
if ($allowHeaders) {
$headers['Access-Control-Allow-Headers'] = implode(', ', $allowHeaders);
}
$maxAge = $this->config->get('plugins.api.cors.max_age', 86400);
$headers['Access-Control-Max-Age'] = (string) $maxAge;
$credentials = $this->config->get('plugins.api.cors.credentials', false);
if ($credentials) {
$headers['Access-Control-Allow-Credentials'] = 'true';
}
$headers['Content-Length'] = '0';
return new Response(204, $headers);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Middleware;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Psr\Http\Message\ServerRequestInterface;
class JsonBodyParserMiddleware
{
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
$contentType = $request->getHeaderLine('Content-Type');
if (!str_contains($contentType, 'application/json')) {
return $request;
}
$body = (string) $request->getBody();
if ($body === '') {
return $request->withAttribute('json_body', []);
}
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new ValidationException('Invalid JSON in request body: ' . json_last_error_msg());
}
return $request->withAttribute('json_body', $decoded);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Middleware;
use Psr\Http\Message\ServerRequestInterface;
/**
* Transparent POST → {DELETE,PATCH,PUT} rewrite for clients behind restrictive
* reverse proxies that reject non-standard HTTP verbs.
*
* Some managed nginx configurations (notably shared-hosting providers) strip
* or 405 DELETE/PATCH before the request reaches PHP. This middleware lets the
* admin-next client keep using semantic methods internally but fall back to
* `POST + X-HTTP-Method-Override: <METHOD>` when it detects a proxy block. The
* header is only honored on POST (other methods pass through untouched), and
* only for the safelisted mutation verbs — no route should ever see an
* "overridden GET", which would sidestep CSRF-shaped assumptions baked into
* the routing layer.
*/
class MethodOverrideMiddleware
{
private const ALLOWED_OVERRIDES = ['DELETE', 'PATCH', 'PUT'];
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
if (strtoupper($request->getMethod()) !== 'POST') {
return $request;
}
$override = strtoupper(trim($request->getHeaderLine('X-HTTP-Method-Override')));
if ($override === '' || !in_array($override, self::ALLOWED_OVERRIDES, true)) {
return $request;
}
return $request->withMethod($override);
}
}
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Middleware;
use Grav\Common\Config\Config;
use Psr\Http\Message\ServerRequestInterface;
/**
* File-based token bucket rate limiter.
* Cloud-safe: each Grav instance has its own cache directory.
*/
class RateLimitMiddleware
{
public function __construct(
protected readonly Config $config,
) {}
/**
* Check rate limit for the current request.
*
* @return array{limited: bool, limit: int, remaining: int, reset: int}
*/
public function check(ServerRequestInterface $request): array
{
$enabled = $this->config->get('plugins.api.rate_limit.enabled', true);
$limit = (int) $this->config->get('plugins.api.rate_limit.requests', 120);
$window = (int) $this->config->get('plugins.api.rate_limit.window', 60);
if (!$enabled) {
return [
'limited' => false,
'limit' => $limit,
'remaining' => $limit,
'reset' => time() + $window,
];
}
// Path-prefix exclusions. Used to keep high-frequency API
// surfaces (collab polling, etc.) out of the per-user bucket so a
// single typing user doesn't trip the global anti-abuse limit.
// Defaults to excluding the sync plugin's endpoints; operators can
// override via plugins.api.rate_limit.excluded_paths.
$excluded = (array) $this->config->get('plugins.api.rate_limit.excluded_paths', ['/sync/']);
$path = $request->getUri()->getPath();
foreach ($excluded as $prefix) {
if (!is_string($prefix) || $prefix === '') continue;
if (str_contains($path, $prefix)) {
return [
'limited' => false,
'limit' => $limit,
'remaining' => $limit,
'reset' => time() + $window,
];
}
}
$identifier = $this->getIdentifier($request);
$storageDir = $this->getStorageDir();
if (!is_dir($storageDir)) {
@mkdir($storageDir, 0775, true);
}
$file = $storageDir . '/' . md5($identifier) . '.json';
return $this->checkLimit($file, $limit, $window);
}
protected function getIdentifier(ServerRequestInterface $request): string
{
// Use authenticated user if available, otherwise fall back to IP
$user = $request->getAttribute('api_user');
if ($user) {
return 'user:' . $user->username;
}
return 'ip:' . ($request->getServerParams()['REMOTE_ADDR'] ?? 'unknown');
}
protected function checkLimit(string $file, int $limit, int $window): array
{
$now = time();
$data = ['tokens' => $limit, 'last_refill' => $now];
// Use file locking for concurrency safety
$fp = fopen($file, 'c+');
if (!$fp) {
// If we can't open the file, allow the request
return ['limited' => false, 'limit' => $limit, 'remaining' => $limit, 'reset' => $now + $window];
}
flock($fp, LOCK_EX);
$contents = stream_get_contents($fp);
if ($contents) {
$data = json_decode($contents, true) ?: $data;
}
// Refill tokens based on elapsed time
$elapsed = $now - ($data['last_refill'] ?? $now);
$refillRate = $limit / $window;
$data['tokens'] = min($limit, ($data['tokens'] ?? $limit) + ($elapsed * $refillRate));
$data['last_refill'] = $now;
// Try to consume a token
$limited = $data['tokens'] < 1;
if (!$limited) {
$data['tokens'] -= 1;
}
// Write back
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data));
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
$remaining = max(0, (int) floor($data['tokens']));
$reset = $now + (int) ceil(($limit - $data['tokens']) / $refillRate);
return [
'limited' => $limited,
'limit' => $limit,
'remaining' => $remaining,
'reset' => $reset,
];
}
protected function getStorageDir(): string
{
$locator = \Grav\Common\Grav::instance()['locator'];
return $locator->findResource('cache://api/ratelimit', true, true);
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Framework\Acl\Permissions;
/**
* Hierarchical permission resolver for the API layer.
*
* Grav's User::authorize() requires admin context, so the API uses direct
* access-array lookups. This class adds parent-key inheritance so granting
* "api.pages" implicitly covers "api.pages.read", matching how Grav's
* core Access::get() resolves permissions.
*/
class PermissionResolver
{
/** @var array<string, mixed>|null Lazy-flattened user access map (one per instance). */
private ?array $flatAccess = null;
/** @var UserInterface|null The user whose access was flattened — used to invalidate cache. */
private ?UserInterface $flatAccessUser = null;
public function __construct(private readonly Permissions $permissions) {}
/**
* Resolve a single permission for a user with parent-key inheritance.
*
* Walks up the dot-path (api.pages.read → api.pages → api) and returns
* the first explicitly set value, or null if nothing is set at any level.
*/
public function resolve(UserInterface $user, string $permission): ?bool
{
$flat = $this->getFlatAccess($user);
$key = $permission;
while ($key !== '') {
if (array_key_exists($key, $flat)) {
$value = $flat[$key];
if (is_bool($value)) {
return $value;
}
if ($value === 1 || $value === '1' || $value === 'true') {
return true;
}
if ($value === 0 || $value === '0' || $value === 'false' || $value === null) {
return false;
}
}
$pos = strrpos($key, '.');
$key = $pos !== false ? substr($key, 0, $pos) : '';
}
return null;
}
/**
* Build a flat map of all registered api.* permissions with resolved
* true/false values. Super-admins receive true for everything.
*
* @return array<string, bool>
*/
public function resolvedMap(UserInterface $user, bool $isSuperAdmin): array
{
$allInstances = $this->permissions->getInstances();
$result = [];
foreach ($allInstances as $name => $action) {
if (!str_starts_with($name, 'api.')) {
continue;
}
$result[$name] = $isSuperAdmin ? true : (bool) $this->resolve($user, $name);
}
return $result;
}
/**
* Lazily flatten $user->get('access') from nested array to dot-notation keys.
* Cached per user instance within this resolver.
*/
private function getFlatAccess(UserInterface $user): array
{
if ($this->flatAccess === null || $this->flatAccessUser !== $user) {
$nested = $user->get('access');
$this->flatAccess = is_array($nested)
? Utils::arrayFlattenDotNotation($nested)
: [];
$this->flatAccessUser = $user;
}
return $this->flatAccess;
}
}
@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Popularity;
use Grav\Common\Grav;
/**
* Single-file flat-JSON storage for page-view popularity data.
*
* Replaces admin-classic's four-JSON-file scheme (daily.json, monthly.json,
* totals.json, visitors.json) with one combined `popularity.json` guarded
* by an exclusive flock(). Wins vs. the old design:
*
* - One file open + one lock per hit, vs. four uncoordinated read/writes
* that could race and silently corrupt each other.
* - `pages` (formerly `totals.json`) is capped at PAGES_CAP entries, so
* it can no longer grow unbounded with every URL ever visited.
* - ISO date keys (YYYY-MM-DD / YYYY-MM) sort lexicographically and are
* locale-stable, fixing the old `d-m-Y` ordering bug.
*
* On first construction in a site that still has the old four JSONs but no
* combined file yet, the store imports them once and renames them to
* `*.migrated` so nothing is lost and a re-run won't double-count.
*/
class PopularityStore
{
private const SCHEMA_VERSION = 2;
private const COMBINED_FILE = 'popularity.json';
private const PAGES_CAP = 500;
private const LEGACY_FILES = [
'daily' => 'daily.json',
'monthly' => 'monthly.json',
'totals' => 'totals.json',
'visitors' => 'visitors.json',
];
private string $dataDir;
private string $filePath;
public function __construct(?string $dataDir = null)
{
$this->dataDir = $dataDir ?? Grav::instance()['locator']
->findResource('log://popularity', true, true);
$this->filePath = $this->dataDir . '/' . self::COMBINED_FILE;
}
/**
* Record a single page hit. All four counters update inside one locked
* read-modify-write cycle, so a concurrent hit can't tear the file or
* lose updates.
*/
public function recordHit(
string $route,
string $ipHash,
?int $now = null,
int $dailyHistory = 30,
int $monthlyHistory = 12,
int $visitorHistory = 20,
): void {
$now ??= time();
$today = date('Y-m-d', $now);
$month = date('Y-m', $now);
$this->withLock(function (array $data) use (
$route, $ipHash, $now, $today, $month,
$dailyHistory, $monthlyHistory, $visitorHistory,
): array {
$data['daily'][$today] = ($data['daily'][$today] ?? 0) + 1;
$data['monthly'][$month] = ($data['monthly'][$month] ?? 0) + 1;
$data['pages'][$route] = ($data['pages'][$route] ?? 0) + 1;
$data['visitors'][$ipHash] = $now;
return $this->prune($data, $dailyHistory, $monthlyHistory, $visitorHistory);
});
}
public function getDaily(int $limit = 365): array
{
$data = $this->read();
$daily = $data['daily'] ?? [];
ksort($daily);
return array_slice($daily, -$limit, $limit, true);
}
public function getMonthly(int $limit = 24): array
{
$data = $this->read();
$monthly = $data['monthly'] ?? [];
ksort($monthly);
return array_slice($monthly, -$limit, $limit, true);
}
public function getTopPages(int $limit = 10): array
{
$data = $this->read();
$pages = $data['pages'] ?? [];
arsort($pages);
return array_slice($pages, 0, $limit, true);
}
public function getRecentVisitors(int $limit = 20): array
{
$data = $this->read();
$visitors = $data['visitors'] ?? [];
arsort($visitors);
return array_slice($visitors, 0, $limit, true);
}
public function flush(): void
{
$this->withLock(fn() => $this->emptyData());
}
/**
* Trim each section to its configured retention. Daily/monthly are
* trimmed by date threshold (not just count) so an old, never-pruned
* file gets cleaned up promptly. `pages` is capped to PAGES_CAP by
* descending views — pages with no recent traffic naturally fall off.
*/
private function prune(
array $data,
int $dailyHistory,
int $monthlyHistory,
int $visitorHistory,
): array {
$cutDay = date('Y-m-d', strtotime("-{$dailyHistory} days"));
$data['daily'] = array_filter(
$data['daily'] ?? [],
static fn($_, $k) => $k >= $cutDay,
ARRAY_FILTER_USE_BOTH,
);
$cutMonth = date('Y-m', strtotime("-{$monthlyHistory} months"));
$data['monthly'] = array_filter(
$data['monthly'] ?? [],
static fn($_, $k) => $k >= $cutMonth,
ARRAY_FILTER_USE_BOTH,
);
$pages = $data['pages'] ?? [];
if (count($pages) > self::PAGES_CAP) {
arsort($pages);
$pages = array_slice($pages, 0, self::PAGES_CAP, true);
}
$data['pages'] = $pages;
$visitors = $data['visitors'] ?? [];
if (count($visitors) > $visitorHistory) {
arsort($visitors);
$visitors = array_slice($visitors, 0, $visitorHistory, true);
}
$data['visitors'] = $visitors;
return $data;
}
/**
* Acquire exclusive lock, read current state (importing legacy files
* the first time), apply the mutator, write atomically.
*/
private function withLock(callable $mutator): void
{
if (!is_dir($this->dataDir)) {
mkdir($this->dataDir, 0755, true);
}
$fp = fopen($this->filePath, 'c+');
if ($fp === false) {
return;
}
try {
if (!flock($fp, LOCK_EX)) {
return;
}
$contents = stream_get_contents($fp);
$data = $this->decodeOrMigrate($contents);
$data = $mutator($data);
$data['version'] = self::SCHEMA_VERSION;
$encoded = json_encode($data, JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return;
}
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, $encoded);
fflush($fp);
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}
private function read(): array
{
if (!is_file($this->filePath)) {
// Trigger migration if legacy files exist but combined doesn't
if ($this->legacyFilesExist()) {
$this->withLock(static fn(array $d) => $d);
} else {
return $this->emptyData();
}
}
$fp = @fopen($this->filePath, 'r');
if ($fp === false) {
return $this->emptyData();
}
try {
if (!flock($fp, LOCK_SH)) {
return $this->emptyData();
}
$contents = stream_get_contents($fp);
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
return $this->decodeOrMigrate($contents);
}
private function decodeOrMigrate(string $contents): array
{
$data = $contents !== '' ? json_decode($contents, true) : null;
if (is_array($data) && isset($data['version'])) {
return $this->ensureSections($data);
}
// Either empty file, malformed JSON, or unversioned legacy state.
// Try to import legacy four-file data once.
return $this->importLegacy();
}
private function importLegacy(): array
{
$data = $this->emptyData();
$imported = false;
foreach (self::LEGACY_FILES as $type => $name) {
$path = $this->dataDir . '/' . $name;
if (!is_file($path)) {
continue;
}
$raw = @file_get_contents($path);
$legacy = $raw === false ? null : json_decode($raw, true);
if (!is_array($legacy)) {
@rename($path, $path . '.migrated');
continue;
}
switch ($type) {
case 'daily':
foreach ($legacy as $key => $count) {
$iso = self::convertDailyKey((string) $key);
if ($iso !== null) {
$data['daily'][$iso] = (int) $count;
}
}
break;
case 'monthly':
foreach ($legacy as $key => $count) {
$iso = self::convertMonthlyKey((string) $key);
if ($iso !== null) {
$data['monthly'][$iso] = (int) $count;
}
}
break;
case 'totals':
foreach ($legacy as $route => $count) {
$data['pages'][(string) $route] = (int) $count;
}
break;
case 'visitors':
foreach ($legacy as $hash => $ts) {
$data['visitors'][(string) $hash] = (int) $ts;
}
break;
}
@rename($path, $path . '.migrated');
$imported = true;
}
if ($imported) {
ksort($data['daily']);
ksort($data['monthly']);
}
return $data;
}
private function legacyFilesExist(): bool
{
foreach (self::LEGACY_FILES as $name) {
if (is_file($this->dataDir . '/' . $name)) {
return true;
}
}
return false;
}
private function emptyData(): array
{
return [
'version' => self::SCHEMA_VERSION,
'daily' => [],
'monthly' => [],
'pages' => [],
'visitors' => [],
];
}
private function ensureSections(array $data): array
{
return array_merge($this->emptyData(), $data);
}
private static function convertDailyKey(string $key): ?string
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $key)) {
return $key;
}
// Legacy d-m-Y → Y-m-d
if (preg_match('/^(\d{2})-(\d{2})-(\d{4})$/', $key, $m)) {
return $m[3] . '-' . $m[2] . '-' . $m[1];
}
return null;
}
private static function convertMonthlyKey(string $key): ?string
{
if (preg_match('/^\d{4}-\d{2}$/', $key)) {
return $key;
}
// Legacy m-Y → Y-m
if (preg_match('/^(\d{2})-(\d{4})$/', $key, $m)) {
return $m[2] . '-' . $m[1];
}
return null;
}
}
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Popularity;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\Yaml;
/**
* Records page views into PopularityStore. Mirrors the behaviour of
* admin-classic's tracker (bot/DNT respect, configurable ignore globs)
* but writes to a SQLite database instead of four JSON files.
*/
class PopularityTracker
{
private Config $config;
private PopularityStore $store;
public function __construct(?PopularityStore $store = null)
{
$this->config = Grav::instance()['config'];
$this->store = $store ?? new PopularityStore();
}
public function trackHit(): void
{
if (!$this->config->get('plugins.api.popularity.enabled', true)) {
return;
}
$grav = Grav::instance();
if (!$grav['browser']->isHuman()) {
return;
}
if (!$grav['browser']->isTrackable()) {
return;
}
/** @var \Grav\Common\Page\Interfaces\PageInterface|null $page */
$page = $grav['page'] ?? null;
if ($page === null || !$page->route()) {
return;
}
if ($page->template() === 'error') {
return;
}
$route = $page->route();
$url = (string) str_replace($grav['base_url_relative'], '', $page->url());
foreach ((array) $this->config->get('plugins.api.popularity.ignore', []) as $ignore) {
if (fnmatch((string) $ignore, $url)) {
return;
}
}
try {
// Keyed HMAC over the visitor IP with a server-private salt.
// GDPR Recital 26 / Art. 4(1): plain sha1(ip) is reversible via
// a precomputed rainbow table of the ~4.3B IPv4 space (trivial
// on a modern GPU), so the hash remains personal data. Keying
// with a per-install secret the attacker can't compute against
// breaks that re-identification path while preserving stable
// bucketing for the unique-visitor counter.
$ipHash = hash_hmac('sha256', (string) $grav['uri']->ip(), $this->getSalt());
// Pruning happens inside recordHit() under the same lock — every
// write trims to the configured retention window, so the file
// can never grow beyond bounded size between hits.
$this->store->recordHit(
$route,
$ipHash,
null,
(int) $this->config->get('plugins.api.popularity.history.daily', 30),
(int) $this->config->get('plugins.api.popularity.history.monthly', 12),
(int) $this->config->get('plugins.api.popularity.history.visitors', 20),
);
} catch (\Throwable) {
// Tracking must never break the page response — swallow.
}
}
/**
* Read the popularity HMAC salt from config, auto-generating + persisting
* one on first use. The salt MUST stay stable across requests so the
* unique-visitor bucket for a given IP stays the same; regenerating per
* request would balloon the visitors map with duplicate entries.
*
* Stored under plugins.api.popularity.salt in user/config/plugins/api.yaml.
* Never shipped with a default — a committed salt would be globally known
* and defeat the keyed-hash protection entirely.
*/
private function getSalt(): string
{
$salt = (string) $this->config->get('plugins.api.popularity.salt', '');
if ($salt !== '') {
return $salt;
}
$salt = bin2hex(random_bytes(32));
$this->config->set('plugins.api.popularity.salt', $salt);
// Persist so subsequent requests reuse the same salt. If we can't
// write the file (perms, missing config stream), fall through with
// the in-memory salt — tracking still works for this request and we
// retry on the next hit.
$grav = Grav::instance();
$locator = $grav['locator'];
$file = $locator->findResource('config://plugins/api.yaml');
if (!$file) {
$configDir = $locator->findResource('config://', true);
if (!$configDir) {
if (isset($grav['log'])) {
$grav['log']->warning('api.popularity: could not resolve config:// stream to persist popularity salt; visitor counts may double until salt is configured.');
}
return $salt;
}
$file = $configDir . '/plugins/api.yaml';
}
$dir = dirname($file);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
if (isset($grav['log'])) {
$grav['log']->warning(sprintf('api.popularity: could not create %s to persist popularity salt.', $dir));
}
return $salt;
}
$yaml = Yaml::parse(file_exists($file) ? (string) file_get_contents($file) : '') ?? [];
$yaml['popularity']['salt'] = $salt;
if (@file_put_contents($file, Yaml::dump($yaml)) === false) {
if (isset($grav['log'])) {
$grav['log']->warning(sprintf('api.popularity: could not write popularity salt to %s — visitor counts may double until next successful write.', $file));
}
}
return $salt;
}
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Response;
use Grav\Framework\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
class ApiResponse
{
/**
* Create a standard JSON response with the data envelope.
*/
public static function create(mixed $data, int $status = 200, array $headers = [], ?array $meta = null): ResponseInterface
{
$body = [
'data' => $data,
];
if ($meta !== null) {
$body['meta'] = $meta;
}
$headers = array_merge($headers, [
'Content-Type' => 'application/json',
'Cache-Control' => 'no-store, max-age=0',
]);
return new Response($status, $headers, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
/**
* Create a paginated response with meta and links.
*/
public static function paginated(
array $data,
int $total,
int $page,
int $perPage,
string $baseUrl,
int $status = 200,
array $headers = [],
array $extraMeta = [],
?int $locatedAtIndex = null,
): ResponseInterface {
$totalPages = $perPage > 0 ? (int) ceil($total / $perPage) : 1;
$pagination = [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => $totalPages,
];
if ($locatedAtIndex !== null) {
$pagination['located_at_index'] = $locatedAtIndex;
}
$meta = [
'pagination' => $pagination,
];
if ($extraMeta !== []) {
$meta = array_merge($meta, $extraMeta);
}
$body = [
'data' => $data,
'meta' => $meta,
'links' => [
'self' => $baseUrl . '?' . http_build_query(['page' => $page, 'per_page' => $perPage]),
],
];
if ($page > 1) {
$body['links']['first'] = $baseUrl . '?' . http_build_query(['page' => 1, 'per_page' => $perPage]);
$body['links']['prev'] = $baseUrl . '?' . http_build_query(['page' => $page - 1, 'per_page' => $perPage]);
}
if ($page < $totalPages) {
$body['links']['next'] = $baseUrl . '?' . http_build_query(['page' => $page + 1, 'per_page' => $perPage]);
$body['links']['last'] = $baseUrl . '?' . http_build_query(['page' => $totalPages, 'per_page' => $perPage]);
}
$headers = array_merge($headers, [
'Content-Type' => 'application/json',
'Cache-Control' => 'no-store, max-age=0',
]);
return new Response($status, $headers, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
/**
* 200 OK with data envelope.
*/
public static function ok(mixed $data, array $headers = []): ResponseInterface
{
return self::create($data, 200, $headers);
}
/**
* 201 Created with Location header.
*/
public static function created(mixed $data, string $location, array $headers = []): ResponseInterface
{
return self::create($data, 201, array_merge($headers, ['Location' => $location]));
}
/**
* 204 No Content.
*/
public static function noContent(array $headers = []): ResponseInterface
{
return new Response(204, $headers);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Response;
use Grav\Framework\Psr7\Response;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Psr\Http\Message\ResponseInterface;
/**
* RFC 7807 Problem Details response builder.
*/
class ErrorResponse
{
/**
* @param array<string,mixed> $headers
* @param array<string,mixed>|null $toast Optional toast hint honored by Admin
* Next: { message?, type?, duration?, dismissible? }. `duration` is in ms;
* use 0 (or dismissible:true) for a toast that stays until manually closed.
*/
public static function create(int $status, string $title, string $detail, array $headers = [], ?array $toast = null): ResponseInterface
{
$body = [
'status' => $status,
'title' => $title,
'detail' => $detail,
];
if ($toast !== null) {
$body['toast'] = $toast;
}
$headers = array_merge($headers, [
'Content-Type' => 'application/problem+json',
'Cache-Control' => 'no-store, max-age=0',
]);
return new Response($status, $headers, json_encode($body, JSON_UNESCAPED_SLASHES));
}
public static function fromException(ApiException $e): ResponseInterface
{
$body = [
'status' => $e->getStatusCode(),
'title' => $e->getErrorTitle(),
'detail' => $e->getMessage(),
];
if ($e instanceof ValidationException && $e->getValidationErrors()) {
$body['errors'] = $e->getValidationErrors();
}
$headers = array_merge($e->getHeaders(), [
'Content-Type' => 'application/problem+json',
'Cache-Control' => 'no-store, max-age=0',
]);
return new Response($e->getStatusCode(), $headers, json_encode($body, JSON_UNESCAPED_SLASHES));
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use Grav\Common\Flex\Types\UserGroups\UserGroupObject;
class GroupSerializer implements SerializerInterface
{
public function serialize(object $resource, array $options = []): array
{
/** @var UserGroupObject $resource */
return [
'groupname' => (string) $resource->getProperty('groupname', ''),
'readableName' => (string) ($resource->getProperty('readableName') ?? ''),
'description' => (string) ($resource->getProperty('description') ?? ''),
'icon' => (string) ($resource->getProperty('icon') ?? ''),
'enabled' => (bool) $resource->getProperty('enabled', true),
'access' => $resource->getProperty('access') ?? [],
];
}
/**
* Serialize a plain array entry from groups.yaml (used by the fallback
* non-Flex listing path, where there is no UserGroupObject yet).
*
* @param array<string,mixed> $entry
*/
public function serializeArray(string $groupname, array $entry): array
{
return [
'groupname' => $groupname,
'readableName' => (string) ($entry['readableName'] ?? ''),
'description' => (string) ($entry['description'] ?? ''),
'icon' => (string) ($entry['icon'] ?? ''),
'enabled' => (bool) ($entry['enabled'] ?? true),
'access' => $entry['access'] ?? [],
];
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use Grav\Plugin\Api\Services\ThumbnailService;
class MediaSerializer implements SerializerInterface
{
public function __construct(
private ?ThumbnailService $thumbnailService = null,
private string $thumbnailBaseUrl = '',
) {}
/**
* Serialize a single Grav Medium object to an API response array.
*/
public function serialize(object $medium, array $options = []): array
{
$mime = $medium->get('mime') ?? 'application/octet-stream';
$data = [
'filename' => $medium->filename,
'url' => $medium->url(),
'type' => $mime,
'size' => (int) ($medium->get('size') ?? 0),
];
if (str_starts_with($mime, 'image/')) {
$width = $medium->get('width');
$height = $medium->get('height');
if ($width !== null && $height !== null) {
$data['dimensions'] = [
'width' => (int) $width,
'height' => (int) $height,
];
}
// Generate thumbnail URL for images
if ($this->thumbnailService) {
$sourcePath = $this->resolveSourcePath($medium);
if ($sourcePath) {
$thumbFilename = $this->thumbnailService->getThumbnailFilename($sourcePath);
if ($thumbFilename) {
// Trigger thumbnail generation
$this->thumbnailService->getThumbnail($sourcePath);
$data['thumbnail_url'] = $this->thumbnailBaseUrl . '/thumbnails/' . $thumbFilename;
}
}
}
}
$data['modified'] = $this->resolveModifiedTime($medium);
return $data;
}
/**
* Serialize an iterable collection of Medium objects.
*/
public function serializeCollection(iterable $media, array $options = []): array
{
$items = [];
foreach ($media as $medium) {
$items[] = $this->serialize($medium, $options);
}
return $items;
}
/**
* Resolve the physical file path for a medium.
*/
private function resolveSourcePath(object $medium): ?string
{
if (method_exists($medium, 'path')) {
$path = $medium->path();
if ($path && file_exists($path)) {
return $path;
}
}
return null;
}
/**
* Resolve the last-modified timestamp for a medium, returning an ISO 8601 string.
*/
private function resolveModifiedTime(object $medium): string
{
$timestamp = null;
if (method_exists($medium, 'modified')) {
$timestamp = $medium->modified();
}
if (!$timestamp && method_exists($medium, 'path')) {
$path = $medium->path();
if ($path && file_exists($path)) {
$timestamp = filemtime($path);
}
}
$timestamp = $timestamp ?: time();
return date(\DateTimeInterface::ATOM, (int) $timestamp);
}
}
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use Grav\Common\GPM\Licenses;
use Parsedown;
class PackageSerializer implements SerializerInterface
{
private static ?Parsedown $parsedown = null;
public function serialize(object $resource, array $options = []): array
{
$description = $resource->description ?? null;
$data = [
'slug' => $resource->slug ?? null,
'name' => $resource->name ?? null,
'version' => $resource->version ?? null,
'type' => $options['type'] ?? null,
'description' => $description,
'description_html' => $this->renderMarkdown($description),
'author' => $this->serializeAuthor($resource),
'homepage' => $resource->homepage ?? $resource->url ?? null,
];
// Include enabled status + symlink detection for installed packages
if ($options['installed'] ?? false) {
$data['enabled'] = $this->isEnabled($resource, $options);
$data['is_symlink'] = $this->isSymlinked($resource, $options);
}
// Include update info if available
if (isset($resource->available)) {
$data['available_version'] = $resource->available;
$data['updatable'] = !empty($resource->available);
}
// Include premium status and purchase info
if (!empty($resource->premium)) {
$slug = $resource->slug ?? $options['slug_key'] ?? '';
$premium = $resource->premium;
$permalink = is_object($premium) ? ($premium->permalink ?? null) : ($premium['permalink'] ?? null);
$data['premium'] = true;
$data['licensed'] = !empty(Licenses::get($slug));
if ($permalink) {
$data['purchase_url'] = 'https://licensing.getgrav.org/buy/' . $permalink;
}
}
// Include dependencies
if (!empty($resource->dependencies)) {
$data['dependencies'] = $resource->dependencies;
}
// Include compatibility metadata. Grav core resolves
// `compatibility.grav` / `compatibility.api` (and infers grav from the
// dependencies array as a fallback). Any keys core doesn't currently
// resolve (e.g. a future `compatibility.php`) come straight from the
// blueprint via the `compatibility_raw` fallback below.
$compatibility = $this->normalizeCompatibility($resource->compatibility ?? null);
$rawCompat = is_object($resource) && method_exists($resource, 'toArray')
? ($resource->toArray()['compatibility'] ?? null)
: null;
if (is_array($rawCompat)) {
foreach ($rawCompat as $key => $value) {
if (!isset($compatibility[$key])) {
$compatibility[$key] = is_array($value) ? array_map('strval', $value) : (string) $value;
}
}
}
if (!empty($compatibility)) {
$data['compatibility'] = $compatibility;
}
// Include keywords/tags
if (!empty($resource->keywords)) {
$data['keywords'] = $resource->keywords;
}
// Include icon
if (!empty($resource->icon)) {
$data['icon'] = $resource->icon;
}
// Include screenshot URL for themes (from GPM repository data)
if (!empty($resource->screenshot)) {
$screenshot = $resource->screenshot;
// GPM returns just a filename — resolve to full URL
if (!str_starts_with($screenshot, 'http')) {
$screenshot = 'https://getgrav.org/images/' . $screenshot;
}
$data['screenshot'] = $screenshot;
}
return $data;
}
/**
* Serialize a collection of packages.
*/
public function serializeCollection(iterable $packages, array $options = []): array
{
$result = [];
foreach ($packages as $slug => $package) {
$opts = array_merge($options, ['slug_key' => $slug]);
$serialized = $this->serialize($package, $opts);
// Ensure slug is set (some iterators use slug as key)
if ($serialized['slug'] === null && is_string($slug)) {
$serialized['slug'] = $slug;
}
$result[] = $serialized;
}
return $result;
}
private function serializeAuthor(object $resource): ?array
{
$author = $resource->author ?? null;
if ($author === null) {
return null;
}
if (is_object($author)) {
return [
'name' => $author->name ?? null,
'email' => $author->email ?? null,
'url' => $author->url ?? null,
];
}
if (is_array($author)) {
return [
'name' => $author['name'] ?? null,
'email' => $author['email'] ?? null,
'url' => $author['url'] ?? null,
];
}
return null;
}
private function isEnabled(object $resource, array $options): bool
{
$type = $options['type'] ?? 'plugin';
$slug = $resource->slug ?? $options['slug_key'] ?? '';
if ($type === 'plugin') {
return (bool) (\Grav\Common\Grav::instance()['config']->get("plugins.{$slug}.enabled", false));
}
// For themes, check if it's the active theme
$activeTheme = \Grav\Common\Grav::instance()['config']->get('system.pages.theme');
return $slug === $activeTheme;
}
/**
* Render a plugin/theme description as safe HTML. Descriptions are
* YAML-authored and routinely contain inline markdown (links, bold,
* emphasis) that renders as literal syntax in UIs without processing.
* Returns null for empty input so clients can trivially fall back.
*/
private function renderMarkdown(?string $markdown): ?string
{
if ($markdown === null || $markdown === '') {
return null;
}
if (self::$parsedown === null) {
self::$parsedown = new Parsedown();
// Untrusted YAML input — sanitize any inline HTML and disable unsafe protocols.
self::$parsedown->setSafeMode(true);
self::$parsedown->setBreaksEnabled(false);
}
return self::$parsedown->text($markdown);
}
/**
* Normalize Grav's resolved compatibility array into a stable client shape.
* Strips empty keys so consumers don't render `Grav: ` with nothing after.
*
* @param mixed $compatibility
* @return array<string, mixed>
*/
private function normalizeCompatibility($compatibility): array
{
if (!is_array($compatibility)) {
return [];
}
$out = [];
foreach ($compatibility as $key => $value) {
if (is_array($value)) {
$value = array_values(array_filter(array_map('strval', $value), 'strlen'));
if (!empty($value)) {
$out[(string) $key] = $value;
}
} elseif ($value !== null && $value !== '') {
$out[(string) $key] = (string) $value;
}
}
return $out;
}
private function isSymlinked(object $resource, array $options): bool
{
$type = $options['type'] ?? 'plugin';
$slug = $resource->slug ?? $options['slug_key'] ?? '';
if (!$slug) {
return false;
}
$scheme = $type === 'theme' ? 'themes' : 'plugins';
$path = \Grav\Common\Grav::instance()['locator']->findResource("{$scheme}://{$slug}", true);
return $path ? is_link($path) : false;
}
}
@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use DateTimeImmutable;
use DateTimeZone;
use Grav\Common\Page\Interfaces\PageInterface;
class PageSerializer implements SerializerInterface
{
public function __construct(
private ?MediaSerializer $mediaSerializer = null,
) {}
public function serialize(object $resource, array $options = []): array
{
/** @var PageInterface $resource */
$includeContent = $options['include_content'] ?? true;
$renderContent = $options['render_content'] ?? false;
$includeChildren = $options['include_children'] ?? false;
$childrenDepth = $options['children_depth'] ?? 1;
$includeMedia = $options['include_media'] ?? true;
$includeTranslations = $options['include_translations'] ?? false;
$headerArr = $this->serializeHeader($resource->header());
// Flex-indexed PageObject instances expose EMPTY headers during
// listing (the index only materializes summary fields). That makes
// $resource->published() / visible() fall back to Grav's default
// "true" even when the frontmatter explicitly says false. Swap in the
// fully-loaded legacy Page so every downstream field reads correctly.
// Flex-indexed PageObject instances expose EMPTY headers during
// listing (the index only materializes summary fields). Read the
// frontmatter directly from the .md file so published/visible and
// everything else in the header are accurate regardless of which
// controller path we came through.
if (empty($headerArr) && $resource instanceof \Grav\Framework\Flex\Pages\FlexPageObject) {
$path = method_exists($resource, 'path') ? $resource->path() : null;
$template = $resource->template();
if ($path && $template) {
$candidates = [];
// Prefer the page's own language, then the active language,
// then the untyped default, then any matching {template}*.md.
$pageLang = $resource->language();
if ($pageLang) {
$candidates[] = $path . '/' . $template . '.' . $pageLang . '.md';
}
$grav = \Grav\Common\Grav::instance();
$lang = $grav['language'] ?? null;
if ($lang && method_exists($lang, 'getLanguage')) {
$active = $lang->getLanguage();
if ($active) {
$candidates[] = $path . '/' . $template . '.' . $active . '.md';
}
}
$candidates[] = $path . '/' . $template . '.md';
foreach ($candidates as $file) {
if (is_file($file)) {
$parsed = $this->parseFrontmatter($file);
if (!empty($parsed)) {
$headerArr = $parsed;
break;
}
}
}
// Fallback: glob for any {template}*.md file in the directory
if (empty($headerArr)) {
foreach (glob($path . '/' . $template . '*.md') ?: [] as $file) {
$parsed = $this->parseFrontmatter($file);
if (!empty($parsed)) {
$headerArr = $parsed;
break;
}
}
}
}
}
// For flex-indexed PageObject listings the in-memory header is empty
// (we re-parsed the .md file into $headerArr above). $resource->title()
// and ->menu() in that mode fall back to a slug-derived label
// ("Contact-us") even when the frontmatter has a real title.
// Prefer the parsed-header value so the listing reads the same as
// the detail endpoint.
$headerTitle = $headerArr['title'] ?? null;
$headerMenu = $headerArr['menu'] ?? null;
$data = [
'route' => $resource->route(),
// Structural route — for the home page, route() returns the
// public alias '/' but rawRoute() returns the actual page like
// '/home'. Clients editing/finding pages should prefer this.
'raw_route' => $resource->rawRoute(),
'slug' => $resource->slug(),
// The on-disk folder basename, including any numeric ordering
// prefix (e.g. `01.consulting`). `slug` is the prefix-stripped
// name; admin UIs need the real folder to show/diagnose ordering.
'folder' => $resource->folder(),
'title' => is_string($headerTitle) && $headerTitle !== '' ? $headerTitle : $resource->title(),
'menu' => is_string($headerMenu) && $headerMenu !== '' ? $headerMenu : (is_string($headerTitle) && $headerTitle !== '' ? $headerTitle : $resource->menu()),
'template' => $resource->template(),
'language' => $resource->language(),
'header' => $headerArr,
'taxonomy' => $resource->taxonomy(),
// Prefer the explicit frontmatter value for published/visible over
// the object method. During flex-indexed collection listings
// (GET /pages) the indexed PageObject::published() can return a
// stale/default "true" when the header isn't fully materialized,
// while GET /pages/{route} goes through enablePages() and reads a
// legacy Page where the method is correct. Reading the serialized
// header array (same one we return to the client) gives the same
// answer in both paths.
'published' => array_key_exists('published', $headerArr) ? (bool)$headerArr['published'] : $resource->published(),
'visible' => array_key_exists('visible', $headerArr) ? (bool)$headerArr['visible'] : $resource->visible(),
'routable' => $resource->routable(),
'date' => $this->formatTimestamp($resource->date()),
'modified' => $this->formatTimestamp($resource->modified()),
'order' => $resource->order(),
'has_children' => count($resource->children()) > 0,
];
if ($includeTranslations) {
$data['translated_languages'] = $resource->translatedLanguages();
$data['untranslated_languages'] = $resource->untranslatedLanguages();
// Disambiguate Grav's translated_languages response: when the page
// has an untyped base file (e.g. default.md), Grav reports every
// site language as "translated" because default.md acts as a
// fallback for any active lang. These two fields let admin UIs
// tell whether each language is backed by an EXPLICIT file
// (default.<lang>.md) or by the implicit default.md fallback.
$pagePath = $resource->path();
$template = $resource->template();
$data['has_default_file'] = $pagePath && $template
? is_file($pagePath . '/' . $template . '.md')
: false;
// List of language codes that have a concrete `{template}.{lang}.md`
// file on disk. Everything else in translated_languages is falling
// back to default.md. Empty array when multilang is off.
$explicit = [];
if ($pagePath && $template) {
$lang = \Grav\Common\Grav::instance()['language'] ?? null;
$langCodes = $lang && method_exists($lang, 'getLanguages')
? (array) $lang->getLanguages()
: [];
foreach ($langCodes as $code) {
if (is_file($pagePath . '/' . $template . '.' . $code . '.md')) {
$explicit[] = $code;
}
}
}
$data['explicit_language_files'] = $explicit;
}
if ($includeContent) {
$data['content'] = $resource->rawMarkdown();
}
if ($renderContent) {
$data['content_html'] = $resource->content();
}
$includeSummary = $options['include_summary'] ?? false;
if ($includeSummary) {
$summarySize = $options['summary_size'] ?? null;
// summary() runs the page through the full Twig / shortcode pipeline,
// so any page with a plugin shortcode whose dependencies aren't
// available in the API request context (e.g. a `[poll]` that wants
// the frontend theme's Twig env) can throw — we don't want that to
// take down the whole response for something the client is treating
// as a preview. Fall back to a plain-text rendering of the raw
// markdown, trimmed to the requested size.
try {
$data['summary'] = $summarySize
? $resource->summary($summarySize)
: $resource->summary();
} catch (\Throwable $e) {
$raw = (string) $resource->rawMarkdown();
// Strip frontmatter artifacts, shortcodes, markdown syntax.
$plain = preg_replace('/\[[^\]]+\s*\/?\]/', '', $raw) ?? $raw;
$plain = preg_replace('/[#*_`>]/', '', $plain) ?? $plain;
$plain = trim(preg_replace('/\s+/', ' ', $plain) ?? $plain);
$max = $summarySize ?: 300;
$data['summary'] = mb_strlen($plain) > $max
? rtrim(mb_substr($plain, 0, $max)) . '…'
: $plain;
}
}
if ($includeMedia) {
$data['media'] = $this->serializeMedia($resource);
}
if ($includeChildren && $childrenDepth > 0) {
$data['children'] = $this->serializeChildren(
$resource,
$options,
$childrenDepth,
);
}
return $data;
}
/**
* Parse the YAML frontmatter from a Grav .md file. Returns the header
* array, or empty array if there's no frontmatter / on parse failure.
*/
private function parseFrontmatter(string $file): array
{
$contents = @file_get_contents($file);
if ($contents === false) {
return [];
}
// Grav frontmatter: content between leading `---\n` and the next `---\n`.
if (!preg_match('/^---\r?\n(.*?)\r?\n---\r?\n/s', $contents, $m)) {
return [];
}
try {
$parsed = \Symfony\Component\Yaml\Yaml::parse($m[1]);
return is_array($parsed) ? $parsed : [];
} catch (\Throwable) {
return [];
}
}
/**
* Serialize a collection of pages.
*/
public function serializeCollection(iterable $pages, array $options = []): array
{
$result = [];
foreach ($pages as $page) {
$result[] = $this->serialize($page, $options);
}
return $result;
}
/**
* Convert page header object to an associative array.
*/
private function serializeHeader(object|null $header): array
{
if ($header === null) {
return [];
}
return json_decode(json_encode($header), true) ?: [];
}
/**
* Serialize the media collection attached to a page.
*/
private function serializeMedia(PageInterface $page): array
{
$media = $page->media();
if ($this->mediaSerializer) {
return $this->mediaSerializer->serializeCollection($media->all());
}
$result = [];
foreach ($media->all() as $filename => $medium) {
$result[] = [
'filename' => $medium->filename,
'type' => $medium->get('mime'),
'size' => $medium->get('size'),
];
}
return $result;
}
/**
* Recursively serialize children pages up to the specified depth.
*/
private function serializeChildren(PageInterface $page, array $options, int $depth): array
{
$childOptions = array_merge($options, [
'include_children' => $depth > 1,
'children_depth' => $depth - 1,
]);
$result = [];
foreach ($page->children() as $child) {
$result[] = $this->serialize($child, $childOptions);
}
return $result;
}
/**
* Format a Unix timestamp as ISO 8601.
*/
private function formatTimestamp(int|null $timestamp): ?string
{
if ($timestamp === null || $timestamp === 0) {
return null;
}
return (new DateTimeImmutable('@' . $timestamp))
->setTimezone(new DateTimeZone('UTC'))
->format(DateTimeImmutable::ATOM);
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
interface SerializerInterface
{
public function serialize(object $resource, array $options = []): array;
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use DateTimeImmutable;
use DateTimeZone;
use Grav\Common\User\Interfaces\UserInterface;
class UserSerializer implements SerializerInterface
{
public function serialize(object $resource, array $options = []): array
{
/** @var UserInterface $resource */
return [
'username' => $resource->username,
'email' => $resource->get('email'),
'fullname' => $resource->get('fullname'),
'title' => $resource->get('title'),
'state' => $resource->get('state', 'enabled'),
'language' => $resource->get('language', ''),
'content_editor' => $resource->get('content_editor', ''),
'access' => $resource->get('access', []),
'groups' => array_values(array_filter(
(array) $resource->get('groups', []),
'is_string',
)),
'avatar_url' => self::resolveAvatarUrl($resource),
'twofa_enabled' => (bool) $resource->get('twofa_enabled', false),
'twofa_secret' => $resource->get('twofa_secret') ? true : false,
'created' => $this->formatTimestamp($resource->get('created')),
'modified' => $this->formatTimestamp($resource->get('modified')),
];
}
/**
* Resolve the avatar URL for a user.
* Returns the URL to an uploaded avatar, or null if none exists.
*/
public static function resolveAvatarUrl(UserInterface $resource): ?string
{
$avatar = $resource->get('avatar');
// Avatar is stored as { filename: { name, type, size, path } } or similar
if (is_array($avatar) && !empty($avatar)) {
$first = reset($avatar);
if (is_array($first) && isset($first['path'])) {
// path is relative to Grav root (e.g. user/accounts/avatars/file.jpg)
$filePath = GRAV_ROOT . '/' . $first['path'];
if (file_exists($filePath)) {
// Generate a thumbnail URL via the thumbnail service
$locator = \Grav\Common\Grav::instance()['locator'];
$cacheDir = $locator->findResource('cache://', true, true) . '/api/thumbnails';
$thumbService = new \Grav\Plugin\Api\Services\ThumbnailService($cacheDir, 200);
$filename = $thumbService->getThumbnailFilename($filePath);
if ($filename) {
$thumbService->getThumbnail($filePath);
$config = \Grav\Common\Grav::instance()['config'];
$route = $config->get('plugins.api.route', '/api');
$prefix = $config->get('plugins.api.version_prefix', 'v1');
return $route . '/' . $prefix . '/thumbnails/' . $filename;
}
}
}
}
return null;
}
private function formatTimestamp(mixed $timestamp): ?string
{
if ($timestamp === null || $timestamp === 0) {
return null;
}
return (new DateTimeImmutable('@' . (int) $timestamp))
->setTimezone(new DateTimeZone('UTC'))
->format(DateTimeImmutable::ATOM);
}
}
@@ -0,0 +1,332 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\PermissionResolver;
/**
* Resolves blueprint-field path inputs (destination / folder) to an absolute
* filesystem directory, mirroring admin-classic's logic in
* AdminBaseController::taskFilesUpload / taskGetFilesInFolder.
*
* Inputs supported:
* - `self@:subpath`, `@self:subpath` — relative to the scope owner
* (plugins/<slug>, themes/<slug>, pages/<route>, users/<username>)
* - Grav streams: `user://`, `theme://`, `themes://`, `plugins://`,
* `account://`, `image://`, `asset://`, `page://`, etc.
* - Plain relative paths — resolved under `user/`, confined to it.
*
* Extracted from BlueprintUploadController so the same resolution is used
* by the read-only browse endpoint (BlueprintFilesController). All security
* gates that previously lived on the upload controller remain there; this
* service is the path-resolution primitive only.
*/
class BlueprintPathResolver
{
public function __construct(
private readonly Grav $grav,
) {}
/**
* Reject traversal / null-byte / backslash strings before stream resolution.
* Mirrors BlueprintUploadController::assertSafeDestination.
*/
public function assertSafe(string $input): void
{
if (str_contains($input, "\0") || str_contains($input, '\\')) {
throw new ValidationException('Invalid path.');
}
$path = $input;
if (preg_match('/^(?:self@|@self)(?::(.*))?$/', $input, $m)) {
$path = $m[1] ?? '';
} elseif (preg_match('#^[A-Za-z][A-Za-z0-9+.-]*://(.*)$#', $input, $m)) {
$path = $m[1] ?? '';
}
foreach (explode('/', trim($path, '/')) as $segment) {
if ($segment === '') {
continue;
}
if ($segment === '.' || $segment === '..') {
throw new ValidationException('Traversal not allowed.');
}
}
}
/**
* Detect a `@self` / `self@` / `@self@` literal (no subpath). The browse
* endpoint treats these specially — they mean "use the page's own media"
* which is served via /pages/{route}/media, not a generic folder browse.
*/
public function isSelfLiteral(string $input): bool
{
return in_array($input, ['@self', 'self@', '@self@', '@self/', 'self@/'], true);
}
/**
* Resolve a blueprint destination/folder + scope to an absolute filesystem
* directory.
*
* Streams and `self@:` owner roots are trusted as-is — Grav's resource
* locator is the authority on where they point. Plain relative paths are
* gated to stay under `user/`.
*
* @param UserInterface|null $caller Required to resolve `users/<username>` scope.
*/
public function resolve(string $input, string $scope, ?UserInterface $caller = null): string
{
$locator = $this->locator();
// `self@:subpath` / `@self:subpath` — relative to the blueprint owner.
if (preg_match('/^(?:self@|@self)(?::(.*))?$/', $input, $m)) {
$sub = $m[1] ?? '';
if (str_contains($sub, '..')) {
throw new ValidationException('Traversal not allowed in self@: subpath.');
}
$base = $this->resolveScopeRoot($scope, $caller);
if ($base === null) {
throw new ValidationException(
"Cannot resolve 'self@:' path: scope '{$scope}' is not a supported owner."
);
}
return $sub === '' ? $base : $base . '/' . ltrim($sub, '/');
}
// Grav stream — user://, theme://, account://, etc.
if ($locator->isStream($input)) {
$resolved = $locator->findResource($input, true, true);
if ($resolved === false || !is_string($resolved)) {
throw new ValidationException("Stream not resolvable: '{$input}'.");
}
return $resolved;
}
// Plain path — must be relative to user root and stay inside it.
if (str_starts_with($input, '/') || str_contains($input, '..')) {
throw new ValidationException('Absolute or traversal paths are not allowed.');
}
$userRoot = $this->userRoot();
if ($userRoot === null) {
throw new ValidationException('User root is not available.');
}
return $this->assertInsideUserRoot($userRoot . '/' . $input);
}
/**
* Map a scope (plugins/<slug>, themes/<slug>, pages/<route>, users/<username>)
* to its filesystem root. Returns null for unsupported scope types.
*/
public function resolveScopeRoot(string $scope, ?UserInterface $caller = null): ?string
{
if ($scope === '') return null;
$parts = explode('/', $scope, 2);
$type = $parts[0];
$name = $parts[1] ?? '';
$locator = $this->locator();
return match ($type) {
'plugins' => $this->resolveStreamOrNull($locator, 'plugins://', $name),
'themes' => $this->resolveStreamOrNull($locator, 'themes://', $name),
'pages' => $this->resolvePageScope($name),
'users' => $name !== '' ? $this->resolveUserScope($name, $caller) : null,
default => null,
};
}
/**
* Compute the Grav-root-relative directory path for a destination, used to
* produce stable round-trip identifiers (returned by upload, accepted by
* delete). Survives symlinks because it's derived from the logical input,
* not the realpath.
*/
public function logicalParent(string $destination, string $scope): ?string
{
// self@:sub — resolve relative to scope owner
if (preg_match('/^(?:self@|@self)(?::(.*))?$/', $destination, $m)) {
$sub = ltrim($m[1] ?? '', '/');
[$type, $name] = array_pad(explode('/', $scope, 2), 2, '');
$parent = match ($type) {
'plugins' => $name ? "plugins/{$name}" : null,
'themes' => $name ? "themes/{$name}" : null,
'users' => 'accounts',
'pages' => $name ? "pages/{$name}" : null,
default => null,
};
if ($parent === null) return null;
return $sub === '' ? $parent : $parent . '/' . $sub;
}
// Known Grav streams that map 1:1 to user/ subdirs.
$streamMap = [
'user://' => '',
'theme://' => $this->activeThemeDir(),
'themes://' => 'themes',
'plugins://' => 'plugins',
'account://' => 'accounts',
'image://' => 'images',
'asset://' => 'assets',
'page://' => 'pages',
];
foreach ($streamMap as $prefix => $replace) {
if ($replace !== null && str_starts_with($destination, $prefix)) {
$rest = ltrim(substr($destination, strlen($prefix)), '/');
$parts = array_filter([$replace, $rest], static fn($p) => $p !== '' && $p !== null);
return implode('/', $parts);
}
}
// Plain relative path — treated as user-rooted already.
if (!str_starts_with($destination, '/') && !str_contains($destination, '..')) {
return trim($destination, '/');
}
return null;
}
public function userRoot(): ?string
{
$locator = $this->locator();
$root = $locator->findResource('user://', true, true);
if ($root === false || !is_string($root)) return null;
$real = realpath($root);
return $real === false ? null : $real;
}
/**
* Classify a resolved directory against the config-bearing dirs under
* `user/`. Returns 'accounts', 'config', 'env', or null.
*
* Used by upload-side guards. Browse callers can ignore this since
* Media::all() filters non-media files anyway and reading config is
* harmless — but exposing the same method here keeps the security
* logic centralized.
*/
public function classifyTargetDir(string $absoluteDir): ?string
{
$userRoot = $this->userRoot();
if ($userRoot === null) return null;
$probe = $absoluteDir;
while ($probe !== '' && !file_exists($probe)) {
$parent = dirname($probe);
if ($parent === $probe) break;
$probe = $parent;
}
$real = realpath($probe !== '' ? $probe : $absoluteDir);
if ($real === false) {
$real = $absoluteDir;
}
$normalizedTarget = rtrim(str_replace('\\', '/', $absoluteDir), '/');
$map = [
'accounts' => $userRoot . '/accounts',
'config' => $userRoot . '/config',
'env' => $userRoot . '/env',
];
foreach ($map as $label => $forbidden) {
$normalizedForbidden = rtrim(str_replace('\\', '/', $forbidden), '/');
if (
$real === $forbidden
|| str_starts_with($real, $forbidden . '/')
|| $normalizedTarget === $normalizedForbidden
|| str_starts_with($normalizedTarget, $normalizedForbidden . '/')
) {
return $label;
}
}
return null;
}
public function assertInsideUserRoot(string $path): string
{
$userRoot = $this->userRoot();
if ($userRoot === null) {
throw new ValidationException('User root is not available.');
}
$probe = $path;
while ($probe !== '' && !file_exists($probe)) {
$parent = dirname($probe);
if ($parent === $probe) break;
$probe = $parent;
}
$real = realpath($probe !== '' ? $probe : $userRoot);
if ($real === false || (!str_starts_with($real, $userRoot . '/') && $real !== $userRoot)) {
throw new ValidationException('Path escapes the user directory.');
}
return rtrim($path, '/');
}
private function resolveStreamOrNull($locator, string $stream, string $name): ?string
{
if ($name === '') return null;
$resolved = $locator->findResource($stream . $name, true, true);
return is_string($resolved) ? $resolved : null;
}
private function resolvePageScope(string $route): ?string
{
if ($route === '') return null;
$pages = $this->grav['pages'];
if (method_exists($pages, 'enablePages')) {
$pages->enablePages();
}
/** @var PageInterface|null $page */
$page = $pages->find('/' . ltrim($route, '/'));
return $page?->path() ?: null;
}
/**
* Resolve `users/<username>` scope to the accounts directory.
*
* Tight gating: the caller must be editing their own account OR hold
* `api.users.write`. Without this, any holder of `api.media.write` could
* target other users' avatar slots — see GHSA-6xx2-m8wv-756h.
*/
private function resolveUserScope(string $name, ?UserInterface $caller): ?string
{
if (!preg_match('/^[A-Za-z0-9_.-]+$/', $name)) {
throw new ValidationException("Invalid users scope: '{$name}'.");
}
if ($caller === null) {
throw new ForbiddenException("The 'users/{$name}' scope requires an authenticated caller.");
}
$isSelf = strcasecmp($caller->username, $name) === 0;
$resolver = new PermissionResolver($this->grav['permissions']);
$isSuper = (bool) $caller->get('access.api.super');
$hasUsersWrite = (bool) $resolver->resolve($caller, 'api.users.write');
if (!$isSelf && !$isSuper && !$hasUsersWrite) {
throw new ForbiddenException(
"The 'users/{$name}' scope requires editing your own account or holding the 'api.users.write' permission."
);
}
$accounts = $this->locator()->findResource('account://', true, true);
return is_string($accounts) ? $accounts : null;
}
private function activeThemeDir(): ?string
{
$theme = (string)($this->grav['config']->get('system.pages.theme') ?? '');
return $theme === '' ? null : 'themes/' . $theme;
}
private function locator()
{
return $this->grav['locator'];
}
}
@@ -0,0 +1,506 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
use Grav\Common\Yaml;
use Grav\Plugin\Api\Services\EnvironmentService;
/**
* Differential config-save support.
*
* Admin writes should only persist values that actually override the parent —
* matching how developers hand-edit Grav configs. The parent of each config
* scope is:
*
* system / site / media / security / scheduler / backups
* → system/config/<scope>.yaml (Grav core defaults)
*
* plugins/<name>
* → user/plugins/<name>/<name>.yaml (plugin's own defaults)
*
* themes/<name>
* → user/themes/<name>/<name>.yaml (theme's own defaults)
*
* For env-targeted writes the parent is defaults merged with the current
* user/config/<scope>.yaml, so env files store only values that differ from
* the effective base config.
*
* Note: we deliberately use the raw YAML files as the source of defaults, not
* blueprint defaults. Blueprints describe the admin form; they can diverge
* from what the yaml actually supplies at load time.
*/
class ConfigDiffer
{
private const CORE_SCOPES = ['system', 'site', 'media', 'security', 'scheduler', 'backups'];
public function __construct(private Grav $grav)
{
}
/**
* Return the subset of $current that differs from $parent.
*
* Associative arrays recurse; sequential arrays are treated as atomic
* values (any difference → the whole new list is retained). This avoids
* the classic admin-classic trap where shortening a list silently merged
* removed entries back in.
*
* @param array<mixed> $current
* @param array<mixed> $parent
* @return array<mixed>
*/
public function diff(array $current, array $parent): array
{
$out = [];
foreach ($current as $key => $value) {
if (!array_key_exists($key, $parent)) {
$out[$key] = $value;
continue;
}
$parentValue = $parent[$key];
if (self::valuesEqual($value, $parentValue)) {
continue;
}
if (is_array($value) && is_array($parentValue)
&& self::isAssoc($value) && self::isAssoc($parentValue)) {
$sub = $this->diff($value, $parentValue);
if ($sub !== []) {
$out[$key] = $sub;
}
continue;
}
// Scalar change, sequential-array change, or shape change (assoc↔list).
$out[$key] = $value;
}
return $out;
}
/**
* Parent config for a scope + optional env target.
* See class docblock for parent resolution rules.
*
* @return array<mixed>
*/
public function parent(string $scope, ?string $targetEnv): array
{
$defaults = $this->loadYamlAtPath($this->defaultsPath($scope)) ?? [];
if ($targetEnv === null || $targetEnv === '') {
return $defaults;
}
$base = $this->loadYamlAtPath($this->baseFilePath($scope)) ?? [];
if ($base === []) return $defaults;
return $this->deepMergeAssoc($defaults, $base);
}
/**
* The effective merged config for $scope under $targetEnv, computed purely
* from YAML files:
*
* defaults ⊕ user/config ⊕ user/env/<targetEnv>/config (when targetEnv set)
*
* then with GRAV_CONFIG__* environment overrides re-applied so the result
* matches what Grav resolves at runtime. Used as the baseline the admin
* reads and edits when the requested target differs from the environment
* Grav booted under — notably base/"Default" while a hostname overlay is
* active. Grav can't re-resolve its environment mid-request, so we resolve
* the files ourselves; this is what stops "Default" from showing — and a
* save from inheriting — the env overlay.
*
* @return array<mixed>
*/
public function effective(string $scope, ?string $targetEnv): array
{
$merged = $this->loadYamlAtPath($this->defaultsPath($scope)) ?? [];
$base = $this->loadYamlAtPath($this->baseFilePath($scope)) ?? [];
if ($base !== []) {
$merged = $this->deepMergeAssoc($merged, $base);
}
if ($targetEnv !== null && $targetEnv !== '') {
$overlay = $this->loadYamlAtPath($this->envFilePath($scope, $targetEnv)) ?? [];
if ($overlay !== []) {
$merged = $this->deepMergeAssoc($merged, $overlay);
}
}
return $this->applyEnvironmentOverrides($merged, $scope);
}
/**
* Re-apply GRAV_CONFIG__* overrides for $scope on top of $data, mirroring
* the runtime layering Grav core does (InitializeProcessor), so a file-based
* effective() shows the same value Grav serves. Values are read from the
* live config — env-var overrides are environment-agnostic, so they apply
* identically regardless of the target. The inverse of
* stripEnvironmentOverrides(), which removes these on save.
*
* @param array<mixed> $data
* @return array<mixed>
*/
public function applyEnvironmentOverrides(array $data, string $scope): array
{
$envKeys = $this->environmentOverrideKeys();
if ($envKeys === [] || $scope === '') {
return $data;
}
$prefix = str_replace('/', '.', $scope);
$config = $this->grav['config'] ?? null;
foreach ($envKeys as $key) {
$isWholeScope = $key === $prefix;
if (!$isWholeScope && !str_starts_with($key, $prefix . '.')) {
continue;
}
$value = is_object($config) && method_exists($config, 'get') ? $config->get($key) : null;
if ($value === null) {
continue;
}
if ($isWholeScope) {
return is_array($value) ? $value : $data;
}
$data = $this->setDotPath($data, substr($key, strlen($prefix) + 1), $value);
}
return $data;
}
/**
* Set a dotted path in a nested array, creating intermediate maps. The
* counterpart to unsetDotPath().
*
* @param array<mixed> $data
* @return array<mixed>
*/
private function setDotPath(array $data, string $path, mixed $value): array
{
$parts = explode('.', $path);
$ref = &$data;
foreach ($parts as $i => $part) {
if ($i === array_key_last($parts)) {
$ref[$part] = $value;
break;
}
if (!isset($ref[$part]) || !is_array($ref[$part])) {
$ref[$part] = [];
}
$ref = &$ref[$part];
}
unset($ref);
return $data;
}
/**
* Remove from $data any values that are currently supplied by GRAV_CONFIG__*
* environment variables for this scope, pruning subtrees that empty out.
*
* Those overrides are layered onto the compiled config at runtime by Grav
* core (InitializeProcessor) and always win, so they must never be written
* back to a YAML file on save — doing so would persist a secret provided
* through `.env` (or the server environment) into the config on disk. This
* is scope-agnostic: it works for system/site/plugins/themes and any other
* config namespace because a scope maps to its config key by turning the
* `/` separator into a `.`.
*
* @param array<mixed> $data
* @return array<mixed>
*/
public function stripEnvironmentOverrides(array $data, string $scope): array
{
$envKeys = $this->environmentOverrideKeys();
if ($envKeys === [] || $scope === '') {
return $data;
}
$prefix = str_replace('/', '.', $scope);
foreach ($envKeys as $key) {
if ($key === $prefix) {
// The entire scope is provided by the environment.
return [];
}
if (str_starts_with($key, $prefix . '.')) {
$data = $this->unsetDotPath($data, substr($key, strlen($prefix) + 1));
}
}
return $data;
}
/**
* Dotted config keys currently supplied via GRAV_CONFIG__* environment
* variables, with GRAV_CONFIG_ALIAS__ substitution applied. Mirrors the
* resolution in Grav core's InitializeProcessor::initializeConfig() so the
* keys we skip on save are exactly the keys core injects at runtime. Empty
* when the GRAV_CONFIG switch is off.
*
* @return list<string>
*/
public function environmentOverrideKeys(): array
{
if (!getenv('GRAV_CONFIG')) {
return [];
}
$prefix = 'GRAV_CONFIG';
$cPrefix = $prefix . '__';
$aPrefix = $prefix . '_ALIAS__';
$cLen = strlen($cPrefix);
$aLen = strlen($aPrefix);
$keys = [];
$aliases = [];
foreach ($_ENV + $_SERVER as $name => $value) {
$name = (string) $name;
if (!str_starts_with($name, $prefix)) {
continue;
}
if (str_starts_with($name, $cPrefix)) {
$keys[] = str_replace('__', '.', substr($name, $cLen));
} elseif (str_starts_with($name, $aPrefix)) {
$aliases[substr($name, $aLen)] = (string) $value;
}
}
foreach ($keys as $i => $key) {
foreach ($aliases as $alias => $real) {
$key = str_replace($alias, $real, $key);
}
$keys[$i] = $key;
}
return $keys;
}
/**
* Flatten a nested config delta to its dotted leaf paths. A "leaf" is a
* scalar, a sequential (list) array — treated atomically, matching diff() —
* or an empty array; only associative maps recurse. Used to map a persisted
* override delta onto blueprint field names for the override indicators.
*
* @param array<mixed> $data
* @return list<string>
*/
public static function flattenLeaves(array $data, string $prefix = ''): array
{
$out = [];
foreach ($data as $key => $value) {
$path = $prefix === '' ? (string) $key : $prefix . '.' . $key;
if (is_array($value) && self::isAssoc($value)) {
$out = array_merge($out, self::flattenLeaves($value, $path));
} else {
$out[] = $path;
}
}
return $out;
}
/**
* Dig a dotted path out of a nested array, or null if any segment is
* missing. Callers treat "absent in the parent" as "reverts to the
* blueprint default / unset".
*
* @param array<mixed> $data
*/
public static function valueAtPath(array $data, string $path): mixed
{
$ref = $data;
foreach (explode('.', $path) as $part) {
if (!is_array($ref) || !array_key_exists($part, $ref)) {
return null;
}
$ref = $ref[$part];
}
return $ref;
}
/**
* Unset a dotted path from a nested array, pruning parents left empty.
*
* @param array<mixed> $data
* @return array<mixed>
*/
public function unsetDotPath(array $data, string $path): array
{
$parts = explode('.', $path);
$key = array_shift($parts);
if (!array_key_exists($key, $data)) {
return $data;
}
if ($parts === []) {
unset($data[$key]);
return $data;
}
if (is_array($data[$key])) {
$data[$key] = $this->unsetDotPath($data[$key], implode('.', $parts));
if ($data[$key] === []) {
unset($data[$key]);
}
}
return $data;
}
/**
* Recursive merge: $override wins, assoc subtrees recurse, sequential
* arrays are REPLACED (not concatenated).
*
* @param array<mixed> $base
* @param array<mixed> $override
* @return array<mixed>
*/
public function deepMergeAssoc(array $base, array $override): array
{
foreach ($override as $k => $v) {
if (is_array($v) && isset($base[$k]) && is_array($base[$k])
&& self::isAssoc($v) && self::isAssoc($base[$k])) {
$base[$k] = $this->deepMergeAssoc($base[$k], $v);
} else {
$base[$k] = $v;
}
}
return $base;
}
/**
* Path to the defaults file for $scope, or null if none resolvable.
*/
private function defaultsPath(string $scope): ?string
{
$locator = $this->grav['locator'];
if (in_array($scope, self::CORE_SCOPES, true)) {
$p = $locator->findResource('system://config/' . $scope . '.yaml', true);
return $p ?: null;
}
if (str_starts_with($scope, 'plugins/')) {
$name = substr($scope, 8);
$p = $locator->findResource('plugins://' . $name . '/' . $name . '.yaml', true);
return $p ?: null;
}
if (str_starts_with($scope, 'themes/')) {
$name = substr($scope, 7);
$p = $locator->findResource('themes://' . $name . '/' . $name . '.yaml', true);
return $p ?: null;
}
return null;
}
/**
* Path to the base user/config file for $scope, or null if missing.
*/
private function baseFilePath(string $scope): ?string
{
$userConfig = $this->grav['locator']->findResource('user://config', true);
if (!$userConfig) return null;
$relative = $this->scopeRelativeFile($scope);
if ($relative === null) return null;
$full = $userConfig . '/' . $relative;
return is_file($full) ? $full : null;
}
/**
* Path to an env overlay file for $scope under $targetEnv, or null if the
* env (or file) doesn't exist. Resolves user/env/<env>/config first, then
* the legacy user/<env>/config layout — same as EnvironmentService.
*/
private function envFilePath(string $scope, string $targetEnv): ?string
{
$root = (new EnvironmentService($this->grav))->envConfigRoot($targetEnv);
if ($root === null) return null;
$relative = $this->scopeRelativeFile($scope);
if ($relative === null) return null;
$full = $root . '/' . $relative;
return is_file($full) ? $full : null;
}
/**
* The config filename for $scope relative to a config dir
* (e.g. 'system.yaml', 'plugins/foo.yaml'), or null for unknown scopes.
*/
private function scopeRelativeFile(string $scope): ?string
{
return match (true) {
in_array($scope, self::CORE_SCOPES, true) => $scope . '.yaml',
str_starts_with($scope, 'plugins/') => 'plugins/' . substr($scope, 8) . '.yaml',
str_starts_with($scope, 'themes/') => 'themes/' . substr($scope, 7) . '.yaml',
// Site-authored top-level config: a flat user/config/<scope>.yaml,
// so base + env overlay reads resolve like the core scopes.
ConfigScopes::isCustom($this->grav, $scope) => $scope . '.yaml',
default => null,
};
}
/**
* @return array<mixed>|null
*/
private function loadYamlAtPath(?string $path): ?array
{
if ($path === null || !is_file($path)) return null;
try {
$content = Yaml::parse((string)file_get_contents($path));
} catch (\Throwable) {
return null;
}
return is_array($content) ? $content : null;
}
/**
* @param array<mixed> $arr
*/
public static function isAssoc(array $arr): bool
{
if ($arr === []) return false;
return !array_is_list($arr);
}
/**
* Deep value equality with canonical key order for associative arrays so
* the same logical config hashes equal regardless of key insertion order.
*/
public static function valuesEqual(mixed $a, mixed $b): bool
{
if (is_array($a) && is_array($b)) {
return self::canonicalize($a) === self::canonicalize($b);
}
return $a === $b;
}
/**
* Recursively sort associative arrays by key so the same logical config
* serializes (and therefore hashes) identically regardless of key order.
* Sequential arrays keep their order.
*
* @param array<mixed> $arr
* @return array<mixed>
*/
public static function canonicalize(array $arr): array
{
if (self::isAssoc($arr)) {
ksort($arr);
}
foreach ($arr as $k => $v) {
if (is_array($v)) {
$arr[$k] = self::canonicalize($v);
}
}
return $arr;
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
/**
* Decides which config scopes the generic /config and /blueprints/config
* endpoints accept.
*
* Core scopes (system, site, media, security, scheduler, backups) are handled
* by explicit arms in ConfigController / BlueprintController. Beyond those,
* site authors can drop a top-level config in via the cookbook "add a custom
* yaml file" recipe — a `user/blueprints/config/<scope>.yaml` paired with a
* `user/config/<scope>.yaml`. Admin-classic showed those as config tabs
* automatically; admin2's API used to reject them because every downstream
* handler hardcoded the 6-scope whitelist.
*
* {@see isCustom()} is the single gate those handlers now share. It deliberately
* keys off a *user/environment-authored* blueprint, NOT the merged
* `blueprints://config` stream: core ships system blueprints there too (e.g.
* `streams.yaml`), and those must never become writable through the generic
* config permission. Requiring the blueprint to live under user:// or
* environment:// limits custom scopes to ones the site itself defined.
*/
final class ConfigScopes
{
/**
* Config scopes the API handles with explicit, individually-guarded arms.
* Custom scopes can never collide with these — the explicit arms win first.
*/
public const CORE = ['system', 'site', 'media', 'security', 'scheduler', 'backups'];
/**
* True when $scope is a site-authored top-level config (the cookbook custom
* yaml recipe).
*
* A valid custom scope is a flat slug (no slashes or dots — this also blocks
* path traversal through the `/config/{scope:.+}` route), is not one of the
* explicitly-handled CORE scopes, and has its config blueprint under the
* user:// or environment:// blueprints stream.
*/
public static function isCustom(Grav $grav, ?string $scope): bool
{
if ($scope === null || !preg_match('/^[a-z0-9][a-z0-9_-]*$/', $scope)) {
return false;
}
if (in_array($scope, self::CORE, true)) {
return false;
}
$locator = $grav['locator'];
foreach (['user://blueprints/config/', 'environment://blueprints/config/'] as $base) {
if ($locator->findResource($base . $scope . '.yaml', true)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\PermissionResolver;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\YamlFile;
/**
* Resolves the final dashboard widget list for a given user by merging:
* 1. Built-in core widget registry
* 2. Plugin-contributed widgets via onApiDashboardWidgets
* 3. Site layout (super-admin defaults — visibility floor)
* 4. User layout (per-user overrides — order, size, visible)
*
* Site-hidden widgets are stripped before user layout is applied; users can
* never re-enable a widget the site admin has turned off.
*/
class DashboardLayoutResolver
{
public const SITE_CONFIG_FILE = 'admin-next.yaml';
public const VALID_SIZES = ['xs', 'sm', 'md', 'lg', 'xl'];
public function __construct(
private readonly Grav $grav,
private readonly PermissionResolver $permissions,
) {}
/**
* Built-in core widgets shipped with admin-next.
*
* @return array<int, array<string, mixed>>
*/
public function coreRegistry(): array
{
return [
[
'id' => 'core.stats',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.STATS',
'icon' => 'BarChart3',
'sizes' => ['md', 'lg', 'xl'],
'defaultSize' => 'xl',
'authorize' => 'api.system.read',
'priority' => 100,
],
[
'id' => 'core.popularity',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.POPULARITY',
'icon' => 'TrendingUp',
'sizes' => ['md', 'lg', 'xl'],
'defaultSize' => 'lg',
'authorize' => 'api.system.read',
'priority' => 90,
],
[
'id' => 'core.system-health',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.SYSTEM_HEALTH',
'icon' => 'Activity',
'sizes' => ['sm', 'md', 'lg'],
'defaultSize' => 'sm',
'authorize' => 'api.system.read',
'priority' => 80,
],
[
'id' => 'core.recent-pages',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.RECENT_PAGES',
'icon' => 'FileText',
'sizes' => ['sm', 'md'],
'defaultSize' => 'md',
'authorize' => 'api.pages.read',
'priority' => 70,
],
[
'id' => 'core.top-pages',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.TOP_PAGES',
'icon' => 'Flame',
'sizes' => ['sm', 'md'],
'defaultSize' => 'sm',
'authorize' => 'api.system.read',
'priority' => 60,
],
[
'id' => 'core.backups',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.BACKUPS',
'icon' => 'Archive',
'sizes' => ['sm', 'md'],
'defaultSize' => 'sm',
'authorize' => 'api.system.read',
'priority' => 50,
],
[
'id' => 'core.notifications',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.NOTIFICATIONS',
'icon' => 'Bell',
'sizes' => ['sm', 'md', 'lg'],
'defaultSize' => 'md',
'authorize' => 'api.system.read',
'priority' => 40,
],
[
'id' => 'core.news-feed',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.NEWS_FEED',
'icon' => 'Rss',
'sizes' => ['sm', 'md', 'lg'],
'defaultSize' => 'md',
'authorize' => 'api.system.read',
'priority' => 30,
],
];
}
/**
* Collect plugin-contributed widgets via the onApiDashboardWidgets event.
*
* @return array<int, array<string, mixed>>
*/
public function pluginRegistry(UserInterface $user): array
{
$event = new Event(['widgets' => [], 'user' => $user]);
$this->grav->fireEvent('onApiDashboardWidgets', $event);
$items = [];
foreach ($event['widgets'] as $widget) {
if (!is_array($widget) || empty($widget['id'])) {
continue;
}
$widget['source'] = 'plugin';
$items[] = $widget;
}
return $items;
}
/**
* Read the site-wide dashboard layout from user/config/admin-next.yaml.
*
* @return array<string, mixed>
*/
public function siteLayout(): array
{
$path = $this->siteConfigFilePath();
if (!$path || !is_file($path)) {
return [];
}
$content = (array) YamlFile::instance($path)->content();
$layout = $content['dashboard']['site_layout'] ?? [];
return is_array($layout) ? $layout : [];
}
/**
* Read this user's saved dashboard layout from their account YAML.
*
* Storage location is the top-level `admin_next.dashboard` key. Older
* builds wrote to `state.admin_next.dashboard`, which collided with
* Grav's account-state string (`state: enabled` / `state: disabled`)
* and caused affected users to render as Disabled in user lists.
* `migrateLegacyState()` lifts that legacy data out and restores the
* account-state string on first read.
*
* @return array<string, mixed>
*/
public function userLayout(UserInterface $user): array
{
$this->migrateLegacyState($user);
$adminNext = $user->get('admin_next');
if (!is_array($adminNext)) {
return [];
}
$layout = $adminNext['dashboard'] ?? [];
return is_array($layout) ? $layout : [];
}
/**
* One-time migration: if `state` was clobbered by an older build with a
* map containing `admin_next.dashboard`, lift the dashboard layout out
* to the top-level `admin_next.dashboard` key and restore `state` to
* the standard `enabled` / `disabled` string.
*/
private function migrateLegacyState(UserInterface $user): bool
{
$state = $user->get('state');
if (!is_array($state)) {
return false;
}
$legacyDashboard = $state['admin_next']['dashboard'] ?? null;
if (is_array($legacyDashboard)) {
$adminNext = $user->get('admin_next');
$adminNext = is_array($adminNext) ? $adminNext : [];
// New location wins if both are present (shouldn't happen, but
// be defensive — the new write path is authoritative).
if (!isset($adminNext['dashboard'])) {
$adminNext['dashboard'] = $legacyDashboard;
$user->set('admin_next', $adminNext);
}
}
// Restore the account-state string. If a legacy install ever wrote
// an explicit `state.enabled: false`, honor it; otherwise default
// to `enabled` since the account exists and was being used.
$restored = ($state['enabled'] ?? null) === false ? 'disabled' : 'enabled';
$user->set('state', $restored);
$user->save();
return true;
}
/**
* Resolve the final widget list for a user.
*
* Returns the merged list with each widget annotated with its effective
* `visible`, `size`, and `order`, plus a flag indicating whether the
* widget was hidden by the site admin (in which case the user cannot
* override).
*
* @return array{
* widgets: array<int, array<string, mixed>>,
* user_layout: array<string, mixed>,
* site_layout: array<string, mixed>,
* can_edit_site: bool
* }
*/
public function resolve(UserInterface $user, bool $isSuperAdmin): array
{
$registry = array_merge($this->coreRegistry(), $this->pluginRegistry($user));
// Permission filter
$available = [];
foreach ($registry as $widget) {
$authorize = $widget['authorize'] ?? null;
if ($authorize !== null && !$isSuperAdmin && !(bool) $this->permissions->resolve($user, $authorize)) {
continue;
}
$available[$widget['id']] = $widget;
}
$siteLayout = $this->siteLayout();
$userLayout = $this->userLayout($user);
$siteEntries = $this->indexEntries($siteLayout['widgets'] ?? []);
$userEntries = $this->indexEntries($userLayout['widgets'] ?? []);
$merged = [];
$defaultOrder = 0;
foreach ($available as $id => $widget) {
$siteEntry = $siteEntries[$id] ?? null;
$userEntry = $userEntries[$id] ?? null;
$siteHidden = $siteEntry !== null && ($siteEntry['visible'] ?? true) === false;
// If site admin hid this widget, drop it entirely from the user's view.
if ($siteHidden) {
continue;
}
$size = $userEntry['size'] ?? $siteEntry['size'] ?? $widget['defaultSize'];
if (!in_array($size, self::VALID_SIZES, true)) {
$size = $widget['defaultSize'];
}
// Coerce to a size the widget supports
if (!in_array($size, $widget['sizes'], true)) {
$size = $widget['defaultSize'];
}
$visible = $userEntry !== null
? (bool) ($userEntry['visible'] ?? true)
: (bool) ($siteEntry['visible'] ?? true);
$order = $userEntry['order']
?? $siteEntry['order']
?? (1000 - (int) ($widget['priority'] ?? 0)) * 10 + $defaultOrder++;
$widget['visible'] = $visible;
$widget['size'] = $size;
$widget['order'] = (int) $order;
// Strip server-only annotation
unset($widget['authorize']);
$merged[] = $widget;
}
usort($merged, static fn($a, $b) => $a['order'] <=> $b['order']);
return [
'widgets' => $merged,
'user_layout' => $userLayout,
'site_layout' => $siteLayout,
'can_edit_site' => $isSuperAdmin,
];
}
/**
* Persist a user's layout to their account YAML under admin_next.dashboard.
*
* Note: this used to write to `state.admin_next.dashboard`, which
* collided with Grav's `state: enabled|disabled` account-state field.
* Legacy data is migrated on read by `migrateLegacyState()`.
*
* @param array<string, mixed> $layout
*/
public function saveUserLayout(UserInterface $user, array $layout): void
{
$this->migrateLegacyState($user);
$adminNext = $user->get('admin_next');
$adminNext = is_array($adminNext) ? $adminNext : [];
$adminNext['dashboard'] = $this->normalizeLayout($layout);
$user->set('admin_next', $adminNext);
$user->save();
}
/**
* Persist the site-wide layout to user/config/admin-next.yaml.
*
* @param array<string, mixed> $layout
*/
public function saveSiteLayout(array $layout): void
{
$path = $this->siteConfigFilePath(true);
if (!$path) {
throw new \RuntimeException('Unable to resolve user/config path for admin-next.yaml.');
}
$file = YamlFile::instance($path);
$content = (array) $file->content();
$content['dashboard'] = is_array($content['dashboard'] ?? null) ? $content['dashboard'] : [];
$content['dashboard']['site_layout'] = $this->normalizeLayout($layout);
$file->content($content);
$file->save();
// Make the saved layout visible to the running config in this request
$config = $this->grav['config'] ?? null;
if ($config) {
$config->set('admin-next.dashboard.site_layout', $content['dashboard']['site_layout']);
}
}
/**
* Normalize a layout payload, dropping unknown keys and bad types.
*
* @param array<string, mixed> $layout
* @return array<string, mixed>
*/
public function normalizeLayout(array $layout): array
{
$out = [];
$preset = $layout['preset'] ?? 'custom';
if (is_string($preset) && in_array($preset, ['default', 'minimal', 'compact', 'custom'], true)) {
$out['preset'] = $preset;
} else {
$out['preset'] = 'custom';
}
$widgets = [];
foreach ((array) ($layout['widgets'] ?? []) as $entry) {
if (!is_array($entry) || empty($entry['id']) || !is_string($entry['id'])) {
continue;
}
$size = $entry['size'] ?? null;
$widgets[] = [
'id' => $entry['id'],
'visible' => array_key_exists('visible', $entry) ? (bool) $entry['visible'] : true,
'size' => is_string($size) && in_array($size, self::VALID_SIZES, true) ? $size : null,
'order' => isset($entry['order']) ? (int) $entry['order'] : 0,
];
}
$out['widgets'] = $widgets;
return $out;
}
/**
* @param array<int, array<string, mixed>> $entries
* @return array<string, array<string, mixed>>
*/
private function indexEntries(mixed $entries): array
{
if (!is_array($entries)) {
return [];
}
$indexed = [];
foreach ($entries as $entry) {
if (is_array($entry) && !empty($entry['id']) && is_string($entry['id'])) {
$indexed[$entry['id']] = $entry;
}
}
return $indexed;
}
/**
* Resolve the absolute path to user/config/admin-next.yaml.
*/
private function siteConfigFilePath(bool $createDir = false): ?string
{
$locator = $this->grav['locator'] ?? null;
if ($locator === null) {
return null;
}
$userConfigDir = $locator->findResource('user://config', true) ?: null;
if ($userConfigDir === null) {
$userPath = $locator->findResource('user://', true);
if ($userPath && $createDir) {
$userConfigDir = $userPath . '/config';
if (!is_dir($userConfigDir)) {
mkdir($userConfigDir, 0775, true);
}
}
}
if (!$userConfigDir) {
return null;
}
return $userConfigDir . '/' . self::SITE_CONFIG_FILE;
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
use Grav\Common\Utils;
use Grav\Common\Yaml;
/**
* Index of translation keys contributed exclusively by disabled plugins, keyed
* by language code.
*
* Grav core's `Languages::flattenByLang()` reads every plugin's lang yaml
* regardless of whether the plugin is enabled — fine for legacy admin, broken
* for admin2 where a disabled plugin (most notably admin classic, mid-migration
* on Grav 2 sites) would otherwise leak its strings into both the dictionary
* served to the SPA and the server-side blueprint label resolver.
*
* This service walks `user/plugins/<name>/languages/<lang>.yaml` and
* `user/plugins/<name>/languages.yaml` (single-file multi-lang format), buckets
* keys by enabled-vs-disabled provenance, and returns the keys present only in
* the disabled bucket. Keys also contributed by an enabled plugin are kept —
* the enabled plugin owns them, even if a disabled plugin happens to ship the
* same key.
*
* The result is cached per-language for the request lifecycle since the
* underlying YAML files don't change mid-request.
*/
final class DisabledPluginLangIndex
{
/** @var array<string, array<int, string>> */
private array $cache = [];
public function __construct(private readonly Grav $grav)
{
}
/**
* @return array<int, string> flat translation keys (e.g. `PLUGIN_ADMIN.ADD_FOLDER`)
*/
public function disabledOnlyKeys(string $lang): array
{
if (isset($this->cache[$lang])) {
return $this->cache[$lang];
}
$plugins = $this->grav['plugins'];
$config = $this->grav['config'];
$locator = $this->grav['locator'];
$enabled = [];
$disabled = [];
foreach ($plugins as $plugin) {
$name = $plugin->name;
$resolved = $locator->findResource("plugin://{$name}");
if (!$resolved || !is_dir($resolved)) {
continue;
}
$keys = $this->collectPluginLangKeys($resolved, $lang);
if (empty($keys)) {
continue;
}
$isEnabled = (bool) $config->get("plugins.{$name}.enabled", false);
foreach ($keys as $k) {
if ($isEnabled) {
$enabled[$k] = true;
} else {
$disabled[$k] = true;
}
}
}
$result = array_keys(array_diff_key($disabled, $enabled));
$this->cache[$lang] = $result;
return $result;
}
/**
* True if `$key` is contributed only by disabled plugins for `$lang`.
*/
public function isDisabledOnly(string $key, string $lang): bool
{
return in_array($key, $this->disabledOnlyKeys($lang), true);
}
/**
* @return array<int, string>
*/
private function collectPluginLangKeys(string $pluginDir, string $lang): array
{
$keys = [];
$perLangFile = "{$pluginDir}/languages/{$lang}.yaml";
if (is_file($perLangFile)) {
$data = $this->safeParseYaml($perLangFile);
if (is_array($data)) {
foreach (array_keys(Utils::arrayFlattenDotNotation($data)) as $k) {
$keys[$k] = true;
}
}
}
$singleFile = "{$pluginDir}/languages.yaml";
if (is_file($singleFile)) {
$data = $this->safeParseYaml($singleFile);
$langData = is_array($data) ? ($data[$lang] ?? null) : null;
if (is_array($langData)) {
foreach (array_keys(Utils::arrayFlattenDotNotation($langData)) as $k) {
$keys[$k] = true;
}
}
}
return array_keys($keys);
}
private function safeParseYaml(string $file): mixed
{
try {
return Yaml::parse(file_get_contents($file));
} catch (\Throwable) {
return null;
}
}
}
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
/**
* Resolves environment folders for config writes.
*
* The base write target is always user/config/. Named environments live in
* user/env/<name>/ (preferred) or legacy user/<name>/ layouts from Grav 1.6.
* We never auto-create env folders — they must be opted into via the
* environments API.
*/
class EnvironmentService
{
private const RESERVED_USER_DIRS = [
'accounts', 'blueprints', 'config', 'data', 'env',
'images', 'languages', 'media', 'pages', 'plugins', 'themes',
];
/**
* Names the admin uses as the "base / no overlay" sentinel. The admin-next
* environment switcher maps its base ("Default") selection to the env name
* `default` for X-Grav-Environment, relying on there being no
* user/env/default/ folder so Grav resolves config base-only (Setup empties
* the environment:// stream for a non-existent env dir). Allowing an env
* folder with one of these names would let an overlay silently shadow the
* base-only view, so we refuse to create them.
*/
private const RESERVED_ENV_NAMES = ['default', 'base'];
public function __construct(private Grav $grav)
{
}
/**
* Absolute path to an env's config dir, or null if it doesn't exist.
* Checks user/env/<name>/config first, then legacy user/<name>/config.
*/
public function envConfigRoot(string $name): ?string
{
$userRoot = $this->userRoot();
if ($userRoot === null) return null;
foreach ([
$userRoot . '/env/' . $name . '/config',
$userRoot . '/' . $name . '/config',
] as $dir) {
if (is_dir($dir)) return $dir;
}
return null;
}
/**
* List existing env folder names — user/env/* plus legacy user/<host>/
* that have a config/ subdir. Sorted, case-insensitive natural order.
*
* @return string[]
*/
public function listEnvironments(): array
{
$names = [];
$userRoot = $this->userRoot();
if ($userRoot === null) return $names;
$envDir = $userRoot . '/env';
if (is_dir($envDir)) {
foreach (new \DirectoryIterator($envDir) as $item) {
if ($item->isDot() || !$item->isDir()) continue;
$names[$item->getFilename()] = true;
}
}
foreach (new \DirectoryIterator($userRoot) as $item) {
if ($item->isDot() || !$item->isDir()) continue;
$n = $item->getFilename();
if (in_array($n, self::RESERVED_USER_DIRS, true) || str_starts_with($n, '.')) continue;
if (is_dir($item->getPathname() . '/config')) {
$names[$n] = true;
}
}
$names = array_keys($names);
sort($names, SORT_NATURAL | SORT_FLAG_CASE);
return $names;
}
/**
* The environment Grav is currently loading config under, if any, AND only
* when that env has a config dir on disk. Used by the config-write path so
* saves land where reads come from — otherwise an active env overlay can
* silently shadow a write to base.
*
* The env Grav actually booted its overlay under (`Setup::$environment`) is
* authoritative. Behind a reverse proxy that is the REAL connection host —
* e.g. `localhost` via `SERVER_NAME` — captured at boot, whereas
* `$uri->environment()` reflects the FORWARDED host (e.g.
* `translations.rhuk.net`) and so names an env whose overlay was never
* loaded. We therefore trust the booted env first: if it has a config dir
* that overlay is live, so return it; if it doesn't, no overlay is active
* and base is correct (return null) — we must NOT fall through to a
* forwarded-host env that isn't actually loaded. The Uri is consulted only
* when the booted env is unknown (non-standard bootstrap, or unit tests).
*
* Returns null when no env is active, the env name is malformed, or the
* active env has no config dir (in which case base writes are correct).
*/
public function activeEnvironment(): ?string
{
$booted = $this->bootedEnvironment();
if ($booted !== null) {
return $this->envConfigRoot($booted) !== null ? $booted : null;
}
$name = $this->uriEnvironment();
if ($name === null) {
return null;
}
return $this->envConfigRoot($name) !== null ? $name : null;
}
/**
* The environment Grav resolved at boot (`Setup::$environment`), normalized.
* This is the env whose config overlay is actually loaded for the request.
* Null when the static is unset/malformed or Grav core isn't available.
*/
private function bootedEnvironment(): ?string
{
if (!class_exists(\Grav\Common\Config\Setup::class)) {
return null;
}
$name = \Grav\Common\Config\Setup::$environment;
return is_string($name) && $name !== '' && self::isValidName($name) ? $name : null;
}
/**
* The environment derived from the Grav Uri service (the request host, with
* forwarded-host handling applied). Defensive fallback only — see
* {@see activeEnvironment()}.
*/
private function uriEnvironment(): ?string
{
$uri = $this->grav['uri'] ?? null;
if (!is_object($uri) || !method_exists($uri, 'environment')) {
return null;
}
$name = $uri->environment();
return is_string($name) && $name !== '' && self::isValidName($name) ? $name : null;
}
public function envHasOverrides(string $name): bool
{
$root = $this->envConfigRoot($name);
if ($root === null) return false;
foreach (new \FilesystemIterator($root) as $_) {
return true;
}
return false;
}
/**
* Create a new env/<name>/config/ folder. Returns the created config dir.
* Throws \InvalidArgumentException on invalid names and \RuntimeException on fs failure.
*/
public function createEnvironment(string $name): string
{
if (!self::isValidName($name)) {
throw new \InvalidArgumentException("Invalid environment name '{$name}'.");
}
if (in_array(strtolower($name), self::RESERVED_ENV_NAMES, true)) {
throw new \InvalidArgumentException("Environment name '{$name}' is reserved for the base configuration.");
}
if (in_array($name, $this->listEnvironments(), true)) {
throw new \InvalidArgumentException("Environment '{$name}' already exists.");
}
$userRoot = $this->userRoot();
if ($userRoot === null) {
throw new \RuntimeException('user:// path not resolvable.');
}
$configDir = $userRoot . '/env/' . $name . '/config';
if (!mkdir($configDir, 0775, true) && !is_dir($configDir)) {
throw new \RuntimeException("Failed to create environment directory: {$configDir}");
}
return $configDir;
}
/**
* Delete an env folder (user/env/<name>/) and everything under it.
*
* Refuses to act on legacy user/<name>/ layouts (Grav 1.6 fallback) because
* those directory names overlap freely with user-managed paths, so removing
* them carries too much blast radius. Operators must clean those up by hand.
* Refuses to delete the env Grav resolved for the current request so the
* running session can't have its config yanked out from under it.
*
* Throws \InvalidArgumentException on validation failures and \RuntimeException
* on filesystem failures.
*/
public function deleteEnvironment(string $name): void
{
if (!self::isValidName($name)) {
throw new \InvalidArgumentException("Invalid environment name '{$name}'.");
}
if ($name === $this->activeEnvironment()) {
throw new \InvalidArgumentException(
"Cannot delete environment '{$name}': it is the active environment for this request."
);
}
$userRoot = $this->userRoot();
if ($userRoot === null) {
throw new \RuntimeException('user:// path not resolvable.');
}
$modernDir = $userRoot . '/env/' . $name;
$legacyDir = $userRoot . '/' . $name;
if (!is_dir($modernDir)) {
if (is_dir($legacyDir) && is_dir($legacyDir . '/config')) {
throw new \InvalidArgumentException(
"Environment '{$name}' uses the legacy user/{$name}/ layout. "
. "Remove it manually so unrelated files are not deleted."
);
}
throw new \InvalidArgumentException("Environment '{$name}' does not exist.");
}
// Guard against symlink escape: the resolved path must still live under
// user/env/. If something has replaced user/env/<name>/ with a symlink
// pointing elsewhere, we refuse rather than recursively delete outside
// the user tree.
$real = realpath($modernDir);
$envRootReal = realpath($userRoot . '/env');
if ($real === false || $envRootReal === false || !str_starts_with($real, $envRootReal . DIRECTORY_SEPARATOR)) {
throw new \RuntimeException("Refusing to delete '{$modernDir}': path resolves outside user/env/.");
}
self::rmrf($real);
}
public static function isValidName(string $name): bool
{
return $name !== '' && (bool)preg_match('/^[a-z0-9][a-z0-9._-]*$/i', $name);
}
/**
* Whether a name is the admin's base/"no overlay" sentinel (`default` /
* `base`). Such names are refused as env folders, and the config write path
* treats them as a base (user/config) write target.
*/
public static function isReservedName(string $name): bool
{
return in_array(strtolower($name), self::RESERVED_ENV_NAMES, true);
}
private static function rmrf(string $dir): void
{
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iter as $entry) {
/** @var \SplFileInfo $entry */
if ($entry->isDir() && !$entry->isLink()) {
rmdir($entry->getPathname());
} else {
unlink($entry->getPathname());
}
}
rmdir($dir);
}
private function userRoot(): ?string
{
$root = $this->grav['locator']->findResource('user://', true);
return $root !== false && is_string($root) ? $root : null;
}
}
@@ -0,0 +1,514 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Cache;
use Grav\Common\Filesystem\Folder;
use Grav\Common\GPM\Common\Package;
use Grav\Common\GPM\GPM as GravGPM;
use Grav\Common\GPM\Installer;
use Grav\Common\GPM\Licenses;
use Grav\Common\GPM\Upgrader;
use Grav\Common\Grav;
use Grav\Common\HTTP\Response;
use Grav\Common\Utils;
/**
* GpmService — GPM write operations (install / update / remove / direct-install / self-upgrade).
*
* This is a port of Grav\Plugin\Admin\Gpm that removes the dependency on the
* classic admin plugin so admin-next / admin2 users can manage packages
* without needing the old admin plugin installed.
*
* Admin-specific callsites (Admin::translate, Admin::getTempDir) have been
* replaced with inlined English strings and a local temp-dir resolver.
*/
class GpmService
{
/** @var GravGPM|null */
protected static ?GravGPM $GPM = null;
/** @var string|null Raw installer error captured during the last selfupgrade(). */
protected static ?string $lastError = null;
/** @var array<string, mixed>|null Preflight report captured during the last selfupgrade(). */
protected static ?array $lastPreflightReport = null;
/**
* Default options for install operations.
*
* @var array<string, mixed>
*/
protected static array $options = [
'destination' => GRAV_ROOT,
'overwrite' => true,
'ignore_symlinks' => true,
'skip_invalid' => true,
'install_deps' => false,
'theme' => false,
];
public static function GPM(): GravGPM
{
if (self::$GPM === null) {
self::$GPM = new GravGPM();
}
return self::$GPM;
}
/**
* Install one or more packages.
*
* @param Package[]|string[]|string $packages
* @param array<string, mixed> $options
* @return string|bool
*/
public static function install($packages, array $options)
{
$options = array_merge(self::$options, $options);
if (!Installer::isGravInstance($options['destination']) || !Installer::isValidDestination($options['destination'],
[Installer::EXISTS, Installer::IS_LINK])
) {
return false;
}
$packages = is_array($packages) ? $packages : [$packages];
$count = count($packages);
$packages = array_filter(array_map(static function ($p) {
return !is_string($p) ? ($p instanceof Package ? $p : false) : self::GPM()->findPackage($p);
}, $packages));
if (!$options['skip_invalid'] && $count !== count($packages)) {
return false;
}
$messages = '';
foreach ($packages as $package) {
// Dependency resolution is the caller's responsibility (see
// GpmController::install / ::update which use GPM::getDependencies()).
// The blueprint `dependencies` structure is a list of
// ['name' => slug, 'version' => constraint] entries, not slugs or
// Package objects, so it can't be passed back into install().
Installer::isValidDestination($options['destination'] . DS . $package->install_path);
if (!$options['overwrite'] && Installer::lastErrorCode() === Installer::EXISTS) {
return false;
}
if (!$options['ignore_symlinks'] && Installer::lastErrorCode() === Installer::IS_LINK) {
return false;
}
$license = Licenses::get($package->slug);
$local = static::download($package, $license);
Installer::install(
$local,
$options['destination'],
['install_path' => $package->install_path, 'theme' => $options['theme']]
);
Folder::delete(dirname($local));
$errorCode = Installer::lastErrorCode();
if ($errorCode) {
throw new \RuntimeException(Installer::lastErrorMsg());
}
if (count($packages) === 1) {
$message = Installer::getMessage();
if ($message) {
return $message;
}
$messages .= $message;
}
}
Cache::clearCache();
return $messages !== '' ? $messages : true;
}
/**
* Update one or more packages.
*
* @param Package[]|string[]|string $packages
* @param array<string, mixed> $options
* @return string|bool
*/
public static function update($packages, array $options)
{
$options['overwrite'] = true;
return static::install($packages, $options);
}
/**
* Uninstall one or more packages.
*
* @param Package[]|string[]|string $packages
* @param array<string, mixed> $options
* @return string|bool
*/
public static function uninstall($packages, array $options)
{
$options = array_merge(self::$options, $options);
$packages = (array) $packages;
$count = count($packages);
$packages = array_filter(array_map(static function ($p) {
if (is_string($p)) {
$p = strtolower($p);
$plugin = self::GPM()->getInstalledPlugin($p);
$p = $plugin ?: self::GPM()->getInstalledTheme($p);
}
return $p instanceof Package ? $p : false;
}, $packages));
if (!$options['skip_invalid'] && $count !== count($packages)) {
return false;
}
foreach ($packages as $package) {
$location = Grav::instance()['locator']->findResource($package->package_type . '://' . $package->slug);
Installer::isValidDestination($location);
if (!$options['ignore_symlinks'] && Installer::lastErrorCode() === Installer::IS_LINK) {
return false;
}
Installer::uninstall($location);
$errorCode = Installer::lastErrorCode();
if ($errorCode && $errorCode !== Installer::IS_LINK && $errorCode !== Installer::EXISTS) {
throw new \RuntimeException(Installer::lastErrorMsg());
}
if (count($packages) === 1) {
$message = Installer::getMessage();
if ($message) {
return $message;
}
}
}
Cache::clearCache();
return true;
}
/**
* Install a package directly from a local zip or remote URL.
*
* @param string $packageFile
* @return string|bool
*/
public static function directInstall(string $packageFile)
{
if ($packageFile === '') {
return 'No package file provided.';
}
$tmpDir = static::getTempDir();
$tmpZip = $tmpDir . '/Grav-' . uniqid('', false);
if (Response::isRemote($packageFile)) {
$zip = GravGPM::downloadPackage($packageFile, $tmpZip);
} else {
$zip = GravGPM::copyPackage($packageFile, $tmpZip);
}
if (!file_exists($zip)) {
return 'Zip package not found.';
}
$tmpSource = $tmpDir . '/Grav-' . uniqid('', false);
$extracted = Installer::unZip($zip, $tmpSource);
if (!$extracted) {
Folder::delete($tmpSource);
Folder::delete($tmpZip);
return 'Package extraction failed.';
}
$type = GravGPM::getPackageType($extracted);
if (!$type) {
Folder::delete($tmpSource);
Folder::delete($tmpZip);
return 'Not a valid Grav package.';
}
if ($type === 'grav') {
Installer::isValidDestination(GRAV_ROOT . '/system');
if (Installer::IS_LINK === Installer::lastErrorCode()) {
Folder::delete($tmpSource);
Folder::delete($tmpZip);
return 'Cannot overwrite symlinks.';
}
static::upgradeGrav($zip, $extracted);
} else {
$name = GravGPM::getPackageName($extracted);
if (!$name) {
Folder::delete($tmpSource);
Folder::delete($tmpZip);
return 'Package name could not be determined.';
}
$installPath = GravGPM::getInstallPath($type, $name);
$isUpdate = file_exists($installPath);
Installer::isValidDestination(GRAV_ROOT . DS . $installPath);
if (Installer::lastErrorCode() === Installer::IS_LINK) {
Folder::delete($tmpSource);
Folder::delete($tmpZip);
return 'Cannot overwrite symlinks.';
}
Installer::install(
$zip,
GRAV_ROOT,
['install_path' => $installPath, 'theme' => $type === 'theme', 'is_update' => $isUpdate],
$extracted
);
}
Folder::delete($tmpSource);
if (Installer::lastErrorCode()) {
return Installer::lastErrorMsg();
}
Folder::delete($tmpZip);
Cache::clearCache();
return true;
}
/**
* Self-upgrade Grav core to the latest release.
*
* @param array<string, mixed> $options Supported: 'override' (bool) to bypass
* blocking preflight checks, mirroring the CLI.
* @return bool
*/
public static function selfupgrade(array $options = []): bool
{
static::$lastError = null;
static::$lastPreflightReport = null;
$upgrader = new Upgrader();
if (!Installer::isGravInstance(GRAV_ROOT)) {
static::$lastError = 'Target directory is not a valid Grav instance.';
return false;
}
if (is_link(GRAV_ROOT . DS . 'index.php')) {
Installer::setError(Installer::IS_LINK);
static::$lastError = 'Cannot self-upgrade: index.php is a symlink.';
return false;
}
if (method_exists($upgrader, 'meetsRequirements') &&
method_exists($upgrader, 'minPHPVersion') &&
!$upgrader->meetsRequirements()) {
$error = [];
$error[] = '<p>Grav has increased the minimum PHP requirement.<br />';
$error[] = 'You are currently running PHP <strong>' . phpversion() . '</strong>';
$error[] = ', but PHP <strong>' . $upgrader->minPHPVersion() . '</strong> is required.</p>';
Installer::setError(implode("\n", $error));
static::$lastError = sprintf(
'PHP %s or higher is required; this server runs PHP %s.',
$upgrader->minPHPVersion(),
phpversion()
);
return false;
}
$update = $upgrader->getAssets()['grav-update'];
$tmp = static::getTempDir() . '/Grav-' . uniqid('', false);
$file = static::downloadSelfupgrade($update, $tmp);
$folder = Installer::unZip($file, $tmp . '/zip');
static::upgradeGrav($file, $folder, false, $options);
$errorCode = Installer::lastErrorCode();
Folder::delete($tmp);
$success = !(is_string($errorCode) || ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR)));
// Capture the real reason so the controller can surface it instead of a generic 500.
if (!$success && null === static::$lastError) {
$msg = Installer::lastErrorMsg();
static::$lastError = ('' !== $msg && 'No Error' !== $msg) ? $msg : 'Failed to upgrade Grav core.';
}
return $success;
}
/**
* The raw installer error from the last selfupgrade() attempt, if any.
*/
public static function getLastError(): ?string
{
return static::$lastError;
}
/**
* The preflight report from the last selfupgrade() attempt, if one was generated.
*
* @return array<string, mixed>|null
*/
public static function getLastPreflightReport(): ?array
{
return static::$lastPreflightReport;
}
/**
* Download a GPM package zip into a temp directory.
*/
private static function download(Package $package, ?string $license = null): string
{
$query = '';
if ($package->premium) {
$query = \json_encode(array_merge($package->premium, [
'slug' => $package->slug,
'license_key' => $license,
'sid' => md5(GRAV_ROOT),
]));
$query = '?d=' . base64_encode($query);
}
try {
$contents = Response::get($package->zipball_url . $query, []);
} catch (\Exception $e) {
throw new \RuntimeException($e->getMessage());
}
$tmpDir = static::getTempDir() . '/Grav-' . uniqid('', false);
Folder::mkdir($tmpDir);
$badChars = array_merge(array_map('chr', range(0, 31)), ['<', '>', ':', '"', '/', '\\', '|', '?', '*']);
$filename = $package->slug . str_replace($badChars, '', Utils::basename($package->zipball_url));
$filename = preg_replace('/[\\\\\/:"*?&<>|]+/m', '-', $filename);
file_put_contents($tmpDir . DS . $filename . '.zip', $contents);
return $tmpDir . DS . $filename . '.zip';
}
/**
* Download the Grav self-upgrade zip.
*
* @param array<string, mixed> $package
*/
private static function downloadSelfupgrade(array $package, string $tmp): string
{
$output = Response::get($package['download'], []);
Folder::mkdir($tmp);
file_put_contents($tmp . DS . $package['name'], $output);
return $tmp . DS . $package['name'];
}
/**
* Run the Grav core upgrade install script against an extracted zip.
*/
private static function upgradeGrav(string $zip, string $folder, bool $keepFolder = false, array $options = []): void
{
static $ignores = [
'backup',
'cache',
'images',
'logs',
'tmp',
'user',
'.htaccess',
'robots.txt',
];
if (!is_dir($folder)) {
Installer::setError('Invalid source folder');
return;
}
try {
$script = $folder . '/system/install.php';
if ((file_exists($script) && $install = include $script) && is_callable($install)) {
// Preflight parity with `bin/gpm self-upgrade`: inspect the blocking checks
// and honor an explicit override, rather than failing with an opaque error.
if (is_object($install) && method_exists($install, 'generatePreflightReport')) {
$report = $install->generatePreflightReport();
static::$lastPreflightReport = $report;
if (!empty($report['blocking'] ?? [])) {
if (!empty($options['override'])) {
if (method_exists($install, 'allowIncompatibleOverride')) {
$install::allowIncompatibleOverride(true);
}
if (method_exists($install, 'allowPendingOverride')) {
$install::allowPendingOverride(true);
}
// Recompute so install() reuses an unblocked, cached report.
$report = $install->generatePreflightReport();
static::$lastPreflightReport = $report;
}
if (!empty($report['blocking'] ?? [])) {
Installer::setError('Upgrade preflight checks failed.');
return;
}
}
}
$install($zip);
} else {
Installer::install(
$zip,
GRAV_ROOT,
['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $ignores],
$folder,
$keepFolder
);
Cache::clearCache();
}
} catch (\Throwable $e) {
Installer::setError($e->getMessage());
static::$lastError = $e->getMessage();
}
}
/**
* Resolve a writable temporary directory, falling back to cache/tmp if tmp://
* isn't configured.
*/
private static function getTempDir(): string
{
try {
$tmpDir = Grav::instance()['locator']->findResource('tmp://', true, true);
} catch (\Exception $e) {
$tmpDir = Grav::instance()['locator']->findResource('cache://', true, true) . '/tmp';
}
return $tmpDir;
}
}
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Config\Config;
/**
* Builds a structured password policy from Grav's single `system.pwd_regex`
* string. Admin-next uses the result to render a live rule checklist and
* strength meter without baking assumptions into the UI.
*
* Source of truth order:
* 1. system.pwd_rules (optional, admin-supplied list of labeled rules)
* 2. Heuristic parse of system.pwd_regex (handles the common lookahead form)
* 3. Opaque fallback — one generic "must match policy" rule
*
* The combined regex is always returned unchanged for server-side validation.
*/
class PasswordPolicyService
{
public static function build(Config $config): array
{
$regex = (string) $config->get('system.pwd_regex', '');
$rules = self::configuredRules($config);
if ($rules === null) {
$rules = self::parseRules($regex);
}
return [
'regex' => $regex,
'min_length' => self::extractMinLength($regex),
'rules' => $rules,
];
}
/**
* @return list<array{id:string,label:string,pattern:string}>|null
*/
private static function configuredRules(Config $config): ?array
{
$raw = $config->get('system.pwd_rules');
if (!is_array($raw) || $raw === []) {
return null;
}
$out = [];
foreach ($raw as $i => $entry) {
if (!is_array($entry)) continue;
$pattern = (string) ($entry['pattern'] ?? '');
$label = (string) ($entry['label'] ?? '');
if ($pattern === '' || $label === '') continue;
$out[] = [
'id' => (string) ($entry['id'] ?? ('rule_' . $i)),
'label' => $label,
'pattern' => $pattern,
];
}
return $out === [] ? null : $out;
}
/**
* Heuristic parse of the common lookahead form used by Grav's default
* pwd_regex: `(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}`.
*
* @return list<array{id:string,label:string,pattern:string}>
*/
private static function parseRules(string $regex): array
{
$rules = [];
$min = self::extractMinLength($regex);
if ($min > 0) {
$rules[] = [
'id' => 'length',
'label' => sprintf('At least %d characters', $min),
'pattern' => '.{' . $min . ',}',
];
}
$lookaheads = [];
if (preg_match_all('/\(\?=([^)]+)\)/', $regex, $m)) {
$lookaheads = $m[1];
}
$seen = [];
foreach ($lookaheads as $inner) {
$rule = self::classifyLookahead($inner);
if ($rule === null) continue;
if (isset($seen[$rule['id']])) continue;
$seen[$rule['id']] = true;
$rules[] = $rule;
}
if ($rules === []) {
$rules[] = [
'id' => 'policy',
'label' => 'Must match the configured password policy',
'pattern' => $regex !== '' ? $regex : '.+',
];
}
return $rules;
}
/**
* @return array{id:string,label:string,pattern:string}|null
*/
private static function classifyLookahead(string $inner): ?array
{
// Strip the `.*` prefix that typically precedes the character class.
$body = preg_replace('/^\.\*/', '', $inner) ?? $inner;
// Digit: \d or [0-9]
if ($body === '\\d' || preg_match('/^\[0-9\]$/', $body)) {
return ['id' => 'digit', 'label' => 'At least one number', 'pattern' => '\\d'];
}
if ($body === '[a-z]') {
return ['id' => 'lowercase', 'label' => 'At least one lowercase letter', 'pattern' => '[a-z]'];
}
if ($body === '[A-Z]') {
return ['id' => 'uppercase', 'label' => 'At least one uppercase letter', 'pattern' => '[A-Z]'];
}
// Special char — a handful of common forms
$specialForms = ['\\W', '[^\\w]', '[^a-zA-Z0-9]', '[^\\w\\s]'];
if (in_array($body, $specialForms, true) || preg_match('/^\[[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?`~\s]+\]$/', $body)) {
return ['id' => 'symbol', 'label' => 'At least one symbol', 'pattern' => '[^a-zA-Z0-9]'];
}
return null;
}
private static function extractMinLength(string $regex): int
{
if (preg_match('/\.\{(\d+),?\d*\}/', $regex, $m)) {
return (int) $m[1];
}
return 0;
}
}
@@ -0,0 +1,544 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserInterface;
use RocketTheme\Toolbox\File\YamlFile;
/**
* Resolves admin-next UI preferences across three storage tiers:
*
* Tier A — Site branding (logo + text), stored in user/config/admin-next.yaml
* under `ui.branding`. No per-user override (uniform brand).
*
* Tier B — Site default + per-user override (theme, accent, fonts, editor
* mode, auto-save, collab, language, page-list size). Defaults in
* `ui.defaults`; user overrides under `admin_next.preferences` in
* the account YAML. A user override of `null` removes that key.
*
* Tier C — Per-user synced (currently `menubarLinks`). No site default;
* same per-user storage as Tier B.
*
* Device-local UI state (sidebar collapse, page list view mode, etc.) is NOT
* managed here; the SPA keeps that in localStorage.
*/
class PreferencesResolver
{
public const SITE_CONFIG_FILE = 'admin-next.yaml';
private const VALID_COLOR_MODE = ['', 'light', 'dark'];
private const VALID_FONT_FAMILY = ['inter', 'google-sans', 'public-sans', 'nunito-sans', 'jost'];
private const VALID_FONT_SIZE = ['small', 'normal', 'large', 'xlarge'];
private const VALID_EDITOR_MODE = ['normal', 'expert'];
private const VALID_LOGO_MODE = ['default', 'text', 'custom'];
private const VALID_PAGES_VIEW_MODE = ['tree', 'list', 'miller'];
private const VALID_ACCOUNTS_VIEW_MODE = ['cards', 'table'];
public function __construct(
private readonly Grav $grav,
) {}
/**
* Tier B built-in baseline — keys that can be overridden per-user. Used
* when neither site nor user has set a value.
*
* @return array<string, mixed>
*/
public function defaultPreferences(): array
{
return [
'colorMode' => '',
'accentHue' => 271,
'accentSaturation' => 91,
'fontFamily' => 'google-sans',
'fontSize' => 'normal',
'editorMode' => 'normal',
'editorStickyToolbar' => true,
'editorFixedHeight' => 0,
'adminLanguage' => 'en',
'pagesPerPage' => 20,
'pagesViewMode' => 'tree',
'usersViewMode' => 'cards',
'groupsViewMode' => 'cards',
'pluginsViewMode' => 'cards',
'themesViewMode' => 'cards',
];
}
/**
* Tier A2 built-in baseline — site-only behavioral settings that are
* not user-overridable (auto-save, real-time collab, menubar links).
* The admin sets these once for everyone.
*
* @return array<string, mixed>
*/
public function defaultSiteSettings(): array
{
return [
'autoSaveEnabled' => false,
'autoSaveToolbarUndo' => true,
'autoSaveBatchWindowMs' => 0,
'collabEnabled' => true,
'menubarLinks' => [],
];
}
/**
* @return array<string, mixed>
*/
public function defaultBranding(): array
{
return [
'mode' => 'default',
'text' => 'Grav',
'logoLight' => '',
'logoDark' => '',
];
}
/**
* @return array<string, mixed>
*/
public function siteBranding(): array
{
$ui = $this->readSiteUiBlock();
$raw = is_array($ui['branding'] ?? null) ? $ui['branding'] : [];
return $this->normalizeBranding($raw, $this->defaultBranding());
}
/**
* @return array<string, mixed>
*/
public function sitePreferences(): array
{
$ui = $this->readSiteUiBlock();
$raw = is_array($ui['defaults'] ?? null) ? $ui['defaults'] : [];
return $this->normalizePreferences($raw, $this->defaultPreferences(), strict: false);
}
/**
* @return array<string, mixed>
*/
public function siteSettings(): array
{
$ui = $this->readSiteUiBlock();
$raw = is_array($ui['settings'] ?? null) ? $ui['settings'] : [];
return $this->normalizeSiteSettings($raw, $this->defaultSiteSettings(), strict: false);
}
/**
* Read the user's saved overrides from their account YAML.
*
* Stored under `admin_next.preferences`. Sits next to `admin_next.dashboard`
* which is owned by DashboardLayoutResolver — the two are independent.
*
* @return array<string, mixed>
*/
public function userPreferences(UserInterface $user): array
{
$adminNext = $user->get('admin_next');
if (!is_array($adminNext)) {
return [];
}
$prefs = $adminNext['preferences'] ?? [];
return is_array($prefs) ? $prefs : [];
}
/**
* Return the full resolved preferences payload for the SPA.
*
* @return array{
* branding: array<string, mixed>,
* site: array<string, mixed>,
* user: array<string, mixed>,
* effective: array<string, mixed>,
* can_edit_site: bool
* }
*/
public function resolve(UserInterface $user, bool $canEditSite): array
{
$defaults = $this->defaultPreferences();
$site = $this->sitePreferences();
$userPrefs = $this->userPreferences($user);
$siteSettings = $this->siteSettings();
// Tier B resolution: built-in defaults ⊕ site defaults ⊕ user overrides.
$effective = array_replace($defaults, $site);
foreach ($userPrefs as $key => $value) {
if ($value === null || !array_key_exists($key, $defaults)) {
continue;
}
$effective[$key] = $value;
}
// Tier A2 site-only behavioral settings are applied last and are not
// user-overridable. Merging them into `effective` lets the SPA read
// every applicable value from one map.
$effective = array_replace($effective, $siteSettings);
return [
'branding' => $this->siteBranding(),
'site' => $site,
'site_settings' => $siteSettings,
'user' => $userPrefs,
'effective' => $effective,
'can_edit_site' => $canEditSite,
];
}
/**
* Persist site-wide defaults. Replaces the entire `ui.defaults` block.
*
* @param array<string, mixed> $payload
*/
public function saveSitePreferences(array $payload): void
{
$normalized = $this->normalizePreferences($payload, $this->defaultPreferences(), strict: true);
$this->writeSiteUiKey('defaults', $normalized);
}
/**
* Persist site branding. Replaces the entire `ui.branding` block.
*
* @param array<string, mixed> $payload
*/
public function saveSiteBranding(array $payload): void
{
$normalized = $this->normalizeBranding($payload, $this->defaultBranding());
$this->writeSiteUiKey('branding', $normalized);
}
/**
* Persist site-only Tier A2 settings (auto-save, collab, menubar links).
* Patch semantics: only keys present in the payload are written; others
* are read from the existing yaml so callers can update a subset.
*
* @param array<string, mixed> $payload
*/
public function saveSiteSettings(array $payload): void
{
$merged = array_replace($this->siteSettings(), $payload);
$normalized = $this->normalizeSiteSettings($merged, $this->defaultSiteSettings(), strict: true);
$this->writeSiteUiKey('settings', $normalized);
}
/**
* Patch the current user's overrides.
*
* Semantics: keys with `null` values are removed from the override map
* (i.e. "reset to site default"). Keys not present in the payload are
* left alone. Pass an explicit empty array to clear an override list
* (e.g. `menubarLinks: []`).
*
* @param array<string, mixed> $payload
*/
public function saveUserPreferences(UserInterface $user, array $payload): void
{
$current = $this->userPreferences($user);
$whitelist = $this->userKeyWhitelist();
foreach ($payload as $key => $value) {
if (!in_array($key, $whitelist, true)) {
continue;
}
if ($value === null) {
unset($current[$key]);
continue;
}
$coerced = $this->coerceValue($key, $value);
if ($coerced === null) {
// Invalid input — silently drop rather than corrupt the file.
continue;
}
$current[$key] = $coerced;
}
$adminNext = $user->get('admin_next');
$adminNext = is_array($adminNext) ? $adminNext : [];
if ($current === []) {
unset($adminNext['preferences']);
} else {
$adminNext['preferences'] = $current;
}
$user->set('admin_next', $adminNext);
$user->save();
}
/**
* Clear ALL user overrides — used by "Reset to site defaults" in the UI.
*/
public function clearUserPreferences(UserInterface $user): void
{
$adminNext = $user->get('admin_next');
if (!is_array($adminNext)) {
return;
}
unset($adminNext['preferences']);
$user->set('admin_next', $adminNext);
$user->save();
}
/**
* Resolve `user://media/admin-next/` and ensure it exists if requested.
*/
public function brandingMediaDir(bool $createDir = false): ?string
{
$locator = $this->grav['locator'] ?? null;
if ($locator === null) {
return null;
}
$base = $locator->findResource('user://', true);
if (!$base) {
return null;
}
$dir = $base . '/media/admin-next';
if (!is_dir($dir)) {
if (!$createDir) {
return $dir;
}
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
return null;
}
}
return $dir;
}
/**
* Public URL fragment a logo path resolves to, relative to the site root.
* Returns empty string for empty/missing paths so the SPA can treat that
* as "fall back to built-in logo".
*/
public function brandingMediaUrl(string $filename): string
{
$filename = trim($filename);
if ($filename === '') {
return '';
}
// Strip any leading slashes / path traversal; we only store basenames.
$filename = basename($filename);
return '/user/media/admin-next/' . $filename;
}
/**
* Whitelist of keys the user may override (Tier B only — Tier A2 are
* site-only and rejected here).
*
* @return array<int, string>
*/
private function userKeyWhitelist(): array
{
return array_keys($this->defaultPreferences());
}
/**
* @param array<string, mixed> $input
* @param array<string, mixed> $defaults
* @return array<string, mixed>
*/
private function normalizePreferences(array $input, array $defaults, bool $strict): array
{
$out = $strict ? [] : $defaults;
foreach ($defaults as $key => $defaultValue) {
if (!array_key_exists($key, $input)) {
continue;
}
$coerced = $this->coerceValue($key, $input[$key]);
if ($coerced === null) {
// Bad value — fall back to default in non-strict mode, drop in strict.
if (!$strict) {
$out[$key] = $defaultValue;
}
continue;
}
$out[$key] = $coerced;
}
return $out;
}
/**
* @param array<string, mixed> $input
* @param array<string, mixed> $defaults
* @return array<string, mixed>
*/
private function normalizeSiteSettings(array $input, array $defaults, bool $strict): array
{
$out = $strict ? [] : $defaults;
foreach ($defaults as $key => $defaultValue) {
if (!array_key_exists($key, $input)) {
continue;
}
if ($key === 'menubarLinks') {
$out[$key] = $this->normalizeMenubarLinks($input[$key]);
continue;
}
$coerced = $this->coerceValue($key, $input[$key]);
if ($coerced === null) {
if (!$strict) {
$out[$key] = $defaultValue;
}
continue;
}
$out[$key] = $coerced;
}
return $out;
}
/**
* Coerce a single Tier-B key to its valid type, or return null if the
* value cannot be coerced. `null` from this method always means "reject".
*/
private function coerceValue(string $key, mixed $value): mixed
{
return match ($key) {
'colorMode' => is_string($value) && in_array($value, self::VALID_COLOR_MODE, true) ? $value : null,
'accentHue' => is_numeric($value) ? max(0, min(360, (int) $value)) : null,
'accentSaturation' => is_numeric($value) ? max(0, min(100, (int) $value)) : null,
'fontFamily' => is_string($value) && in_array($value, self::VALID_FONT_FAMILY, true) ? $value : null,
'fontSize' => is_string($value) && in_array($value, self::VALID_FONT_SIZE, true) ? $value : null,
'editorMode' => is_string($value) && in_array($value, self::VALID_EDITOR_MODE, true) ? $value : null,
'editorStickyToolbar', 'autoSaveEnabled', 'autoSaveToolbarUndo', 'collabEnabled' => is_bool($value) ? $value : (is_scalar($value) ? (bool) $value : null),
// 0 = auto-grow (disabled); any other value is clamped to a sane fixed-height range.
'editorFixedHeight' => is_numeric($value) ? (((int) $value) <= 0 ? 0 : max(300, min(1200, (int) $value))) : null,
'autoSaveBatchWindowMs' => is_numeric($value) ? max(0, (int) $value) : null,
'adminLanguage' => is_string($value) && $value !== '' ? substr($value, 0, 32) : null,
'pagesPerPage' => is_numeric($value) ? max(1, min(200, (int) $value)) : null,
'pagesViewMode' => is_string($value) && in_array($value, self::VALID_PAGES_VIEW_MODE, true) ? $value : null,
'usersViewMode', 'groupsViewMode', 'pluginsViewMode', 'themesViewMode' => is_string($value) && in_array($value, self::VALID_ACCOUNTS_VIEW_MODE, true) ? $value : null,
default => null,
};
}
/**
* @param array<string, mixed> $input
* @param array<string, mixed> $defaults
* @return array<string, mixed>
*/
private function normalizeBranding(array $input, array $defaults): array
{
$mode = $input['mode'] ?? $defaults['mode'];
if (!is_string($mode) || !in_array($mode, self::VALID_LOGO_MODE, true)) {
$mode = $defaults['mode'];
}
$text = $input['text'] ?? $defaults['text'];
if (!is_string($text)) {
$text = $defaults['text'];
}
$text = trim($text);
if ($text === '') {
$text = $defaults['text'];
}
return [
'mode' => $mode,
'text' => substr($text, 0, 64),
'logoLight' => $this->sanitizeLogoPath($input['logoLight'] ?? ''),
'logoDark' => $this->sanitizeLogoPath($input['logoDark'] ?? ''),
];
}
private function sanitizeLogoPath(mixed $value): string
{
if (!is_string($value) || $value === '') {
return '';
}
// Store only the basename; resolver controls the directory.
$name = basename(trim($value));
if (str_contains($name, '..') || str_contains($name, "\0") || str_starts_with($name, '.')) {
return '';
}
return $name;
}
/**
* @param mixed $value
* @return array<int, array<string, mixed>>
*/
private function normalizeMenubarLinks(mixed $value): array
{
if (!is_array($value)) {
return [];
}
$out = [];
foreach ($value as $entry) {
if (!is_array($entry)) {
continue;
}
$label = is_string($entry['label'] ?? null) ? trim($entry['label']) : '';
$url = is_string($entry['url'] ?? null) ? trim($entry['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$link = ['label' => substr($label, 0, 64), 'url' => substr($url, 0, 512)];
if (isset($entry['icon']) && is_string($entry['icon']) && $entry['icon'] !== '') {
$link['icon'] = substr($entry['icon'], 0, 64);
}
if (isset($entry['external'])) {
$link['external'] = (bool) $entry['external'];
}
$out[] = $link;
}
return $out;
}
/**
* @return array<string, mixed>
*/
private function readSiteUiBlock(): array
{
$path = $this->siteConfigFilePath();
if (!$path || !is_file($path)) {
return [];
}
$content = (array) YamlFile::instance($path)->content();
return is_array($content['ui'] ?? null) ? $content['ui'] : [];
}
/**
* @param array<string, mixed> $value
*/
private function writeSiteUiKey(string $key, array $value): void
{
$path = $this->siteConfigFilePath(true);
if (!$path) {
throw new \RuntimeException('Unable to resolve user/config path for admin-next.yaml.');
}
$file = YamlFile::instance($path);
$content = (array) $file->content();
$content['ui'] = is_array($content['ui'] ?? null) ? $content['ui'] : [];
$content['ui'][$key] = $value;
$file->content($content);
$file->save();
$config = $this->grav['config'] ?? null;
if ($config) {
$config->set('admin-next.ui.' . $key, $value);
}
}
/**
* Mirror of DashboardLayoutResolver::siteConfigFilePath() so the two
* resolvers stay loosely coupled. Resolves to user/config/admin-next.yaml.
*/
private function siteConfigFilePath(bool $createDir = false): ?string
{
$locator = $this->grav['locator'] ?? null;
if ($locator === null) {
return null;
}
$userConfigDir = $locator->findResource('user://config', true) ?: null;
if ($userConfigDir === null) {
$userPath = $locator->findResource('user://', true);
if ($userPath && $createDir) {
$userConfigDir = $userPath . '/config';
if (!is_dir($userConfigDir)) {
mkdir($userConfigDir, 0775, true);
}
}
}
if (!$userConfigDir) {
return null;
}
return $userConfigDir . '/' . self::SITE_CONFIG_FILE;
}
}
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
class ThumbnailService
{
private string $cacheDir;
private int $maxSize;
private int $quality;
public function __construct(string $cacheDir, int $maxSize = 500, int $quality = 85)
{
$this->cacheDir = rtrim($cacheDir, '/');
$this->maxSize = $maxSize;
$this->quality = $quality;
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
}
/**
* Get the hash for a thumbnail based on source path and modification time.
*/
public function getHash(string $sourcePath): string
{
$mtime = file_exists($sourcePath) ? filemtime($sourcePath) : 0;
return md5($sourcePath . '|' . $mtime . '|' . $this->maxSize);
}
/**
* Get the thumbnail filename (hash.ext) for a source image.
* Returns null if not a supported image.
*/
public function getThumbnailFilename(string $sourcePath): ?string
{
if (!file_exists($sourcePath)) {
return null;
}
$mime = mime_content_type($sourcePath);
if (!$mime || !str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') {
return null;
}
return $this->getHash($sourcePath) . '.' . $this->getOutputExtension($mime);
}
/**
* Get the cached thumbnail path, generating it if needed.
* Returns null if the source is not a supported image.
*/
public function getThumbnail(string $sourcePath): ?string
{
if (!file_exists($sourcePath)) {
return null;
}
$mime = mime_content_type($sourcePath);
if (!$mime || !str_starts_with($mime, 'image/')) {
return null;
}
// Skip SVGs — serve as-is
if ($mime === 'image/svg+xml') {
return null;
}
$hash = $this->getHash($sourcePath);
$ext = $this->getOutputExtension($mime);
$cachePath = $this->cacheDir . '/' . $hash . '.' . $ext;
if (file_exists($cachePath)) {
return $cachePath;
}
return $this->generate($sourcePath, $cachePath, $mime);
}
/**
* Generate a thumbnail and save to cache.
*/
private function generate(string $sourcePath, string $cachePath, string $mime): ?string
{
$sourceImage = $this->loadImage($sourcePath, $mime);
if (!$sourceImage) {
return null;
}
$origWidth = imagesx($sourceImage);
$origHeight = imagesy($sourceImage);
// Already small enough — cache as-is so we don't re-check every time
if ($origWidth <= $this->maxSize && $origHeight <= $this->maxSize) {
return $this->saveImage($sourceImage, $cachePath, $mime, $origWidth, $origHeight);
}
// Calculate new dimensions maintaining aspect ratio
if ($origWidth >= $origHeight) {
$newWidth = $this->maxSize;
$newHeight = (int) round($origHeight * ($this->maxSize / $origWidth));
} else {
$newHeight = $this->maxSize;
$newWidth = (int) round($origWidth * ($this->maxSize / $origHeight));
}
$thumb = imagecreatetruecolor($newWidth, $newHeight);
if (!$thumb) {
imagedestroy($sourceImage);
return null;
}
// Preserve transparency for PNG/WebP
if ($mime === 'image/png' || $mime === 'image/webp') {
imagealphablending($thumb, false);
imagesavealpha($thumb, true);
$transparent = imagecolorallocatealpha($thumb, 0, 0, 0, 127);
imagefill($thumb, 0, 0, $transparent);
}
imagecopyresampled($thumb, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $origWidth, $origHeight);
imagedestroy($sourceImage);
return $this->saveImage($thumb, $cachePath, $mime, $newWidth, $newHeight);
}
/**
* Load an image resource from file.
*/
private function loadImage(string $path, string $mime): ?\GdImage
{
return match ($mime) {
'image/jpeg' => @imagecreatefromjpeg($path) ?: null,
'image/png' => @imagecreatefrompng($path) ?: null,
'image/gif' => @imagecreatefromgif($path) ?: null,
'image/webp' => @imagecreatefromwebp($path) ?: null,
'image/avif' => function_exists('imagecreatefromavif') ? (@imagecreatefromavif($path) ?: null) : null,
default => null,
};
}
/**
* Save an image resource to the cache path.
*/
private function saveImage(\GdImage $image, string $cachePath, string $mime, int $width, int $height): ?string
{
$result = match ($mime) {
'image/png' => imagepng($image, $cachePath, 6),
'image/gif' => imagegif($image, $cachePath),
'image/webp' => imagewebp($image, $cachePath, $this->quality),
'image/avif' => function_exists('imageavif') ? imageavif($image, $cachePath, $this->quality) : false,
default => imagejpeg($image, $cachePath, $this->quality),
};
imagedestroy($image);
return $result ? $cachePath : null;
}
/**
* Get the output file extension for a MIME type.
*/
private function getOutputExtension(string $mime): string
{
return match ($mime) {
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/avif' => 'avif',
default => 'jpg',
};
}
}
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Utils;
use Grav\Plugin\Api\Exceptions\ValidationException;
use function in_array;
use function is_array;
use function is_bool;
use function is_numeric;
use function is_string;
/**
* Per-field upload settings for blueprint `type: file` fields.
*
* Carries the subset of Grav's core upload settings (MediaUploadTrait's
* `$_upload_defaults` and the form plugin's per-field schema) that the API
* honors, so admin-next file fields behave like admin-classic ones:
*
* - random_name randomize the stored filename
* - avoid_overwriting datetime-prefix on a name conflict instead of clobbering
* - accept mime / extension allowlist
* - filesize per-field maximum size in MB
*
* TRUST MODEL: these values arrive from the client (the blueprint the SPA
* renders), exactly as `destination`/`scope` already do on the blueprint-upload
* endpoint. They can only *further restrict* an upload (`accept`, `filesize`)
* or change the *output filename* (`random_name`, `avoid_overwriting`) — never
* relax the immovable server-side security floor (dangerous/forbidden
* extensions, accounts image-only, the hard size cap, traversal guards), which
* each controller enforces separately and never delegates to the client.
*/
final class UploadFieldSettings
{
/**
* @param string[] $accept
*/
private function __construct(
public readonly bool $randomName = false,
public readonly bool $avoidOverwriting = false,
public readonly array $accept = [],
public readonly ?float $filesizeMb = null,
) {
}
/**
* A settings object with nothing active — every upload behaves as before.
*/
public static function none(): self
{
return new self();
}
/**
* Build from an associative array of request parameters (parsed body /
* uploaded-file metadata). Missing or unrecognized keys fall back to the
* inert default, so a request that carries no field settings is a no-op.
*
* @param array<string, mixed> $params
*/
public static function fromParams(array $params): self
{
return new self(
randomName: self::toBool($params['random_name'] ?? false),
avoidOverwriting: self::toBool($params['avoid_overwriting'] ?? false),
accept: self::toAcceptList($params['accept'] ?? null),
filesizeMb: self::toFilesize($params['filesize'] ?? null),
);
}
/**
* Whether any field-level setting is active.
*/
public function isEmpty(): bool
{
return !$this->randomName
&& !$this->avoidOverwriting
&& $this->accept === []
&& $this->filesizeMb === null;
}
/**
* Enforce the per-field maximum filesize (MB). The endpoint's own hard cap
* is applied separately and always wins; this only tightens it.
*/
public function assertFilesize(?int $size): void
{
if ($this->filesizeMb === null || $this->filesizeMb <= 0 || $size === null) {
return;
}
$max = (int) round($this->filesizeMb * 1_048_576);
if ($size > $max) {
$label = rtrim(rtrim(number_format($this->filesizeMb, 2), '0'), '.');
throw new ValidationException(
sprintf('File exceeds the maximum allowed size of %s MB for this field.', $label)
);
}
}
/**
* Enforce the field's `accept` allowlist (mime types such as `image/*`, or
* extensions such as `.pdf`). Mirrors the form plugin's matching, including
* deriving the mime from the filename rather than trusting the browser.
*/
public function assertAccepted(string $filename): void
{
if ($this->accept === []) {
return;
}
$mime = Utils::getMimeByFilename($filename);
foreach ($this->accept as $type) {
if ($type === '') {
continue;
}
if ($type === '*') {
return;
}
$isMime = str_contains($type, '/');
$pattern = '#' . str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type) . '$#';
$subject = $isMime ? $mime : $filename;
if (preg_match($pattern, $subject)) {
return;
}
}
throw new ValidationException(
sprintf("File '%s' does not match the accepted types for this field.", $filename)
);
}
/**
* Decide the final stored filename, applying `random_name` then
* `avoid_overwriting` against the resolved target directory.
*
* Both transforms preserve the file extension (random names re-append it;
* the conflict guard only prepends a datetime), so a caller that already
* validated the extension on the incoming name does not need to re-check.
*/
public function resolveFilename(string $filename, string $targetDir): string
{
if ($this->randomName) {
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$random = Utils::generateRandomString(15);
$filename = strtolower($extension !== '' ? "{$random}.{$extension}" : $random);
}
if ($this->avoidOverwriting && is_file($targetDir . '/' . $filename)) {
$filename = date('YmdHis') . '-' . $filename;
}
return $filename;
}
private static function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_string($value)) {
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
return (bool) $value;
}
/**
* Accept may arrive as an array (JSON body) or a comma-separated string
* (multipart meta). Normalize to a trimmed list of non-empty entries.
*
* @return string[]
*/
private static function toAcceptList(mixed $value): array
{
if (is_string($value)) {
$value = $value === '' ? [] : explode(',', $value);
}
if (!is_array($value)) {
return [];
}
$out = [];
foreach ($value as $item) {
$item = trim((string) $item);
if ($item !== '') {
$out[] = $item;
}
}
return $out;
}
private static function toFilesize(mixed $value): ?float
{
if ($value === null || $value === '' || !is_numeric($value)) {
return null;
}
$filesize = (float) $value;
return $filesize > 0 ? $filesize : null;
}
}
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Webhooks;
use Grav\Common\HTTP\Response;
class WebhookDispatcher
{
/**
* Map of internal event names to webhook event names.
*/
private const EVENT_MAP = [
'onApiPageCreated' => 'page.created',
'onApiPageUpdated' => 'page.updated',
'onApiPageDeleted' => 'page.deleted',
'onApiPageMoved' => 'page.moved',
'onApiPageTranslated' => 'page.translated',
'onApiPagesReordered' => 'pages.reordered',
'onApiMediaUploaded' => 'media.uploaded',
'onApiMediaDeleted' => 'media.deleted',
'onApiUserCreated' => 'user.created',
'onApiUserUpdated' => 'user.updated',
'onApiUserDeleted' => 'user.deleted',
'onApiConfigUpdated' => 'config.updated',
'onApiPackageInstalled' => 'gpm.installed',
'onApiPackageRemoved' => 'gpm.removed',
'onApiGravUpgraded' => 'grav.upgraded',
];
private WebhookManager $manager;
public function __construct(?WebhookManager $manager = null)
{
$this->manager = $manager ?? new WebhookManager();
}
/**
* Get the list of subscribed events for the plugin.
*/
public static function getSubscribedEvents(): array
{
$events = [];
foreach (array_keys(self::EVENT_MAP) as $eventName) {
$events[$eventName] = ['dispatch', -100]; // Low priority - run after main handlers
}
return $events;
}
/**
* Dispatch webhooks for an event.
*/
public function dispatch(string $internalEvent, array $eventData): void
{
$webhookEvent = self::EVENT_MAP[$internalEvent] ?? null;
if (!$webhookEvent) {
return;
}
$webhooks = $this->manager->getForEvent($webhookEvent);
if (empty($webhooks)) {
return;
}
$payload = $this->buildPayload($webhookEvent, $eventData);
foreach ($webhooks as $webhook) {
$this->send($webhook, $payload);
}
}
/**
* Send a test payload to a webhook.
*/
public function sendTest(array $webhook): array
{
$payload = $this->buildPayload('test', [
'message' => 'This is a test webhook delivery.',
]);
return $this->send($webhook, $payload);
}
/**
* Build the webhook payload.
*/
private function buildPayload(string $event, array $data): array
{
// Serialize objects in data to arrays
$cleanData = $this->serializeEventData($data);
return [
'event' => $event,
'timestamp' => date('c'),
'data' => $cleanData,
];
}
/**
* Send a webhook HTTP request and record the delivery.
*/
private function send(array $webhook, array $payload): array
{
$payload['webhook_id'] = $webhook['id'];
$jsonPayload = json_encode($payload, JSON_UNESCAPED_SLASHES);
// Generate HMAC signature
$signature = hash_hmac('sha256', $jsonPayload, $webhook['secret'] ?? '');
$headers = array_merge(
[
'Content-Type' => 'application/json',
'X-Grav-Signature' => $signature,
'X-Grav-Event' => $payload['event'],
'X-Grav-Delivery' => 'dlv_' . bin2hex(random_bytes(8)),
'User-Agent' => 'Grav-Webhook/1.0',
],
$webhook['headers'] ?? []
);
$delivery = [
'id' => $headers['X-Grav-Delivery'],
'event' => $payload['event'],
'url' => $webhook['url'],
'request_headers' => $headers,
'request_body' => $payload,
'created' => time(),
];
$startTime = microtime(true);
try {
$response = $this->httpPost($webhook['url'], $jsonPayload, $headers);
$duration = (int) ((microtime(true) - $startTime) * 1000);
$delivery['status_code'] = $response['status_code'];
$delivery['response_body'] = mb_substr($response['body'] ?? '', 0, 1000);
$delivery['duration_ms'] = $duration;
$delivery['success'] = $response['status_code'] >= 200 && $response['status_code'] < 300;
if ($delivery['success']) {
$this->manager->resetFailureCount($webhook['id']);
} else {
$this->manager->recordFailure($webhook['id']);
}
} catch (\Exception $e) {
$duration = (int) ((microtime(true) - $startTime) * 1000);
$delivery['status_code'] = 0;
$delivery['error'] = $e->getMessage();
$delivery['duration_ms'] = $duration;
$delivery['success'] = false;
$this->manager->recordFailure($webhook['id']);
}
$this->manager->recordDelivery($webhook['id'], $delivery);
return $delivery;
}
/**
* Make an HTTP POST request.
*/
private function httpPost(string $url, string $body, array $headers): array
{
$ch = curl_init($url);
if ($ch === false) {
throw new \RuntimeException('Failed to initialize cURL');
}
$headerLines = [];
foreach ($headers as $key => $value) {
$headerLines[] = "{$key}: {$value}";
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => false,
]);
$responseBody = curl_exec($ch);
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($responseBody === false) {
throw new \RuntimeException('Webhook request failed: ' . $error);
}
return [
'status_code' => $statusCode,
'body' => is_string($responseBody) ? $responseBody : '',
];
}
/**
* Convert event data objects to serializable arrays.
*/
private function serializeEventData(array $data): array
{
$result = [];
foreach ($data as $key => $value) {
if (is_object($value)) {
// Try common serialization methods
if (method_exists($value, 'route')) {
$result[$key] = [
'route' => $value->route(),
'title' => method_exists($value, 'title') ? $value->title() : null,
'slug' => method_exists($value, 'slug') ? $value->slug() : null,
];
} elseif (method_exists($value, 'toArray')) {
$result[$key] = $value->toArray();
} elseif (method_exists($value, 'jsonSerialize')) {
$result[$key] = $value->jsonSerialize();
} else {
$result[$key] = '(object)';
}
} elseif (is_array($value)) {
$result[$key] = $this->serializeEventData($value);
} else {
$result[$key] = $value;
}
}
return $result;
}
}
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Webhooks;
use Grav\Common\Grav;
use RocketTheme\Toolbox\File\YamlFile;
class WebhookManager
{
private string $storagePath;
private string $deliveryPath;
private ?array $webhooksCache = null;
public function __construct()
{
$grav = Grav::instance();
$this->storagePath = $grav['locator']->findResource('user://data/api', true, true);
$this->deliveryPath = $this->storagePath . '/webhook-deliveries';
}
/**
* Get all webhooks.
*
* @return array
*/
public function getAll(): array
{
return $this->load();
}
/**
* Get a webhook by ID.
*/
public function get(string $id): ?array
{
$webhooks = $this->load();
foreach ($webhooks as $webhook) {
if ($webhook['id'] === $id) {
return $webhook;
}
}
return null;
}
/**
* Create a new webhook.
*/
public function create(array $data): array
{
$webhook = [
'id' => 'wh_' . bin2hex(random_bytes(12)),
'url' => $data['url'],
'secret' => 'whsec_' . bin2hex(random_bytes(24)),
'events' => $data['events'] ?? ['*'],
'enabled' => $data['enabled'] ?? true,
'headers' => $data['headers'] ?? [],
'created' => time(),
'failure_count' => 0,
];
$webhooks = $this->load();
$webhooks[] = $webhook;
$this->save($webhooks);
return $webhook;
}
/**
* Update a webhook.
*/
public function update(string $id, array $data): ?array
{
$webhooks = $this->load();
foreach ($webhooks as &$webhook) {
if ($webhook['id'] === $id) {
if (isset($data['url'])) {
$webhook['url'] = $data['url'];
}
if (isset($data['events'])) {
$webhook['events'] = $data['events'];
}
if (isset($data['enabled'])) {
$webhook['enabled'] = (bool) $data['enabled'];
}
if (isset($data['headers'])) {
$webhook['headers'] = $data['headers'];
}
$this->save($webhooks);
return $webhook;
}
}
return null;
}
/**
* Delete a webhook.
*/
public function delete(string $id): bool
{
$webhooks = $this->load();
$filtered = array_values(array_filter($webhooks, fn($w) => $w['id'] !== $id));
if (count($filtered) === count($webhooks)) {
return false;
}
$this->save($filtered);
// Clean up delivery logs
$deliveryDir = $this->deliveryPath . '/' . $id;
if (is_dir($deliveryDir)) {
$files = glob($deliveryDir . '/*.yaml');
foreach ($files as $file) {
@unlink($file);
}
@rmdir($deliveryDir);
}
return true;
}
/**
* Record a delivery log entry.
*/
public function recordDelivery(string $webhookId, array $delivery): void
{
$dir = $this->deliveryPath . '/' . $webhookId;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$file = $dir . '/' . $delivery['id'] . '.yaml';
$yamlFile = YamlFile::instance($file);
$yamlFile->content($delivery);
$yamlFile->save();
// Keep only last 50 deliveries
$this->pruneDeliveries($webhookId, 50);
}
/**
* Get delivery history for a webhook.
*/
public function getDeliveries(string $webhookId, int $limit = 20, int $offset = 0): array
{
$dir = $this->deliveryPath . '/' . $webhookId;
if (!is_dir($dir)) {
return ['deliveries' => [], 'total' => 0];
}
$files = glob($dir . '/*.yaml');
// Sort by modification time descending
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
$total = count($files);
$slice = array_slice($files, $offset, $limit);
$deliveries = [];
foreach ($slice as $file) {
$yamlFile = YamlFile::instance($file);
$deliveries[] = $yamlFile->content();
}
return ['deliveries' => $deliveries, 'total' => $total];
}
/**
* Get webhooks matching a specific event.
*/
public function getForEvent(string $event): array
{
$webhooks = $this->load();
return array_filter($webhooks, function ($webhook) use ($event) {
if (!($webhook['enabled'] ?? true)) {
return false;
}
$events = $webhook['events'] ?? ['*'];
return in_array('*', $events, true) || in_array($event, $events, true);
});
}
/**
* Increment failure count and auto-disable if threshold reached.
*/
public function recordFailure(string $id): void
{
$webhooks = $this->load();
foreach ($webhooks as &$webhook) {
if ($webhook['id'] === $id) {
$webhook['failure_count'] = ($webhook['failure_count'] ?? 0) + 1;
if ($webhook['failure_count'] >= 5) {
$webhook['enabled'] = false;
$webhook['disabled_reason'] = 'Auto-disabled after 5 consecutive failures';
}
break;
}
}
$this->save($webhooks);
}
/**
* Reset failure count on successful delivery.
*/
public function resetFailureCount(string $id): void
{
$webhooks = $this->load();
foreach ($webhooks as &$webhook) {
if ($webhook['id'] === $id) {
$webhook['failure_count'] = 0;
unset($webhook['disabled_reason']);
break;
}
}
$this->save($webhooks);
}
private function load(): array
{
if ($this->webhooksCache !== null) {
return $this->webhooksCache;
}
$file = YamlFile::instance($this->storagePath . '/webhooks.yaml');
$content = $file->content();
$this->webhooksCache = $content['webhooks'] ?? [];
return $this->webhooksCache;
}
private function save(array $webhooks): void
{
$file = YamlFile::instance($this->storagePath . '/webhooks.yaml');
$file->content(['webhooks' => array_values($webhooks)]);
$file->save();
$this->webhooksCache = array_values($webhooks);
}
private function pruneDeliveries(string $webhookId, int $keep): void
{
$dir = $this->deliveryPath . '/' . $webhookId;
if (!is_dir($dir)) {
return;
}
$files = glob($dir . '/*.yaml');
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
$toDelete = array_slice($files, $keep);
foreach ($toDelete as $file) {
@unlink($file);
}
}
}