Files
intotheeast-com-content/plugins/api/classes/Api/Controllers/AbstractApiController.php
T

548 lines
20 KiB
PHP

<?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;
}
}