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

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
@@ -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);
}
}