feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user