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