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 $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 $tags * @return array */ 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 $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; } }