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

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
+340
View File
@@ -0,0 +1,340 @@
# v1.0.0-rc.15
## 06/16/2026
1. [](#new)
* Added a dashboard security endpoint that hands the admin Dashboard a sentinel URL under `user/data`, so it can detect from the browser whether the sensitive `user/` folders are downloadable over the web.
* Added user preferences for keeping the Markdown editor toolbar pinned while scrolling and for setting a fixed editor height.
* Plugins can now contribute dashboard notifications via a new `onApiDashboardNotifications` event, letting them raise a persistent, dismissible admin banner (grouped by location — `top`, `dashboard`, `feed`) that flows through the existing dismiss and `reappear_after` handling.
* Plugin endpoints can now return a toast hint to control the message, type and duration of the notification the admin shows after a save, including errors that stay until dismissed ([getgrav/grav-plugin-admin2#38](https://github.com/getgrav/grav-plugin-admin2/issues/38)).
2. [](#improved)
* Public API endpoints now recognize logged-in callers when credentials are provided, returning their richer permission-filtered responses instead of treating everyone as a guest.
* Plugins can now mark public routes as read-only by method, so browsing stays open while writes on the same paths still require login.
* Page responses now include the on-disk folder name, including any numeric ordering prefix, so admin tools can show and diagnose page ordering.
* File upload fields now honor their blueprint's `random_name`, `avoid_overwriting`, `accept`, and `filesize` settings, matching the classic admin.
3. [](#bugfix)
* Creating, listing, downloading, and deleting site backups now requires a dedicated backup permission (or API super user) instead of the broader system read/write access, because the backup archive includes account password hashes and configuration secrets.
* Page and account content saved through the API is now checked for cross-site scripting, closing a hole where an editor without full admin rights could store a script that later ran in other visitors' browsers.
* Blueprint fields that use relative dot-naming inside a section (such as `.optionA`) now save their values again, restoring the nested-field behaviour from the classic admin ([getgrav/grav#4120](https://github.com/getgrav/grav/issues/4120)).
* Pages on a template the current theme doesn't define now fall back to the default page form in the editor instead of showing a blank screen, matching the classic admin.
* Toggle and select options whose Yes/No labels were turned into booleans by strict YAML parsing now render as Yes/No again instead of a blank or "true" button ([getgrav/grav-plugin-admin2#36](https://github.com/getgrav/grav-plugin-admin2/issues/36)).
* A caller restored via the login plugin's *remember me* cookie (left `authenticated` but not `authorized`) is now accepted by session authentication, so a remembered user who shows as signed in can use the API instead of being silently rejected on every write until a fresh login. Per-route permission checks still gate what they can actually do.
* The page template selector no longer breaks with a "callable not found" error when a blueprint references the classic admin's page-types helper but the classic admin isn't active, falling back to the built-in page types instead ([getgrav/grav-plugin-admin2#41](https://github.com/getgrav/grav-plugin-admin2/issues/41)).
* Custom fields provided by a theme are now reported with their provider type and included in the theme's own info, so the admin can load them from the correct route instead of always assuming a plugin ([getgrav/grav-admin-next#3](https://github.com/getgrav/grav-admin-next/issues/3)).
* Editing or deleting a specific translation with `?lang=` now targets that language's file instead of silently overwriting or failing to find the default language ([getgrav/grav-plugin-api#6](https://github.com/getgrav/grav-plugin-api/issues/6)).
# v1.0.0-rc.14
## 06/09/2026
1. [](#new)
* The page editor now includes a Security tab for setting page access and permissions, matching the classic admin, with the permissions section limited to users who hold API super or configuration rights.
* Page Authors is now a searchable multiselect of the users who can edit pages, instead of free-text username entry.
* The users API can now filter accounts by permission or group, so the admin can list everyone who holds a given permission such as admin access.
* The user listing now includes each account's group memberships.
2. [](#improved)
* A failed Grav core upgrade now reports the real reason and records it in the log, instead of a generic "Failed to upgrade Grav core" message.
* A Grav core upgrade blocked by a compatibility check now lists the packages responsible and can be retried with an explicit override, matching the command-line upgrader.
* Saving config, pages or accounts now validates the submitted fields against the blueprint, so a required field left empty or an invalid value is rejected instead of silently saved ([getgrav/grav-plugin-admin2#30](https://github.com/getgrav/grav-plugin-admin2/issues/30)).
* Media upload handling is now shared, so other plugins such as Flex Objects can let you attach files to their own records.
3. [](#bugfix)
* Saving a page no longer corrupts its frontmatter with stray internal keys, which previously accumulated on every save when editing in Expert mode ([getgrav/grav-plugin-admin2#31](https://github.com/getgrav/grav-plugin-admin2/issues/31)).
* Saving a page through the API no longer fails when an admin-aware plugin posts a flash message from its save handler ([#5](https://github.com/getgrav/grav-plugin-api/issues/5)).
* Pages under the home page now appear nested beneath it in the tree and columns views, instead of being listed at the top level ([getgrav/grav-plugin-admin2#32](https://github.com/getgrav/grav-plugin-admin2/issues/32)).
# v1.0.0-rc.13
## 06/04/2026
1. [](#new)
* **Custom top-level configuration files now appear as their own tab in admin2**, alongside System, Site, Media and Security, so the long-standing "add a custom YAML file" cookbook recipe works again.
* **Configuration fields that override an inherited default now expose a one-click revert**, with a "Reset overrides" action to clear them all at once, for both the base configuration and per-environment overlays.
2. [](#bugfix)
* **Saving configuration no longer fails with a "modified elsewhere" error on servers that compress responses with zstd**, matching the gzip and brotli handling already in place (follow-up to [getgrav/grav-plugin-admin2#28](https://github.com/getgrav/grav-plugin-admin2/issues/28)).
* **Configuration now reads from the correct source behind a reverse proxy that forwards a different hostname than the server booted under**, so the base "Default" view no longer shows another environment's overridden values.
* **Configuration saved without an explicit environment now lands in the environment the site is actually running, even behind a reverse proxy**, instead of silently writing to the base configuration.
# v1.0.0-rc.12
## 06/03/2026
1. [](#new)
* **Invite a user by email** instead of creating the account yourself: pre-set their permissions and groups, send a time-limited invite link, and they choose their own username, name and password when they accept (they can never grant themselves more access than you set).
2. [](#improved)
* **Usernames containing periods (e.g. `john.doe`) are now accepted and listed correctly**, matching the characters admin classic has always allowed.
3. [](#bugfix)
* **Saving a configuration twice in a row no longer fails with a "Configuration was modified elsewhere" error** when you toggle an option whose neighbour is left at its default value. Fixes [getgrav/grav-plugin-admin2#28](https://github.com/getgrav/grav-plugin-admin2/issues/28).
# v1.0.0-rc.11
## 05/29/2026
1. [](#improved)
* **Sidebar `authorize` now accepts an array of permissions** (any-of semantics, matching admin-classic's `nav-quick-tray.html.twig` pattern), and the **menubar and floating-widget APIs gained the same `authorize` filtering**. Plugins that register sidebar / menubar / widget items can now hide them from users who lack the relevant permission without needing to check inside their own event handler. Used across grav-plugin-git-sync, grav-plugin-license-manager, grav-plugin-algolia-pro, grav-plugin-cloudflare, grav-plugin-comments-pro, grav-plugin-image-optimize, grav-plugin-rsync, grav-plugin-seo-magic, grav-plugin-translation-service, grav-plugin-warm-cache, grav-plugin-ai-pro, and grav-plugin-ai-translate to fix [getgrav/grav-plugin-admin2#23](https://github.com/getgrav/grav-plugin-admin2/issues/23).
2. [](#bugfix)
* **Hebrew and Arabic admin language responses are correctly marked right-to-left again**, restoring the RTL admin shell that broke after the BCP-47 language code switch.
* **Media files and folders whose names contain non-ASCII characters (e.g. `imäge1.png`, `Földer1`) can be deleted again.** Captured route params arrived percent-encoded because Grav's URL parser does not decode them, so the controller looked for a file that didn't exist. Route params are now rawurldecoded once in the dispatcher. Fixes [getgrav/grav-plugin-api#3](https://github.com/getgrav/grav-plugin-api/issues/3).
* **Media files whose extension matches a configured page type (`.txt`, `.md`, `.html`, …) can be deleted again.** Grav's URL router strips known page-type extensions before any plugin sees the route, so `/media/notes.txt` arrived as `/media/notes` and 404'd. The dispatcher now re-attaches the stripped extension before matching, fixing every plugin route at once. Fixes [getgrav/grav-plugin-api#3](https://github.com/getgrav/grav-plugin-api/issues/3).
* **Saving a page or configuration with YAML list values no longer grows the file with quoted `'0'`, `'1'`, `'2'` keys mixed in alongside the original entries.** Reported via [getgrav/grav-theme-quark2#8](https://github.com/getgrav/grav-theme-quark2/issues/8).
# v1.0.0-rc.10
## 05/26/2026
1. [](#new)
* New `GET /blueprint-files` endpoint browses any Grav stream (`user://media`, `theme://images`, `account://`, …), `self@:` scope token, or relative path under `user/`, so file-picker blueprint fields can list arbitrary folders the way admin-classic always could.
* New `?locate=/some/route` parameter on `GET /pages` returns the page-of-results that contains a given route in one round trip, so the admin can jump straight to a deep child of a long folder without walking the listing.
2. [](#bugfix)
* Listing a folder with more than 100 children no longer silently drops the rest — the per-request cap default is now 1000 (raise it further via `plugins.api.pagination.max_per_page` if you need to). Fixes [getgrav/grav#4096](https://github.com/getgrav/grav/issues/4096).
# v1.0.0-rc.9
## 05/21/2026
1. [](#new)
* Page show, create, and update endpoints now enforce the new `security.twig_content.*` gates. Requests that try to enable Twig on a page without permission, or to load a page that already has Twig enabled when the current user can't edit it, return a 403 with a stable reason code so the admin UI can render the right toast. Requires grav ≥ 2.0.0-rc.4.
* **New `DELETE /system/environments/{name}` endpoint.** Removes the `user/env/<name>/` folder and everything under it after validating the name, refusing to delete the request's currently active environment, refusing to act on legacy `user/<name>/config/` layouts (those overlap with user-managed paths and must be cleaned up by hand), and guarding against symlink escape via a realpath boundary check.
* **New User Groups CRUD endpoints.** `GET /groups`, `POST /groups`, `GET /groups/{name}`, `PATCH /groups/{name}`, `DELETE /groups/{name}` for managing entries in `user/config/groups.yaml`. Reads through the Flex `user-groups` directory when available, falls back to direct YAML I/O otherwise. ETag concurrency on show/update; writes gated on `admin.super` to match the `security@` on the group blueprint.
* **New Accounts Configuration endpoints.** `GET /config/accounts` and `PATCH /config/accounts` read and write `user/config/flex/accounts.yaml` — the Flex compatibility + caching toggles classic admin shows under the Users → Configuration tab. Gated on `admin.super`.
* **New blueprint endpoints for group editing and accounts config.** `GET /blueprints/groups` and `GET /blueprints/groups/new` serve the resolved `user/group.yaml` and `user/group_new.yaml` blueprints. `GET /blueprints/config/accounts` delegates to `FlexDirectory::getDirectoryBlueprint()` so both the Compatibility tab (from the user-accounts blueprint's `blueprints.configure.fields.import@`) and the shared Caching tab (from `blueprints://flex/shared/configure.yaml`) come through together, matching admin classic.
* **Four new Tier B preference keys.** `usersViewMode`, `groupsViewMode`, `pluginsViewMode`, `themesViewMode` (each `cards` or `table`) let admin2 remember per-user list-view choices server-side, the same way `pagesViewMode` already does.
2. [](#bugfix)
* **`POST /pages/{route}/move` no longer corrupts folder names with a double-dot prefix.** `$page->order()` in Grav core returns the matched numeric prefix *including* the trailing dot (e.g. `'04.'`), and the endpoint was treating it as a plain number then appending its own separator, producing folders like `04..an-api-drive-future-for-grav`. Grav then strips only the first `04.` from that name and exposes a slug starting with `.`, which gives the page a route like `/blog/.foo` that the SvelteKit router silently normalizes back to `/blog/foo` — net result was a "Page not found" right after a successful move. The fallback now strips the trailing dot and converts to int before building `dirName`.
* **`POST /pages/reorganize` rejects batches where a destination parent is also being moved.** Mid-batch, Phase 2 renames every page to a temp directory under its captured destination path; if the destination parent itself is in the operation list, its on-disk path moves out from under any earlier op that already landed in it, and Phase 3's rename then fails with the confusing `rename(...): No such file or directory`. The endpoint now walks the ancestor chain of every op's destination during validation and throws a clear `ValidationException` naming the conflicting op, so the rollback path keeps disk state intact and the client sees something actionable.
* **`POST /pages/reorganize` no longer rejects batches that contain no-op renumbers of the source's parent.** The "destination parent is also being moved" guard was flagging every route in the batch as moved, even ones whose position and parent were unchanged on disk. Dragging a child page back to root with the tree-view drop handler (which renumbers all root siblings to match the new order) failed with `Operation index N targets parent '/foo', but '/foo' is also being moved in the same batch` even though `/foo` was just being renumbered to its existing position. The conflict check now only counts routes that actually rename on disk (parent changed OR position changed vs current order).
* **`POST /pages/reorganize` defensively strips leading dots from page slugs.** Mirrors the sanitization the single-page `/move` endpoint already applies. Without this, a page whose slug somehow started with `.` (e.g. inherited from an earlier corrupted folder) would get rebuilt into another double-dot folder on the next batch operation.
* **Page listings now serve the real frontmatter title instead of a slug-humanized fallback.** The flex-indexed `PageObject` returned by `GET /pages` doesn't materialize the page header in memory, so `$page->title()` and `->menu()` were silently falling back to `ucfirst(slug)` — pages named `contact-us` with a real `title: "Contact Us"` in their `.md` showed up as `"Contact-us"`. `PageSerializer` already re-parses the frontmatter from disk when the in-memory header is empty; it now also reads `title` and `menu` out of that parsed array in preference to the page-object accessors.
* **`POST /menubar/actions/{plugin}/{action}` returns HTTP 200 when a handler ran and reported a domain-level failure**, instead of HTTP 400 with the failure embedded in the body. The action result envelope (`{ status, message }`) already differentiates success from failure, so the admin client's `result.status` toast branch handles both — but on 400 the client's generic error handler was looking for `detail`/`title` fields that aren't part of the menubar envelope, leaving the error toast blank. HTTP 4xx is now reserved for the genuine API error of "no plugin registered for that action" (returns 404). Visible symptom this fixes: clicking the Cloudflare purge button with bad credentials used to surface an empty red toast instead of "Cloudflare purge failed: Authentication error".
# v1.0.0-rc.8
## 05/17/2026
1. [](#new)
* **New `GET /admin/languages` endpoint.** Enumerates `user/plugins/admin2/languages/*.yaml` and returns each available admin UI locale with its native name and RTL flag. Distinct from `GET /languages` (which lists *site content* languages from `system.yaml`) — the admin UI language and site content language are different concepts and shouldn't be conflated.
* **`POST /pages` now accepts a `kind` parameter.** Mirrors classic admin's three-way add-page split: `page` (default — folder + `<template>.md`, unchanged behaviour), `folder` (creates the folder only, no `.md` file), or `module` (a modular sub-page — the slug is automatically prefixed with `_` per Grav's modular-folder convention). Validation rejects any other value.
* **`GET /blueprints/pages` now accepts `?modular=true`.** Returns `Pages::modularTypes()` instead of `Pages::types()` so the admin can populate a Module-specific template picker (the four `modular/*` templates a theme provides, rather than every standard template).
* **`GET /translations/{lang}` now returns `dir: 'rtl'|'ltr'`.** Lets admin2 set `<html dir>` and `window.__GRAV_I18N.dir` from the same payload it's already fetching, avoiding a second roundtrip on every locale switch.
1. [](#bugfix)
* **`GET /translations/{lang}` no longer silently falls back to the default language when the requested locale isn't a *site content* language.** The validation was gating on `$language->getLanguages()` (the list configured under `system.languages.supported`), so a request for Hebrew on an English-only site quietly returned English strings. Admin UI translations and site content languages are independent — the endpoint now validates only the shape of the lang code and lets `Languages::flattenByLang()` decide whether the strings exist.
* **Blueprint labels now translate against the user's admin language, not the site default.** Every `/blueprints/*` endpoint (pages, plugins, themes, users, permissions, config, plugin pages, page types) used to call `$lang->translate()` with no language hint, so labels resolved against Grav's globally-active content language. The same admin-next user could see their topbar nav in Hebrew (those come from `/translations/he` which knows the language) and the System config page still in English (those come pre-resolved here). `BlueprintController` now reads each request's authenticated user, resolves their effective `adminLanguage` via `PreferencesResolver`, and passes `[$adminLanguage, 'en']` as the language fallback chain on every translate call. Falls back cleanly to English when no user is attached.
* **Modular templates now save with the correct on-disk filename.** `POST /pages` with template `modular/hero` was writing `<folder>/modular/hero.md` (creating a nested `modular/` subdirectory inside the new page folder); it now correctly writes `<folder>/hero.md`. The `modular/` prefix in the template key is purely for template resolution, never part of the filename.
# v1.0.0-rc.7
## 05/14/2026
1. [](#new)
* **New `/admin-next/preferences` endpoint family for admin2 UI settings.** `GET /admin-next/preferences` returns a single resolved payload of branding, site defaults, the caller's overrides, and the merged effective values. `PATCH /admin-next/preferences/user` saves the current user's overrides (debounced from the SPA); `DELETE` clears them. `PATCH /admin-next/preferences/site` writes site-wide defaults (super-admin only) and routes Tier B keys (overridable per-user) to `user/config/admin-next.yaml > ui.defaults` and Tier A2 keys (site-only behavioral — auto-save, real-time collab, menubar links) to `ui.settings`. `PATCH /admin-next/branding` and `POST/DELETE /admin-next/branding/logo` handle the site logo (mode/text plus light/dark image uploads stored under `user://media/admin-next/`). The new `PreferencesResolver` service in `classes/Api/Services/` mirrors the dashboard-layout pattern: built-in defaults overlaid with site values overlaid with per-user overrides, all schema-validated on write.
# v1.0.0-rc.6
## 05/13/2026
1. [](#new)
* **Plugin log files now show up in the admin Logs viewer.** A new `onApiLogFiles` event lets plugins register their own log file alongside the core `grav.log` / `email.log` / `scheduler.log` set, and `GET /system/logs` accepts a `?file=` query to pick which one to read. A companion `GET /system/logs/files` endpoint returns the registered list so the admin can populate a selector. File names are whitelisted by the registered set to prevent path traversal.
* **`POST /pages` accepts `order: "auto"` for sibling-aware numeric prefixing.** Mirrors admin-classic's add-page behavior: when the parent already has numerically prefixed children the new page is created with the next free number; when no sibling uses a prefix the new page is created without one. Existing integer and `null` semantics are unchanged.
1. [](#improved)
* **Blueprint serializer now passes through the `create` field property.** Lets array fields opt into admin2's constrained-dropdown rendering by setting `create: false` in their blueprint.
* **Self-service paths on `/users` no longer require `api.users.read`.** A caller can fetch their own row (`GET /users/{me}`) and the user-form schema (`GET /blueprints/users`) with just `api.access` — symmetric with what `PATCH /users/{me}` already permitted. The blueprint endpoint is just the form definition with no per-user data leak; the show endpoint still requires `api.users.read` for anyone else's account.
* **`GET /users` auto-filters to self for restricted callers.** Without `api.users.read` the listing endpoint used to 403 outright; it now returns a single-row paginated envelope containing only the caller's own user. Admins (super or `api.users.read`) still get the full listing as before.
* **Admin login now flows through the standard `Login::login()` event chain.** The previous direct `User::authenticate()` call skipped `onUserLoginAuthenticate`, which meant the LDAP plugin (and any other auth-extending plugin that hooks that event) couldn't participate in admin2 logins. Auth requests now route through `Login::login()` with `'admin' => true` and `'authorize' => 'admin.login'`, with a clean fallback to direct authentication when the Login plugin isn't installed ([grav-plugin-admin2#9](https://github.com/getgrav/grav-plugin-admin2/issues/9)).
* **Popularity visitor IPs now hashed with HMAC-SHA256 plus a server-private salt.** The legacy `sha1($ip)` hash was reversible via precomputed table over the IPv4 space, which under GDPR Recital 26 / Art. 4(1) kept the stored value classified as personal data. Hashing now uses `hash_hmac('sha256', $ip, $salt)` with an auto-generated 32-byte salt stored in `user/config/plugins/api.yaml` (never shipped with a default), treated like the JWT secret and redacted from config-API responses. Legacy hashes already on disk age out via the existing visitor-history cap, so no migration is needed ([grav-plugin-admin2#12](https://github.com/getgrav/grav-plugin-admin2/issues/12)).
1. [](#bugfix)
* **API plugin now activates and dispatches correctly on Grav installs mounted at a subpath.** Both the activation check in `setup()` and the path-stripping in `ApiRouter::dispatch()` were comparing against the raw request path, which on a subpath install starts with the base — so `/<base>/api/...` requests fell through to a page-not-found 404. Both code paths now strip the base before testing the api prefix.
* **Page Template selector now shows the right list for modular children.** Serializing a `modular/*` page blueprint was hardcoded to call `Pages::pageTypes('standard')`, so a modular child's template dropdown listed every standard template instead of the four `modular/*` ones the theme provides. Serializer now picks `'modular'` or `'standard'` based on the blueprint name. The merge of resolved options with whatever Grav core's `dynamicData()` may have already filled in was also changed to a replace, so themes can't end up with the standard and modular lists concatenated.
# v1.0.0-rc.5
## 05/08/2026
1. [](#improved)
* **Blueprint label resolver now prefers `ICU.<key>` over the flat `<key>`.** Admin2 ships its canonical `PLUGIN_ADMIN.*` vocabulary under the ICU namespace; checking ICU first guarantees admin2 wins for every key it ships, even when admin classic (or any plugin still using the Grav 1 flat convention) is also present. The flat lookup remains as a transition fallback for keys admin2 doesn't ship — but only for keys contributed by *enabled* plugins (see below).
* **Disabled plugins no longer influence translations.** Grav core's `flattenByLang()` reads every plugin's lang yaml regardless of enabled state, so a disabled plugin (most painfully, admin classic mid-migration on a Grav 2 site) used to leak its strings into both the `/translations/{lang}` payload served to admin2 and the server-side blueprint label resolver. Both code paths now consult a new `DisabledPluginLangIndex` service that returns the keys contributed exclusively by disabled plugins; those keys are stripped from the response and skipped in `translateLabel()`. Keys also shipped by an enabled plugin stay — the enabled plugin owns them.
* **`/translations/{lang}` shadow-strips flat duplicates.** When the same key exists under both `<key>` and `ICU.<key>`, only the ICU side is sent to admin2. Admin2's client-side `t()` already preferred ICU, but stripping flat duplicates at the source shrinks the payload and removes ambiguity for any caller that might bypass the ICU-first lookup.
# v1.0.0-rc.4
## 05/06/2026
1. [](#improved)
* **`POST /gpm/upgrade` now emits a `grav:update` invalidation header** alongside `gpm:update`, so admin clients can refresh cached version info (e.g. the sidebar `Grav v…` label) after a Grav core self-upgrade without waiting for a full page reload.
# v1.0.0-rc.3
## 05/05/2026
1. [](#bugfix)
* **Config saves now land where Grav loads them.** When an environment overlay was active (e.g. a hostname-derived `user/<host>/config/`), `PATCH /config/{scope}` always wrote to base `user/config/` regardless — so any field already pinned in the env file silently shadowed the write and the change appeared to "succeed but not stick" (classic case: enabling a plugin pinned `enabled: false` in `user/localhost/config/plugins/`). Writes now follow Grav's active environment by default; the `X-Config-Environment` header still wins when set, and an explicitly-empty header opts back into a base write.
# v1.0.0-rc.2
## 05/05/2026
1. [](#bugfix)
* **Module-page blueprints (`modular/hero`, `modular/feature`, etc.) now resolve.** `GET /blueprints/pages/{template}` was registered with FastRoute's default placeholder, which doesn't allow slashes — so the embedded `/` in module-page templates 404'd before the controller ran ([grav-plugin-admin2#1](https://github.com/getgrav/grav-plugin-admin2/issues/1)). Route placeholder widened to accept slashes; downstream resolver already handled slashed templates.
* **Custom theme page blueprints (replace@, @extends.context, import@, ordering@) now render correctly.** The hand-rolled YAML resolver in `BlueprintController::loadPageBlueprint()` silently dropped `replace@` / `unset@` / `replace-<prop>@` directives, ignored `@extends.context`, and merged `import@` as a map instead of inline-inserting fields — so themes that override fields, switch the parent context, or import partials saw most of their customizations vanish. Resolver now delegates to Grav core's standard `Pages::blueprints()` pipeline (`Blueprint::load()->init()`), the same path admin-classic uses, which honors every BlueprintForm directive ([grav-plugin-admin2#3](https://github.com/getgrav/grav-plugin-admin2/issues/3)).
* **`pagemediaselect` / `filepicker` field properties now round-trip.** Blueprint serializer's field-property whitelist was missing `preview_images`, `preview_image`, `on_demand`, `folder`, `filter`, `self`, `display`, `resize`, and `media_picker_field`, so themes that configured those props on a media picker saw them silently stripped from the API response.
* **Folders prefixed `00.` now sort to the top of the page tree.** Default-sort branch in `PagesController::indexViaDefaultSort()` bucketed pages by `if ($page->order())`, but Flex's `Page::order()` returns `(int) 0` for `00.` — so `00.sections` landed in the "unordered" bucket and sorted alphabetically after every numbered sibling instead of first ([grav-plugin-admin2#5](https://github.com/getgrav/grav-plugin-admin2/issues/5)). Bucket check changed to `!== false` (the actual sentinel for unordered folders).
# v1.0.0-rc.1
## 05/04/2026
1. [](#new)
* Fire new `onApiBlueprintResolved()` event
* Add support for configurable ordering prefixes
1. [](#improved)
* Page creation and reorder now match the digit width of the parent's existing children — adding into a 3-digit collection stays 3-digit, and reorder no longer renormalizes existing 3- or 4-digit prefixes back to two ([grav-plugin-admin#2492](https://github.com/getgrav/grav-plugin-admin/issues/2492)). New collections fall back to the new `system.pages.order_digits` setting.
# v1.0.0-beta.17
## 04/28/2026
1. [](#bugfix)
* **Security: privilege escalation via blueprint-upload (GHSA-6xx2-m8wv-756h).** A user with only basic media-upload permission could plant an account file and instantly become a super-admin. The endpoint now restricts where files can land, who can target another user's avatar, and which file types are allowed.
* **Security: hardened destination handling on `/blueprint-upload`.** Added pre-locator input validation that rejects path-traversal characters in the `destination` string before it ever reaches Grav's stream resolver.
* **Security: path-traversal hardening in GPM endpoints.** `GET /gpm/{plugins,themes}/{slug}/...` (readme, changelog, blueprints, fields, etc.) now validates the `slug` parameter so a `..` slug can no longer reach files outside the package directory.
# v1.0.0-beta.16
## 04/28/2026
1. [](#bugfix)
* **Fix: `POST /gpm/update-all` now updates packages instead of skipping all of them.** A regression introduced by beta.14's dep-resolution rewrite tripped a Grav core static-cache quirk and mis-labelled every outdated package as "already up to date (installed as a dependency)". The per-package `POST /gpm/update` endpoint was unaffected and remained a working workaround.
# v1.0.0-beta.15
## 04/27/2026
1. [](#new)
* **`onApiBlueprintResolved` now fires for the user-account blueprint.** `GET /blueprints/users` previously returned the serialized `account.yaml` straight to the wire — plugins could extend page / plugin / theme blueprints via the event but had no way to add fields to the user form. The handler now fires `onApiBlueprintResolved` with `template: 'account'` and the requesting user, so admin2 can inject the state-toggle field (or other plugins can add their own fields) on a permission-aware basis.
* **`onApiBlueprintResolved` now carries an explicit `context` discriminator, and theme blueprints now fire the event.** Every firing tags itself with `context: 'page' | 'plugin' | 'theme' | 'account'` alongside the existing `template` / `plugin` / `theme` keys. Lets listeners gate behavior to a specific blueprint family without inferring from which sibling key happens to be set — e.g. ai-translate auto-annotates only `context === 'page'` and lets plugins hook a separate `onAiTranslateAnnotateFields` event for opt-in coverage of other contexts. `themeBlueprint()` previously returned the serialized YAML straight to the wire and skipped the event entirely; it now fires `context: 'theme'` symmetrically with the others, so theme-targeted blueprint extensions are finally possible.
2. [](#bugfix)
* **Dashboard layout no longer disables the user account.** `DashboardLayoutResolver::saveUserLayout()` was writing the per-user widget layout to `state.admin_next.dashboard` in the user's account YAML, but Grav's top-level `state:` field is the account-state string (`enabled` / `disabled`). Replacing it with a map flipped affected accounts to "Disabled" in the users list and broke any code that read `state === 'enabled'`. Storage moved to the top-level `admin_next.dashboard` key (no collision), and a one-time read-side migration in `migrateLegacyState()` lifts any pre-existing legacy data out of `state.*` and restores `state: enabled` (or `disabled` if a legacy `state.enabled: false` flag was present). Migration runs on the next dashboard read for each affected user; the save path also calls it as a safety net so an admin reordering widgets self-heals their own record.
* **Security: privilege escalation via self-edit (GHSA-r945-h4vm-h736).** `PATCH /users/{username}` allowed any authenticated user with `api.access` to send an `access` payload against their own profile and self-promote to super admin. The self-edit branch only required `api.access` (not `api.users.write`), but the field whitelist still included `access` (and `state`) for everyone — overwriting `access.api.super` / `access.admin.super` on yourself granted full system control and a Twig-template path to RCE. `UsersController::update` now splits the whitelist into self-editable fields (email, fullname, title, language, content_editor, twofa_enabled) and admin-only fields (state, access); a non-manager that sends `access` or `state` in the body now gets a `403 Forbidden` with an explicit "requires the 'api.users.write' permission" message instead of having the field silently land. Managers (super-admin or `api.users.write`) keep full control over both fields, including on their own account. New regression test `UsersControllerUpdatePrivescTest` pins the boundary across five cases: low-priv self-edit of `access` rejected (and access map verified untouched), low-priv self-edit of `state` rejected, low-priv self-edit of plain profile fields succeeds, an admin updates another user's `access` field, and a user holding `api.users.write` self-edits their own `access`. `Grav\Framework\Acl\Permissions` and `Grav\Common\Utils::arrayFlattenDotNotation` were added as minimal stubs in `tests/Stubs/GravStubs.php` so `PermissionResolver` can be exercised in unit tests without the Grav core on the classpath.
# v1.0.0-beta.14
## 04/25/2026
1. [](#bugfix)
* `core.recent-pages` dashboard widget's registered `defaultSize` was `sm` instead of `md` — out of sync with the `Default` preset, which sets it to `md`. Fresh installs (no saved user layout, no site layout) fell through to the registered default and rendered Recent Pages at `sm` even though clicking the Default preset would correctly snap it back to `md`. Bumped the registered `defaultSize` to `md` so a new account's first dashboard render matches the canonical Default layout. The other seven core widgets were already aligned with the preset, audited as part of the fix.
* `POST /gpm/update-all` now enforces the same dependency validation as the per-package `POST /gpm/update` path. The old bulk flow iterated `getUpdatable()` and called `GpmService::update()` directly per slug with no checks, so a "Update All" click would happily update plugins whose blueprint declared `grav` (or `php`) requirements the running install didn't satisfy — the dep-resolution intelligence already living in `GPM::checkPackagesCanBeInstalled()` + `GPM::getDependencies()` was being bypassed entirely. The bulk path now runs each package through `getDependencies()` up-front: a Grav-too-old or PHP-too-old failure surfaces as a `failed[]` entry with the original GPM message (color tags stripped) so the toast reads "needy: One of the packages require Grav >=2.0.0-beta.2. Please update Grav to the latest release." instead of silently mis-updating. Plugin-level deps that themselves need an update are processed *before* the package that needs them, mirroring admin-classic's resolution order — a plugin update that requires `shortcode-core >= 5.0.0` will pull `shortcode-core` forward first. The response shape gains `skipped[]` (packages that were originally listed updatable but became current via cascade earlier in the batch — re-checked against a fresh GPM read each iteration, no double-update) and `cascaded_dependencies[]` (slugs installed/updated as deps of others in the batch), so callers can render "also updated as deps: x, y". Admin-next's three "Update All" toasts (dashboard, plugins, themes) now expand failure reasons inline (`"<slug>: <reason>"`) instead of just listing slugs, so the underlying constraint is visible without opening the network panel.
* 6 new unit tests in `GpmControllerUpdateAllTest` cover the matrix: Grav-dep mismatch lands in `failed[]` and never invokes `updatePackage`; cascade install runs in correct order and the cascaded dep lands in `skipped[]` (not `updated[]`) on its own iteration; a throwing dep install aborts the parent update with a partial-failure message; `theme: true` is passed only for theme packages and `install_deps: false` is always set; an `updatePackage()` returning non-success surfaces as a `failed[]` entry; an empty batch returns four empty buckets. To enable mocking, `GpmController::getGpm()` was promoted from `private` to `protected` and the static `GpmService::install/update` calls in `updateAll()` now route through new protected `installPackage()` / `updatePackage()` wrappers — overridable in a test subclass without touching network or filesystem. A minimal `Grav\Common\GPM\GPM` stub was added to `tests/Stubs/GravStubs.php` so `createMock(GPM::class)` works when running the suite outside a Grav installation.
# v1.0.0-beta.13
## 04/25/2026
1. [](#new)
* **Customizable dashboard widgets** — three new endpoints back the new admin-next dashboard's per-user / per-site customization. `GET /dashboard/widgets` returns the resolved widget list (visibility, size, order) merged from a built-in core registry + plugin contributions via `onApiDashboardWidgets` + the site-default layout (super-admin) + the current user's overrides. `PATCH /dashboard/layout` saves the user's layout (visibility/size/order per widget); `PATCH /dashboard/site-layout` saves the site-wide default and is super-admin only. The merge is layered: site-hidden widgets are dropped entirely from the user's view (cannot be re-enabled per-user), the user's overrides win for size/order on the rest, and any widget not in either layout falls back to its registered `defaultSize` / priority. A new `DashboardLayoutResolver` service owns the resolution and persistence; resolved widgets carry their `sizes[]` / `defaultSize` / icon / authorize permission, so the client can render the customize-mode size picker without a second round-trip. Plugin widgets are contributed by listening to `onApiDashboardWidgets` and pushing entries into `$event['widgets']`; each widget can declare its own permission gate so the resolver hides widgets the user lacks access to. Allowed sizes are validated server-side against a per-widget allowlist (`xs`, `sm`, `md`, `lg`, `xl`) and silently coerced back to `defaultSize` if a stale layout asks for an unsupported size.
* **Notifications v2** — `GET /dashboard/notifications` now fetches from `https://getgrav.org/notifications2.json` and stores its cache under `user/data/notifications/{md5}_v2.yaml` (separate file so v1 caches don't collide). The new schema replaces the v1 "embedded HTML in `message`" approach with structured fields: `type` (`info` | `notice` | `warning` | `promo`), `icon` (emoji or icon name), `title`, `message` (markdown), `link` (whole-row click), `image` + `accent` (for `promo` cards), `action: {label, url}`, and `dependencies` (e.g. `admin: '>= 2.0.0'`). Lets admin-next render every notification natively in its own design language instead of dumping admin-classic CSS into the page.
* **Password policy endpoint** at `GET /auth/password-policy` (public, no auth). Parses the configured `system.pwd_regex` into a structured `{ regex, min_length, rules[] }` response by recognizing the common lookahead form (`(?=.*\d)`, `(?=.*[a-z])`, `(?=.*[A-Z])`, `(?=.*\W)`, `.{N,}`) and mapping each to a human-readable rule label. Admins can override the auto-detected rules with an optional `system.pwd_rules: [{id, label, pattern}, …]` list for custom or localized messaging without touching `pwd_regex`. The same structure is piggybacked on the `GET /auth/setup` response so the first-run setup screen can render its strength meter without a second round-trip. `POST /auth/setup` additionally validates the first-user password against `pwd_regex` server-side (previously only enforced `>= 8` chars) — keeps the server authoritative regardless of what the UI is showing.
* **HTTP method override** fallback for shared-hosting nginx configs that 405 `DELETE` / `PATCH` / `PUT` at the edge before the request ever reaches PHP. A new `MethodOverrideMiddleware` runs right after CORS/body-parse and transparently rewrites any `POST` that carries an `X-HTTP-Method-Override: DELETE|PATCH|PUT` header to the target method before dispatch, so the FastRoute handlers downstream see the semantic verb they expect. Only the three mutation verbs are honored (never `GET`), and the override is opt-in per request — clients that don't need it pay zero cost. Admin-next complements this with client-side auto-detection: a failed mutation that 405s is retried once as `POST + override`, and the fallback is cached in `sessionStorage` so subsequent requests in the same session skip straight to the compatible path.
* **Destination-aware blueprint file uploads** at `POST /blueprint-upload` and `DELETE /blueprint-upload`. Accepts a blueprint `destination` (Grav stream like `theme://images/logo`, `user://assets`, `account://avatars`; `self@:subpath` relative to a blueprint owner; or a plain user-rooted relative path) plus a `scope` (`plugins/<slug>`, `themes/<slug>`, `pages/<route>`, `users/<username>`) and writes the uploaded file to the right place, mirroring admin-classic's `taskFilesUpload` semantics. Streams resolve through Grav's locator so symlinked theme/plugin folders (common in dev setups) work cleanly — the response returns a *logical* user-rooted path (`user/themes/quark2/images/logo/foo.png`) independent of realpath, so a subsequent `DELETE` round-trips through the symlink to remove the actual file. Enforces the usual safety gates: `..` traversal and absolute paths are rejected, filenames are sanitized, and the dangerous-extension allowlist is checked. This gives admin-next parity with admin-classic's file-field uploads for theme/plugin config forms that previously only worked on page-media contexts.
2. [](#improved)
* **Rate limiter `excluded_paths`** — new `plugins.api.rate_limit.excluded_paths` config (defaults to `['/sync/']`) skips the per-user bucket for matching path prefixes. Phase 6 of the sync plugin fires steady ~90 req/min per editing client (1s pull + ~0.5s presence + per-keystroke pushes); the default 120 req/min anti-abuse bucket would trip an actively-typing user within a minute and put sync into a 429 stop/start loop. The bypass is gated by normal auth (sync still requires `api.access` + `api.collab.*`), so it's not a free pass — just removes the global anti-scraping limit from authenticated collaboration traffic. Operators can extend the list to exempt other high-frequency authenticated endpoints (e.g. polling integrations from internal services).
3. [](#bugfix)
* `PATCH /config/{scope}` (and every other ETag-guarded endpoint) no longer returns a spurious `409 Conflict` when the admin is served behind Apache `mod_deflate` or an nginx build that applies gzip/br compression. Both servers weaken the ETag on compressed responses by suffixing it (`<hash>-gzip`, `<hash>-br`, sometimes `;gzip`), and clients echo that suffixed value back in `If-Match` on the next PATCH. The PATCH response body is typically uncompressed, so `generateEtag()` produced the bare hash and the strict equality check failed on every first save. `validateEtag()` now strips known transport suffixes (`-gzip`, `;gzip`, `-br`, `-deflate`) and the weak-validator prefix (`W/`) from inbound `If-Match` headers before comparing, so the hash round-trips cleanly through a compressing proxy. Invisible on `php -S` / MAMP; only reproduces behind real reverse proxies with content compression enabled.
* `GET /users` no longer emits phantom entries for stray files in `user/accounts/`. Grav's Flex `FileStorage::buildIndex()` indexes every file in the accounts folder regardless of extension, so snapshot/backup files from other plugins (e.g. revisions-pro's `.rev` snapshots) surfaced as indexable "user" objects. `UsersController::indexViaFlex` now constrains the collection to keys matching the username pattern `[a-z0-9_-]+` before search / sort / pagination, so stray files are filtered out before they ever reach the serializer.
* `PATCH /config/{scope}` now uses Grav's blueprint-aware merge (`Blueprint::mergeData()`) instead of a blind `array_replace_recursive`. The old recursive merge deep-merged map values at every level, so when a client sent a file field as `{}` after the user removed the last file, the old path keys from `$existing` survived the merge and the YAML kept referencing the deleted file. Blueprint-aware merge respects field-type semantics — `type: file` (and any other "collection of items" field) is REPLACED wholesale from the incoming body, so removing map entries actually propagates. Falls back to `array_replace_recursive` when no blueprint is available (rare — mostly test fixtures).
* `DELETE /blueprint-upload` is now idempotent: a missing file returns `204 No Content` instead of `404 Not Found`. The endpoint's contract is "this file should not be on disk" — already-gone and just-deleted are indistinguishable end states, and surfacing a 404 forced clients into special-case error suppression. Real misuses (path resolves to a directory or something non-file) still error.
# v1.0.0-beta.12
## 04/22/2026
1. [](#new)
* **Environment management API** at `GET /system/environments` (now returning a richer shape: `detected` host, `environments[]` with `name`, `label`, `exists`, `hasOverrides`) and `POST /system/environments` to create a new `user/env/<name>/config/` folder. Writes to `config/plugins/*`, `config/themes/*`, and `config/{system,site,media,security,…}` now honor a new `X-Config-Environment` request header that targets an existing env folder — empty/missing defaults to base `user/config/`, and a non-empty value that doesn't match an existing folder returns a clear `400` instead of silently creating anything. Env folders are **never** created implicitly; clients must opt in via `POST /system/environments`. A shared `EnvironmentService` owns resolution across `user/env/*` and legacy Grav 1.6 `user/<host>/` layouts so both the listing endpoint and the write path see the same set of envs.
* **Differential config saves.** Config writes now persist only the delta against the relevant parent yaml, matching the hand-edit workflow instead of forking full defaulted copies. Parent resolution:
* `system / site / media / security / scheduler / backups``system/config/<scope>.yaml` (Grav core defaults)
* `plugins/<name>``user/plugins/<name>/<name>.yaml` (the plugin's own shipped defaults)
* `themes/<name>``user/themes/<name>/<name>.yaml` (the theme's own shipped defaults)
* Env-targeted writes (`X-Config-Environment` set) additionally layer `user/config/<scope>.yaml` on top of defaults, so env files only carry keys that differ from the effective base — move a key from base to env by setting it to a different value; leave it alone to inherit.
* Defaults come from the raw yaml files on disk, **not** from blueprints — blueprints describe the admin form and routinely diverge from what actually loads at runtime. Sequential arrays (`languages.supported`, `pages.types`, etc.) are treated atomically: any difference retains the whole new list, avoiding the classic admin-classic bug where shortening a list silently merged removed entries back in. Ships with 24 unit tests covering diff semantics, deep merges, key-ordering tolerance, null overrides, and a full parent-resolution round trip against a tempdir Grav layout.
2. [](#bugfix)
* Config saves no longer return a `409 Conflict` on every second edit when sensitive fields are present. `ConfigController::update()` was hashing the PATCH response body from the in-memory `$merged` (non-redacted) but the next save's `If-Match` validation hashed the redacted `config->get()` representation, so the etag the client stored was never going to match on the next round-trip. Both the response body and the `If-Match` comparison now flow through a single `configEtagData()` helper that reads via `config->get()` after the save and applies the same redaction, so the client's stored etag stays valid across consecutive saves and reflects the shape a fresh `GET` would return (including any blueprint defaults or type coercion applied server-side during the filter step).
* Config writes no longer silently create `user/<hostname>/config/` folders on save. `writeConfigFile()` was resolving the target directory via `locator->findResource('config://', true, true)`, whose first match can be the hostname-derived env path Grav auto-infers when `user/env/` doesn't exist — then `mkdir` materialized the path on first save, producing orphan `user/localhost/config/`, `user/<ddev-host>/config/`, etc. that then began overriding `user/config/` on every subsequent read. The write path now explicitly resolves to `user://config` (or an existing `user/env/<env>/` when `X-Config-Environment` is set), and the `mkdir` is reserved for plugin/theme sub-directories inside an already-existing write root. Env roots must be created deliberately via `POST /system/environments`.
# v1.0.0-beta.11
## 04/21/2026
1. [](#bugfix)
* `POST /gpm/install` and `POST /gpm/update` now install missing blueprint dependencies before installing the requested package — mirroring admin-classic's behavior via `GPM::checkPackagesCanBeInstalled()` + `GPM::getDependencies()`, which resolves version constraints, checks PHP/Grav requirements, and returns a slug-keyed `install` / `update` / `ignore` map. Previously the naive recursive branch in `GpmService::install()` passed the raw blueprint `dependencies:` list (arrays of `{name, version}`) back into itself where `array_map` silently filtered them all to `false`, so deps were never installed and the user got a half-wired plugin (e.g. installing `shortcode-ui` without `shortcode-core`). Response bodies and `onApiPackageInstalled` / `onApiPackageUpdated` events now carry a `dependencies: string[]` list of slugs that were installed alongside, and cache-invalidation tags cover each new dep so list views refresh accordingly. Failure modes are surfaced cleanly: requiring a newer Grav core, a newer PHP version, or hitting an incompatible version constraint between packages returns a `422 Unprocessable Entity` with the original `GPM::getDependencies()` error message (e.g. "One of the packages require Grav 1.8.0. Please update Grav to the latest release.") — the API never auto-upgrades Grav itself, matching admin-classic. CLI color markup (`<red>`, `<cyan>`, …) is stripped from the propagated message. Deps are installed one-at-a-time so mid-install failures report partial state — the 500 detail includes a "Dependencies already installed before failure: foo, bar." suffix so callers know exactly what got through before the failure.
# v1.0.0-beta.10
## 04/19/2026
1. [](#bugfix)
* `GET /pages` now returns accurate `published` and `visible` values for every page. Flex-indexed `PageObject` instances expose an empty header during listings, so `$page->published()` / `visible()` fell back to Grav's default "true" even when the frontmatter explicitly set them to false — making draft / hidden pages indistinguishable from published ones in list/tree/columns views. `PageSerializer` now parses the YAML frontmatter directly from the `.md` file on disk (with multilang filename resolution: page language → active language → untyped default → glob) whenever it gets a flex page with an empty header, and re-exposes the full header dict alongside correct `published` / `visible` booleans.
* `PATCH /pages/{route}` now reflects `published` / `visible` changes in the response without requiring a reload. Legacy `Page` caches `$this->published` and `$this->visible` at init and doesn't re-derive them from header mutations, so after updating the header the API was returning the pre-save values. The update controller now calls the `Page::published()` and `Page::visible()` setters in addition to header replacement whenever those fields are sent (either as top-level keys or nested under `header`), keeping the in-memory object in sync with the just-written file.
# v1.0.0-beta.9
## 04/17/2026
1. [](#bugfix)
* `GET /blueprints/pages/{template}` now honours the newer `'@extends':` and `'@import':` directives (string or `{type, context}` array form) alongside the legacy `extends@:` / `import@:` spellings. Previously, page blueprints using the newer syntax silently lost their inheritance chain — fields defined in the parent (e.g. `content: type: markdown`, `header.media_order: type: pagemedia` from `system://blueprints/pages/default.yaml`) were dropped, leaving only the fields the child blueprint declared locally. Caused custom page templates in themes like Helios to render with raw text inputs instead of markdown editors / page media uploaders in admin-next.
* `GET /blueprints/pages/{template}` now fires `Pages::getTypes()` before resolving, which triggers the `onGetPageBlueprints` event and registers plugin-contributed blueprint paths into the `blueprints://pages/` locator stream. Without this, blueprints declared by plugins (via `$types->scanBlueprints('plugin://.../blueprints')`) were unreachable from the API even when the plugin was subscribed correctly.
# v1.0.0-beta.8
## 04/17/2026
1. [](#new)
* `description_html` field added to the plugin/theme package serializer. Plugin and theme `description` strings are YAML-authored and routinely contain inline markdown (links, bold, emphasis) that renders as literal syntax in admin UIs. The API now ships a safe-mode Parsedown rendering alongside the raw `description` so clients can `{@html}` it for detail views and strip tags for one-line list cards without reinventing a markdown pipeline. Present on `GET /gpm/plugins`, `GET /gpm/plugins/{slug}`, `GET /gpm/themes`, `GET /gpm/themes/{slug}`, and the `/gpm/repository/*` endpoints.
2. [](#bugfix)
* `GET /pages/{route}?summary=true` no longer 500s on pages whose content contains plugin shortcodes that rely on the frontend Twig/theme environment (e.g. `[poll]`). Shortcode processing runs as part of Grav's `summary()` pipeline and can throw when it tries to render template partials that aren't wired up in the API request context. The page serializer now catches the failure and falls back to a plain-text rendering of the raw markdown (shortcodes stripped, trimmed to `summary_size` or 300 chars) so admin previews keep working.
# v1.0.0-beta.7
## 04/17/2026
1. [](#new)
* **`X-API-Token` header** added as the preferred transport for JWT access tokens. Sidesteps FastCGI / PHP-FPM / CGI setups (notably MAMP's `mod_fastcgi`) that silently strip the standard `Authorization` header before it reaches PHP — a common source of 401 errors on shared hosts. Accepts either a bare JWT (`X-API-Token: eyJ...`) or the traditional Bearer form (`X-API-Token: Bearer eyJ...`). `Authorization: Bearer` still works as a fallback for standards-compliant clients on hosts that don't strip it.
* `GET /me` now returns `grav_version` and `admin_version` so admin UIs can surface the running Grav core and admin plugin versions without a separate request. `admin_version` resolves to the enabled admin2 or admin-classic plugin blueprint.
* `is_symlink` field added to the installed-package serializer (present on `GET /gpm/plugins`, `GET /gpm/plugins/{slug}`, `GET /gpm/themes`, `GET /gpm/themes/{slug}`). Detected via `is_link()` on the resolved `plugins://{slug}` or `themes://{slug}` path so admin UIs can flag symlinked packages.
* `POST /pages/{route}/adopt-language` — claims an untyped base page file (e.g., `default.md`) as a specific language by renaming it in-place to `{template}.{lang}.md`. Pure filesystem rename + cache bust; content is untouched. Fails if the page already has an explicit file for that language, or if the page has no untyped base file. Fires `onApiBeforePageAdoptLanguage` / `onApiPageLanguageAdopted`. Enables "Save as English" workflows on sites that started single-language and later enabled multilang.
* Page translation response (`GET /pages`, `GET /pages/{route}` with `?translations=true`) now includes two new fields to disambiguate Grav's fallback behaviour: `has_default_file` (true when an untyped `{template}.md` exists) and `explicit_language_files` (the subset of site languages with a real `{template}.{lang}.md` on disk). Needed because Grav reports the default lang in `translated_languages` whenever `default.md` exists — admin UIs can now tell whether each lang is backed by an explicit file or the implicit fallback.
2. [](#improved)
* `JwtAuthenticator::extractBearerToken()` now reads `X-API-Token` first, then falls back to `Authorization: Bearer`, then `?token=` query param. When both custom and standard headers are set, the custom header wins (so clients can send both for maximum host compatibility without ambiguity).
* OpenAPI spec, README, and Newman test runner updated to lead with `X-API-Token`.
* Default CORS allow-headers list in `api.yaml` now includes `X-API-Token` alongside the existing entries, so cross-origin preflights succeed out of the box on fresh installs.
3. [](#bugfix)
* `GET /me` no longer 500s when resolving the admin plugin version. Previous implementation called `$grav['plugins']->get($slug)->getBlueprint()`, but `->get()` returns a `Data` config object, not a `Plugin` instance (no `getBlueprint()` method). Now reads `plugins://{slug}/blueprints.yaml` directly via the locator, matching the pattern used for themes.
* `POST /pages/{route}/adopt-language` no longer spuriously rejects the default language with "A translation already exists". The previous check used `$page->translatedLanguages()` which always includes the default lang when `default.md` exists (because it serves as a fallback). The guard now checks the filesystem directly for `{template}.{lang}.md`, so adoption proceeds whenever the concrete language file is genuinely absent.
# v1.0.0-beta.6
## 04/16/2026
1. [](#new)
* `POST /gpm/update-all` — bulk update every updatable plugin + theme in one request (returns `{updated[], failed[]}`)
* `POST /gpm/upgrade` — Grav core self-upgrade (refuses to run when Grav is installed via symlink)
* `GET /gpm/updates` response now includes `grav.is_symlink` and counts Grav itself in `total` so admin UIs can show the true update count
* Events `onApiBeforePackageUpdate` / `onApiPackageUpdated` / `onApiBeforeGravUpgrade` / `onApiGravUpgraded` fire around the new write operations
2. [](#improved)
* `POST /gpm/update` auto-detects whether the slug is a theme and passes `theme: true` to the installer so theme updates land in the right directory
* `GpmService` — all GPM write operations (install / update / remove / direct-install / self-upgrade) are now implemented locally in the API plugin, removing the hard dependency on `Grav\Plugin\Admin\Gpm`. admin2 users can manage packages without the classic admin plugin installed
3. [](#bugfix)
* Previously `POST /gpm/update` called the admin plugin's Gpm helper, which meant admin2-only sites (no classic admin) got `500 Admin Plugin Required` when trying to update anything
# v1.0.0-beta.5
## 04/16/2026
1. [](#bugfix)
* `/auth/token` now delegates password check to `User::authenticate()` so the core trait's plaintext-password fallback fires — restores long-standing Grav behavior (admin-classic, Login plugin, frontend login) where a `password:` declared directly in `user/accounts/*.yaml` auto-hashes on first successful login. Previous direct `Authentication::verify()` call required users to pre-populate `hashed_password`, which broke the "edit yaml and log in" workflow that operators rely on when the CLI is unavailable
* Persist the auto-generated JWT secret on fresh installs. The previous `findResource(..., true, true)` call returned an array, the fallback concatenated that array into `"Array/..."`, and the write silently went nowhere — so every request minted a different secret, producing a login-then-immediately-expire loop on every fresh 2.0 install. Now resolves the path with default flags and logs+degrades gracefully if persistence genuinely fails.
# v1.0.0-beta.4
## 04/15/2026
1. [](#new)
* Page-view popularity tracker — single-file flat-JSON store with `flock()`, replaces admin-classic's four-file scheme; subscribes `onPageInitialized` for frontend hits only
* One-shot import + rename of legacy `daily/monthly/totals/visitors.json` into the new `popularity.json` (ISO-keyed, `pages` capped at 500)
* `popularity.{enabled, history.daily/monthly/visitors, ignore}` config block in `api.yaml`
* `raw_route` field on serialized pages so admin clients can navigate home / aliased pages correctly
2. [](#improved)
* Strict super-user scoping: `isSuperAdmin()` honors only `access.api.super` (no fallback to `admin.super`); operators can grant API authority without admin-classic implications
* `SetupController` writes a minimal admin-next-native account (`site.login` + `api.super` only), with race guards and explicit avatar/2FA reset to prevent flex-stored ghost data
* `issueTokenPair()` lifted to `AbstractApiController` so setup, login, refresh, and 2FA share one token-shape source
* Pages list / dashboard stats no longer skip the home page — the virtual pages-root is now distinguished by `$page->exists()` instead of by `route() === '/'`
* Dashboard `popularity` endpoint reads from `PopularityStore` (handles legacy import transparently)
3. [](#bugfix)
* Pages list and dashboard `pages.total` undercounted by 1 (the home page was being filtered out)
# v1.0.0-beta.3
## 04/15/2026
1. [new]
* Add intial user funtionality
* Add `ai.super` permissions
* Add missing vendor library
# v1.0.0-beta.2
## 04/15/2026
1. [improved]
* Default `enabled` to `true` since the plugin is not installed by default and admin2 requires it
# v1.0.0-beta.1
## 04/12/2026
1. [new]
* Initial beta release
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Trilby Media, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
+372
View File
@@ -0,0 +1,372 @@
/**
* Page Access ACL picker (admin-next custom field).
*
* Mirrors admin-classic's `acl_picker` with `data_type: access`: a list of
* rows, each pairing an access action (e.g. `admin.login`) with an
* Allowed / Denied choice.
*
* Value shape (what grav-core reads from `header.access`):
* { "admin.login": true, "site.login": false }
* true = Allowed, false = Denied. Absent keys are unset.
*
* The action dropdown is a type-ahead popover with a searchable, expandable
* tree (the action names are hierarchical: admin > admin.pages >
* admin.pages.create), modelled on admin-next's page-parent picker. Options
* are baked into `field.options` server-side as [{ value, label }] where value
* is the dotted action name and label is the short action label.
*/
const TAG = window.__GRAV_FIELD_TAG;
class AclAccessField extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._field = null;
this._value = null;
this._rows = null; // [{ key, allowed }]
this._lastEmitted = null;
this._openRow = -1; // index of the row whose popover is open
this._filter = '';
this._expanded = new Set(); // expanded tree nodes (by action name)
this._onDocDown = this._onDocDown.bind(this);
}
set field(v) { this._field = v; this._render(); }
get field() { return this._field; }
set value(v) {
const serialized = JSON.stringify(v ?? {});
if (serialized === this._lastEmitted) return;
this._value = v;
this._rows = this._rowsFromValue(v);
this._render();
}
get value() { return this._value; }
connectedCallback() {
if (!this._rows) this._rows = this._rowsFromValue(this._value);
document.addEventListener('mousedown', this._onDocDown, true);
this._render();
}
disconnectedCallback() {
document.removeEventListener('mousedown', this._onDocDown, true);
}
// ─── State ──────────────────────────────────────────────────────────
_rowsFromValue(v) {
const rows = [];
if (v && typeof v === 'object' && !Array.isArray(v)) {
for (const [key, val] of Object.entries(v)) {
rows.push({ key, allowed: val !== false });
}
}
// Show a single blank starter row only when nothing is set yet, so the
// controls are visible. Once there's an entry, the "+" button adds the
// next blank row — we don't auto-trail one.
if (rows.length === 0) rows.push({ key: '', allowed: true });
return rows;
}
_options() {
const opts = this._field?.options;
return Array.isArray(opts) ? opts : [];
}
_optionLabel(value) {
const o = this._options().find((x) => String(x.value) === String(value));
return o ? String(o.label ?? o.value) : '';
}
_commit() {
const out = {};
for (const row of this._rows) {
if (row.key) out[row.key] = !!row.allowed;
}
this._value = out;
this._lastEmitted = JSON.stringify(out);
this.dispatchEvent(new CustomEvent('change', { detail: out, bubbles: true }));
}
_ensureTrailingBlank() {
const last = this._rows[this._rows.length - 1];
if (!last || last.key !== '') this._rows.push({ key: '', allowed: true });
}
// ─── Render ─────────────────────────────────────────────────────────
_render() {
if (!this.shadowRoot || !this.isConnected) return;
if (!this._rows) this._rows = this._rowsFromValue(this._value);
const rowsHtml = this._rows.map((row, i) => `
<div class="row" data-i="${i}">
<button type="button" class="icon-btn del" title="Remove" data-act="del">${TRASH}</button>
<div class="select-wrap">
<button type="button" class="combo-trigger ${row.key ? '' : 'empty'}" data-act="open">
${row.key
? `<span class="lbl">${esc(this._optionLabel(row.key) || row.key)}</span><span class="muted">${esc(row.key)}</span>`
: `<span class="ph">Select access…</span>`}
${UPDOWN}
</button>
</div>
<div class="toggle" role="group">
<button type="button" class="seg allow ${row.allowed ? 'on' : ''}" data-act="allow">${CHECK}<span>Allowed</span></button>
<button type="button" class="seg deny ${row.allowed ? '' : 'on'}" data-act="deny">${BAN}<span>Denied</span></button>
</div>
<button type="button" class="icon-btn add" title="Add" data-act="add">${PLUS}</button>
</div>
`).join('');
this.shadowRoot.innerHTML = `<style>${STYLE}</style><div class="wrap">${rowsHtml}</div>`;
this.shadowRoot.querySelectorAll('.row').forEach((el) => {
const i = Number(el.dataset.i);
el.querySelector('[data-act="open"]')?.addEventListener('click', () => this._toggleOpen(i));
el.querySelector('[data-act="allow"]')?.addEventListener('click', () => this._onToggle(i, true));
el.querySelector('[data-act="deny"]')?.addEventListener('click', () => this._onToggle(i, false));
el.querySelector('[data-act="del"]')?.addEventListener('click', () => this._onDelete(i));
el.querySelector('[data-act="add"]')?.addEventListener('click', () => this._onAdd());
});
if (this._openRow >= 0 && this._openRow < this._rows.length) {
this._mountPopover(this._openRow);
}
}
// ─── Type-ahead popover ─────────────────────────────────────────────
_toggleOpen(i) {
this._openRow = this._openRow === i ? -1 : i;
this._filter = '';
this._render();
}
_mountPopover(i) {
const wrap = this.shadowRoot.querySelector(`.row[data-i="${i}"] .select-wrap`);
if (!wrap) return;
const pop = document.createElement('div');
pop.className = 'popover';
pop.innerHTML = `
<div class="search">${SEARCH}
<input type="text" placeholder="Filter access…" />
<button type="button" class="clear" hidden>${CLOSE}</button>
</div>
<div class="results"></div>`;
wrap.appendChild(pop);
this._popoverEl = pop;
this._resultsEl = pop.querySelector('.results');
this._searchInput = pop.querySelector('input');
const clearBtn = pop.querySelector('.clear');
this._searchInput.addEventListener('input', () => {
this._filter = this._searchInput.value;
clearBtn.hidden = !this._filter;
this._renderResults(i);
});
this._searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { this._openRow = -1; this._render(); }
});
clearBtn.addEventListener('click', () => {
this._filter = '';
this._searchInput.value = '';
clearBtn.hidden = true;
this._renderResults(i);
this._searchInput.focus();
});
this._renderResults(i);
requestAnimationFrame(() => this._searchInput?.focus());
}
_buildTree() {
const nodes = new Map();
for (const o of this._options()) {
const v = String(o.value);
nodes.set(v, { value: v, label: String(o.label ?? v), children: [] });
}
const roots = [];
for (const o of this._options()) {
const v = String(o.value);
const node = nodes.get(v);
const pi = v.lastIndexOf('.');
const parent = pi >= 0 ? nodes.get(v.slice(0, pi)) : null;
if (parent) parent.children.push(node); else roots.push(node);
}
return roots;
}
_renderResults(rowIndex) {
if (!this._resultsEl) return;
const selected = this._rows[rowIndex]?.key || '';
const q = this._filter.trim().toLowerCase();
let html = '';
if (q) {
// Flat, depth-indented list of every matching action.
const matches = this._options().filter((o) =>
String(o.label).toLowerCase().includes(q) || String(o.value).toLowerCase().includes(q));
html = matches.map((o) => this._nodeRow(String(o.value), String(o.label ?? o.value),
depthOf(String(o.value)), false, false, String(o.value) === selected)).join('');
if (!matches.length) html = `<div class="empty-msg">No matching access</div>`;
} else {
const walk = (node, depth) => {
const hasKids = node.children.length > 0;
const isExp = this._expanded.has(node.value);
let out = this._nodeRow(node.value, node.label, depth, hasKids, isExp, node.value === selected);
if (hasKids && isExp) out += node.children.map((c) => walk(c, depth + 1)).join('');
return out;
};
html = this._buildTree().map((n) => walk(n, 0)).join('');
}
this._resultsEl.innerHTML = html;
this._resultsEl.querySelectorAll('[data-value]').forEach((el) => {
el.querySelector('.exp')?.addEventListener('click', (e) => {
e.stopPropagation();
const v = el.dataset.value;
if (this._expanded.has(v)) this._expanded.delete(v); else this._expanded.add(v);
this._renderResults(rowIndex);
});
el.addEventListener('click', () => this._select(rowIndex, el.dataset.value));
});
}
_nodeRow(value, label, depth, hasKids, isExpanded, selected) {
const chevron = hasKids
? `<span class="exp">${isExpanded ? CHEVRON_DOWN : CHEVRON_RIGHT}</span>`
: `<span class="exp spacer"></span>`;
return `
<div class="node ${selected ? 'sel' : ''}" data-value="${esc(value)}" style="padding-inline-start:${depth * 14 + 6}px">
${chevron}
<span class="node-lbl"><span class="lbl">${esc(label)}</span><span class="muted">${esc(value)}</span></span>
${selected ? `<span class="tick">${CHECK}</span>` : ''}
</div>`;
}
_select(i, value) {
if (!this._rows[i]) return;
this._rows[i].key = value;
this._openRow = -1;
this._commit();
this._render();
}
_onDocDown(e) {
if (this._openRow < 0) return;
if (this._popoverEl && e.composedPath().includes(this._popoverEl)) return;
// Clicking the same trigger is handled by its own click toggler.
const trigger = this.shadowRoot.querySelector(`.row[data-i="${this._openRow}"] [data-act="open"]`);
if (trigger && e.composedPath().includes(trigger)) return;
this._openRow = -1;
this._render();
}
// ─── Row actions ────────────────────────────────────────────────────
_onToggle(i, allowed) {
if (!this._rows[i]) return;
this._rows[i].allowed = allowed;
this._commit();
this._render();
}
_onDelete(i) {
this._rows.splice(i, 1);
// Keep one blank starter row when the list is emptied entirely.
if (!this._rows.length) this._rows.push({ key: '', allowed: true });
if (this._openRow === i) this._openRow = -1;
this._commit();
this._render();
}
_onAdd() {
// Add a blank row to type into — but never stack multiple blanks.
this._ensureTrailingBlank();
this._render();
}
}
function depthOf(value) { return value.split('.').length - 1; }
// ─── Inline SVG icons (Lucide-style, currentColor) ──────────────────────
const UPDOWN = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="updown"><polyline points="7 15 12 20 17 15"/><polyline points="7 9 12 4 17 9"/></svg>';
const CHEVRON_DOWN = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
const CHEVRON_RIGHT = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
const SEARCH = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
const CLOSE = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const CHECK = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const BAN = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"/></svg>';
const PLUS = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
const TRASH = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
function esc(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
const STYLE = `
:host { display: block; font-family: inherit; }
.wrap { display: flex; flex-direction: column; gap: 8px; }
.row { display: flex; align-items: center; gap: 8px; }
.select-wrap { position: relative; flex: 1; min-width: 0; }
.combo-trigger {
display: flex; align-items: center; gap: 8px;
width: 100%; height: 40px; padding: 0 10px 0 12px;
border: 1px solid var(--border, #e2e8f0); border-radius: 8px;
background: var(--muted, #f8fafc); color: var(--foreground, #0f172a);
font-size: 14px; font-family: inherit; cursor: pointer; text-align: start;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.combo-trigger:hover { background: var(--accent, #f1f5f9); }
.combo-trigger .lbl { font-weight: 500; white-space: nowrap; }
.combo-trigger .muted { color: var(--muted-foreground, #64748b); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.combo-trigger .ph { color: var(--muted-foreground, #94a3b8); }
.combo-trigger .updown { margin-inline-start: auto; flex: none; color: var(--muted-foreground, #64748b); }
.popover {
position: absolute; top: calc(100% + 4px); inset-inline-start: 0;
width: max(100%, 340px); max-width: 92vw; z-index: 60;
background: var(--popover, var(--background, #fff)); color: var(--foreground, #0f172a);
border: 1px solid var(--border, #e2e8f0); border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,.18); overflow: hidden;
}
.search { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border, #e2e8f0); color: var(--muted-foreground, #64748b); }
.search input { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--foreground, #0f172a); font-size: 14px; font-family: inherit; outline: none; }
.search .clear { border: 0; background: transparent; color: var(--muted-foreground, #64748b); cursor: pointer; display: inline-flex; padding: 2px; }
.search .clear:hover { color: var(--foreground, #0f172a); }
.results { max-height: 288px; overflow-y: auto; padding: 4px; }
.node {
display: flex; align-items: center; gap: 6px;
padding: 6px 8px; border-radius: 8px; cursor: pointer;
color: var(--foreground, #0f172a);
}
.node:hover { background: var(--accent, #f1f5f9); }
.node.sel { background: color-mix(in srgb, var(--primary, #6366f1) 14%, transparent); color: var(--primary, #6366f1); }
.node .exp { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 4px; color: var(--muted-foreground, #64748b); flex: none; }
.node .exp:not(.spacer):hover { background: color-mix(in srgb, var(--foreground, #000) 8%, transparent); }
.node-lbl { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.node-lbl .lbl { font-size: 14px; white-space: nowrap; }
.node-lbl .muted { font-size: 12px; color: var(--muted-foreground, #64748b); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.node .tick { margin-inline-start: auto; flex: none; display: inline-flex; color: var(--primary, #6366f1); }
.empty-msg { padding: 14px; text-align: center; font-size: 13px; color: var(--muted-foreground, #64748b); }
.toggle { display: inline-flex; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; overflow: hidden; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.seg { display: inline-flex; align-items: center; gap: 6px; height: 40px; padding: 0 14px; border: 0; cursor: pointer; background: var(--background, #fff); color: var(--muted-foreground, #64748b); font-size: 14px; font-family: inherit; font-weight: 500; }
.seg + .seg { border-inline-start: 1px solid var(--border, #e2e8f0); }
.seg.allow.on { background: #16a34a; color: #fff; }
.seg.deny.on { background: var(--muted, #f1f5f9); color: var(--foreground, #0f172a); }
.seg svg { flex: none; }
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border: 0; border-radius: 8px; cursor: pointer; background: transparent; color: var(--muted-foreground, #64748b); }
.icon-btn:hover { background: var(--accent, #f1f5f9); color: var(--foreground, #0f172a); }
.icon-btn.add { background: var(--primary, #6366f1); color: #fff; }
.icon-btn.add:hover { filter: brightness(1.05); }
.icon-btn.del:hover { color: #dc2626; }
`;
customElements.define(TAG, AclAccessField);
@@ -0,0 +1,364 @@
/**
* Page Groups ACL picker (admin-next custom field).
*
* Mirrors admin-classic's `acl_picker` with `data_type: permissions`: a list
* of rows, each pairing a group (or special ACL target) with per-action
* Create / Read / Update / Delete states, each tri-state:
* unset → allow → deny → unset.
*
* Value shape (what grav-core reads from `header.permissions.groups`):
* { "Registered": "cru", "limited": "-c-r-u-d" }
* Each value is a compact ACL string: letters c/r/u/d (and any others such as
* `p`); a leading "-" denies the letters that follow it, "+" (or no sign)
* allows them. Letters absent from the string are unset. Non-CRUD letters in
* an existing value (e.g. publish `p`) are preserved on round-trip.
*
* The group dropdown is a type-ahead popover (searchable list), modelled on
* admin-next's page-parent picker. Options are baked into `field.options`
* server-side as [{ value, label }].
*/
const TAG = window.__GRAV_FIELD_TAG;
const CRUD = ['c', 'r', 'u', 'd'];
const CRUD_LABEL = { c: 'C', r: 'R', u: 'U', d: 'D' };
class AclPermissionsField extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._field = null;
this._value = null;
this._rows = null; // [{ key, states: {letter:'allow'|'deny'} }]
this._lastEmitted = null;
this._openRow = -1;
this._filter = '';
this._onDocDown = this._onDocDown.bind(this);
}
set field(v) { this._field = v; this._render(); }
get field() { return this._field; }
set value(v) {
const serialized = JSON.stringify(v ?? {});
if (serialized === this._lastEmitted) return;
this._value = v;
this._rows = this._rowsFromValue(v);
this._render();
}
get value() { return this._value; }
connectedCallback() {
if (!this._rows) this._rows = this._rowsFromValue(this._value);
document.addEventListener('mousedown', this._onDocDown, true);
this._render();
}
disconnectedCallback() {
document.removeEventListener('mousedown', this._onDocDown, true);
}
// ─── State ──────────────────────────────────────────────────────────
_rowsFromValue(v) {
const rows = [];
if (v && typeof v === 'object' && !Array.isArray(v)) {
for (const [key, raw] of Object.entries(v)) {
rows.push({ key, states: parseAcl(raw) });
}
}
// Show a single blank starter row only when nothing is set yet, so the
// controls are visible. Once there's an entry, the "+" button adds the
// next blank row — we don't auto-trail one.
if (rows.length === 0) rows.push({ key: '', states: {} });
return rows;
}
_options() {
const opts = this._field?.options;
return Array.isArray(opts) ? opts : [];
}
_optionLabel(value) {
const o = this._options().find((x) => String(x.value) === String(value));
return o ? String(o.label ?? o.value) : '';
}
_commit() {
const out = {};
for (const row of this._rows) {
if (row.key) out[row.key] = serializeAcl(row.states);
}
this._value = out;
this._lastEmitted = JSON.stringify(out);
this.dispatchEvent(new CustomEvent('change', { detail: out, bubbles: true }));
}
_ensureTrailingBlank() {
const last = this._rows[this._rows.length - 1];
if (!last || last.key !== '') this._rows.push({ key: '', states: {} });
}
// ─── Render ─────────────────────────────────────────────────────────
_render() {
if (!this.shadowRoot || !this.isConnected) return;
if (!this._rows) this._rows = this._rowsFromValue(this._value);
const crudHtml = (states) => CRUD.map((letter) => {
const st = states[letter] || 'unset';
const icon = st === 'allow' ? LOCK_OPEN : LOCK;
return `<button type="button" class="crud ${st}" data-letter="${letter}" title="${CRUD_LABEL[letter]}">${icon}<span>${CRUD_LABEL[letter]}</span></button>`;
}).join('');
const rowsHtml = this._rows.map((row, i) => `
<div class="row" data-i="${i}">
<button type="button" class="icon-btn del" title="Remove" data-act="del">${TRASH}</button>
<div class="select-wrap">
<button type="button" class="combo-trigger ${row.key ? '' : 'empty'}" data-act="open">
${row.key
? `<span class="lbl">${esc(this._optionLabel(row.key) || row.key)}</span>`
: `<span class="ph">Select group…</span>`}
${UPDOWN}
</button>
</div>
<div class="crud-group" role="group">${crudHtml(row.states)}</div>
<button type="button" class="icon-btn add" title="Add" data-act="add">${PLUS}</button>
</div>
`).join('');
this.shadowRoot.innerHTML = `<style>${STYLE}</style><div class="wrap">${rowsHtml}</div>`;
this.shadowRoot.querySelectorAll('.row').forEach((el) => {
const i = Number(el.dataset.i);
el.querySelector('[data-act="open"]')?.addEventListener('click', () => this._toggleOpen(i));
el.querySelector('[data-act="del"]')?.addEventListener('click', () => this._onDelete(i));
el.querySelector('[data-act="add"]')?.addEventListener('click', () => this._onAdd());
el.querySelectorAll('.crud').forEach((btn) =>
btn.addEventListener('click', () => this._onCycle(i, btn.dataset.letter)));
});
if (this._openRow >= 0 && this._openRow < this._rows.length) {
this._mountPopover(this._openRow);
}
}
// ─── Type-ahead popover ─────────────────────────────────────────────
_toggleOpen(i) {
this._openRow = this._openRow === i ? -1 : i;
this._filter = '';
this._render();
}
_mountPopover(i) {
const wrap = this.shadowRoot.querySelector(`.row[data-i="${i}"] .select-wrap`);
if (!wrap) return;
const pop = document.createElement('div');
pop.className = 'popover';
pop.innerHTML = `
<div class="search">${SEARCH}
<input type="text" placeholder="Filter groups…" />
<button type="button" class="clear" hidden>${CLOSE}</button>
</div>
<div class="results"></div>`;
wrap.appendChild(pop);
this._popoverEl = pop;
this._resultsEl = pop.querySelector('.results');
this._searchInput = pop.querySelector('input');
const clearBtn = pop.querySelector('.clear');
this._searchInput.addEventListener('input', () => {
this._filter = this._searchInput.value;
clearBtn.hidden = !this._filter;
this._renderResults(i);
});
this._searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { this._openRow = -1; this._render(); }
});
clearBtn.addEventListener('click', () => {
this._filter = '';
this._searchInput.value = '';
clearBtn.hidden = true;
this._renderResults(i);
this._searchInput.focus();
});
this._renderResults(i);
requestAnimationFrame(() => this._searchInput?.focus());
}
_renderResults(rowIndex) {
if (!this._resultsEl) return;
const selected = this._rows[rowIndex]?.key || '';
const q = this._filter.trim().toLowerCase();
const matches = this._options().filter((o) =>
!q || String(o.label).toLowerCase().includes(q) || String(o.value).toLowerCase().includes(q));
this._resultsEl.innerHTML = matches.length
? matches.map((o) => {
const v = String(o.value);
const lbl = String(o.label ?? v);
const sel = v === selected;
return `
<div class="node ${sel ? 'sel' : ''}" data-value="${esc(v)}">
<span class="node-lbl"><span class="lbl">${esc(lbl)}</span></span>
${sel ? `<span class="tick">${CHECK}</span>` : ''}
</div>`;
}).join('')
: `<div class="empty-msg">No matching groups</div>`;
this._resultsEl.querySelectorAll('[data-value]').forEach((el) =>
el.addEventListener('click', () => this._select(rowIndex, el.dataset.value)));
}
_select(i, value) {
if (!this._rows[i]) return;
this._rows[i].key = value;
this._openRow = -1;
this._commit();
this._render();
}
_onDocDown(e) {
if (this._openRow < 0) return;
if (this._popoverEl && e.composedPath().includes(this._popoverEl)) return;
const trigger = this.shadowRoot.querySelector(`.row[data-i="${this._openRow}"] [data-act="open"]`);
if (trigger && e.composedPath().includes(trigger)) return;
this._openRow = -1;
this._render();
}
// ─── Row actions ────────────────────────────────────────────────────
_onCycle(i, letter) {
const row = this._rows[i];
if (!row) return;
const current = row.states[letter] || 'unset';
const next = current === 'unset' ? 'allow' : current === 'allow' ? 'deny' : 'unset';
if (next === 'unset') delete row.states[letter];
else row.states[letter] = next;
this._commit();
this._render();
}
_onDelete(i) {
this._rows.splice(i, 1);
// Keep one blank starter row when the list is emptied entirely.
if (!this._rows.length) this._rows.push({ key: '', states: {} });
if (this._openRow === i) this._openRow = -1;
this._commit();
this._render();
}
_onAdd() {
// Add a blank row to type into — but never stack multiple blanks.
this._ensureTrailingBlank();
this._render();
}
}
// ─── Compact ACL string <-> states map ──────────────────────────────────
function parseAcl(raw) {
const states = {};
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
for (const [letter, val] of Object.entries(raw)) {
if (val === true || val === 1 || val === '1') states[letter] = 'allow';
else if (val === false || val === 0 || val === '0') states[letter] = 'deny';
}
return states;
}
let sign = '+';
for (const ch of String(raw ?? '')) {
if (ch === '+' || ch === '-') { sign = ch; continue; }
if (/\s/.test(ch)) continue;
states[ch] = sign === '-' ? 'deny' : 'allow';
}
return states;
}
function serializeAcl(states) {
const order = [...CRUD, ...Object.keys(states).filter((l) => !CRUD.includes(l))];
let out = '';
let cur = '+';
for (const letter of order) {
const st = states[letter];
if (!st) continue;
const sign = st === 'deny' ? '-' : '+';
if (sign !== cur) { out += sign; cur = sign; }
out += letter;
}
return out;
}
// ─── Inline SVG icons ───────────────────────────────────────────────────
const UPDOWN = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="updown"><polyline points="7 15 12 20 17 15"/><polyline points="7 9 12 4 17 9"/></svg>';
const SEARCH = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
const CLOSE = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const CHECK = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const PLUS = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
const TRASH = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
const LOCK = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
const LOCK_OPEN = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>';
function esc(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
const STYLE = `
:host { display: block; font-family: inherit; }
.wrap { display: flex; flex-direction: column; gap: 8px; }
.row { display: flex; align-items: center; gap: 8px; }
.select-wrap { position: relative; flex: 1; min-width: 0; }
.combo-trigger {
display: flex; align-items: center; gap: 8px;
width: 100%; height: 40px; padding: 0 10px 0 12px;
border: 1px solid var(--border, #e2e8f0); border-radius: 8px;
background: var(--muted, #f8fafc); color: var(--foreground, #0f172a);
font-size: 14px; font-family: inherit; cursor: pointer; text-align: start;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.combo-trigger:hover { background: var(--accent, #f1f5f9); }
.combo-trigger .lbl { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.combo-trigger .ph { color: var(--muted-foreground, #94a3b8); }
.combo-trigger .updown { margin-inline-start: auto; flex: none; color: var(--muted-foreground, #64748b); }
.popover {
position: absolute; top: calc(100% + 4px); inset-inline-start: 0;
width: max(100%, 300px); max-width: 92vw; z-index: 60;
background: var(--popover, var(--background, #fff)); color: var(--foreground, #0f172a);
border: 1px solid var(--border, #e2e8f0); border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,.18); overflow: hidden;
}
.search { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border, #e2e8f0); color: var(--muted-foreground, #64748b); }
.search input { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--foreground, #0f172a); font-size: 14px; font-family: inherit; outline: none; }
.search .clear { border: 0; background: transparent; color: var(--muted-foreground, #64748b); cursor: pointer; display: inline-flex; padding: 2px; }
.search .clear:hover { color: var(--foreground, #0f172a); }
.results { max-height: 288px; overflow-y: auto; padding: 4px; }
.node { display: flex; align-items: center; gap: 6px; padding: 7px 10px; border-radius: 8px; cursor: pointer; color: var(--foreground, #0f172a); }
.node:hover { background: var(--accent, #f1f5f9); }
.node.sel { background: color-mix(in srgb, var(--primary, #6366f1) 14%, transparent); color: var(--primary, #6366f1); }
.node-lbl { min-width: 0; }
.node-lbl .lbl { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.node .tick { margin-inline-start: auto; flex: none; display: inline-flex; color: var(--primary, #6366f1); }
.empty-msg { padding: 14px; text-align: center; font-size: 13px; color: var(--muted-foreground, #64748b); }
.crud-group { display: inline-flex; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; overflow: hidden; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.crud { display: inline-flex; align-items: center; gap: 5px; height: 40px; padding: 0 12px; border: 0; cursor: pointer; background: var(--background, #fff); color: var(--muted-foreground, #94a3b8); font-size: 13px; font-weight: 600; font-family: inherit; }
.crud + .crud { border-inline-start: 1px solid var(--border, #e2e8f0); }
.crud svg { flex: none; opacity: .85; }
.crud.unset { color: var(--muted-foreground, #94a3b8); }
.crud.allow { background: #16a34a; color: #fff; }
.crud.deny { background: #dc2626; color: #fff; }
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border: 0; border-radius: 8px; cursor: pointer; background: transparent; color: var(--muted-foreground, #64748b); }
.icon-btn:hover { background: var(--accent, #f1f5f9); color: var(--foreground, #0f172a); }
.icon-btn.add { background: var(--primary, #6366f1); color: #fff; }
.icon-btn.add:hover { filter: brightness(1.05); }
.icon-btn.del:hover { color: #dc2626; }
`;
customElements.define(TAG, AclPermissionsField);
+251
View File
@@ -0,0 +1,251 @@
/**
* `users` field — a reusable, type-ahead, chip-style multiselect of users
* (admin-next custom field).
*
* Drop it into any blueprint to pick accounts filtered by access or group:
*
* header.permissions.authors:
* type: users
* access: api.pages.write # min permission (string or list, any-of)
* # groups: [editors] # group membership (string or list, any-of)
*
* The candidate list is resolved server-side from that config and handed in as
* `field.options` [{ value, label }] (value = username). The component is
* config-agnostic — it just renders whatever options it's given.
*
* Value shape — a plain list of usernames, e.g. ["admin", "claire.danes"] —
* so it's a drop-in for the username text-arrays it replaces.
*/
const TAG = window.__GRAV_FIELD_TAG;
class UsersField extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._field = null;
this._value = null;
this._selected = []; // string[]
this._lastEmitted = null;
this._open = false;
this._filter = '';
this._onDocDown = this._onDocDown.bind(this);
}
set field(v) { this._field = v; this._render(); }
get field() { return this._field; }
set value(v) {
const serialized = JSON.stringify(v ?? []);
if (serialized === this._lastEmitted) return;
this._value = v;
this._selected = Array.isArray(v) ? v.map(String) : [];
this._render();
}
get value() { return this._value; }
connectedCallback() {
document.addEventListener('mousedown', this._onDocDown, true);
this._render();
}
disconnectedCallback() {
document.removeEventListener('mousedown', this._onDocDown, true);
}
// ─── State ──────────────────────────────────────────────────────────
_options() {
const opts = this._field?.options;
return Array.isArray(opts) ? opts : [];
}
_label(value) {
const o = this._options().find((x) => String(x.value) === String(value));
return o ? String(o.label ?? o.value) : String(value);
}
_commit() {
const out = [...this._selected];
this._value = out;
this._lastEmitted = JSON.stringify(out);
this.dispatchEvent(new CustomEvent('change', { detail: out, bubbles: true }));
}
_toggle(username) {
const i = this._selected.indexOf(username);
if (i >= 0) this._selected.splice(i, 1);
else this._selected.push(username);
this._commit();
this._renderControl();
this._renderResults();
}
_remove(username) {
const i = this._selected.indexOf(username);
if (i < 0) return;
this._selected.splice(i, 1);
this._commit();
this._renderControl();
if (this._open) this._renderResults();
}
// ─── Render ─────────────────────────────────────────────────────────
_render() {
if (!this.shadowRoot || !this.isConnected) return;
this.shadowRoot.innerHTML = `<style>${STYLE}</style>
<div class="wrap">
<div class="control"></div>
${this._open ? `
<div class="popover">
<div class="search">${SEARCH}
<input type="text" placeholder="Filter users…" />
<button type="button" class="clear" hidden>${CLOSE}</button>
</div>
<div class="results"></div>
</div>` : ''}
</div>`;
this._controlEl = this.shadowRoot.querySelector('.control');
this._renderControl();
if (this._open) {
this._popoverEl = this.shadowRoot.querySelector('.popover');
this._resultsEl = this.shadowRoot.querySelector('.results');
this._searchInput = this.shadowRoot.querySelector('.search input');
const clearBtn = this.shadowRoot.querySelector('.clear');
this._searchInput.value = this._filter;
clearBtn.hidden = !this._filter;
this._searchInput.addEventListener('input', () => {
this._filter = this._searchInput.value;
clearBtn.hidden = !this._filter;
this._renderResults();
});
this._searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this._close();
});
clearBtn.addEventListener('click', () => {
this._filter = '';
this._searchInput.value = '';
clearBtn.hidden = true;
this._renderResults();
this._searchInput.focus();
});
this._renderResults();
requestAnimationFrame(() => this._searchInput?.focus());
}
}
_renderControl() {
if (!this._controlEl) return;
const chips = this._selected.map((u) => `
<span class="chip">
<span class="chip-lbl">${esc(this._label(u))}</span>
<button type="button" class="chip-x" data-user="${esc(u)}" title="Remove">${CLOSE}</button>
</span>`).join('');
this._controlEl.innerHTML = `
${chips || `<span class="ph">Select authors…</span>`}
<span class="open-chevron">${UPDOWN}</span>`;
this._controlEl.addEventListener('click', (e) => {
if (e.target.closest('.chip-x')) return;
if (!this._open) this._open = true, this._render();
});
this._controlEl.querySelectorAll('.chip-x').forEach((b) =>
b.addEventListener('click', (e) => { e.stopPropagation(); this._remove(b.dataset.user); }));
}
_renderResults() {
if (!this._resultsEl) return;
const q = this._filter.trim().toLowerCase();
const matches = this._options().filter((o) =>
!q || String(o.label).toLowerCase().includes(q) || String(o.value).toLowerCase().includes(q));
this._resultsEl.innerHTML = matches.length
? matches.map((o) => {
const v = String(o.value);
const on = this._selected.includes(v);
return `
<div class="node ${on ? 'sel' : ''}" data-user="${esc(v)}">
<span class="box">${on ? CHECK : ''}</span>
<span class="node-lbl">${esc(String(o.label ?? v))}</span>
</div>`;
}).join('')
: `<div class="empty-msg">No matching users</div>`;
this._resultsEl.querySelectorAll('[data-user]').forEach((el) =>
el.addEventListener('click', () => this._toggle(el.dataset.user)));
}
_close() {
if (!this._open) return;
this._open = false;
this._filter = '';
this._render();
}
_onDocDown(e) {
if (!this._open) return;
const path = e.composedPath();
if ((this._popoverEl && path.includes(this._popoverEl)) ||
(this._controlEl && path.includes(this._controlEl))) return;
this._close();
}
}
// ─── Inline SVG icons ───────────────────────────────────────────────────
const UPDOWN = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 15 12 20 17 15"/><polyline points="7 9 12 4 17 9"/></svg>';
const SEARCH = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
const CLOSE = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const CHECK = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
function esc(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
const STYLE = `
:host { display: block; font-family: inherit; }
.wrap { position: relative; }
.control {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
min-height: 40px; padding: 5px 34px 5px 8px; position: relative;
border: 1px solid var(--border, #e2e8f0); border-radius: 8px;
background: var(--muted, #f8fafc); color: var(--foreground, #0f172a);
cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.control:hover { background: var(--accent, #f1f5f9); }
.ph { color: var(--muted-foreground, #94a3b8); font-size: 14px; padding-inline-start: 4px; }
.open-chevron { position: absolute; inset-inline-end: 10px; top: 50%; transform: translateY(-50%); color: var(--muted-foreground, #64748b); display: flex; pointer-events: none; }
.chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 4px 3px 9px; border-radius: 6px; font-size: 13px; font-weight: 500;
background: color-mix(in srgb, var(--primary, #6366f1) 16%, transparent);
color: var(--primary, #6366f1);
}
.chip-x { display: inline-flex; border: 0; background: transparent; color: inherit; cursor: pointer; padding: 1px; border-radius: 4px; opacity: .8; }
.chip-x:hover { opacity: 1; background: color-mix(in srgb, var(--primary, #6366f1) 25%, transparent); }
.popover {
position: absolute; top: calc(100% + 4px); inset-inline-start: 0;
width: max(100%, 300px); max-width: 92vw; z-index: 60;
background: var(--popover, var(--background, #fff)); color: var(--foreground, #0f172a);
border: 1px solid var(--border, #e2e8f0); border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,.18); overflow: hidden;
}
.search { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border, #e2e8f0); color: var(--muted-foreground, #64748b); }
.search input { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--foreground, #0f172a); font-size: 14px; font-family: inherit; outline: none; }
.search .clear { border: 0; background: transparent; color: var(--muted-foreground, #64748b); cursor: pointer; display: inline-flex; padding: 2px; }
.search .clear:hover { color: var(--foreground, #0f172a); }
.results { max-height: 288px; overflow-y: auto; padding: 4px; }
.node { display: flex; align-items: center; gap: 9px; padding: 7px 10px; border-radius: 8px; cursor: pointer; color: var(--foreground, #0f172a); }
.node:hover { background: var(--accent, #f1f5f9); }
.node.sel { color: var(--primary, #6366f1); }
.box { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex: none; border-radius: 5px; border: 1.5px solid var(--border, #cbd5e1); background: var(--background, #fff); }
.node.sel .box { background: var(--primary, #6366f1); border-color: var(--primary, #6366f1); color: #fff; }
.node-lbl { font-size: 14px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty-msg { padding: 14px; text-align: center; font-size: 13px; color: var(--muted-foreground, #64748b); }
`;
customElements.define(TAG, UsersField);
+328
View File
@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin;
use Composer\Autoload\ClassLoader;
use Grav\Common\Plugin;
use Grav\Common\Processors\Events\RequestHandlerEvent;
use Grav\Common\Utils;
use Grav\Events\PermissionsRegisterEvent;
use Grav\Framework\Acl\PermissionsReader;
use Grav\Plugin\Api\ApiRouter;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Grav\Plugin\Api\Popularity\PopularityTracker;
use Grav\Plugin\Api\Webhooks\WebhookDispatcher;
use RocketTheme\Toolbox\Event\Event;
class ApiPlugin extends Plugin
{
public $features = [
'blueprints' => 1000,
];
protected $active = false;
protected string $base = '';
protected string $apiRoute = '';
public static function getSubscribedEvents(): array
{
return [
'onPluginsInitialized' => [
['setup', 100000],
['onPluginsInitialized', 1001],
],
'onRequestHandlerInit' => [
['onRequestHandlerInit', 99000],
],
'onBeforeCacheClear' => ['onBeforeCacheClear', 0],
PermissionsRegisterEvent::class => ['onRegisterPermissions', 1000],
];
}
public function autoload(): ClassLoader
{
return require __DIR__ . '/vendor/autoload.php';
}
/**
* Early setup - determine if we're on an API route.
*/
public function setup(): void
{
$route = $this->config->get('plugins.api.route');
if (!$route) {
return;
}
$this->base = '/' . trim($route, '/');
$prefix = $this->config->get('plugins.api.version_prefix', 'v1');
$this->apiRoute = $this->base . '/' . $prefix;
$uri = $this->grav['uri'];
$currentPath = $uri->path();
// On subpath installs (e.g. /sync-testing/grav-c) $uri->path() may
// include Grav's base; strip it before testing the api prefix so
// the plugin still activates and the api router gets installed.
$gravBase = rtrim((string)$uri->rootUrl(false), '/');
if ($gravBase !== '' && str_starts_with($currentPath, $gravBase)) {
$currentPath = substr($currentPath, strlen($gravBase)) ?: '/';
}
if (str_starts_with($currentPath, $this->base)) {
$this->active = true;
}
}
public function onPluginsInitialized(): void
{
// Register webhook event listeners (always active, not just on API routes)
$this->registerWebhookListeners();
// Page-view tracking subscribes for FRONTEND requests only — the
// handler itself short-circuits for admin/API/non-page requests.
if (!$this->active && !$this->isAdmin()) {
$this->enable([
'onPageInitialized' => ['onFrontendPageInitialized', 0],
]);
}
if ($this->active) {
// Disable pages processing for API requests - we don't need Twig/templates
$this->grav['pages']->disablePages();
// Register the plugin's templates path so server-side operations
// that need to render Twig (e.g. password reset emails composed
// by AuthController) can find emails/api/*.html.twig.
$this->enable([
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
]);
return;
}
// Handle admin API key tasks and templates
if ($this->isAdmin()) {
// Intercept API key tasks early, before admin's Flex routing
$this->handleAdminApiKeyTask();
$this->enable([
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
'onTwigExtensions' => ['onTwigExtensions', 0],
]);
}
}
/**
* Register Twig function to read API keys from centralized store.
*/
public function onTwigExtensions(): void
{
$manager = new ApiKeyManager();
$this->grav['twig']->twig()->addFunction(
new \Twig\TwigFunction('api_keys_for_user', function (string $username) use ($manager) {
$accounts = $this->grav['accounts'];
$user = $accounts->load($username);
if (!$user->exists()) {
return [];
}
return $manager->listKeys($user);
})
);
}
/**
* Check for and handle API key admin tasks directly.
* This runs before admin's Flex controller, which doesn't fire onAdminTaskExecute.
*/
protected function handleAdminApiKeyTask(): void
{
$uri = $this->grav['uri'];
$task = $uri->param('task') ?? $_POST['task'] ?? null;
if (!$task || !in_array($task, ['apiKeyGenerate', 'apiKeyRevoke'], true)) {
return;
}
// Validate nonce
$nonce = $uri->param('admin-nonce') ?? $_POST['admin-nonce'] ?? null;
if (!$nonce || !Utils::verifyNonce($nonce, 'admin-form')) {
$this->outputJson(['status' => 'error', 'message' => 'Invalid security nonce.']);
}
// Verify admin is logged in
$this->grav['session']->init();
$user = $this->grav['session']->user ?? null;
if (!$user || !$user->authorized || !$user->authorize('admin.login')) {
$this->outputJson(['status' => 'error', 'message' => 'Not authorized.']);
}
match ($task) {
'apiKeyGenerate' => $this->handleApiKeyGenerate(),
'apiKeyRevoke' => $this->handleApiKeyRevoke(),
};
}
protected function handleApiKeyGenerate(): void
{
$post = $_POST;
$username = $this->getAdminRouteUsername();
if (!$username) {
$this->outputJson(['status' => 'error', 'message' => 'Could not determine username.']);
}
$user = $this->grav['accounts']->load($username);
if (!$user->exists()) {
$this->outputJson(['status' => 'error', 'message' => "User '{$username}' not found."]);
}
$name = $post['name'] ?? 'API Key';
$expiryDays = !empty($post['expiry_days']) ? (int) $post['expiry_days'] : null;
$manager = new ApiKeyManager();
$result = $manager->generateKey($user, $name, [], $expiryDays);
$this->outputJson([
'status' => 'success',
'key' => $result['key'],
'id' => $result['id'],
'message' => 'API key generated successfully.',
]);
}
protected function handleApiKeyRevoke(): void
{
$post = $_POST;
$keyId = $post['key_id'] ?? '';
$username = $this->getAdminRouteUsername();
if (!$username || !$keyId) {
$this->outputJson(['status' => 'error', 'message' => 'Missing parameters.']);
}
$user = $this->grav['accounts']->load($username);
if (!$user->exists()) {
$this->outputJson(['status' => 'error', 'message' => "User '{$username}' not found."]);
}
$manager = new ApiKeyManager();
$revoked = $manager->revokeKey($user, $keyId);
$this->outputJson([
'status' => $revoked ? 'success' : 'error',
'message' => $revoked ? 'API key revoked.' : 'API key not found.',
]);
}
/**
* Output JSON and terminate. Used for admin AJAX tasks.
*/
protected function outputJson(array $data): never
{
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode($data);
exit;
}
/**
* Extract username from admin route (e.g. /admin/accounts/admin)
*/
protected function getAdminRouteUsername(): ?string
{
$uri = $this->grav['uri'];
$path = $uri->path();
if (preg_match('#/(?:accounts|user)/([^/]+)#', $path, $matches)) {
return $matches[1];
}
return null;
}
/**
* Register plugin templates so admin can find the api_keys field type.
*/
public function onTwigTemplatePaths(): void
{
$this->grav['twig']->twig_paths[] = __DIR__ . '/templates';
}
/**
* Register the API router middleware into Grav's request pipeline.
*/
public function onRequestHandlerInit(RequestHandlerEvent $event): void
{
if (!$this->active) {
return;
}
$route = $event->getRoute();
$path = $route->getRoute();
if (str_starts_with($path, $this->base)) {
$event->addMiddleware('api_router', new ApiRouter($this->grav, $this->config));
}
}
/**
* Register webhook event listeners for all API mutation events.
*/
protected function registerWebhookListeners(): void
{
$events = WebhookDispatcher::getSubscribedEvents();
/** @var \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher */
$eventDispatcher = $this->grav['events'];
$webhookDispatcher = null;
foreach ($events as $eventName => [$method, $priority]) {
$eventDispatcher->addListener($eventName, function (Event $event) use ($eventName, &$webhookDispatcher) {
// Lazy-load dispatcher only when first event fires
if ($webhookDispatcher === null) {
$webhookDispatcher = new WebhookDispatcher();
}
$webhookDispatcher->dispatch($eventName, $event->toArray());
}, $priority);
}
}
/**
* Register API-specific permissions.
*/
/**
* Clear the API route cache when Grav cache is cleared.
*/
public function onBeforeCacheClear(\RocketTheme\Toolbox\Event\Event $event): void
{
$locator = $this->grav['locator'];
$cacheDir = $locator->findResource('cache://', true);
if ($cacheDir) {
$apiCachePath = $cacheDir . '/api';
if (is_dir($apiCachePath)) {
$paths = $event['paths'] ?? [];
$paths[] = $apiCachePath;
$event['paths'] = $paths;
}
}
}
public function onRegisterPermissions(PermissionsRegisterEvent $event): void
{
$actions = PermissionsReader::fromYaml("plugin://{$this->name}/permissions.yaml");
$event->permissions->addActions($actions);
}
/**
* Track a frontend page view. Replaces admin-classic's Popularity
* tracker so popularity stats keep working in admin-next-only installs.
*/
public function onFrontendPageInitialized(): void
{
(new PopularityTracker())->trackHit();
}
}
+67
View File
@@ -0,0 +1,67 @@
enabled: true
route: /api
version_prefix: v1
auth:
api_keys_enabled: true
jwt_enabled: true
jwt_secret: ''
jwt_algorithm: HS256
jwt_expiry: 3600
jwt_refresh_expiry: 604800
session_enabled: true
cors:
enabled: true
origins:
- '*'
methods:
- GET
- POST
- PATCH
- DELETE
- OPTIONS
headers:
- Content-Type
- Authorization
- X-API-Key
- X-API-Token
- X-Grav-Environment
- If-Match
- If-None-Match
expose_headers:
- ETag
- X-Invalidates
- X-RateLimit-Limit
- X-RateLimit-Remaining
- X-RateLimit-Reset
max_age: 86400
credentials: false
rate_limit:
enabled: true
requests: 120
window: 60
storage: file
flex_backend:
pages: true
accounts: true
pagination:
default_per_page: 20
max_per_page: 1000
invitations:
# Default lifetime of a user invite link, in seconds (default 7 days).
expiration: 604800
popularity:
enabled: true
history:
daily: 30
monthly: 12
visitors: 20
ignore:
- '/test*'
- '/modular'
+222
View File
@@ -0,0 +1,222 @@
name: API
slug: api
type: plugin
version: 1.0.0-rc.15
description: RESTful API for Grav CMS. Provides headless access to pages, media, configuration, users, and system management.
icon: plug
author:
name: Team Grav
email: devs@getgrav.org
url: https://getgrav.org
homepage: https://github.com/getgrav/grav-plugin-api
keywords: api, rest, headless, json
bugs: https://github.com/getgrav/grav-plugin-api/issues
docs: https://learn.getgrav.org/api
license: MIT
compatibility:
grav: ["2.0"]
dependencies:
- { name: grav, version: ">=2.0.0-rc.9" }
- { name: login, version: ">=3.8.3" }
form:
validation: loose
fields:
enabled:
type: toggle
label: Plugin Status
highlight: 1
default: 1
options:
1: Enabled
0: Disabled
validate:
type: bool
section_general:
type: section
title: General Settings
underline: true
route:
type: text
label: API Route
help: The base route for the API
default: /api
validate:
type: text
version_prefix:
type: text
label: Version Prefix
help: Current API version prefix
default: v1
validate:
type: text
section_auth:
type: section
title: Authentication
underline: true
auth.api_keys_enabled:
type: toggle
label: API Key Authentication
highlight: 1
default: 1
options:
1: Enabled
0: Disabled
validate:
type: bool
auth.jwt_enabled:
type: toggle
label: JWT Authentication
highlight: 1
default: 1
options:
1: Enabled
0: Disabled
validate:
type: bool
auth.jwt_expiry:
type: text
label: JWT Access Token Expiry
help: Access token lifetime in seconds
default: 3600
validate:
type: int
auth.jwt_refresh_expiry:
type: text
label: JWT Refresh Token Expiry
help: Refresh token lifetime in seconds
default: 604800
validate:
type: int
auth.session_enabled:
type: toggle
label: Session Authentication
help: Allow existing admin sessions to access the API
highlight: 1
default: 1
options:
1: Enabled
0: Disabled
validate:
type: bool
section_cors:
type: section
title: CORS Settings
underline: true
cors.enabled:
type: toggle
label: Enable CORS
highlight: 1
default: 1
options:
1: Enabled
0: Disabled
validate:
type: bool
cors.origins:
type: array
label: Allowed Origins
help: List of allowed origins. Use * for all origins.
default:
- "*"
value_only: true
cors.methods:
type: selectize
label: Allowed Methods
help: HTTP methods allowed for CORS requests
default:
- GET
- POST
- PATCH
- DELETE
- OPTIONS
multiple: true
validate:
type: commalist
cors.headers:
type: array
label: Allowed Headers
default:
- Content-Type
- Authorization
- X-API-Key
- X-Grav-Environment
- If-Match
- If-None-Match
value_only: true
section_backend:
type: section
title: Backend
underline: true
flex_backend.pages:
type: toggle
label: Flex Pages Backend
help: Use Flex-Objects for page listings (faster search, filtering, and pagination)
highlight: 1
default: 1
options:
1: Enabled
0: Disabled
validate:
type: bool
flex_backend.accounts:
type: toggle
label: Flex Accounts Backend
help: Use Flex-Objects for user listings (faster search and pagination)
highlight: 1
default: 1
options:
1: Enabled
0: Disabled
validate:
type: bool
section_rate_limit:
type: section
title: Rate Limiting
underline: true
rate_limit.enabled:
type: toggle
label: Enable Rate Limiting
highlight: 1
default: 1
options:
1: Enabled
0: Disabled
validate:
type: bool
rate_limit.requests:
type: text
label: Requests Per Window
help: Maximum number of requests per time window
default: 120
validate:
type: int
rate_limit.window:
type: text
label: Time Window
help: Rate limit window in seconds
default: 60
validate:
type: int
+19
View File
@@ -0,0 +1,19 @@
extends@:
type: user/account
context: system://blueprints
form:
fields:
api_check:
type: conditional
condition: config.plugins.api.enabled
fields:
api_section:
title: API Keys
type: section
underline: true
api_keys_display:
type: api_keys
label: false
+289
View File
@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api;
use Grav\Common\Data\Blueprints;
use Grav\Common\Grav;
use Grav\Common\Language\Language;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Pages;
use Grav\Common\User\Interfaces\UserInterface;
/**
* Lightweight admin proxy registered as $grav['admin'] during API requests.
*
* Grav core checks `isset($grav['admin'])` in multiple places to alter
* behavior: page routing/visibility, Flex authorization scope, blueprint
* field handling, and event firing. Without this proxy, API-driven changes
* operate in "site" scope rather than "admin" scope, causing subtle bugs:
*
* - Non-routable/hidden pages invisible to API (Pages.php:1047)
* - Flex onAdminSave/AfterSave events don't fire (FlexGravTrait.php:60)
* - Blueprint edit mode not set (Page.php:1261)
* - Flex authorization uses 'site' scope instead of 'admin' (FlexObject.php)
* - Plugins checking isAdmin() return false
*
* This class implements the minimum interface that Grav core actually calls
* on $grav['admin'], without pulling in the full admin plugin dependency.
*/
class AdminProxy
{
/** @var string Admin base route (not applicable for API, but required by getRouteDetails) */
public string $base = '';
/** @var string Current location segment */
public string $location = '';
/** @var string Current route */
public string $route = '';
/** @var UserInterface The authenticated API user */
public UserInterface $user;
/** @var bool Whether multilang is enabled */
public bool $multilang = false;
/** @var string Active language */
public string $language = '';
/** @var string[] Enabled languages */
public array $languages_enabled = [];
/** @var array<int, array{message: string, scope: string}> Queued temp messages */
public array $temp_messages = [];
private Grav $grav;
private ?Blueprints $blueprintsLoader = null;
/** @var array<string, PageInterface|null> Page cache */
private array $pages = [];
public function __construct(Grav $grav, UserInterface $user)
{
$this->grav = $grav;
$this->user = $user;
/** @var Language $language */
$language = $grav['language'];
$this->multilang = $language->enabled();
if ($this->multilang) {
$this->language = $language->getActive() ?? '';
$this->languages_enabled = (array) $grav['config']->get('system.languages.supported', []);
}
}
/**
* Register this proxy as $grav['admin'].
*/
public function register(): void
{
$this->grav['admin'] = $this;
}
/**
* Get the current admin page (used by Pages.php and Plugin.php).
*
* In API context there's no "current admin page" being edited, so this
* returns the page at the current route if set, or null.
*/
public function page($route = false, $path = null): ?PageInterface
{
if (!$path) {
$path = $this->route;
}
if ($route && !$path) {
$path = '/';
}
if (!$path) {
return null;
}
if (!isset($this->pages[$path])) {
$this->pages[$path] = $this->getPage($path);
}
return $this->pages[$path];
}
/**
* Find a page by path (used by Pages.php for parent resolution).
*/
public function getPage(string $path): ?PageInterface
{
$pages = self::enablePages();
if ($path && $path[0] !== '/') {
$path = "/{$path}";
}
$path = urldecode($path);
return $path ? $pages->find($path, true) : $pages->root();
}
/**
* Return route details as [base, location, route] tuple.
*
* Used by Pages.php and AccountsServiceProvider.php to determine
* which admin section is active. For API requests, we return empty
* values since there's no admin page navigation happening.
*/
public function getRouteDetails(): array
{
return [$this->base, $this->location, $this->route];
}
/**
* Load a blueprint by type (used by Flex PageObject).
*/
public function blueprints(string $type)
{
if ($this->blueprintsLoader === null) {
$this->blueprintsLoader = new Blueprints('blueprints://');
}
return $this->blueprintsLoader->get($type);
}
/**
* Translate a string using Grav's language system.
*
* This is a static method in the real Admin class, but core calls it
* on the instance via $grav['admin']->translate().
*
* @param array|string $args
* @param array|string|null $languages
* @return string
*/
public static function translate($args, $languages = null): string
{
$grav = Grav::instance();
if (is_array($args)) {
$lookup = array_shift($args);
} else {
$lookup = $args;
$args = [];
}
if (!$languages) {
if ($grav['config']->get('system.languages.translations_fallback', true)) {
$languages = $grav['language']->getFallbackLanguages();
} else {
$languages = (array) $grav['language']->getDefault();
}
if (isset($grav['user']) && $grav['user']->authenticated) {
$languages = [$grav['user']->language];
}
} else {
$languages = (array) $languages;
}
foreach ((array) $languages as $lang) {
$translation = $grav['language']->getTranslation($lang, $lookup, true);
if (!$translation) {
$language = $grav['language']->getDefault() ?: 'en';
$translation = $grav['language']->getTranslation($language, $lookup, true);
}
if (!$translation) {
$translation = $grav['language']->getTranslation('en', $lookup, true);
}
if ($translation) {
if (count($args) >= 1) {
return vsprintf($translation, $args);
}
return $translation;
}
}
return $lookup;
}
/**
* Add a flash message to the session queue.
*
* Mirrors Admin::setMessage(). Admin-aware plugins routinely call this from
* onAdminSave/onAdminAfterSave handlers (e.g. to report generated image
* derivatives). The core `messages` service always resolves — returning a
* transient Messages instance when there's no active session — so queuing
* here is harmless under the API and simply discarded after the request.
*
* @param string $msg
* @param string $type
* @return void
*/
public function setMessage($msg, $type = 'info'): void
{
$messages = $this->grav['messages'];
$messages->add($msg, $type);
}
/**
* Fetch and clear messages from the session queue.
*
* Mirrors Admin::messages().
*
* @param string|null $type
* @return array
*/
public function messages($type = null): array
{
$messages = $this->grav['messages'];
return $messages->fetch($type);
}
/**
* Queue a temporary message.
*
* Mirrors Admin::addTempMessage().
*
* @param string $msg
* @param string $type
* @return void
*/
public function addTempMessage($msg, $type): void
{
$this->temp_messages[] = ['message' => $msg, 'scope' => $type];
}
/**
* Return queued temporary messages.
*
* Mirrors Admin::getTempMessages().
*
* @return array
*/
public function getTempMessages(): array
{
return $this->temp_messages;
}
/**
* Enable and return the Pages service.
*
* Mirrors Admin::enablePages() — ensures pages are initialized
* (they are disabled by default during API requests for performance).
*/
public static function enablePages(): Pages
{
static $pages;
if ($pages) {
return $pages;
}
$grav = Grav::instance();
/** @var Pages $pages */
$pages = $grav['pages'];
$pages->enablePages();
return $pages;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api;
use FastRoute\RouteCollector;
/**
* Wrapper around FastRoute's RouteCollector that provides a clean API
* for plugins to register their own API routes.
*
* Usage in a plugin:
* public function onApiRegisterRoutes(Event $event) {
* $routes = $event['routes'];
* $routes->get('/comments', [CommentsController::class, 'index']);
* $routes->post('/comments', [CommentsController::class, 'create']);
* $routes->group('/webhooks', function(ApiRouteCollector $group) {
* $group->get('', [WebhookController::class, 'index']);
* $group->post('', [WebhookController::class, 'create']);
* });
* }
*/
class ApiRouteCollector
{
protected string $prefix = '';
public function __construct(
protected readonly RouteCollector $collector,
) {}
public function get(string $route, array $handler): self
{
$this->collector->addRoute('GET', $this->prefix . $route, $handler);
return $this;
}
public function post(string $route, array $handler): self
{
$this->collector->addRoute('POST', $this->prefix . $route, $handler);
return $this;
}
public function patch(string $route, array $handler): self
{
$this->collector->addRoute('PATCH', $this->prefix . $route, $handler);
return $this;
}
public function delete(string $route, array $handler): self
{
$this->collector->addRoute('DELETE', $this->prefix . $route, $handler);
return $this;
}
public function put(string $route, array $handler): self
{
$this->collector->addRoute('PUT', $this->prefix . $route, $handler);
return $this;
}
public function addRoute(string|array $methods, string $route, array $handler): self
{
$this->collector->addRoute($methods, $this->prefix . $route, $handler);
return $this;
}
/**
* Register a group of routes under a shared prefix.
*/
public function group(string $prefix, callable $callback): self
{
$group = new self($this->collector);
$group->prefix = $this->prefix . $prefix;
$callback($group);
return $this;
}
}
+590
View File
@@ -0,0 +1,590 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api;
use FastRoute\Dispatcher;
use FastRoute\RouteCollector;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\Processors\ProcessorBase;
use Grav\Framework\Psr7\Response;
use Grav\Plugin\Api\Controllers\AuthController;
use Grav\Plugin\Api\Controllers\BlueprintController;
use Grav\Plugin\Api\Controllers\BlueprintFilesController;
use Grav\Plugin\Api\Controllers\BlueprintUploadController;
use Grav\Plugin\Api\Controllers\ConfigController;
use Grav\Plugin\Api\Controllers\DashboardController;
use Grav\Plugin\Api\Controllers\DashboardWidgetController;
use Grav\Plugin\Api\Controllers\GpmController;
use Grav\Plugin\Api\Controllers\MediaController;
use Grav\Plugin\Api\Controllers\SchedulerController;
use Grav\Plugin\Api\Controllers\PagesController;
use Grav\Plugin\Api\Controllers\PreferencesController;
use Grav\Plugin\Api\Controllers\ReportsController;
use Grav\Plugin\Api\Controllers\MenubarController;
use Grav\Plugin\Api\Controllers\PasswordPolicyController;
use Grav\Plugin\Api\Controllers\SettingsController;
use Grav\Plugin\Api\Controllers\SetupController;
use Grav\Plugin\Api\Controllers\SidebarController;
use Grav\Plugin\Api\Controllers\FloatingWidgetController;
use Grav\Plugin\Api\Controllers\ContextPanelController;
use Grav\Plugin\Api\Controllers\SystemController;
use Grav\Plugin\Api\Controllers\UsersController;
use Grav\Plugin\Api\Controllers\GroupsController;
use Grav\Plugin\Api\Controllers\InvitationsController;
use Grav\Plugin\Api\Controllers\AccountsConfigController;
use Grav\Plugin\Api\Controllers\WebhookController;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Middleware\AuthMiddleware;
use Grav\Plugin\Api\Middleware\CorsMiddleware;
use Grav\Plugin\Api\Middleware\JsonBodyParserMiddleware;
use Grav\Plugin\Api\Middleware\MethodOverrideMiddleware;
use Grav\Plugin\Api\Middleware\RateLimitMiddleware;
use Grav\Plugin\Api\Response\ErrorResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use RocketTheme\Toolbox\Event\Event;
use Throwable;
use function FastRoute\cachedDispatcher;
class ApiRouter extends ProcessorBase
{
public $id = 'api_router';
public $title = 'API Router';
protected Config $config;
/** @var array<int,string>|null Cached public-route prefixes after plugin contributions. */
protected ?array $publicPrefixes = null;
/** @var array<int,string>|null Cached public-route exact paths after plugin contributions. */
protected ?array $publicExact = null;
public function __construct(Grav $container, Config $config)
{
parent::__construct($container);
$this->config = $config;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->startTimer();
try {
// Run through API middleware chain
$request = (new JsonBodyParserMiddleware())->processRequest($request);
$request = (new CorsMiddleware($this->config))->processRequest($request);
// Must run before routing so dispatch sees the overridden method.
$request = (new MethodOverrideMiddleware())->processRequest($request);
// Handle CORS preflight
if ($request->getMethod() === 'OPTIONS') {
return (new CorsMiddleware($this->config))->createPreflightResponse();
}
// Require and apply Grav environment
$this->applyEnvironment($request);
// Authenticate (skip for public endpoints - use Grav route which is subdirectory-safe)
$route = $request->getAttribute('route');
$routePath = $route ? $route->getRoute() : '';
$base = $this->config->get('plugins.api.route', '/api');
$prefix = $this->config->get('plugins.api.version_prefix', 'v1');
$apiBase = '/' . trim($base, '/') . '/' . $prefix;
$publicPrefixes = [
$apiBase . '/auth/',
$apiBase . '/translations/',
$apiBase . '/thumbnails/',
];
$publicExact = [
$apiBase . '/ping',
];
// Let plugins contribute additional public routes. The event fires once and
// its result is cached on this ApiRouter instance.
if ($this->publicPrefixes === null) {
$event = new Event([
'api_base' => $apiBase,
'prefixes' => $publicPrefixes,
'exact' => $publicExact,
]);
$this->container->fireEvent('onApiCollectPublicRoutes', $event);
$this->publicPrefixes = (array) $event['prefixes'];
$this->publicExact = (array) $event['exact'];
}
$publicPrefixes = $this->publicPrefixes;
$publicExact = $this->publicExact;
// Entries may be method-scoped as "METHOD /path" (e.g. "GET /api/v1/foo/")
// so plugins can expose public reads while writes on the same paths
// still require authentication. Method-less entries match all methods.
$method = $request->getMethod();
$matches = static function (string $entry, bool $prefix) use ($method, $routePath): bool {
$entryMethod = null;
if (str_contains($entry, ' ')) {
[$entryMethod, $entry] = explode(' ', $entry, 2);
}
if ($entryMethod !== null && strcasecmp($entryMethod, $method) !== 0) {
return false;
}
return $prefix ? str_starts_with($routePath, $entry) : $routePath === $entry;
};
$isPublic = false;
foreach ($publicExact as $entry) {
if ($matches($entry, false)) {
$isPublic = true;
break;
}
}
if (!$isPublic) {
foreach ($publicPrefixes as $entry) {
if ($matches($entry, true)) {
$isPublic = true;
break;
}
}
}
if (!$isPublic) {
$request = (new AuthMiddleware($this->container, $this->config))->processRequest($request);
} else {
// Optimistic auth: public endpoints still see the caller when
// credentials are supplied (richer, permission-filtered
// responses); anonymous callers continue as guests.
$request = (new AuthMiddleware($this->container, $this->config))->processOptional($request);
}
// Register admin proxy so Grav core treats API requests as
// admin-scoped (page visibility, Flex auth scope, events, etc.)
$user = $request->getAttribute('api_user');
if ($user && !isset($this->container['admin'])) {
(new AdminProxy($this->container, $user))->register();
}
// Rate limit (after auth so we can rate limit per-user)
$rateLimitResult = (new RateLimitMiddleware($this->config))->check($request);
if ($rateLimitResult['limited']) {
$response = ErrorResponse::create(429, 'Too Many Requests', 'Rate limit exceeded. Try again later.');
return $this->addRateLimitHeaders($response, $rateLimitResult);
}
// Dispatch the route
$response = $this->dispatch($request);
// Add rate limit headers to successful responses
$response = $this->addRateLimitHeaders($response, $rateLimitResult);
// Add CORS headers to response
$response = (new CorsMiddleware($this->config))->addHeaders($request, $response);
} catch (ApiException $e) {
$response = ErrorResponse::fromException($e);
if (isset($rateLimitResult)) {
$response = $this->addRateLimitHeaders($response, $rateLimitResult);
}
// CORS headers on error responses so browsers don't block them
$response = (new CorsMiddleware($this->config))->addHeaders($request, $response);
} catch (Throwable $e) {
$this->container['log']->error('API unhandled exception: ' . $e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
$response = ErrorResponse::create(
500,
'Internal Server Error',
$this->config->get('system.debugger.enabled') ? $e->getMessage() : 'An unexpected error occurred.'
);
// CORS headers on error responses so browsers don't block them
$response = (new CorsMiddleware($this->config))->addHeaders($request, $response);
}
$this->stopTimer();
return $response;
}
protected function dispatch(ServerRequestInterface $request): ResponseInterface
{
$dispatcher = $this->createDispatcher();
$base = $this->config->get('plugins.api.route', '/api');
$prefix = $this->config->get('plugins.api.version_prefix', 'v1');
$basePath = '/' . trim($base, '/') . '/' . $prefix;
// Use Grav's route (base-path-stripped) not the raw URI
$route = $request->getAttribute('route');
$gravPath = $route ? $route->getRoute() : $request->getUri()->getPath();
// Grav's Uri::init strips trailing extensions that match a registered
// page type (e.g. .md, .txt, .html) before the route is built. Without
// this re-attach, `DELETE /api/v1/media/notes.txt` would arrive as
// `/media/notes` and 404.
if ($route) {
$extension = (string)$route->getExtension();
if ($extension !== '' && !str_ends_with($gravPath, '.' . $extension)) {
$gravPath .= '.' . $extension;
}
}
// On subpath installs (e.g. /sync-testing/grav-c) the PSR-7 URI
// path includes Grav's base; strip it so substr below cleanly peels
// off `$basePath` to leave just the route path.
$gravBase = rtrim((string)$this->container['uri']->rootUrl(false), '/');
if ($gravBase !== '' && str_starts_with($gravPath, $gravBase)) {
$gravPath = substr($gravPath, strlen($gravBase)) ?: '/';
}
$routePath = substr($gravPath, strlen($basePath)) ?: '/';
// Ensure leading slash
if (!str_starts_with($routePath, '/')) {
$routePath = '/' . $routePath;
}
$method = $request->getMethod();
$routeInfo = $dispatcher->dispatch($method, $routePath);
return match ($routeInfo[0]) {
Dispatcher::NOT_FOUND => ErrorResponse::create(404, 'Not Found', "No route matches '{$method} {$routePath}'."),
Dispatcher::METHOD_NOT_ALLOWED => ErrorResponse::create(
405,
'Method Not Allowed',
"Method '{$method}' is not allowed. Allowed: " . implode(', ', $routeInfo[1]) . '.',
['Allow' => implode(', ', $routeInfo[1])]
),
Dispatcher::FOUND => $this->handleRoute($request, $routeInfo[1], $routeInfo[2]),
};
}
protected function handleRoute(ServerRequestInterface $request, array $handler, array $vars): ResponseInterface
{
[$controllerClass, $method] = $handler;
$controller = new $controllerClass($this->container, $this->config);
// Grav builds route paths from parse_url() which does not decode
// percent-escaped octets, so captured params still contain raw %xx
// sequences (e.g. "imäge1.png" arrives as "im%C3%A4ge1.png").
// Decode once here so every controller sees real filenames.
$vars = array_map(
static fn($v) => is_string($v) ? rawurldecode($v) : $v,
$vars
);
$request = $request->withAttribute('route_params', $vars);
return $controller->$method($request);
}
protected function createDispatcher(): Dispatcher
{
$cacheFile = $this->container['locator']->findResource('cache://api', true, true) . '/route.cache';
$cacheDisabled = $this->config->get('system.debugger.enabled', false);
return cachedDispatcher(function (RouteCollector $r) {
$this->registerCoreRoutes($r);
$this->registerPluginRoutes($r);
}, [
'cacheFile' => $cacheFile,
'cacheDisabled' => $cacheDisabled,
]);
}
protected function registerCoreRoutes(RouteCollector $r): void
{
// Auth (no auth required for these)
$r->addRoute('POST', '/auth/token', [AuthController::class, 'token']);
$r->addRoute('POST', '/auth/2fa/verify', [AuthController::class, 'verify2fa']);
$r->addRoute('POST', '/auth/refresh', [AuthController::class, 'refresh']);
$r->addRoute('POST', '/auth/revoke', [AuthController::class, 'revoke']);
$r->addRoute('POST', '/auth/forgot-password', [AuthController::class, 'forgotPassword']);
$r->addRoute('POST', '/auth/reset-password', [AuthController::class, 'resetPassword']);
// Invitation acceptance (public — under /auth/ so it inherits the
// public-route prefix; the token is the only credential needed).
$r->addRoute('GET', '/auth/invite/{token}', [InvitationsController::class, 'validate']);
$r->addRoute('POST', '/auth/invite/{token}', [InvitationsController::class, 'accept']);
$r->addRoute('GET', '/auth/setup', [SetupController::class, 'status']);
$r->addRoute('POST', '/auth/setup', [SetupController::class, 'create']);
$r->addRoute('GET', '/auth/password-policy', [PasswordPolicyController::class, 'show']);
// Current user profile + resolved permissions (protected — auth required)
$r->addRoute('GET', '/me', [AuthController::class, 'me']);
// Languages
$r->addRoute('GET', '/languages', [PagesController::class, 'siteLanguages']);
// Pages
$r->addRoute('GET', '/pages', [PagesController::class, 'index']);
$r->addRoute('POST', '/pages', [PagesController::class, 'create']);
$r->addRoute('POST', '/pages/batch', [PagesController::class, 'batch']);
$r->addRoute('POST', '/pages/reorganize', [PagesController::class, 'reorganize']);
$r->addRoute('GET', '/pages/{route:.+}/languages', [PagesController::class, 'languages']);
$r->addRoute('POST', '/pages/{route:.+}/translate', [PagesController::class, 'translate']);
$r->addRoute('POST', '/pages/{route:.+}/adopt-language', [PagesController::class, 'adoptLanguage']);
$r->addRoute('POST', '/pages/{route:.+}/sync', [PagesController::class, 'sync']);
$r->addRoute('GET', '/pages/{route:.+}/compare', [PagesController::class, 'compare']);
$r->addRoute('POST', '/pages/{route:.+}/reorder', [PagesController::class, 'reorder']);
$r->addRoute('GET', '/pages/{route:.+}/media', [MediaController::class, 'pageMedia']);
$r->addRoute('POST', '/pages/{route:.+}/media', [MediaController::class, 'uploadPageMedia']);
$r->addRoute('DELETE', '/pages/{route:.+}/media/{filename}', [MediaController::class, 'deletePageMedia']);
$r->addRoute('POST', '/pages/{route:.+}/move', [PagesController::class, 'move']);
$r->addRoute('POST', '/pages/{route:.+}/copy', [PagesController::class, 'copy']);
$r->addRoute('GET', '/pages/{route:.+}', [PagesController::class, 'show']);
$r->addRoute('PATCH', '/pages/{route:.+}', [PagesController::class, 'update']);
$r->addRoute('DELETE', '/pages/{route:.+}', [PagesController::class, 'delete']);
// Thumbnails
$r->addRoute('GET', '/thumbnails/{file:.+}', [MediaController::class, 'thumbnail']);
// Destination-aware blueprint file-field uploads (theme/plugin/user
// custom file fields that specify `destination:` in their blueprint).
$r->addRoute('POST', '/blueprint-upload', [BlueprintUploadController::class, 'upload']);
$r->addRoute('DELETE', '/blueprint-upload', [BlueprintUploadController::class, 'delete']);
// Read-only browse for blueprint `folder:` fields (filepicker, mediapicker, …)
// — any Grav stream, `self@:` token, or relative path under user/.
$r->addRoute('GET', '/blueprint-files', [BlueprintFilesController::class, 'list']);
// Site-level media
$r->addRoute('GET', '/media', [MediaController::class, 'siteMedia']);
$r->addRoute('POST', '/media', [MediaController::class, 'uploadSiteMedia']);
$r->addRoute('POST', '/media/folders', [MediaController::class, 'createFolder']);
$r->addRoute('POST', '/media/rename', [MediaController::class, 'renameFile']);
$r->addRoute('POST', '/media/folders/rename', [MediaController::class, 'renameFolder']);
$r->addRoute('DELETE', '/media/folders/{path:.+}', [MediaController::class, 'deleteFolder']);
$r->addRoute('DELETE', '/media/{filename:.+}', [MediaController::class, 'deleteSiteMedia']);
// Taxonomy
$r->addRoute('GET', '/taxonomy', [PagesController::class, 'taxonomy']);
// Config
$r->addRoute('GET', '/config', [ConfigController::class, 'index']);
// Static config routes must be registered BEFORE the variable
// /config/{scope:.+} route below — FastRoute rejects statics that
// would be shadowed by an earlier-defined variable on the same path.
$r->addRoute('GET', '/config/accounts', [AccountsConfigController::class, 'show']);
$r->addRoute('PATCH', '/config/accounts', [AccountsConfigController::class, 'update']);
$r->addRoute('POST', '/config/{scope:.+}/revert', [ConfigController::class, 'revert']);
$r->addRoute('GET', '/config/{scope:.+}', [ConfigController::class, 'show']);
$r->addRoute('PATCH', '/config/{scope:.+}', [ConfigController::class, 'update']);
// Users
$r->addRoute('GET', '/users', [UsersController::class, 'index']);
$r->addRoute('POST', '/users', [UsersController::class, 'create']);
$r->addRoute('GET', '/users/{username}', [UsersController::class, 'show']);
$r->addRoute('PATCH', '/users/{username}', [UsersController::class, 'update']);
$r->addRoute('DELETE', '/users/{username}', [UsersController::class, 'delete']);
$r->addRoute('POST', '/users/{username}/avatar', [UsersController::class, 'uploadAvatar']);
$r->addRoute('DELETE', '/users/{username}/avatar', [UsersController::class, 'deleteAvatar']);
$r->addRoute('POST', '/users/{username}/2fa', [UsersController::class, 'generate2fa']);
$r->addRoute('POST', '/users/{username}/2fa/enable', [UsersController::class, 'enable2fa']);
$r->addRoute('POST', '/users/{username}/2fa/disable', [UsersController::class, 'disable2fa']);
$r->addRoute('GET', '/users/{username}/api-keys', [UsersController::class, 'apiKeys']);
$r->addRoute('POST', '/users/{username}/api-keys', [UsersController::class, 'createApiKey']);
$r->addRoute('DELETE', '/users/{username}/api-keys/{keyId}', [UsersController::class, 'deleteApiKey']);
// Groups
$r->addRoute('GET', '/groups', [GroupsController::class, 'index']);
$r->addRoute('POST', '/groups', [GroupsController::class, 'create']);
$r->addRoute('GET', '/groups/{name}', [GroupsController::class, 'show']);
$r->addRoute('PATCH', '/groups/{name}', [GroupsController::class, 'update']);
$r->addRoute('DELETE', '/groups/{name}', [GroupsController::class, 'delete']);
// Invitations (admin). Top-level path (not /users/...) so it never
// collides with the GET /users/{username} catch-all.
$r->addRoute('GET', '/invitations', [InvitationsController::class, 'index']);
$r->addRoute('POST', '/invitations', [InvitationsController::class, 'create']);
$r->addRoute('DELETE', '/invitations/{token}', [InvitationsController::class, 'delete']);
$r->addRoute('POST', '/invitations/{token}/resend', [InvitationsController::class, 'resend']);
// Custom fields discovery (all plugins/themes)
$r->addRoute('GET', '/custom-fields', [GpmController::class, 'allCustomFields']);
// GPM (Package Manager)
$r->addRoute('GET', '/gpm/plugins', [GpmController::class, 'plugins']);
$r->addRoute('GET', '/gpm/plugins/{slug}', [GpmController::class, 'plugin']);
$r->addRoute('GET', '/gpm/plugins/{slug}/readme', [GpmController::class, 'readme']);
$r->addRoute('GET', '/gpm/plugins/{slug}/changelog', [GpmController::class, 'changelog']);
$r->addRoute('GET', '/gpm/plugins/{slug}/field/{type}', [GpmController::class, 'customFieldScript']);
$r->addRoute('GET', '/gpm/plugins/{slug}/page', [GpmController::class, 'pluginPage']);
$r->addRoute('GET', '/gpm/plugins/{slug}/page-script', [GpmController::class, 'customPageScript']);
$r->addRoute('GET', '/gpm/plugins/{slug}/report-script/{reportId}', [GpmController::class, 'reportScript']);
$r->addRoute('GET', '/gpm/themes', [GpmController::class, 'themes']);
$r->addRoute('GET', '/gpm/themes/{slug}', [GpmController::class, 'theme']);
$r->addRoute('GET', '/gpm/themes/{slug}/readme', [GpmController::class, 'readme']);
$r->addRoute('GET', '/gpm/themes/{slug}/changelog', [GpmController::class, 'changelog']);
$r->addRoute('GET', '/gpm/themes/{slug}/field/{type}', [GpmController::class, 'customFieldScript']);
$r->addRoute('GET', '/gpm/updates', [GpmController::class, 'updates']);
$r->addRoute('POST', '/gpm/install', [GpmController::class, 'install']);
$r->addRoute('POST', '/gpm/remove', [GpmController::class, 'remove']);
$r->addRoute('POST', '/gpm/update', [GpmController::class, 'update']);
$r->addRoute('POST', '/gpm/update-all', [GpmController::class, 'updateAll']);
$r->addRoute('POST', '/gpm/upgrade', [GpmController::class, 'upgrade']);
$r->addRoute('POST', '/gpm/direct-install', [GpmController::class, 'directInstall']);
$r->addRoute('GET', '/gpm/search', [GpmController::class, 'search']);
$r->addRoute('GET', '/gpm/repository/plugins', [GpmController::class, 'repositoryPlugins']);
$r->addRoute('GET', '/gpm/repository/themes', [GpmController::class, 'repositoryThemes']);
$r->addRoute('GET', '/gpm/repository/{slug}', [GpmController::class, 'repositoryPackage']);
// Dashboard
$r->addRoute('GET', '/dashboard/notifications', [DashboardController::class, 'notifications']);
$r->addRoute('POST', '/dashboard/notifications/{id}/hide', [DashboardController::class, 'hideNotification']);
$r->addRoute('GET', '/dashboard/feed', [DashboardController::class, 'feed']);
$r->addRoute('GET', '/dashboard/stats', [DashboardController::class, 'stats']);
$r->addRoute('GET', '/dashboard/security/exposure-probe', [DashboardController::class, 'securityProbe']);
$r->addRoute('GET', '/dashboard/popularity', [DashboardController::class, 'popularity']);
$r->addRoute('GET', '/dashboard/widgets', [DashboardWidgetController::class, 'widgets']);
$r->addRoute('PATCH', '/dashboard/layout', [DashboardWidgetController::class, 'saveUserLayout']);
$r->addRoute('PATCH', '/dashboard/site-layout', [DashboardWidgetController::class, 'saveSiteLayout']);
// Admin-next UI preferences (site defaults + per-user overrides + branding)
$r->addRoute('GET', '/admin-next/preferences', [PreferencesController::class, 'show']);
$r->addRoute('PATCH', '/admin-next/preferences/user', [PreferencesController::class, 'saveUser']);
$r->addRoute('DELETE', '/admin-next/preferences/user', [PreferencesController::class, 'resetUser']);
$r->addRoute('PATCH', '/admin-next/preferences/site', [PreferencesController::class, 'saveSite']);
$r->addRoute('PATCH', '/admin-next/branding', [PreferencesController::class, 'saveBranding']);
$r->addRoute('POST', '/admin-next/branding/logo', [PreferencesController::class, 'uploadLogo']);
$r->addRoute('DELETE', '/admin-next/branding/logo', [PreferencesController::class, 'deleteLogo']);
// Scheduler
$r->addRoute('GET', '/scheduler/jobs', [SchedulerController::class, 'jobs']);
$r->addRoute('GET', '/scheduler/status', [SchedulerController::class, 'status']);
$r->addRoute('GET', '/scheduler/history', [SchedulerController::class, 'history']);
$r->addRoute('POST', '/scheduler/run', [SchedulerController::class, 'run']);
// System Info & Reports
$r->addRoute('GET', '/systeminfo', [SchedulerController::class, 'systemInfo']);
$r->addRoute('GET', '/reports', [ReportsController::class, 'index']);
// Webhooks
$r->addRoute('GET', '/webhooks', [WebhookController::class, 'index']);
$r->addRoute('POST', '/webhooks', [WebhookController::class, 'create']);
$r->addRoute('GET', '/webhooks/{id}', [WebhookController::class, 'show']);
$r->addRoute('PATCH', '/webhooks/{id}', [WebhookController::class, 'update']);
$r->addRoute('DELETE', '/webhooks/{id}', [WebhookController::class, 'delete']);
$r->addRoute('GET', '/webhooks/{id}/deliveries', [WebhookController::class, 'deliveries']);
$r->addRoute('POST', '/webhooks/{id}/test', [WebhookController::class, 'test']);
// Data resolver — generic endpoint for data-options@ directives
$r->addRoute('GET', '/data/resolve', [BlueprintController::class, 'resolveData']);
// Blueprints
$r->addRoute('GET', '/blueprints/pages', [BlueprintController::class, 'pageTypes']);
$r->addRoute('GET', '/blueprints/pages/{template:.+}', [BlueprintController::class, 'pageBlueprint']);
$r->addRoute('GET', '/blueprints/plugins/{plugin}', [BlueprintController::class, 'pluginBlueprint']);
$r->addRoute('GET', '/blueprints/plugins/{plugin}/pages/{pageId}', [BlueprintController::class, 'pluginPageBlueprint']);
$r->addRoute('GET', '/blueprints/themes/{theme}', [BlueprintController::class, 'themeBlueprint']);
$r->addRoute('GET', '/blueprints/users', [BlueprintController::class, 'userBlueprint']);
$r->addRoute('GET', '/blueprints/users/permissions', [BlueprintController::class, 'permissionsBlueprint']);
$r->addRoute('GET', '/blueprints/groups', [BlueprintController::class, 'groupBlueprint']);
$r->addRoute('GET', '/blueprints/groups/new', [BlueprintController::class, 'groupNewBlueprint']);
$r->addRoute('GET', '/blueprints/config/accounts', [BlueprintController::class, 'accountsConfigBlueprint']);
$r->addRoute('GET', '/blueprints/config/{scope}', [BlueprintController::class, 'configBlueprint']);
// System
$r->addRoute('GET', '/ping', [SystemController::class, 'ping']);
$r->addRoute('GET', '/system/environments', [SystemController::class, 'environments']);
$r->addRoute('POST', '/system/environments', [SystemController::class, 'createEnvironment']);
$r->addRoute('DELETE', '/system/environments/{name}', [SystemController::class, 'deleteEnvironment']);
$r->addRoute('GET', '/system/info', [SystemController::class, 'info']);
$r->addRoute('DELETE', '/cache', [SystemController::class, 'clearCache']);
$r->addRoute('GET', '/system/logs/files', [SystemController::class, 'logFiles']);
$r->addRoute('GET', '/system/logs', [SystemController::class, 'logs']);
$r->addRoute('POST', '/system/backup', [SystemController::class, 'backup']);
$r->addRoute('GET', '/system/backups', [SystemController::class, 'backups']);
$r->addRoute('DELETE', '/system/backups/{filename}', [SystemController::class, 'deleteBackup']);
$r->addRoute('GET', '/system/backups/{filename}/download', [SystemController::class, 'downloadBackup']);
// Translations
$r->addRoute('GET', '/translations/{lang}', [SystemController::class, 'translations']);
// Admin UI languages (locales the admin itself can be rendered in,
// as opposed to /languages which lists site content languages).
$r->addRoute('GET', '/admin/languages', [SystemController::class, 'adminLanguages']);
// Menubar
$r->addRoute('GET', '/menubar/items', [MenubarController::class, 'items']);
$r->addRoute('POST', '/menubar/actions/{plugin}/{action}', [MenubarController::class, 'executeAction']);
// Sidebar
$r->addRoute('GET', '/sidebar/items', [SidebarController::class, 'items']);
// Admin-next settings panels (plugins register via onApiAdminSettingsPanels)
$r->addRoute('GET', '/settings/panels', [SettingsController::class, 'panels']);
// Floating Widgets
$r->addRoute('GET', '/floating-widgets', [FloatingWidgetController::class, 'items']);
$r->addRoute('GET', '/gpm/plugins/{slug}/widget-script', [GpmController::class, 'widgetScript']);
// Context Panels
$r->addRoute('GET', '/context-panels', [ContextPanelController::class, 'items']);
$r->addRoute('GET', '/gpm/plugins/{slug}/panel-script', [GpmController::class, 'panelScript']);
}
/**
* Fire event to let other plugins register their API routes.
*/
protected function registerPluginRoutes(RouteCollector $r): void
{
$event = new Event(['routes' => new ApiRouteCollector($r)]);
$this->container->fireEvent('onApiRegisterRoutes', $event);
}
/**
* Apply the X-Grav-Environment header if provided.
* Defaults to Grav's auto-detected environment (from hostname) if not set.
*
* NOTE: once Grav has booted, `setup()` is idempotent (it early-returns on
* the `initialized['setup']` guard), so this can only take effect for a
* request that has NOT yet been set up — it does not switch the environment
* of an already-booted request. Per-environment CONFIG reads/writes do not
* rely on this: ConfigController resolves each scope for the requested
* target from YAML files (ConfigDiffer::effective) when the target differs
* from the booted environment, so base/"Default" sees base config even
* though the live Grav instance stays on the hostname overlay.
*/
protected function applyEnvironment(ServerRequestInterface $request): void
{
$environment = $request->getHeaderLine('X-Grav-Environment');
if (!$environment) {
// Default to Grav's auto-detected environment
return;
}
// Sanitize — environment should be a valid hostname-style string
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/', $environment)) {
throw new Exceptions\ApiException(
400,
'Bad Request',
'Invalid environment name. Use a valid hostname (e.g., localhost, mysite.com).'
);
}
$currentEnv = $this->container['uri']->environment();
// Only reinitialize if the requested environment differs from current
if ($environment !== $currentEnv) {
$this->container->setup($environment);
$this->config->reload();
}
}
protected function addRateLimitHeaders(ResponseInterface $response, array $result): ResponseInterface
{
if (!$this->config->get('plugins.api.rate_limit.enabled', true)) {
return $response;
}
return $response
->withHeader('X-RateLimit-Limit', (string) $result['limit'])
->withHeader('X-RateLimit-Remaining', (string) $result['remaining'])
->withHeader('X-RateLimit-Reset', (string) $result['reset']);
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Auth;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Psr\Http\Message\ServerRequestInterface;
class ApiKeyAuthenticator implements AuthenticatorInterface
{
public function __construct(
protected readonly Grav $grav,
) {}
public function authenticate(ServerRequestInterface $request): ?UserInterface
{
$apiKey = $this->extractApiKey($request);
if (!$apiKey || !str_starts_with($apiKey, 'grav_')) {
return null;
}
$manager = new ApiKeyManager();
$match = $manager->findKey($apiKey);
if (!$match) {
return null;
}
$keyData = $match['data'];
$keyId = $match['key_id'];
$username = $match['username'];
// Check if key is active
if (($keyData['active'] ?? true) === false) {
return null;
}
// Check expiry
if (isset($keyData['expires']) && $keyData['expires'] < time()) {
return null;
}
// Load the associated user
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($username);
if (!$user->exists()) {
return null;
}
// Auto-rehash legacy SHA-256 keys to bcrypt
if (!str_starts_with($keyData['hash'], '$2')) {
$manager->rehashKey($keyId, $apiKey);
}
// Update last_used timestamp
$manager->touchKey($keyId);
return $user;
}
protected function extractApiKey(ServerRequestInterface $request): ?string
{
// Check X-API-Key header first
$key = $request->getHeaderLine('X-API-Key');
if ($key) {
return $key;
}
// Fall back to query parameter
$query = $request->getQueryParams();
return $query['api_key'] ?? null;
}
}
@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Auth;
use Grav\Common\Grav;
use Grav\Common\User\Authentication;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Yaml;
/**
* Manages API keys stored centrally in user/data/api-keys.yaml
*/
class ApiKeyManager
{
protected static ?array $keysCache = null;
/**
* Generate a new API key for a user.
*
* @param int|null $expiryDays Number of days until the key expires, or null for no expiry
* @return array{key: string, id: string} The raw key (shown once) and the key ID
*/
public function generateKey(UserInterface $user, string $name = '', array $scopes = [], ?int $expiryDays = null): array
{
$rawKey = 'grav_' . bin2hex(random_bytes(24));
$keyId = bin2hex(random_bytes(8));
$hash = Authentication::create($rawKey);
$expires = $expiryDays !== null ? time() + ($expiryDays * 86400) : null;
$keys = $this->loadKeys();
$keys[$keyId] = [
'id' => $keyId,
'username' => $user->username,
'name' => $name ?: 'API Key',
'hash' => $hash,
'prefix' => substr($rawKey, 0, 12) . '...',
'scopes' => $scopes,
'active' => true,
'created' => time(),
'last_used' => null,
'expires' => $expires,
];
$this->saveKeys($keys);
return [
'key' => $rawKey,
'id' => $keyId,
];
}
/**
* List all API keys for a user (without hashes).
*/
public function listKeys(UserInterface $user): array
{
$keys = $this->loadKeys();
$result = [];
foreach ($keys as $keyData) {
if (!is_array($keyData) || ($keyData['username'] ?? '') !== $user->username) {
continue;
}
$result[] = [
'id' => $keyData['id'] ?? '',
'name' => $keyData['name'] ?? 'API Key',
'prefix' => $keyData['prefix'] ?? '',
'scopes' => $keyData['scopes'] ?? [],
'active' => $keyData['active'] ?? true,
'created' => $keyData['created'] ?? null,
'last_used' => $keyData['last_used'] ?? null,
'expires' => $keyData['expires'] ?? null,
];
}
return $result;
}
/**
* Revoke (delete) an API key.
*/
public function revokeKey(UserInterface $user, string $keyId): bool
{
$keys = $this->loadKeys();
if (!isset($keys[$keyId]) || ($keys[$keyId]['username'] ?? '') !== $user->username) {
return false;
}
unset($keys[$keyId]);
$this->saveKeys($keys);
return true;
}
/**
* Verify a raw API key against a stored hash.
*/
public static function verifyKey(string $rawKey, string $hash): bool
{
// Bcrypt hashes start with $2y$ or $2b$
if (str_starts_with($hash, '$2')) {
return Authentication::verify($rawKey, $hash) > 0;
}
// Legacy SHA-256 fallback
return hash_equals($hash, hash('sha256', $rawKey));
}
/**
* Rehash a legacy SHA-256 key to bcrypt.
*/
public function rehashKey(string $keyId, string $rawKey): void
{
$keys = $this->loadKeys();
if (isset($keys[$keyId]) && is_array($keys[$keyId])) {
$keys[$keyId]['hash'] = Authentication::create($rawKey);
$this->saveKeys($keys);
}
}
/**
* Update last_used timestamp for a key.
*/
public function touchKey(string $keyId): void
{
$keys = $this->loadKeys();
if (isset($keys[$keyId]) && is_array($keys[$keyId])) {
$keys[$keyId]['last_used'] = time();
$this->saveKeys($keys);
}
}
/**
* Find a key entry by raw API key. Returns [keyId, keyData, username] or null.
*/
public function findKey(string $rawKey): ?array
{
$keys = $this->loadKeys();
foreach ($keys as $keyId => $keyData) {
if (!is_array($keyData) || !isset($keyData['hash'])) {
continue;
}
if (self::verifyKey($rawKey, $keyData['hash'])) {
return [
'key_id' => $keyId,
'data' => $keyData,
'username' => $keyData['username'] ?? '',
];
}
}
return null;
}
/**
* Load all API keys from the data file.
*/
public function loadKeys(): array
{
if (static::$keysCache !== null) {
return static::$keysCache;
}
$file = $this->getKeysFile();
if (!file_exists($file)) {
static::$keysCache = [];
return [];
}
$data = Yaml::parse(file_get_contents($file)) ?? [];
static::$keysCache = $data;
return $data;
}
/**
* Save all API keys to the data file.
*/
protected function saveKeys(array $keys): void
{
$file = $this->getKeysFile();
$dir = dirname($file);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
// Write atomically
$tmp = $file . '.tmp';
file_put_contents($tmp, Yaml::dump($keys));
rename($tmp, $file);
static::$keysCache = $keys;
}
/**
* Get the path to the API keys data file.
*/
protected function getKeysFile(): string
{
$locator = Grav::instance()['locator'];
return $locator->findResource('user://data', true, true) . '/api-keys.yaml';
}
/**
* Migrate keys from user account files to centralized storage.
*/
public function migrateFromAccounts(): int
{
$grav = Grav::instance();
$accounts = $grav['accounts'];
$locator = $grav['locator'];
$migrated = 0;
// Scan account files
$accountDir = $locator->findResource('account://', true)
?: $locator->findResource('user://accounts', true);
if (!$accountDir || !is_dir($accountDir)) {
return 0;
}
foreach (new \DirectoryIterator($accountDir) as $file) {
if ($file->isDot() || !$file->isFile() || $file->getExtension() !== 'yaml') {
continue;
}
$username = $file->getBasename('.yaml');
$user = $accounts->load($username);
if (!$user->exists()) {
continue;
}
$userKeys = $user->get('api_keys', []);
if (empty($userKeys)) {
continue;
}
$existingKeys = $this->loadKeys();
foreach ($userKeys as $keyId => $keyData) {
if (!is_array($keyData) || isset($existingKeys[$keyId])) {
continue;
}
$keyData['username'] = $username;
$existingKeys[$keyId] = $keyData;
$migrated++;
}
$this->saveKeys($existingKeys);
static::$keysCache = null; // Clear cache for next loadKeys()
// Remove api_keys from user account
$user->undef('api_keys');
$user->save();
}
return $migrated;
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Auth;
use Grav\Common\User\Interfaces\UserInterface;
use Psr\Http\Message\ServerRequestInterface;
interface AuthenticatorInterface
{
/**
* Attempt to authenticate the request.
* Returns the authenticated user, or null if this authenticator cannot handle the request.
*/
public function authenticate(ServerRequestInterface $request): ?UserInterface;
}
@@ -0,0 +1,323 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Auth;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;
class JwtAuthenticator implements AuthenticatorInterface
{
public function __construct(
protected readonly Grav $grav,
protected readonly Config $config,
) {}
public function authenticate(ServerRequestInterface $request): ?UserInterface
{
$token = $this->extractBearerToken($request);
if (!$token) {
return null;
}
return $this->validateToken($token);
}
/**
* Generate an access token for a user.
*/
public function generateAccessToken(UserInterface $user): string
{
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$expiry = (int) $this->config->get('plugins.api.auth.jwt_expiry', 3600);
$payload = [
'iss' => 'grav-api',
'sub' => $user->username,
'iat' => time(),
'exp' => time() + $expiry,
'type' => 'access',
];
return JWT::encode($payload, $secret, $algorithm);
}
/**
* Generate a refresh token for a user.
*/
public function generateRefreshToken(UserInterface $user): string
{
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$expiry = (int) $this->config->get('plugins.api.auth.jwt_refresh_expiry', 604800);
$payload = [
'iss' => 'grav-api',
'sub' => $user->username,
'iat' => time(),
'exp' => time() + $expiry,
'type' => 'refresh',
'jti' => bin2hex(random_bytes(16)),
];
return JWT::encode($payload, $secret, $algorithm);
}
/**
* Generate a short-lived, single-use challenge token for flows like 2FA
* verification or password reset handoff. The $purpose field is stored in
* the token's `type` claim and must match on validation.
*/
public function generateChallengeToken(UserInterface $user, string $purpose, int $ttl = 300): string
{
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$payload = [
'iss' => 'grav-api',
'sub' => $user->username,
'iat' => time(),
'exp' => time() + $ttl,
'type' => $purpose,
'jti' => bin2hex(random_bytes(16)),
];
return JWT::encode($payload, $secret, $algorithm);
}
/**
* Validate a challenge token and return the associated user. The token must
* carry the expected purpose in its `type` claim and must not have been
* revoked. Returns null if invalid, expired, or revoked.
*/
public function validateChallengeToken(string $token, string $expectedPurpose): ?UserInterface
{
try {
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$decoded = JWT::decode($token, new Key($secret, $algorithm));
if (($decoded->type ?? null) !== $expectedPurpose) {
return null;
}
if ($this->isTokenRevoked($decoded->jti ?? '')) {
return null;
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($decoded->sub);
return $user->exists() ? $user : null;
} catch (Throwable) {
return null;
}
}
/**
* Validate a refresh token and return the associated user.
*/
public function validateRefreshToken(string $token): ?UserInterface
{
try {
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$decoded = JWT::decode($token, new Key($secret, $algorithm));
if (($decoded->type ?? null) !== 'refresh') {
return null;
}
// Check if token has been revoked
if ($this->isTokenRevoked($decoded->jti ?? '')) {
return null;
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($decoded->sub);
return $user->exists() ? $user : null;
} catch (Throwable) {
return null;
}
}
/**
* Revoke a refresh token by its JTI.
*/
public function revokeToken(string $token): bool
{
try {
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$decoded = JWT::decode($token, new Key($secret, $algorithm));
$jti = $decoded->jti ?? null;
if (!$jti) {
return false;
}
$this->addRevokedToken($jti, $decoded->exp ?? time() + 604800);
return true;
} catch (Throwable) {
return false;
}
}
protected function extractBearerToken(ServerRequestInterface $request): ?string
{
// Primary: `X-API-Token` custom header. Preferred because it survives
// FPM / FastCGI / CGI setups that silently strip the `Authorization`
// header (MAMP's mod_fastcgi being the common trigger). Accepts either
// a bare JWT or the traditional `Bearer <jwt>` form.
$custom = trim($request->getHeaderLine('X-API-Token'));
if ($custom !== '') {
return str_starts_with($custom, 'Bearer ') ? substr($custom, 7) : $custom;
}
// Legacy / standards-compliant: `Authorization: Bearer <jwt>`.
// Kept for external clients (curl, Postman, CI) and backward compat.
$header = $request->getHeaderLine('Authorization');
if (str_starts_with($header, 'Bearer ')) {
return substr($header, 7);
}
// Fallback: query parameter for direct links (e.g. file downloads
// where a browser <a download> tag can't attach a header).
$params = $request->getQueryParams();
if (!empty($params['token'])) {
return $params['token'];
}
return null;
}
protected function validateToken(string $token): ?UserInterface
{
try {
$secret = $this->getSecret();
$algorithm = $this->config->get('plugins.api.auth.jwt_algorithm', 'HS256');
$decoded = JWT::decode($token, new Key($secret, $algorithm));
// Only accept access tokens for API authentication
if (($decoded->type ?? null) !== 'access') {
return null;
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($decoded->sub);
return $user->exists() ? $user : null;
} catch (Throwable) {
return null;
}
}
protected function getSecret(): string
{
$secret = $this->config->get('plugins.api.auth.jwt_secret', '');
// Auto-generate secret if not set
if (!$secret) {
$secret = bin2hex(random_bytes(32));
$this->config->set('plugins.api.auth.jwt_secret', $secret);
// Persist the generated secret so subsequent requests can verify
// tokens signed with it. Without persistence every request re-mints
// a different secret, producing the classic "login succeeds, next
// request 401" reauth loop on a fresh install.
//
// findResource() with defaults (absolute=true, all=false) returns
// either the first existing path or false — the previous third
// `true` flag returned an array and silently broke the fallback.
$locator = $this->grav['locator'];
$file = $locator->findResource('config://plugins/api.yaml');
if (!$file) {
$configDir = $locator->findResource('config://', true);
if (!$configDir) {
if (isset($this->grav['log'])) {
$this->grav['log']->warning('api.auth: could not resolve config:// stream to persist JWT secret; tokens will be single-request only until jwt_secret is configured.');
}
return $secret;
}
$file = $configDir . '/plugins/api.yaml';
}
$dir = dirname($file);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
if (isset($this->grav['log'])) {
$this->grav['log']->warning(sprintf('api.auth: could not create %s to persist JWT secret.', $dir));
}
return $secret;
}
$yaml = \Grav\Common\Yaml::parse(file_exists($file) ? file_get_contents($file) : '') ?? [];
$yaml['auth']['jwt_secret'] = $secret;
if (@file_put_contents($file, \Grav\Common\Yaml::dump($yaml)) === false) {
if (isset($this->grav['log'])) {
$this->grav['log']->warning(sprintf('api.auth: could not write JWT secret to %s — tokens will not survive past this request.', $file));
}
}
}
return $secret;
}
protected function isTokenRevoked(string $jti): bool
{
$file = $this->getRevokedTokensFile();
if (!file_exists($file)) {
return false;
}
$revoked = json_decode(file_get_contents($file), true) ?: [];
$this->cleanExpiredRevocations($revoked, $file);
return isset($revoked[$jti]);
}
protected function addRevokedToken(string $jti, int $expiresAt): void
{
$file = $this->getRevokedTokensFile();
$dir = dirname($file);
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$revoked = [];
if (file_exists($file)) {
$revoked = json_decode(file_get_contents($file), true) ?: [];
}
$revoked[$jti] = $expiresAt;
$this->cleanExpiredRevocations($revoked, $file);
}
protected function cleanExpiredRevocations(array &$revoked, string $file): void
{
$now = time();
$revoked = array_filter($revoked, fn($exp) => $exp > $now);
file_put_contents($file, json_encode($revoked));
}
protected function getRevokedTokensFile(): string
{
$locator = $this->grav['locator'];
return $locator->findResource('cache://api', true, true) . '/revoked_tokens.json';
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Auth;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserInterface;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;
class SessionAuthenticator implements AuthenticatorInterface
{
public function __construct(
protected readonly Grav $grav,
) {}
public function authenticate(ServerRequestInterface $request): ?UserInterface
{
try {
/** @var \Grav\Common\Session $session */
$session = $this->grav['session'];
// Only if session is already started (e.g., from admin browsing)
if (!$session->isStarted()) {
return null;
}
/** @var UserInterface|null $user */
$user = $session->user ?? null;
// Accept any authenticated session user, including one restored via the
// login plugin's "remember me" cookie (which leaves the user
// `authenticated` but not `authorized`). Without this, a remembered user
// shows as signed in in the UI yet every write call is rejected until a
// fresh login. Per-route permission checks (the user's `access` map,
// refreshed below) still gate what they can actually do, and the
// remember-me cookie is itself HttpOnly/Secure/SameSite.
if ($user && $user->exists() && $user->authenticated) {
// Session stores a serialized user snapshot whose `access` map
// is frozen at the moment of login. Admin permission changes
// wouldn't take effect until the session is destroyed. Refresh
// `access` from disk so an operator's grant/revoke is honored
// on the next API request without forcing a re-login.
$username = (string) $user->get('username');
if ($username !== '') {
try {
$fresh = $this->grav['accounts']->load($username);
if ($fresh->exists()) {
$user->set('access', $fresh->get('access'));
$user->set('groups', $fresh->get('groups'));
}
} catch (Throwable) {
// Disk reload failed — fall through with stale access
// rather than denying a legitimately authenticated user.
}
}
return $user;
}
} catch (Throwable) {
// Session not available or errored
}
return null;
}
}
@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\Data\Blueprint;
use Grav\Common\Data\Validation;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\UnauthorizedException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\PermissionResolver;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\UserSerializer;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
abstract class AbstractApiController
{
public function __construct(
protected readonly Grav $grav,
protected readonly Config $config,
) {}
/**
* Get the authenticated user from the request.
*/
protected function getUser(ServerRequestInterface $request): UserInterface
{
$user = $request->getAttribute('api_user');
if (!$user) {
throw new UnauthorizedException();
}
return $user;
}
/**
* Verify the user has the required permission.
*/
protected function requirePermission(ServerRequestInterface $request, string $permission): void
{
$user = $this->getUser($request);
// Super admin can do anything
if ($this->isSuperAdmin($user)) {
return;
}
// Check API access first
if (!$this->hasPermission($user, 'api.access')) {
throw new ForbiddenException('API access is not enabled for this user.');
}
// Check specific permission
if (!$this->hasPermission($user, $permission)) {
throw new ForbiddenException("Missing required permission: {$permission}");
}
}
/**
* Check if user is an API super user via direct access array lookup.
*
* API authority is strictly scoped to access.api.super — admin.super
* (admin-classic's legacy global super) is intentionally NOT honored
* here. Grav 2.0 separates admin-classic and API/Admin-Next authority
* so operators can grant one without implicitly granting the other.
*/
protected function isSuperAdmin(UserInterface $user): bool
{
return (bool) $user->get('access.api.super');
}
/**
* Check user permission with parent-key inheritance.
*
* Granting "api.pages" implicitly covers "api.pages.read" via walk-up
* resolution, matching how Grav's core ACL resolves permissions.
*/
protected function hasPermission(UserInterface $user, string $permission): bool
{
return (bool) $this->getPermissionResolver()->resolve($user, $permission);
}
/**
* Check whether a user satisfies an `authorize` requirement attached to a
* sidebar / menubar / widget item. Mirrors admin-classic's pattern:
*
* - `null` (no requirement) → always allowed.
* - string → user must have that permission.
* - array → user must have at least ONE of the listed permissions.
*
* Super-admins pass regardless of the requirement.
*/
protected function userPassesAuthorize(UserInterface $user, mixed $authorize, bool $isSuperAdmin): bool
{
if ($authorize === null) {
return true;
}
if ($isSuperAdmin) {
return true;
}
if (is_string($authorize)) {
return $this->hasPermission($user, $authorize);
}
if (is_array($authorize)) {
foreach ($authorize as $perm) {
if (is_string($perm) && $this->hasPermission($user, $perm)) {
return true;
}
}
return false;
}
// Unknown shape — fail closed.
return false;
}
private ?PermissionResolver $permissionResolver = null;
protected function getPermissionResolver(): PermissionResolver
{
return $this->permissionResolver ??= new PermissionResolver($this->grav['permissions']);
}
/**
* Get the parsed JSON request body.
*/
protected function getRequestBody(ServerRequestInterface $request): array
{
$body = $request->getAttribute('json_body');
if ($body === null) {
$body = $request->getParsedBody();
}
return is_array($body) ? $body : [];
}
/**
* List-aware recursive merge of an incoming patch into existing data.
*
* Unlike array_replace_recursive, this never merges into list-shaped
* nodes: if either side at a given key is a sequential list, the
* incoming value replaces the existing one wholesale. Prevents the
* "'0','1','2' keys alongside named entries" YAML corruption that
* array_replace_recursive produces when a YAML list on disk is sent
* back as a name-keyed map (or vice versa).
*/
protected function mergePatch(array $existing, array $incoming): array
{
foreach ($incoming as $key => $value) {
if (
is_array($value)
&& isset($existing[$key])
&& is_array($existing[$key])
&& !array_is_list($value)
&& !array_is_list($existing[$key])
) {
$existing[$key] = $this->mergePatch($existing[$key], $value);
} else {
$existing[$key] = $value;
}
}
return $existing;
}
/**
* Validate only the fields present in `$changes` against their blueprint
* definitions, throwing the API's ValidationException (HTTP 422) with
* per-field messages on failure.
*
* We validate the submitted delta — NOT the whole merged object — on
* purpose. Grav's own stock config doesn't pass a whole-object
* `$blueprint->validate()`: `system.errors.display` ships as a bool against
* a `type: int` rule, and the core `list` validator rejects complete
* security/backups/scheduler list items (required per-item sub-fields are
* checked at the wrong nesting level). All of those landmines live in
* fields the request never touches, so validating just the changed fields
* sidesteps them while still rejecting an invalid value or a required field
* submitted empty (getgrav/grav-plugin-admin2#30). Completeness — a required
* field the user never filled — is enforced by the admin UI, which renders
* the whole form.
*
* `$changes` is keyed exactly as the blueprint expects (e.g. `errors.display`
* nested under `errors`, page fields under `header`); it is flattened to the
* blueprint's leaf fields here.
*
* @param array $changes Incoming values (possibly nested), as sent by the client.
*/
protected function validateChangedFields(array $changes, ?Blueprint $blueprint): void
{
if ($blueprint === null || $changes === []) {
return;
}
$schema = $blueprint->schema();
$errors = [];
foreach ($blueprint->flattenData($changes) as $name => $value) {
$field = $schema->getProperty($name);
if (!is_array($field) || !isset($field['type'])) {
// Not a blueprint-defined field (extra/legacy key) — nothing to validate.
continue;
}
$value = $this->coerceForValidation($value, $field);
foreach (Validation::validate($value, $field) as $messages) {
foreach ((array) $messages as $message) {
$errors[] = [
'field' => $name,
'message' => trim(strip_tags((string) $message)),
];
}
}
// XSS safety gate. The full blueprint validator (BlueprintSchema::validate())
// runs checkSafety() per field, but this partial-field path validates the
// submitted delta directly and must enforce the same trust boundary itself —
// otherwise a non-superadmin editor could persist stored XSS (e.g. an
// `onerror=` handler in page Markdown) that fires in an admin or visitor
// session. checkSafety() honors security.xss_whitelist (admin.super) and
// per-field `xss_check: false`, so behaviour matches the classic admin exactly.
foreach (Validation::checkSafety($value, $field) as $messages) {
foreach ((array) $messages as $message) {
$errors[] = [
'field' => $name,
'message' => trim(strip_tags((string) $message)),
];
}
}
}
if ($errors !== []) {
throw new ValidationException(
'The submitted data did not pass blueprint validation.',
$errors,
);
}
}
/**
* Mirror Grav's runtime leniency between ints and booleans for int-typed
* fields. `system.errors.display`, for example, is declared `type: int`
* but Grav's error handler (Errors::resetHandlers) treats `true`/`false`
* as `1`/`0`. Grav's `typeInt` validator is stricter (`is_numeric(true)`
* is false), so without this a legitimate boolean value would be rejected.
*/
private function coerceForValidation(mixed $value, array $field): mixed
{
$type = $field['validate']['type'] ?? $field['type'] ?? null;
if (is_bool($value) && ($type === 'int' || $type === 'number')) {
return (int) $value;
}
return $value;
}
/**
* Get route parameters captured by FastRoute.
*/
protected function getRouteParam(ServerRequestInterface $request, string $name): ?string
{
$params = $request->getAttribute('route_params', []);
return $params[$name] ?? null;
}
/**
* Get pagination parameters from query string.
*/
protected function getPagination(ServerRequestInterface $request): array
{
$query = $request->getQueryParams();
$defaultPerPage = $this->config->get('plugins.api.pagination.default_per_page', 20);
$maxPerPage = $this->config->get('plugins.api.pagination.max_per_page', 1000);
$page = max(1, (int) ($query['page'] ?? 1));
$perPage = min($maxPerPage, max(1, (int) ($query['per_page'] ?? $defaultPerPage)));
return [
'page' => $page,
'per_page' => $perPage,
'offset' => ($page - 1) * $perPage,
'limit' => $perPage,
];
}
/**
* Get sort parameters from query string.
*/
protected function getSorting(ServerRequestInterface $request, array $allowedFields = []): array
{
$query = $request->getQueryParams();
$sort = $query['sort'] ?? null;
$order = strtolower($query['order'] ?? 'asc');
if ($sort && $allowedFields && !in_array($sort, $allowedFields, true)) {
throw new ValidationException("Invalid sort field '{$sort}'. Allowed: " . implode(', ', $allowedFields));
}
if (!in_array($order, ['asc', 'desc'], true)) {
$order = 'asc';
}
return [
'sort' => $sort,
'order' => $order,
];
}
/**
* Get filter parameters from query string.
*/
protected function getFilters(ServerRequestInterface $request, array $allowedFilters = []): array
{
$query = $request->getQueryParams();
$filters = [];
foreach ($allowedFilters as $filter) {
// Support dot notation for nested params (e.g., taxonomy.category)
if (str_contains($filter, '.')) {
$parts = explode('.', $filter);
$value = $query;
foreach ($parts as $part) {
$value = $value[$part] ?? null;
if ($value === null) {
break;
}
}
if ($value !== null) {
$filters[$filter] = $value;
}
} elseif (isset($query[$filter])) {
$filters[$filter] = $query[$filter];
}
}
return $filters;
}
/**
* Validate ETag for optimistic concurrency control.
* Returns true if the client's ETag matches the current resource hash.
*/
protected function validateEtag(ServerRequestInterface $request, string $currentHash): void
{
$ifMatch = $request->getHeaderLine('If-Match');
if ($ifMatch && $this->normalizeEtag($ifMatch) !== $currentHash) {
throw new \Grav\Plugin\Api\Exceptions\ConflictException(
'The resource has been modified since you last retrieved it. Please fetch the latest version and try again.'
);
}
}
/**
* Strip transport-layer noise from an inbound ETag so comparisons survive
* reverse proxies that weaken the header.
*
* Apache mod_deflate and some nginx builds append `-gzip` (or `;gzip`) to
* ETags on compressed responses and leave it in place when the client
* echoes the value back in If-Match. Weak markers (`W/`) and surrounding
* quotes are also normalized here so the raw md5 hash is what gets
* compared against generateEtag()'s output.
*/
private function normalizeEtag(string $etag): string
{
$etag = trim($etag);
if (str_starts_with($etag, 'W/')) {
$etag = substr($etag, 2);
}
$etag = trim($etag, '"');
// Strip known transport suffixes a compressing front-end appends to the
// ETag and leaves in place when the client echoes it back in If-Match:
// mod_deflate `-gzip`/`;gzip`, mod_brotli `-br`, and mod_zstd `-zstd`
// (the last surfaced as a false 409 in getgrav/grav-plugin-admin2#28).
$etag = preg_replace('/[-;](?:gzip|br|deflate|zstd)$/i', '', $etag) ?? $etag;
return $etag;
}
/**
* Generate an ETag hash for a resource.
*/
protected function generateEtag(mixed $data): string
{
return md5(json_encode($data));
}
/**
* Create a response with ETag header, optionally paired with invalidation tags.
*
* By default the ETag is hashed from the response body. Pass an explicit
* $etag when the body and the validator must diverge — e.g. config saves
* return the full merged config as the body but key the ETag off the
* persisted delta so it survives the save→reload round-trip.
*
* @param array<int, string> $invalidates
*/
protected function respondWithEtag(mixed $data, int $status = 200, array $invalidates = [], ?string $etag = null, ?array $meta = null): ResponseInterface
{
$etag ??= $this->generateEtag($data);
$headers = ['ETag' => '"' . $etag . '"'];
if ($invalidates !== []) {
$headers['X-Invalidates'] = implode(', ', $invalidates);
}
return ApiResponse::create($data, $status, $headers, $meta);
}
/**
* Build headers array containing just the X-Invalidates header for a set of tags.
* Useful when composing responses via ApiResponse::created() / noContent() etc.
*
* @param array<int, string> $tags
* @return array<string, string>
*/
protected function invalidationHeaders(array $tags): array
{
$tags = array_values(array_filter($tags, static fn($t) => is_string($t) && $t !== ''));
return $tags === [] ? [] : ['X-Invalidates' => implode(', ', $tags)];
}
/**
* Create a response with an X-Invalidates header declaring which client-side
* caches this mutation should evict. Tags follow `resource:action[:id]` form:
*
* pages:update:/blog/post-1
* pages:list
* users:create
*
* The admin-next client reads this header and emits invalidation events on
* its pub/sub bus, causing list/detail views to refetch automatically.
*
* @param array<int, string> $tags
*/
protected function respondWithInvalidation(
mixed $data,
array $tags,
int $status = 200,
array $extraHeaders = [],
): ResponseInterface {
$headers = $extraHeaders;
if ($tags !== []) {
$headers['X-Invalidates'] = implode(', ', $tags);
}
if ($status === 204) {
// 204 responses have no body — use a bare Response with headers only.
$headers['Cache-Control'] = 'no-store, max-age=0';
return new \Grav\Framework\Psr7\Response(204, $headers);
}
return ApiResponse::create($data, $status, $headers);
}
/**
* Build the API base URL for link generation.
*/
protected function getApiBaseUrl(): string
{
$base = $this->config->get('plugins.api.route', '/api');
$prefix = $this->config->get('plugins.api.version_prefix', 'v1');
return '/' . trim($base, '/') . '/' . $prefix;
}
/**
* Validate required fields are present in the request body.
*/
protected function requireFields(array $body, array $fields): void
{
$missing = [];
foreach ($fields as $field) {
if (!isset($body[$field]) || (is_string($body[$field]) && trim($body[$field]) === '')) {
$missing[] = $field;
}
}
if ($missing) {
throw new ValidationException(
'Missing required fields: ' . implode(', ', $missing),
array_map(fn($f) => ['field' => $f, 'message' => "The '{$f}' field is required."], $missing)
);
}
}
/**
* Fire a Grav event with the given data.
* Returns the event object so callers can check for modifications.
*/
protected function fireEvent(string $name, array $data = []): Event
{
$event = new Event($data);
$this->grav->fireEvent($name, $event);
return $event;
}
/**
* Fire an admin-compatible event alongside the API's own events.
*
* Third-party plugins subscribe to onAdmin* events for critical operations
* (SEO indexing, frontmatter injection, cache busting, etc.). These events
* are normally only fired by the admin plugin's controllers, so API-driven
* changes would silently bypass them. This method ensures compatibility by
* firing the same events with the same data signatures the admin uses.
*/
protected function issueTokenPair(JwtAuthenticator $jwt, UserInterface $user): ResponseInterface
{
$accessToken = $jwt->generateAccessToken($user);
$refreshToken = $jwt->generateRefreshToken($user);
$expiresIn = (int) $this->config->get('plugins.api.auth.jwt_expiry', 3600);
$isSuperAdmin = $this->isSuperAdmin($user);
$resolver = $this->getPermissionResolver();
$resolvedAccess = $resolver->resolvedMap($user, $isSuperAdmin);
return ApiResponse::create([
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => 'Bearer',
'expires_in' => $expiresIn,
'user' => [
'username' => $user->username,
'fullname' => $user->get('fullname'),
'email' => $user->get('email'),
'avatar_url' => UserSerializer::resolveAvatarUrl($user),
'super_admin' => $isSuperAdmin,
'access' => $resolvedAccess,
'content_editor' => $user->get('content_editor', ''),
],
]);
}
protected function fireAdminEvent(string $name, array $data = []): Event
{
// Ensure $grav['page'] is set when firing page-related admin events.
// In admin-classic this is always set; with flex-objects via API it may not be,
// causing plugins that read $grav['page'] (SEO Magic, etc.) to get null.
$page = $data['page'] ?? $data['object'] ?? null;
if ($page instanceof PageInterface) {
// Use offsetUnset first to clear any Pimple frozen state, then set.
unset($this->grav['page']);
$this->grav['page'] = $page;
}
$event = new Event($data);
$this->grav->fireEvent($name, $event);
return $event;
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Grav;
use Grav\Common\Yaml;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Services\ConfigDiffer;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Read/write the Flex accounts configuration at user/config/flex/accounts.yaml.
*
* Classic admin exposes this as the "Configuration" tab under Users — it
* carries the Flex compatibility toggles and any caching options the
* Flex-Objects plugin contributes to user-accounts.
*
* Gated on admin.super, matching the security@ on the underlying blueprint.
*/
class AccountsConfigController extends AbstractApiController
{
private const CONFIG_KEY = 'flex.accounts';
private const CONFIG_FILE = 'flex/accounts.yaml';
public function show(ServerRequestInterface $request): ResponseInterface
{
$this->requireSuperOrAdmin($request);
$data = $this->readConfig();
return $this->respondWithEtag($data);
}
public function update(ServerRequestInterface $request): ResponseInterface
{
$this->requireSuperOrAdmin($request);
$current = $this->readConfig();
$this->validateEtag($request, $this->generateEtag($current));
$body = $this->getRequestBody($request);
if (empty($body)) {
throw new ValidationException('Request body must contain configuration values to update.');
}
$merged = $this->mergePatch($current, $body);
$this->writeConfig($merged);
$this->fireEvent('onApiConfigUpdated', ['scope' => 'flex/accounts', 'data' => $merged]);
return $this->respondWithEtag(
$this->readConfig(),
200,
['config:update:flex/accounts'],
);
}
/**
* @return array<string, mixed>
*/
private function readConfig(): array
{
$data = $this->config->get(self::CONFIG_KEY);
return is_array($data) ? $data : [];
}
/**
* Persist to user/config/flex/accounts.yaml. We always write to base
* user/config — env overlays for this file would be unusual and the
* classic admin doesn't support them either.
*
* @param array<string, mixed> $data
*/
private function writeConfig(array $data): void
{
$grav = Grav::instance();
$locator = $grav['locator'];
$userConfig = $locator->findResource('user://config', true);
if (!$userConfig) {
throw new \RuntimeException('Base user/config directory not found.');
}
$filePath = $userConfig . '/' . self::CONFIG_FILE;
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
// Skip persisting any values injected via GRAV_CONFIG__* env vars (.env);
// they win at runtime and must not be written into the config on disk.
$forFile = (new ConfigDiffer($grav))->stripEnvironmentOverrides($data, 'flex/accounts');
file_put_contents($filePath, Yaml::dump($forFile, 99, 2));
$this->config->set(self::CONFIG_KEY, $data);
$grav['cache']->clearCache('standard');
}
private function requireSuperOrAdmin(ServerRequestInterface $request): void
{
$user = $this->getUser($request);
if ($this->isSuperAdmin($user)) {
return;
}
$this->requirePermission($request, 'admin.super');
}
}
@@ -0,0 +1,525 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\TooManyRequestsException;
use Grav\Plugin\Api\Exceptions\UnauthorizedException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\UserSerializer;
use Grav\Plugin\Login\Login;
use Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class AuthController extends AbstractApiController
{
use ResolvesAdminBaseUrl;
private const CHALLENGE_2FA = '2fa_challenge';
private const CHALLENGE_TTL = 300;
public function token(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['username', 'password']);
$username = (string) $body['username'];
$password = (string) $body['password'];
$this->enforceLoginRateLimit($username);
// Route through the Login plugin when available so the full
// onUserLoginAuthenticate / onUserLoginAuthorize / onUserLogin chain
// fires. This is what lets LDAP (and any other auth plugin that
// subscribes to onUserLoginAuthenticate at higher priority) validate
// the credentials and map groups to access levels.
//
// `authorize` is passed as `[]` rather than `admin.login`: the API
// plugin runs its own permission gate further down that handles both
// legacy and Flex users correctly (admin.super, api.access, etc.).
// Letting the Login plugin gate on `admin.login` here breaks logins
// on regular (non-flex) accounts whose legacy User::authorize() lacks
// an admin.super fallback — even super admins are denied unless they
// also have an explicit access.admin.login: true.
//
// Falls back to the legacy User::authenticate() path on sites without
// the Login plugin.
if (class_exists(Login::class) && isset($this->grav['login'])) {
/** @var Login $login */
$login = $this->grav['login'];
$event = $login->login(
['username' => $username, 'password' => $password],
['admin' => true, 'twofa' => false],
['authorize' => [], 'return_event' => true]
);
$user = $event->getUser();
if (!$user || !$user->authenticated) {
$this->fireEvent('onApiUserLoginFailure', [
'username' => $username,
'reason' => 'password',
'ip' => $this->getRequestIp($request),
]);
throw new UnauthorizedException('Invalid username or password.');
}
} else {
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($username);
// Delegate to User::authenticate() so the core trait's plaintext-password
// fallback fires (auto-hashes a yaml-declared `password:` field on first
// successful login, then saves — same behavior admin-classic and the Login
// plugin have always had).
if (!$user->exists() || !$user->authenticate($password)) {
$this->fireEvent('onApiUserLoginFailure', [
'username' => $username,
'reason' => 'password',
'ip' => $this->getRequestIp($request),
]);
throw new UnauthorizedException('Invalid username or password.');
}
}
// Gate API access AFTER the event chain has run, so any onUserLogin
// handlers (LDAP group→access mapping, etc.) have had a chance to
// populate the user's access matrix. Mirrors admin-classic's
// `admin.login` gate but additionally accepts `api.access` for users
// who are API-only and shouldn't be granted full admin entry.
if (
!$this->isSuperAdmin($user)
&& !$user->authorize('admin.login')
&& !$this->hasPermission($user, 'api.access')
) {
$this->fireEvent('onApiUserLoginFailure', [
'username' => $username,
'reason' => 'no_api_access',
'ip' => $this->getRequestIp($request),
]);
throw new ForbiddenException('API access is not enabled for this user.');
}
if ($user->get('state', 'enabled') === 'disabled') {
$this->fireEvent('onApiUserLoginFailure', [
'username' => $username,
'reason' => 'disabled',
'ip' => $this->getRequestIp($request),
]);
throw new ForbiddenException('This user account is disabled.');
}
$jwt = new JwtAuthenticator($this->grav, $this->config);
if ($this->userRequiresTwoFactor($user)) {
// Password was valid — issue a challenge token. Do NOT reset the
// rate limiter yet: the login only counts as successful after the
// 2FA code verifies in /auth/2fa/verify.
$challengeToken = $jwt->generateChallengeToken($user, self::CHALLENGE_2FA, self::CHALLENGE_TTL);
return ApiResponse::create([
'requires_2fa' => true,
'challenge_token' => $challengeToken,
'expires_in' => self::CHALLENGE_TTL,
'token_type' => 'Challenge',
]);
}
$this->resetLoginRateLimit($username);
$this->fireEvent('onApiUserLogin', [
'user' => $user,
'method' => 'password',
'ip' => $this->getRequestIp($request),
'request' => $request,
]);
return $this->issueTokenPair($jwt, $user);
}
public function verify2fa(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['challenge_token', 'code']);
$jwt = new JwtAuthenticator($this->grav, $this->config);
$user = $jwt->validateChallengeToken($body['challenge_token'], self::CHALLENGE_2FA);
if ($user === null) {
throw new UnauthorizedException('Invalid or expired challenge token.');
}
$username = $user->username;
$this->enforceLoginRateLimit($username);
if ($user->get('state', 'enabled') === 'disabled') {
throw new ForbiddenException('This user account is disabled.');
}
if (!class_exists(TwoFactorAuth::class)) {
throw new ForbiddenException('2FA support is not available.');
}
$secret = (string) $user->get('twofa_secret');
$code = (string) $body['code'];
$twoFa = new TwoFactorAuth();
if (!$secret || !$twoFa->verifyCode($secret, $code)) {
$this->fireEvent('onApiUserLoginFailure', [
'username' => $username,
'reason' => '2fa',
'ip' => $this->getRequestIp($request),
]);
throw new UnauthorizedException('Invalid 2FA code.');
}
// Burn the challenge token so it cannot be replayed.
$jwt->revokeToken($body['challenge_token']);
$this->resetLoginRateLimit($username);
$this->fireEvent('onApiUserLogin', [
'user' => $user,
'method' => '2fa',
'ip' => $this->getRequestIp($request),
'request' => $request,
]);
return $this->issueTokenPair($jwt, $user);
}
public function refresh(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['refresh_token']);
$jwt = new JwtAuthenticator($this->grav, $this->config);
$user = $jwt->validateRefreshToken($body['refresh_token']);
if ($user === null) {
throw new UnauthorizedException('Invalid or expired refresh token.');
}
if ($user->get('state', 'enabled') === 'disabled') {
throw new ForbiddenException('This user account is disabled.');
}
// Revoke the old refresh token (rotation)
$jwt->revokeToken($body['refresh_token']);
return $this->issueTokenPair($jwt, $user);
}
public function revoke(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['refresh_token']);
$jwt = new JwtAuthenticator($this->grav, $this->config);
// Best-effort: decode to capture the subject for the logout event.
$user = $jwt->validateRefreshToken($body['refresh_token']);
$jwt->revokeToken($body['refresh_token']);
if ($user !== null) {
$this->fireEvent('onApiUserLogout', [
'user' => $user,
'ip' => $this->getRequestIp($request),
'request' => $request,
]);
}
return ApiResponse::noContent();
}
/**
* POST /auth/forgot-password
*
* Accepts { email } and sends a password reset email if the address
* matches a user. Always returns a neutral success message to prevent
* account enumeration. Rate limited per-username via the login plugin's
* `pw_resets` bucket so enumeration + flood attacks share the login
* plugin's limits.
*/
public function forgotPassword(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['email']);
$email = htmlspecialchars(strip_tags((string) $body['email']), ENT_QUOTES, 'UTF-8');
$neutralResponse = ApiResponse::create([
'message' => 'If an account exists for that email, a reset link has been sent.',
]);
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $neutralResponse;
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->find($email, ['email']);
if (!$user || !$user->exists()) {
return $neutralResponse;
}
if (!isset($this->grav['Email']) || empty($this->config->get('plugins.email.from'))) {
$this->grav['log']->warning('api.auth: forgot-password skipped — email plugin not configured.');
return $neutralResponse;
}
if (!class_exists(Login::class) || !isset($this->grav['login'])) {
$this->grav['log']->warning('api.auth: forgot-password skipped — login plugin not available.');
return $neutralResponse;
}
/** @var Login $login */
$login = $this->grav['login'];
$rateLimiter = $login->getRateLimiter('pw_resets');
$userKey = (string) $user->username;
$rateLimiter->registerRateLimitedAction($userKey);
if ($rateLimiter->isRateLimited($userKey)) {
throw new TooManyRequestsException(
sprintf('Too many password reset requests. Try again in %d minutes.', $rateLimiter->getInterval()),
$rateLimiter->getInterval() * 60,
);
}
try {
$randomBytes = random_bytes(16);
} catch (\Exception) {
$randomBytes = (string) mt_rand();
}
$token = md5(uniqid($randomBytes, true));
$expire = time() + 86400; // 24 hours
// Same storage format as the login plugin's Controller::taskForgot,
// so the reset token is compatible with either admin or site flows.
$user->set('reset', $token . '::' . $expire);
$user->save();
try {
$this->sendAdminNextResetEmail($user, $token, $body['admin_base_url'] ?? null, $request);
} catch (\Throwable $e) {
$this->grav['log']->error('api.auth: failed to send reset email: ' . $e->getMessage());
// Still return neutral success — do not leak mail infrastructure errors.
}
return $neutralResponse;
}
/**
* Send the admin-next password reset email. Self-contained: builds the
* admin-next reset URL (pointing at its own /reset route, not the Grav
* frontend login plugin's /reset_password page) and renders via the
* API plugin's own template so the reset loop never leaves the admin UI.
*/
private function sendAdminNextResetEmail(
UserInterface $user,
string $token,
mixed $clientBaseUrl,
ServerRequestInterface $request,
): void {
if (!isset($this->grav['Email'])) {
throw new \RuntimeException('Email service not available.');
}
$adminBase = $this->resolveAdminBaseUrl($clientBaseUrl, $request);
$resetLink = rtrim($adminBase, '/')
. '/reset?user=' . rawurlencode((string) $user->username)
. '&token=' . rawurlencode($token);
$cfg = $this->grav['config'];
$siteHost = (string) ($cfg->get('plugins.login.site_host') ?: ($this->grav['uri']->host() ?? ''));
$context = [
'reset_link' => $resetLink,
'user' => $user,
'site_name' => $cfg->get('site.title', 'Website'),
'site_host' => $siteHost,
'author' => $cfg->get('site.author.name', ''),
];
$params = [
'to' => $user->email,
'body' => [
[
'content_type' => 'text/html',
'template' => 'emails/api/reset-password.html.twig',
'body' => '',
],
],
];
/** @var \Grav\Plugin\Email\Email $email */
$email = $this->grav['Email'];
$message = $email->buildMessage($params, $context);
$email->send($message);
}
/**
* POST /auth/reset-password
*
* Accepts { username, token, password } and completes the password reset.
* All failures return a deliberately vague error so token probing cannot
* distinguish "no such user" from "wrong token" from "expired token". IP
* is rate-limited via the login plugin's standard login bucket to cap
* token brute-forcing.
*/
public function resetPassword(ServerRequestInterface $request): ResponseInterface
{
$body = $this->getRequestBody($request);
$this->requireFields($body, ['username', 'token', 'password']);
$username = (string) $body['username'];
$token = (string) $body['token'];
$password = (string) $body['password'];
$this->enforceLoginRateLimit($username);
$invalidMessage = 'Invalid or expired reset link.';
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($username);
if (!$user->exists()) {
throw new ValidationException($invalidMessage);
}
$storedReset = (string) $user->get('reset', '');
if (!str_contains($storedReset, '::')) {
throw new ValidationException($invalidMessage);
}
[$goodToken, $expire] = explode('::', $storedReset, 2);
if (!hash_equals($goodToken, $token) || time() > (int) $expire) {
throw new ValidationException($invalidMessage);
}
// Match the login plugin's reset sequence exactly (Controller::taskReset).
unset($user->hashed_password, $user->reset);
$user->password = $password;
$user->save();
$this->resetLoginRateLimit($username);
$this->fireEvent('onApiPasswordReset', [
'user' => $user,
'ip' => $this->getRequestIp($request),
]);
return ApiResponse::create([
'message' => 'Password reset successfully.',
]);
}
/**
* GET /me — Return the authenticated user's profile and resolved permissions.
*/
public function me(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$isSuperAdmin = $this->isSuperAdmin($user);
$resolver = $this->getPermissionResolver();
$resolvedAccess = $resolver->resolvedMap($user, $isSuperAdmin);
return ApiResponse::create([
'username' => $user->username,
'fullname' => $user->get('fullname'),
'email' => $user->get('email'),
'avatar_url' => UserSerializer::resolveAvatarUrl($user),
'super_admin' => $isSuperAdmin,
'access' => $resolvedAccess,
'content_editor' => $user->get('content_editor', ''),
'grav_version' => GRAV_VERSION,
'admin_version' => $this->getAdminPluginVersion(),
]);
}
private function getAdminPluginVersion(): ?string
{
foreach (['admin2', 'admin'] as $slug) {
if (!$this->config->get("plugins.{$slug}.enabled", false)) {
continue;
}
$blueprintFile = $this->grav['locator']->findResource("plugins://{$slug}/blueprints.yaml");
if (!$blueprintFile || !file_exists($blueprintFile)) {
continue;
}
$data = \Grav\Common\Yaml::parse(file_get_contents($blueprintFile));
$version = $data['version'] ?? null;
if ($version) {
return (string) $version;
}
}
return null;
}
private function userRequiresTwoFactor(UserInterface $user): bool
{
if (!class_exists(TwoFactorAuth::class)) {
return false;
}
if (!$this->config->get('plugins.login.twofa_enabled', false)) {
return false;
}
return (bool) $user->get('twofa_enabled') && (bool) $user->get('twofa_secret');
}
/**
* Call the login plugin's checkLoginRateLimit() which both registers and
* checks attempts against max_login_count / max_login_interval using the
* same cache store the frontend login uses. Throws 429 if the caller is
* currently locked out.
*/
private function enforceLoginRateLimit(string $username): void
{
if (!class_exists(Login::class) || !isset($this->grav['login'])) {
return;
}
/** @var Login $login */
$login = $this->grav['login'];
$interval = $login->checkLoginRateLimit($username);
if ($interval > 0) {
throw new TooManyRequestsException(
sprintf('Too many login attempts. Try again in %d minutes.', $interval),
$interval * 60,
);
}
}
private function resetLoginRateLimit(string $username): void
{
if (!class_exists(Login::class) || !isset($this->grav['login'])) {
return;
}
/** @var Login $login */
$login = $this->grav['login'];
$login->resetLoginRateLimit($username);
}
private function getRequestIp(ServerRequestInterface $request): string
{
$server = $request->getServerParams();
return (string) ($server['REMOTE_ADDR'] ?? '');
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Page\Media;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\MediaSerializer;
use Grav\Plugin\Api\Services\BlueprintPathResolver;
use Grav\Plugin\Api\Services\ThumbnailService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Read-only file browse endpoint for blueprint fields that declare a
* `folder:` option (filepicker, mediapicker, …).
*
* Mirrors admin-classic's `taskGetFilesInFolder` semantics — `folder` can be
* any Grav stream (`user://media`, `theme://images`, `account://`, …), a
* `self@:subpath` token resolved against `scope`, or a plain relative path
* confined under `user/`.
*
* The page-attached media case (`@self` / `self@` / empty) is intentionally
* not handled here. The admin-next client already has the page's media via
* `/pages/{route}/media`; rerouting it through this controller would force
* a round-trip for the most common case. Calls with a `@self` literal get
* a 422 sentinel so the client can fall back.
*/
class BlueprintFilesController extends AbstractApiController
{
private ?BlueprintPathResolver $resolver = null;
private ?MediaSerializer $serializer = null;
/**
* GET /blueprint-files?folder=<stream-or-token>&scope=<scope>&accept=<csv>&preview_images=1
*/
public function list(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.read');
$query = $request->getQueryParams();
$folder = (string)($query['folder'] ?? '');
$scope = (string)($query['scope'] ?? '');
$acceptRaw = (string)($query['accept'] ?? '');
if ($folder === '') {
throw new ValidationException('folder is required.');
}
$resolver = $this->resolver();
$resolver->assertSafe($folder);
// `@self` / `self@` literals are page-media — the client has that already.
if ($resolver->isSelfLiteral($folder)) {
return ApiResponse::create([
'error' => 'PAGE_MEDIA_ONLY',
'message' => 'Use /pages/{route}/media for @self / self@ folders.',
], 422);
}
$abs = $resolver->resolve($folder, $scope, $this->getUser($request));
$logicalFolder = $resolver->logicalParent($folder, $scope);
// Resolve the file list (or empty list when the folder doesn't exist
// yet — common for fresh installs targeting `theme://images` on a
// theme that ships no images).
$items = [];
if (is_dir($abs)) {
$accept = $this->parseAccept($acceptRaw);
foreach ($this->iterateMedia($abs) as $name => $medium) {
if (!$this->matchesAccept((string)$name, (string)($medium->get('mime') ?? ''), $accept)) {
continue;
}
$items[] = $this->serializer()->serialize($medium);
}
}
// Use the paginated envelope (`{ data: [...], meta: { pagination, … } }`)
// even though we don't actually paginate — it matches the shape the
// admin-next client already expects from `/media` and avoids the
// double-wrap that `ApiResponse::create` would impose on a hand-built
// `{ data, meta }` payload.
$total = count($items);
return ApiResponse::paginated(
$items,
$total,
1,
max($total, 1),
$this->getApiBaseUrl() . '/blueprint-files',
200,
[],
[
'folder' => $logicalFolder,
'scope' => $scope !== '' ? $scope : null,
'exists' => is_dir($abs),
],
);
}
/**
* Seam for tests. Yields `filename => Medium` over the given absolute
* directory. Production path delegates to Grav's real Media class.
*/
protected function iterateMedia(string $absoluteDir): iterable
{
return (new Media($absoluteDir))->all();
}
/**
* Parse the comma-separated `accept` query param into an array of
* patterns. Empty input → no filtering.
*/
private function parseAccept(string $raw): array
{
if ($raw === '') return [];
$parts = array_filter(array_map('trim', explode(',', $raw)), static fn($s) => $s !== '');
return array_values($parts);
}
/**
* Mirror admin-classic's accept regex: extension form (`.pdf`, `*.jpg`)
* matches the filename; mime form (`image/png`, `image/*`) matches the
* Grav-detected mime. The `*` / `+` / `.` escaping mirrors
* AdminBaseController::taskFilesUpload.
*
* @param string[] $patterns
*/
private function matchesAccept(string $filename, string $mime, array $patterns): bool
{
if ($patterns === []) return true;
foreach ($patterns as $type) {
if ($type === '*') return true;
$find = str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type);
$isMime = str_contains($type, '/');
if ($isMime) {
if (preg_match('#' . $find . '$#', $mime)) return true;
} else {
if (preg_match('#' . $find . '$#', $filename)) return true;
}
}
return false;
}
private function resolver(): BlueprintPathResolver
{
return $this->resolver ??= new BlueprintPathResolver($this->grav);
}
private function serializer(): MediaSerializer
{
if (!$this->serializer) {
$cacheDir = $this->grav['locator']->findResource('cache://') . '/api/thumbnails';
$thumb = new ThumbnailService($cacheDir);
$this->serializer = new MediaSerializer($thumb, $this->getApiBaseUrl());
}
return $this->serializer;
}
}
@@ -0,0 +1,370 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Filesystem\Folder;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\BlueprintPathResolver;
use Grav\Plugin\Api\Services\UploadFieldSettings;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
/**
* Destination-aware file upload for blueprint-driven `type: file` fields.
*
* Mirrors admin-classic's `taskFilesUpload` semantics: the caller supplies a
* blueprint `destination` (Grav stream, `self@:subpath`, or plain relative
* path) plus the owning `scope` (plugins/<slug>, themes/<slug>, pages/<route>,
* users/<username>) and the controller resolves the target directory using
* Grav's locator, writes the file, and returns the saved path.
*
* Scope is required because `self@:` is relative to the blueprint's owner —
* a theme's favicon field saves under `user/themes/<slug>/`, a plugin's logo
* field under `user/plugins/<slug>/`, and so on. Without it we can't resolve
* `self@:` safely.
*/
class BlueprintUploadController extends AbstractApiController
{
private const MAX_UPLOAD_SIZE = 64 * 1_048_576; // 64 MB
/**
* Image-only allowlist for uploads landing in `user/accounts/` (avatars).
*
* `user/accounts/` doubles as the directory Grav reads as authoritative
* account YAML, so allowing arbitrary extensions there is a privilege
* escalation surface (GHSA-6xx2-m8wv-756h: a YAML file dropped here
* becomes a fully functional account, including `access.api.super`).
* The only legitimate blueprint-upload use case for this directory is
* avatars, so the endpoint hard-restricts it to image extensions.
*/
private const ACCOUNTS_IMAGE_EXTENSIONS = [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico',
];
/**
* Per-endpoint extension denylist on top of `security.uploads_dangerous_extensions`.
*
* Not all of these are "code" in the classic sense, but every one is a
* file Grav (or a sibling tool) parses as authoritative configuration if
* it lands in the right directory. Keeping them out of any blueprint-
* upload target — not just `user/accounts/` — closes a class of bugs
* where a future locator/scope edge case unexpectedly resolves into
* `user/config/`, `user/env/<x>/config/`, or a plugin's own config dir.
*/
private const FORBIDDEN_EXTENSIONS = [
'yaml', 'yml', // Grav account / config / blueprint
'json', // generic config / data
'twig', // template code
'env', // env files
'neon', // alt config format
'lock', // composer/npm lockfiles
];
private ?BlueprintPathResolver $resolver = null;
private function resolver(): BlueprintPathResolver
{
return $this->resolver ??= new BlueprintPathResolver($this->grav);
}
public function upload(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$body = $request->getParsedBody() ?? [];
$destination = is_array($body) ? (string)($body['destination'] ?? '') : '';
$scope = is_array($body) ? (string)($body['scope'] ?? '') : '';
if ($destination === '') {
throw new ValidationException('destination is required.');
}
$this->resolver()->assertSafe($destination);
$targetDir = $this->resolver()->resolve($destination, $scope, $this->getUser($request));
$this->guardConfigBearingTarget($targetDir);
$files = $this->flattenUploadedFiles($request->getUploadedFiles());
if ($files === []) {
throw new ValidationException('No file was uploaded.');
}
if (!is_dir($targetDir)) {
Folder::create($targetDir);
}
$isAccountsDir = $this->resolver()->classifyTargetDir($targetDir) === 'accounts';
// Per-field upload settings (random_name, avoid_overwriting, accept,
// filesize) ride in on the same body as destination/scope.
$settings = is_array($body) ? UploadFieldSettings::fromParams($body) : UploadFieldSettings::none();
$saved = [];
foreach ($files as $file) {
$saved[] = $this->processUploadedFile($file, $targetDir, $isAccountsDir, $settings);
}
// Build a response payload describing each saved file in a Grav
// file-field-compatible shape. `path` is the *logical* user-rooted
// path (e.g. `user/themes/quark2/images/logo/file.png`) — derived
// from the original destination+scope inputs, not the realpath, so
// symlinked theme/plugin folders round-trip through a later delete
// cleanly.
$response = [];
$logicalParent = $this->resolver()->logicalParent($destination, $scope);
foreach ($saved as $filename) {
$absolute = $targetDir . '/' . $filename;
$logical = $logicalParent !== null
? 'user/' . trim($logicalParent, '/') . '/' . $filename
: $this->fallbackRelative($absolute);
$response[] = [
'name' => $filename,
'path' => $logical,
'size' => filesize($absolute) ?: 0,
'type' => mime_content_type($absolute) ?: 'application/octet-stream',
'url' => $this->buildPublicUrl($logical),
];
}
return ApiResponse::create($response, 201);
}
/**
* Last-resort relative path: strip user-root prefix when we can, otherwise
* surface the absolute path so at least the server knows what it wrote.
*/
private function fallbackRelative(string $absolute): string
{
$userRoot = $this->resolver()->userRoot();
if ($userRoot !== null && str_starts_with($absolute, $userRoot . '/')) {
return 'user/' . substr($absolute, strlen($userRoot) + 1);
}
return $absolute;
}
public function delete(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$body = $this->getRequestBody($request);
$path = (string)($body['path'] ?? '');
if ($path === '') {
throw new ValidationException('path is required.');
}
$absolute = $this->resolveDeletePath($path);
$targetDir = dirname($absolute);
$filename = basename($absolute);
$this->guardConfigBearingTarget($targetDir, $filename);
// Symmetric to the upload path: deletes targeting `user/accounts/` may
// only act on image files (avatars). Without this gate, a holder of
// `api.media.write` could `unlink` arbitrary account YAMLs.
if ($this->resolver()->classifyTargetDir($targetDir) === 'accounts') {
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (!in_array($extension, self::ACCOUNTS_IMAGE_EXTENSIONS, true)) {
throw new ForbiddenException(
"Deletes under user/accounts/ are restricted to avatar image files."
);
}
}
$this->assertSafeExtension($filename, false);
// Idempotent: a file that's already gone is indistinguishable from a
// file we just deleted, so don't pollute the client with a 404 that
// forces special-case handling. Anything non-file (directory,
// symlink-to-elsewhere, etc.) still errors — those are genuine
// misuses, not "already gone".
if (!file_exists($absolute)) {
return ApiResponse::noContent();
}
if (!is_file($absolute)) {
throw new ValidationException('Target is not a regular file.');
}
unlink($absolute);
// Clean up adjacent metadata if present.
$meta = $absolute . '.meta.yaml';
if (file_exists($meta)) {
unlink($meta);
}
return ApiResponse::noContent();
}
/**
* Resolve the `path` for a delete request.
*
* Clients send the same logical path we returned on upload (e.g.
* `themes/quark2/images/logo/foo.png`), always relative to the user
* root. No absolute paths and no `..` traversal are permitted on input —
* that's what keeps the endpoint safe. Once the path is validated, we
* join it to the user root and trust the resolved location even if it
* passes through a Grav symlink (a common setup where `user/themes/X`
* points at a dev checkout outside `user/`). The symlink is already part
* of Grav's resource map; pretending it isn't would lock out valid
* deletes on every non-trivial install.
*/
private function resolveDeletePath(string $path): string
{
$path = ltrim($path, '/');
// Allow both "themes/..." and "user/themes/..." inputs — the latter
// is what upload returns when the destination lives under user/
// directly (no symlink), so both forms round-trip.
if (str_starts_with($path, 'user/')) {
$path = substr($path, 5);
}
if (str_contains($path, '..') || str_contains($path, "\0")) {
throw new ValidationException('Traversal or null bytes not allowed in path.');
}
$userRoot = $this->resolver()->userRoot();
if ($userRoot === null) {
throw new ValidationException('User root is not available.');
}
return $userRoot . '/' . $path;
}
private function buildPublicUrl(string $relative): ?string
{
$uri = $this->grav['uri'];
$base = method_exists($uri, 'rootUrl') ? $uri->rootUrl() : '';
return rtrim($base, '/') . '/' . ltrim($relative, '/');
}
private function processUploadedFile(
UploadedFileInterface $file,
string $targetDir,
bool $isAccountsDir,
?UploadFieldSettings $settings = null,
): string {
if ($file->getError() !== UPLOAD_ERR_OK) {
throw new ValidationException('File upload failed.');
}
$size = $file->getSize();
if ($size !== null && $size > self::MAX_UPLOAD_SIZE) {
throw new ValidationException(
sprintf('File exceeds maximum allowed size of %d MB.', self::MAX_UPLOAD_SIZE / 1_048_576)
);
}
$settings?->assertFilesize($size);
$originalName = $file->getClientFilename() ?? 'upload';
$filename = basename($originalName);
$this->assertSafeFilename($filename);
// Extension policy first (the security floor), then the field's accept
// allowlist. Both run against the original name; random_name/
// avoid_overwriting are applied afterwards and preserve the extension.
$this->assertSafeExtension($filename, $isAccountsDir);
$settings?->assertAccepted($filename);
if ($settings !== null) {
$filename = $settings->resolveFilename($filename, $targetDir);
}
$file->moveTo($targetDir . '/' . $filename);
return $filename;
}
/**
* Reject filenames that would escape the target dir or hide as a dotfile.
*/
private function assertSafeFilename(string $filename): void
{
if (
$filename === ''
|| str_contains($filename, '..')
|| str_contains($filename, "\0")
|| str_starts_with($filename, '.')
) {
throw new ValidationException("Invalid filename: '{$filename}'.");
}
}
/**
* Apply layered extension policy:
*
* 1. `security.uploads_dangerous_extensions` (Grav-wide denylist: php, js, exe, ...)
* 2. Per-endpoint denylist for known-config formats (yaml, json, twig, ...)
* 3. If target is `user/accounts/`, restrict to image extensions only —
* the directory doubles as Grav's authoritative account store, so
* anything non-image is a privesc surface (GHSA-6xx2-m8wv-756h).
*
* Returns the lowercased extension for callers that want it.
*/
private function assertSafeExtension(string $filename, bool $isAccountsDir): string
{
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($extension === '') {
throw new ValidationException('Uploaded file must have a file extension.');
}
$dangerous = array_map('strtolower', (array) $this->config->get('security.uploads_dangerous_extensions', []));
if (in_array($extension, $dangerous, true)) {
throw new ValidationException("File extension '.{$extension}' is not allowed for security reasons.");
}
if (in_array($extension, self::FORBIDDEN_EXTENSIONS, true)) {
throw new ValidationException("File extension '.{$extension}' is not allowed for blueprint uploads.");
}
if ($isAccountsDir && !in_array($extension, self::ACCOUNTS_IMAGE_EXTENSIONS, true)) {
throw new ValidationException(
"Only image files (" . implode(', ', self::ACCOUNTS_IMAGE_EXTENSIONS) . ") may be uploaded to user/accounts/."
);
}
return $extension;
}
/**
* Hard-deny writes resolving to directories that Grav reads as
* authoritative configuration: `user/config/` and any `user/env/.../config/`.
* `user/accounts/` is allowed (avatars) but extension-restricted in
* `assertSafeExtension()`.
*
* `$filename` is optional — pass it for delete-path checks (where we
* have the final filename) so the error message can name the target;
* for upload checks the per-file extension policy fires later anyway.
*/
private function guardConfigBearingTarget(string $absoluteDir, ?string $filename = null): void
{
$classification = $this->resolver()->classifyTargetDir($absoluteDir);
if ($classification === 'config' || $classification === 'env') {
$where = $filename !== null ? "'{$filename}' under" : 'into';
throw new ForbiddenException(
"Uploads {$where} the '{$classification}' directory are not allowed via this endpoint."
);
}
}
/**
* @param array<UploadedFileInterface|array> $files
* @return UploadedFileInterface[]
*/
private function flattenUploadedFiles(array $files): array
{
$result = [];
foreach ($files as $file) {
if ($file instanceof UploadedFileInterface) {
$result[] = $file;
} elseif (is_array($file)) {
$result = array_merge($result, $this->flattenUploadedFiles($file));
}
}
return $result;
}
}
@@ -0,0 +1,606 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Data\Blueprints;
use Grav\Common\Data\Data;
use Grav\Common\Yaml;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\ConfigDiffer;
use Grav\Plugin\Api\Services\ConfigScopes;
use Grav\Plugin\Api\Services\EnvironmentService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class ConfigController extends AbstractApiController
{
/**
* Tool-managed scopes that carry execution- or security-sensitive sinks and
* must never be reachable through the generic api.config.read/write
* permissions a non-super "configuration admin" can hold.
*
* `scheduler` is the critical case: scheduler.custom_jobs[].command is fed
* straight into a Symfony Process by Job::run(), so write access to this
* scope is arbitrary command execution. The Scheduler tool is super-only in
* admin-classic, and these scopes are already excluded from index() listing
* because they "belong to tools" — but resolveConfigKey()/scopeFileName()
* still accept them, so without this guard a user holding only
* api.config.write could escalate to RCE (GHSA-wx62). Require API super
* authority for these scopes regardless of the generic config permission.
*/
private const PRIVILEGED_SCOPES = ['scheduler', 'backups'];
/**
* Security-sensitive scopes that any config reader may VIEW but only an API
* super user may WRITE. Unlike PRIVILEGED_SCOPES (tool-managed, fully
* hidden from index() and blocked for read+write), these stay listed and
* readable (a non-super "configuration admin" can still inspect them), but
* must not persist changes, because they steer site-wide execution and
* security behavior: `system` carries `twig.safe_functions` (PHP functions
* callable from trusted templates) and `security` owns the Twig content
* sandbox and XSS/CSP settings. The inheritable `admin.configuration`
* permission would otherwise let a non-super admin weaken these
* (GHSA-9wg2-prc3-vx89). Write-only gate; reads are intentionally left open.
*/
private const SUPER_WRITE_SCOPES = ['system', 'security'];
/**
* GET /config - List available configuration sections.
*/
public function index(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.read');
/** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceIterator $iterator */
$iterator = $this->grav['locator']->getIterator('blueprints://config');
$configurations = [];
foreach ($iterator as $file) {
if ($file->isDir() || !preg_match('/^[^.].*.yaml$/', $file->getFilename())) {
continue;
}
$name = pathinfo($file->getFilename(), PATHINFO_FILENAME);
// Skip scheduler and backups (they belong to tools)
if (in_array($name, ['scheduler', 'backups', 'streams'], true)) {
continue;
}
$configurations[$name] = true;
}
// Sort and enforce canonical ordering: system, site first; info last
ksort($configurations);
$configurations = ['system' => true, 'site' => true] + $configurations + ['info' => true];
return ApiResponse::create(array_keys($configurations));
}
public function show(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.read');
$scope = $this->getRouteParam($request, 'scope');
$this->assertScopeAllowed($request, $scope);
$configKey = $this->resolveConfigKey($scope);
if ($this->config->get($configKey) === null) {
throw new NotFoundException("Configuration scope '{$scope}' not found.");
}
// Body is the full merged config resolved for the requested target, so
// base/"Default" shows base config rather than the active env overlay.
// The ETag keys off the persisted delta for the same write target a
// subsequent PATCH would resolve, so the client's stored ETag still
// validates on the next save.
$targetEnv = $this->resolveTargetEnv($request);
$etag = $this->generateEtag($this->configEtagBasis($scope, $targetEnv));
// meta.overrides / meta.fallback drive the per-field override indicators
// and the revert affordance in admin2 (see docs/config-overrides-revert).
$meta = $this->overrideMeta($scope, $targetEnv);
return $this->respondWithEtag($this->effectiveConfig($scope, $targetEnv), 200, [], $etag, $meta);
}
/**
* POST /config/{scope}/revert — drop one or more overridden keys from the
* active layer's file (or reset the whole scope), letting the value beneath
* take over. Body: `{"keys": ["pages.theme", ...]}` or `{"reset": true}`.
*
* The active layer is the same write target show()/update() resolve from
* X-Config-Environment: base `user/config/<scope>.yaml`, or an environment's
* `user/env/<env>/config/<scope>.yaml`. Reverting a key there falls back to
* the layer beneath (base → core/plugin defaults; env → base).
*/
public function revert(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.write');
$scope = $this->getRouteParam($request, 'scope');
$this->assertScopeAllowed($request, $scope);
$this->assertScopeWritable($request, $scope);
$configKey = $this->resolveConfigKey($scope);
if ($this->config->get($configKey) === null) {
throw new NotFoundException("Configuration scope '{$scope}' not found.");
}
$targetEnv = $this->resolveTargetEnv($request);
// Same ETag basis as show()/update(), so the client's stored If-Match validates.
$this->validateEtag($request, $this->generateEtag($this->configEtagBasis($scope, $targetEnv)));
$body = $this->getRequestBody($request);
$reset = !empty($body['reset']);
$keys = $body['keys'] ?? [];
if (!$reset && (!is_array($keys) || $keys === [])) {
throw new ValidationException('Provide a non-empty "keys" array or "reset": true.');
}
$filePath = $this->resolveConfigFile($scope, $targetEnv);
if ($reset) {
// Nuke the active layer's file entirely → falls back to the parent layer.
if ($filePath && is_file($filePath)) {
unlink($filePath);
}
} elseif ($filePath) {
// The file already IS the persisted delta — drop each requested key,
// prune empties, and rewrite, or remove the file if nothing remains.
$delta = is_file($filePath) ? Yaml::parse((string) file_get_contents($filePath)) : [];
if (!is_array($delta)) {
$delta = [];
}
$differ = new ConfigDiffer($this->grav);
foreach ($keys as $key) {
if (is_string($key) && $key !== '') {
$delta = $differ->unsetDotPath($delta, $key);
}
}
if ($delta === []) {
if (is_file($filePath)) {
unlink($filePath);
}
} else {
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
file_put_contents($filePath, Yaml::dump($delta));
}
}
// Refresh in-memory config + clear cache so the next read is correct.
$effective = $this->effectiveConfig($scope, $targetEnv);
$this->config->set($configKey, $effective);
$this->grav['cache']->clearCache('standard');
$this->fireEvent('onApiConfigUpdated', ['scope' => $scope, 'data' => $effective]);
$tags = ['config:update:' . $scope];
if (str_starts_with($scope, 'plugins/')) {
$pluginName = substr($scope, 8);
$tags[] = 'plugins:update:' . $pluginName;
$tags[] = 'plugins:list';
}
$etag = $this->generateEtag($this->configEtagBasis($scope, $targetEnv));
$meta = $this->overrideMeta($scope, $targetEnv);
return $this->respondWithEtag($effective, 200, $tags, $etag, $meta);
}
/**
* Override metadata for the active layer: which dotted leaf paths the
* target's file actually overrides, and the value each would revert to.
*
* @return array{overrides: list<string>, fallback: array<string, mixed>}
*/
private function overrideMeta(string $scope, ?string $targetEnv): array
{
$differ = new ConfigDiffer($this->grav);
$parent = $differ->parent($scope, $targetEnv);
$delta = $differ->diff($this->effectiveConfig($scope, $targetEnv), $parent);
$overrides = ConfigDiffer::flattenLeaves($delta);
$fallback = [];
foreach ($overrides as $path) {
$fallback[$path] = ConfigDiffer::valueAtPath($parent, $path);
}
return ['overrides' => $overrides, 'fallback' => $fallback];
}
public function update(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.write');
$scope = $this->getRouteParam($request, 'scope');
$this->assertScopeAllowed($request, $scope);
$this->assertScopeWritable($request, $scope);
$configKey = $this->resolveConfigKey($scope);
if ($this->config->get($configKey) === null) {
throw new NotFoundException("Configuration scope '{$scope}' not found.");
}
// Write target: X-Config-Environment selects an existing env folder; empty/default = base.
$targetEnv = $this->resolveTargetEnv($request);
// Edit against the baseline for THIS target, not the live (boot-env)
// config — otherwise a save under base/"Default" would diff the active
// env overlay against defaults and copy the overlay into user/config.
$existing = $this->effectiveConfig($scope, $targetEnv);
// ETag validation — key off the persisted delta, the same basis show()
// and the previous save's response used, so If-Match matches.
$this->validateEtag($request, $this->generateEtag($this->configEtagBasis($scope, $targetEnv)));
$body = $this->getRequestBody($request);
if (empty($body)) {
throw new ValidationException('Request body must contain configuration values to update.');
}
// Load the blueprint and apply field-type filtering (e.g., commalist → array)
$blueprint = $this->loadBlueprint($scope);
// Merge provided values with existing config. Prefer Grav's
// blueprint-aware merge — it REPLACES map values at blueprint-defined
// leaf fields instead of deep-merging them, which is what we want for
// e.g. `type: file` fields whose keys are file paths: when the user
// removes a file the client drops that key, and a blind deep-merge
// would revive it from $existing. Fall back to our list-aware
// mergePatch only when no blueprint is available (rare — mostly test
// fixtures); plain array_replace_recursive would corrupt YAML lists.
if ($blueprint !== null && is_array($existing)) {
$merged = $blueprint->mergeData($existing, $body);
} else {
$merged = is_array($existing) ? $this->mergePatch($existing, $body) : $body;
}
// Validate the submitted fields against the blueprint before persisting
// (getgrav/grav-plugin-admin2#30). A `validate.required` field sent
// empty now returns 422 instead of silently saving. We validate the
// delta, not the merged whole — see validateChangedFields() for why
// (stock Grav config doesn't pass a whole-object validate).
$this->validateChangedFields($body, $blueprint);
$obj = new Data($merged, $blueprint);
$obj->filter(true, true);
// Set the config file on the Data object so plugins (e.g., revisions-pro)
// can read the file path for revision tracking.
$configFile = $this->resolveConfigFile($scope, $targetEnv);
if ($configFile) {
$obj->file(\RocketTheme\Toolbox\File\YamlFile::instance($configFile));
}
// Set the AdminProxy route so plugins that detect context from the admin
// route (e.g., revisions-pro getDataType) work correctly in API context.
$admin = $this->grav['admin'] ?? null;
if ($admin && property_exists($admin, 'route')) {
$admin->route = $this->scopeToAdminRoute($scope);
}
// Allow plugins to modify config before save
$this->fireAdminEvent('onAdminSave', ['object' => &$obj]);
// Extract (potentially modified) data back from the Data object
$merged = $obj->toArray();
// Update in-memory config
$this->config->set($configKey, $merged);
// Persist to the appropriate YAML file
$this->writeConfigFile($scope, $merged, $targetEnv);
// Clear config cache
$this->grav['cache']->clearCache('standard');
$this->fireAdminEvent('onAdminAfterSave', ['object' => $obj]);
$this->fireEvent('onApiConfigUpdated', ['scope' => $scope, 'data' => $merged]);
// Emit invalidations — plugin config changes also invalidate the plugins list.
$tags = ['config:update:' . $scope];
if (str_starts_with($scope, 'plugins/')) {
$pluginName = substr($scope, 8);
$tags[] = 'plugins:update:' . $pluginName;
$tags[] = 'plugins:list';
}
// Response body is the full merged config for the target (re-resolved
// from disk so it matches a subsequent show()); the ETag keys off the
// persisted delta, so the client's stored ETag stays valid for the
// next save even though default-equal values aren't written to disk.
$etag = $this->generateEtag($this->configEtagBasis($scope, $targetEnv));
$meta = $this->overrideMeta($scope, $targetEnv);
return $this->respondWithEtag($this->effectiveConfig($scope, $targetEnv), 200, $tags, $etag, $meta);
}
/**
* Full merged config for a scope, resolved for the requested write target —
* the response body for show()/update() and the baseline a save edits.
*
* The live config->get() snapshot only ever represents the ONE environment
* Grav booted under, and Grav resolves that once at boot and can't switch
* mid-request. Any request can target a different env via X-Config-Environment
* (most importantly base/"Default" while a hostname overlay is active), so we
* always recompute the merge from YAML files (ConfigDiffer::effective). That
* keeps "Default" showing — and saving against — base config, not the env
* overlay, and stays correct for any other named target too.
*/
private function effectiveConfig(string $scope, ?string $targetEnv): array
{
// Always resolve from YAML files for the requested target. We must NOT
// shortcut to the live config->get() snapshot even when the target looks
// like the booted environment: behind a reverse proxy Grav loads its
// config overlay from the REAL connection host (e.g. `localhost` via
// SERVER_NAME), which need not match the requested target. (Note
// EnvironmentService::activeEnvironment() now reports that booted host,
// not the forwarded one — but $targetEnv may still be any other env.)
// ConfigDiffer::effective() is target-exact regardless of which host
// booted the request, and already re-applies GRAV_CONFIG__* env-var
// overrides; blueprint field defaults are filled client-side from the
// blueprint, so the form stays complete.
$data = (new ConfigDiffer($this->grav))->effective($scope, $targetEnv);
return is_array($data) ? $data : ['value' => $data];
}
/**
* Representation the ETag is hashed from: the *persisted delta* (values
* that override the parent), NOT the full merged config.
*
* The delta is the only representation that survives the save→reload round-trip.
* writeConfigFile() stores only the delta, so a value equal to its default
* (e.g. `system.pages.events.twig: true`) is present in the in-memory
* config right after config->set() but absent once the file is reloaded
* from disk on the next request. Hashing the full config therefore yielded
* a different ETag on the following save and broke If-Match with a 409
* (getgrav/grav-plugin-admin2#28). The delta is invariant because it is
* defined relative to the parent: a default-equal value is stripped on
* both sides of the round-trip. Canonicalized so key order can't shift the
* hash either.
*/
private function configEtagBasis(string $scope, ?string $targetEnv): array
{
$current = $this->effectiveConfig($scope, $targetEnv);
$differ = new ConfigDiffer($this->grav);
$delta = $differ->diff($current, $differ->parent($scope, $targetEnv));
return ConfigDiffer::canonicalize($delta);
}
/**
* Resolve the scope route parameter to a Grav config key.
*
* Supported scopes:
* - system -> 'system'
* - site -> 'site'
* - plugins/{name} -> 'plugins.{name}'
* - themes/{name} -> 'themes.{name}'
*/
/**
* Map a config scope to the admin route format that plugins expect.
*/
private function scopeToAdminRoute(string $scope): string
{
return match (true) {
str_starts_with($scope, 'plugins/') => '/' . $scope,
str_starts_with($scope, 'themes/') => '/' . $scope,
default => '/config/' . $scope,
};
}
/**
* Resolve the config file path for a given scope.
*
* Writes land in base user/config/ unless $targetEnv is a non-empty string
* matching an existing user/env/<env>/ folder. We deliberately avoid the
* `config://` stream here because its first resolved path can be an env
* folder Grav auto-inferred from the hostname — that would create an
* unintended user/<host>/ folder on save.
*/
private function resolveConfigFile(string $scope, ?string $targetEnv = null): ?string
{
try {
return $this->resolveWriteDir($targetEnv) . '/' . $this->scopeFileName($scope);
} catch (\Throwable) {
return null;
}
}
/**
* Load the blueprint for the given config scope.
*
* Blueprints define field types (e.g., commalist) that determine how
* values are coerced — without this, arrays may be saved as strings.
*/
private function loadBlueprint(string $scope): ?\Grav\Common\Data\Blueprint
{
try {
$blueprintKey = match (true) {
in_array($scope, ConfigScopes::CORE) => 'config/' . $scope,
str_starts_with($scope, 'plugins/') => 'plugins/' . substr($scope, 8),
str_starts_with($scope, 'themes/') => 'themes/' . substr($scope, 7),
ConfigScopes::isCustom($this->grav, $scope) => 'config/' . $scope,
default => null,
};
if ($blueprintKey === null) {
return null;
}
$blueprints = new Blueprints();
return $blueprints->get($blueprintKey);
} catch (\Exception) {
// If blueprint can't be loaded, save without filtering
return null;
}
}
/**
* Reject access to execution- or security-sensitive, tool-managed scopes
* unless the caller is an API super user. See PRIVILEGED_SCOPES (GHSA-wx62).
*/
private function assertScopeAllowed(ServerRequestInterface $request, ?string $scope): void
{
if ($scope !== null && in_array($scope, self::PRIVILEGED_SCOPES, true)
&& !$this->isSuperAdmin($this->getUser($request))) {
throw new ForbiddenException(
"Configuration scope '{$scope}' is tool-managed and restricted to API super users."
);
}
}
/**
* Reject WRITES to security-sensitive scopes unless the caller is an API
* super user. Reads/listing remain open. See SUPER_WRITE_SCOPES
* (GHSA-9wg2-prc3-vx89).
*/
private function assertScopeWritable(ServerRequestInterface $request, ?string $scope): void
{
if ($scope !== null && in_array($scope, self::SUPER_WRITE_SCOPES, true)
&& !$this->isSuperAdmin($this->getUser($request))) {
throw new ForbiddenException(
"Configuration scope '{$scope}' can only be modified by an API super user."
);
}
}
private function resolveConfigKey(?string $scope): string
{
if ($scope === null || $scope === '') {
throw new ValidationException('Configuration scope is required.');
}
return match (true) {
$scope === 'system' => 'system',
$scope === 'site' => 'site',
$scope === 'media' => 'media',
$scope === 'security' => 'security',
$scope === 'scheduler' => 'scheduler',
$scope === 'backups' => 'backups',
str_starts_with($scope, 'plugins/') => 'plugins.' . substr($scope, 8),
str_starts_with($scope, 'themes/') => 'themes.' . substr($scope, 7),
// Site-authored top-level config (cookbook custom yaml): the scope
// name is its own config key (user/config/<scope>.yaml).
ConfigScopes::isCustom($this->grav, $scope) => $scope,
default => throw new NotFoundException("Unknown configuration scope '{$scope}'."),
};
}
/**
* Resolve the scope to a filesystem path and write the YAML config file.
*
* We persist only the delta vs the parent (defaults for base writes;
* defaults+base for env writes). This mirrors how developers hand-edit
* Grav configs — every file contains only the values that actually
* override something lower in the stack.
*/
private function writeConfigFile(string $scope, mixed $data, ?string $targetEnv = null): void
{
$filePath = $this->resolveWriteDir($targetEnv) . '/' . $this->scopeFileName($scope);
$full = is_array($data) ? $data : ['value' => $data];
$differ = new ConfigDiffer($this->grav);
// Never persist values supplied through GRAV_CONFIG__* env vars (.env);
// they're re-applied at runtime and writing them would leak secrets to disk.
$full = $differ->stripEnvironmentOverrides($full, $scope);
$parent = $differ->parent($scope, $targetEnv);
$delta = $differ->diff($full, $parent);
// No overrides and no pre-existing file → don't create an empty placeholder.
if ($delta === [] && !is_file($filePath)) {
return;
}
// Only ever create plugin/theme sub-dirs inside an existing base or env
// write dir. We never create env roots — those must be opted into
// explicitly via POST /system/environments.
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
file_put_contents($filePath, Yaml::dump($delta));
}
/**
* Where config writes land.
*
* Base user/config/ by default. When $targetEnv is set, the matching
* user/env/<env>/config/ is used — but only if it already exists, we
* never implicitly create env folders.
*/
private function resolveWriteDir(?string $targetEnv = null): string
{
if ($targetEnv !== null && $targetEnv !== '') {
$dir = (new EnvironmentService($this->grav))->envConfigRoot($targetEnv);
if ($dir === null) {
throw new ValidationException("Environment '{$targetEnv}' does not exist. Create it first via POST /system/environments.");
}
return $dir;
}
$userConfig = $this->grav['locator']->findResource('user://config', true);
if (!$userConfig) {
throw new \RuntimeException('Base user/config directory not found.');
}
return $userConfig;
}
/**
* Where a write should land for this request.
*
* header present + env name → that env (validated, must exist on disk)
* header present + `default`/base → explicit base write (the admin-next
* sentinel; non-empty so proxies/FPM
* can't strip it the way empty values
* get dropped)
* header present + empty → explicit base write (legacy opt-out)
* header absent → Grav's currently-active env if it has
* a config dir on disk; otherwise base
*
* The auto-detect branch keeps writes consistent with reads: config is
* loaded with `user/<active-env>/config` overlaid on `user/config`, so
* persisting to base when an env overlay exists lets the env file silently
* shadow the write. (See: enabling a plugin that's pinned `enabled: false`
* in a hostname-derived env folder.)
*/
private function resolveTargetEnv(ServerRequestInterface $request): ?string
{
if (!$request->hasHeader('X-Config-Environment')) {
return (new EnvironmentService($this->grav))->activeEnvironment();
}
$name = trim($request->getHeaderLine('X-Config-Environment'));
if ($name === '' || EnvironmentService::isReservedName($name)) {
return null;
}
if (!EnvironmentService::isValidName($name)) {
throw new ValidationException("Invalid X-Config-Environment header: '{$name}'.");
}
return $name;
}
/**
* Filename for a scope, relative to a config directory.
*/
private function scopeFileName(string $scope): string
{
return match (true) {
in_array($scope, ConfigScopes::CORE, true) => $scope . '.yaml',
str_starts_with($scope, 'plugins/') => 'plugins/' . substr($scope, 8) . '.yaml',
str_starts_with($scope, 'themes/') => 'themes/' . substr($scope, 7) . '.yaml',
ConfigScopes::isCustom($this->grav, $scope) => $scope . '.yaml',
default => throw new NotFoundException("Unknown configuration scope '{$scope}'."),
};
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
/**
* Context Panels API — lets plugins register slide-in panels
* triggered by toolbar buttons in the admin-next page editor.
*
* Plugins listen for `onApiContextPanels` to register panels.
*
* Panel format:
* [
* 'id' => 'revisions-pro', // unique identifier
* 'plugin' => 'revisions-pro', // owning plugin slug
* 'label' => 'Revision History', // tooltip / display name
* 'icon' => 'history', // Lucide icon name
* 'contexts' => ['pages'], // where trigger button appears
* 'priority' => 10, // sort order (higher = earlier)
* 'width' => 900, // panel width in pixels
* 'badgeEndpoint' => '/my-plugin/badge', // optional: returns { count: N }
* ]
*/
class ContextPanelController extends AbstractApiController
{
/**
* GET /context-panels — Collect context panel registrations from plugins.
*/
public function items(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$event = new Event(['panels' => [], 'user' => $this->getUser($request)]);
$this->grav->fireEvent('onApiContextPanels', $event);
return ApiResponse::create($event['panels']);
}
}
@@ -0,0 +1,427 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\HTTP\Response;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\YamlFile;
class DashboardController extends AbstractApiController
{
/**
* GET /dashboard/notifications - Get system notifications.
*/
public function notifications(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$query = $request->getQueryParams();
$force = filter_var($query['force'] ?? false, FILTER_VALIDATE_BOOLEAN);
$user = $this->getUser($request);
$username = $user->get('username');
// Load cached notifications (v2 schema — see notifications2.md on getgrav.org)
$cacheFile = $this->grav['locator']->findResource(
'user://data/notifications/' . md5($username) . '_v2.yaml',
true,
true
);
$userStatusFile = $this->grav['locator']->findResource(
'user://data/notifications/' . $username . '.yaml',
true,
true
);
$notificationsFile = YamlFile::instance($cacheFile);
$notificationsContent = (array) $notificationsFile->content();
$userStatusContent = file_exists($userStatusFile)
? (array) YamlFile::instance($userStatusFile)->content()
: [];
$lastChecked = $notificationsContent['last_checked'] ?? null;
$notifications = $notificationsContent['data'] ?? [];
$timeout = $this->grav['config']->get('system.session.timeout', 1800);
// Refresh from remote if needed
if ($force || !$lastChecked || empty($notifications) || (time() - $lastChecked > $timeout)) {
try {
$body = Response::get('https://getgrav.org/notifications2.json?' . time());
$rawNotifications = json_decode($body, true);
if (is_array($rawNotifications)) {
// Sort by date descending
usort($rawNotifications, fn($a, $b) => strcmp($b['date'] ?? '', $a['date'] ?? ''));
// Group by location
$notifications = [];
foreach ($rawNotifications as $notification) {
foreach ($notification['location'] ?? [] as $location) {
$notifications[$location][] = $notification;
}
}
$notificationsFile->content(['last_checked' => time(), 'data' => $notifications]);
$notificationsFile->save();
}
} catch (\Exception $e) {
// Use cached data on failure
}
}
// Let plugins contribute notifications (grouped by location: `top`,
// `dashboard`, `feed`). Fired after the remote refresh so plugin notices
// are merged fresh every request (never cached) yet still flow through
// the dismiss + reappear_after handling below — a plugin-provided `id`
// is dismissed via the same /notifications/{id}/hide endpoint. This is
// how a plugin can raise a persistent, dismissible admin banner.
$event = new Event([
'notifications' => $notifications,
'user' => $user,
'force' => $force,
]);
$this->grav->fireEvent('onApiDashboardNotifications', $event);
$contributed = $event['notifications'];
if (is_array($contributed)) {
$notifications = $contributed;
}
// Filter out hidden notifications
foreach ($notifications as $location => &$list) {
$list = array_values(array_filter($list, function ($notification) use ($userStatusContent) {
$hidden = $userStatusContent[$notification['id']] ?? null;
if ($hidden === null) {
return true;
}
// Check reappear_after
if (isset($notification['reappear_after'])) {
$now = new \DateTime();
$hiddenOn = new \DateTime($hidden);
$hiddenOn->modify($notification['reappear_after']);
return $now >= $hiddenOn;
}
return false;
}));
}
unset($list);
// Filter by location if requested
$filter = $query['location'] ?? null;
if ($filter) {
$notifications = [$filter => $notifications[$filter] ?? []];
}
return ApiResponse::create([
'notifications' => $notifications,
'last_checked' => $lastChecked ? date('c', $lastChecked) : null,
]);
}
/**
* POST /dashboard/notifications/{id}/hide - Dismiss a notification.
*/
public function hideNotification(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.write');
$id = $this->getRouteParam($request, 'id');
$user = $this->getUser($request);
$username = $user->get('username');
$userStatusFile = $this->grav['locator']->findResource(
'user://data/notifications/' . $username . '.yaml',
true,
true
);
$file = YamlFile::instance($userStatusFile);
$content = (array) $file->content();
$content[$id] = date('Y-m-d H:i:s');
$file->content($content);
$file->save();
return ApiResponse::noContent();
}
/**
* GET /dashboard/feed - Get getgrav.org news feed as JSON.
*/
public function feed(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$query = $request->getQueryParams();
$force = filter_var($query['force'] ?? false, FILTER_VALIDATE_BOOLEAN);
$user = $this->getUser($request);
$username = $user->get('username');
$cacheFile = $this->grav['locator']->findResource(
'user://data/feed/' . md5($username) . '.yaml',
true,
true
);
$feedFile = YamlFile::instance($cacheFile);
$feedContent = (array) $feedFile->content();
$lastChecked = $feedContent['last_checked'] ?? null;
$feed = $feedContent['data'] ?? [];
$timeout = $this->grav['config']->get('system.session.timeout', 1800);
// Refresh from remote if needed
if ($force || !$lastChecked || empty($feed) || (time() - $lastChecked > $timeout)) {
try {
$body = Response::get('https://getgrav.org/blog.atom');
$xml = simplexml_load_string($body);
if ($xml) {
$feed = [];
$count = 0;
foreach ($xml->entry as $entry) {
if ($count >= 10) break;
$feed[] = [
'title' => (string) $entry->title,
'url' => (string) $entry->link['href'],
'date' => (string) $entry->updated,
'summary' => (string) ($entry->summary ?? ''),
];
$count++;
}
$feedFile->content(['last_checked' => time(), 'data' => $feed]);
$feedFile->save();
}
} catch (\Exception $e) {
// Use cached data on failure
}
}
return ApiResponse::create([
'feed' => $feed,
'last_checked' => $lastChecked ? date('c', $lastChecked) : null,
]);
}
/**
* GET /dashboard/stats - Dashboard statistics snapshot.
*/
public function stats(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
// Count pages
$pages = $this->grav['pages'];
$pages->enablePages();
$allPages = $pages->instances();
$totalPages = 0;
$publishedPages = 0;
foreach ($allPages as $page) {
// Skip the virtual pages-root container (no file on disk); the
// home page IS a real file-backed page with route '/'.
if (!$page->route() || !$page->exists()) {
continue;
}
$totalPages++;
if ($page->published()) {
$publishedPages++;
}
}
// Count users
$accountDir = $this->grav['locator']->findResource('account://', true);
$totalUsers = 0;
if ($accountDir && is_dir($accountDir)) {
$totalUsers = count(glob($accountDir . '/*.yaml'));
}
// Count plugins
$plugins = $this->grav['plugins']->all();
$activePlugins = 0;
foreach ($plugins as $name => $plugin) {
if ($this->grav['config']->get("plugins.{$name}.enabled", false)) {
$activePlugins++;
}
}
// Count themes
$themes = $this->grav['themes']->all();
$totalThemes = is_countable($themes) ? count($themes) : 0;
// Active theme
$activeTheme = $this->grav['config']->get('system.pages.theme');
// Count media files
$mediaDir = $this->grav['locator']->findResource('user://media', true)
?: $this->grav['locator']->findResource('user://images', true);
$totalMedia = 0;
if ($mediaDir && is_dir($mediaDir)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($mediaDir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$totalMedia++;
}
}
}
// Last backup
$backupsDir = $this->grav['locator']->findResource('backup://', true);
$lastBackup = null;
if ($backupsDir && is_dir($backupsDir)) {
$backups = glob($backupsDir . '/*.zip');
if (!empty($backups)) {
$latest = max(array_map('filemtime', $backups));
$lastBackup = date('c', $latest);
}
}
$data = [
'pages' => [
'total' => $totalPages,
'published' => $publishedPages,
],
'users' => [
'total' => $totalUsers,
],
'plugins' => [
'total' => count($plugins),
'active' => $activePlugins,
],
'themes' => [
'total' => $totalThemes,
],
'media' => [
'total' => $totalMedia,
],
'theme' => $activeTheme,
'grav_version' => GRAV_VERSION,
'php_version' => PHP_VERSION,
'last_backup' => $lastBackup,
];
return ApiResponse::create($data);
}
/**
* GET /dashboard/security/exposure-probe
*
* Returns the public URL of a sentinel file under user/data plus the
* random token it contains. The dashboard fetches that URL directly from
* the browser: a 200 whose body matches the token means the sensitive
* user/ folders are reachable over the web (a misconfigured webserver),
* while a 403/404 means they are correctly blocked.
*
* The sentinel uses a `.dat` extension on purpose — that extension is not
* in the legacy per-extension blocklist, so it is only refused when the
* folder-wide block (Grav 2.0 / 1.7.53+) is actually in place. A plain
* `.txt`/`.yaml` probe would read as "safe" on installs that still expose
* certificates, keys and databases stored with other extensions.
*/
public function securityProbe(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$dataDir = $this->grav['locator']->findResource('user://data', true, true);
$available = false;
$token = '';
if ($dataDir) {
if (!is_dir($dataDir)) {
@mkdir($dataDir, 0770, true);
}
$probeFile = $dataDir . '/grav-security-probe.dat';
// Reuse a stable token so concurrent dashboards don't race each
// other into writing different tokens.
if (is_file($probeFile)) {
$existing = trim((string) @file_get_contents($probeFile));
if (preg_match('/^[a-f0-9]{32,}$/', $existing)) {
$token = $existing;
}
}
if ($token === '') {
$token = bin2hex(random_bytes(16));
@file_put_contents($probeFile, $token);
}
$available = is_file($probeFile);
}
// Public URL to the sentinel, relative to the site web root (honours a
// custom GRAV_USER_PATH and a subfolder install).
$userPath = defined('GRAV_USER_PATH') ? trim(GRAV_USER_PATH, '/') : 'user';
$rootUrl = rtrim($this->grav['uri']->rootUrl(true), '/');
$url = $rootUrl . '/' . $userPath . '/data/grav-security-probe.dat';
return ApiResponse::create([
'url' => $url,
'token' => $token,
'available' => $available,
]);
}
/**
* GET /dashboard/popularity - Page view statistics.
*
* Reads from PopularityStore (single-file flat JSON, ISO date keys).
* On first read after an upgrade from admin-classic, the store imports
* the legacy four-JSON-file layout transparently.
*/
public function popularity(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$store = new \Grav\Plugin\Api\Popularity\PopularityStore();
$daily = $store->getDaily(365);
$monthly = $store->getMonthly(24);
$todayKey = date('Y-m-d');
$thisMonthKey = date('Y-m');
$todayViews = (int) ($daily[$todayKey] ?? 0);
// Sum last 7 days from ISO-keyed daily map
$weekViews = 0;
for ($i = 0; $i < 7; $i++) {
$day = date('Y-m-d', strtotime("-{$i} days"));
$weekViews += (int) ($daily[$day] ?? 0);
}
$monthViews = (int) ($monthly[$thisMonthKey] ?? 0);
// 14-day chart, oldest first
$chartData = [];
for ($i = 13; $i >= 0; $i--) {
$day = date('Y-m-d', strtotime("-{$i} days"));
$chartData[] = [
'date' => date('M j', strtotime("-{$i} days")),
'views' => (int) ($daily[$day] ?? 0),
];
}
$topPages = [];
foreach ($store->getTopPages(10) as $route => $views) {
$topPages[] = ['route' => $route, 'views' => (int) $views];
}
return ApiResponse::create([
'summary' => [
'today' => $todayViews,
'week' => $weekViews,
'month' => $monthViews,
],
'chart' => $chartData,
'top_pages' => $topPages,
]);
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\PermissionResolver;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\DashboardLayoutResolver;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Dashboard widget customization endpoints.
*
* Backs admin-next's per-user / per-site customizable dashboard. The merged
* widget list combines a built-in core registry, plugin contributions via
* `onApiDashboardWidgets`, the site default layout (super-admin), and the
* current user's overrides. Site-hidden widgets are not exposed to users.
*/
class DashboardWidgetController extends AbstractApiController
{
/**
* GET /dashboard/widgets — Resolved widget list + layouts for the current user.
*/
public function widgets(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$resolver = $this->getResolver();
$isSuperAdmin = $this->isSuperAdmin($user);
return ApiResponse::create($resolver->resolve($user, $isSuperAdmin));
}
/**
* PATCH /dashboard/layout — Save the current user's dashboard layout.
*/
public function saveUserLayout(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$body = $this->getRequestBody($request);
if (!is_array($body)) {
throw new ValidationException('Request body must be a JSON object.');
}
$user = $this->getUser($request);
$resolver = $this->getResolver();
$resolver->saveUserLayout($user, $body);
return ApiResponse::create($resolver->resolve($user, $this->isSuperAdmin($user)));
}
/**
* PATCH /dashboard/site-layout — Save the site-wide default dashboard layout.
*
* Super-admin only. Widgets marked invisible here are hidden for all users
* and cannot be re-enabled per-user.
*/
public function saveSiteLayout(ServerRequestInterface $request): ResponseInterface
{
$user = $this->getUser($request);
if (!$this->isSuperAdmin($user)) {
throw new ForbiddenException('Only super-admins can edit the site dashboard layout.');
}
$body = $this->getRequestBody($request);
if (!is_array($body)) {
throw new ValidationException('Request body must be a JSON object.');
}
$resolver = $this->getResolver();
$resolver->saveSiteLayout($body);
return ApiResponse::create($resolver->resolve($user, true));
}
private function getResolver(): DashboardLayoutResolver
{
return new DashboardLayoutResolver(
$this->grav,
new PermissionResolver($this->grav['permissions']),
);
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
/**
* Floating Widgets API — lets plugins register persistent UI widgets
* (e.g. chat assistants, notification panels) in the admin-next shell.
*
* Plugins listen for `onApiFloatingWidgets` to register widgets.
*
* Widget format:
* [
* 'id' => 'ai-pro-chat', // unique identifier
* 'plugin' => 'ai-pro', // owning plugin slug
* 'label' => 'AI Assistant', // tooltip / display name
* 'icon' => 'bot', // Lucide icon name
* 'priority' => 10, // sort order (higher = earlier)
* 'authorize' => 'api.some.permission', // optional — string or array (any-of)
* ]
*
* `authorize` follows the same string-or-array semantics as the sidebar /
* menubar APIs. Widgets without `authorize` are visible to every authenticated
* user.
*/
class FloatingWidgetController extends AbstractApiController
{
/**
* GET /floating-widgets — Collect floating widget registrations from
* plugins, filtered by the current user's permissions.
*/
public function items(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$event = new Event(['widgets' => [], 'user' => $user]);
$this->grav->fireEvent('onApiFloatingWidgets', $event);
$isSuperAdmin = $this->isSuperAdmin($user);
$filtered = [];
foreach ($event['widgets'] as $widget) {
if (!$this->userPassesAuthorize($user, $widget['authorize'] ?? null, $isSuperAdmin)) {
continue;
}
// Strip the authorize field — it's a server-side annotation, not client data
unset($widget['authorize']);
$filtered[] = $widget;
}
return ApiResponse::create($filtered);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserGroupInterface;
use Grav\Common\Yaml;
use Grav\Framework\Flex\FlexDirectory;
use Grav\Plugin\Api\Exceptions\ConflictException;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\FlexBackend;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\GroupSerializer;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* User Groups CRUD.
*
* Groups are stored in `user/config/groups.yaml` as a keyed map. We prefer the
* Flex `user-groups` directory when it's available (richer search/index), and
* fall back to direct YAML I/O when Flex is disabled or the directory hasn't
* been registered yet.
*
* All write operations require `admin.super` — matching the security@ gate on
* the account blueprint's groups/access sections.
*/
class GroupsController extends AbstractApiController
{
use FlexBackend;
private ?GroupSerializer $serializer = null;
public function index(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.read');
$directory = $this->getFlexDirectory('user-groups');
if ($directory) {
return $this->indexViaFlex($request, $directory);
}
return $this->indexViaYaml($request);
}
private function indexViaFlex(ServerRequestInterface $request, FlexDirectory $directory): ResponseInterface
{
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
$search = $query['search'] ?? null;
$collection = $directory->getCollection();
if ($search && $search !== '') {
$collection = $collection->search((string) $search);
}
$collection = $collection->sort(['groupname' => 'asc']);
$total = $collection->count();
$slice = $collection->slice($pagination['offset'], $pagination['limit']);
$data = [];
foreach ($slice as $group) {
if ($group instanceof UserGroupInterface) {
$data[] = $this->getSerializer()->serialize($group);
}
}
return ApiResponse::paginated(
data: $data,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $this->getApiBaseUrl() . '/groups',
);
}
private function indexViaYaml(ServerRequestInterface $request): ResponseInterface
{
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
$search = strtolower((string) ($query['search'] ?? ''));
$groups = $this->loadGroupsArray();
$rows = [];
foreach ($groups as $name => $entry) {
if (!is_array($entry)) continue;
$row = $this->getSerializer()->serializeArray((string) $name, $entry);
if ($search !== '') {
$haystack = strtolower(($row['groupname'] ?? '') . ' ' . ($row['readableName'] ?? '') . ' ' . ($row['description'] ?? ''));
if (!str_contains($haystack, $search)) {
continue;
}
}
$rows[] = $row;
}
usort($rows, static fn($a, $b) => strcasecmp($a['groupname'], $b['groupname']));
$total = count($rows);
$paged = array_slice($rows, $pagination['offset'], $pagination['limit']);
return ApiResponse::paginated(
data: $paged,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $this->getApiBaseUrl() . '/groups',
);
}
public function show(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.read');
$name = $this->getRouteParam($request, 'name');
$data = $this->loadGroupRow($name);
$etag = $this->generateEtag($data);
return ApiResponse::create($data, 200, ['ETag' => '"' . $etag . '"']);
}
public function create(ServerRequestInterface $request): ResponseInterface
{
$this->requireSuperOrAdmin($request);
$body = $this->getRequestBody($request);
$this->requireFields($body, ['groupname']);
$groupname = (string) $body['groupname'];
if (!preg_match('/^[a-zA-Z0-9_-]{1,200}$/', $groupname)) {
throw new ValidationException(
'Invalid group name.',
[['field' => 'groupname', 'message' => 'Group name must be 1-200 characters of letters, numbers, hyphens or underscores.']],
);
}
$groups = $this->loadGroupsArray();
if (isset($groups[$groupname])) {
throw new ConflictException("Group '{$groupname}' already exists.");
}
$entry = $this->normalizeGroupPayload($body);
$entry['groupname'] = $groupname;
$groups[$groupname] = $entry;
$this->saveGroupsArray($groups);
$this->fireEvent('onApiGroupCreated', ['groupname' => $groupname, 'group' => $entry]);
return ApiResponse::created(
data: $this->getSerializer()->serializeArray($groupname, $entry),
location: $this->getApiBaseUrl() . '/groups/' . $groupname,
headers: $this->invalidationHeaders(['groups:create:' . $groupname, 'groups:list']),
);
}
public function update(ServerRequestInterface $request): ResponseInterface
{
$this->requireSuperOrAdmin($request);
$name = $this->getRouteParam($request, 'name');
$groups = $this->loadGroupsArray();
if (!isset($groups[$name])) {
throw new NotFoundException("Group '{$name}' not found.");
}
$current = $this->getSerializer()->serializeArray((string) $name, $groups[$name]);
$this->validateEtag($request, $this->generateEtag($current));
$body = $this->getRequestBody($request);
if (empty($body)) {
throw new ValidationException('Request body must contain fields to update.');
}
$existing = $groups[$name];
$merged = $existing;
foreach (['readableName', 'description', 'icon'] as $field) {
if (array_key_exists($field, $body)) {
$merged[$field] = (string) $body[$field];
}
}
if (array_key_exists('enabled', $body)) {
$merged['enabled'] = (bool) $body['enabled'];
}
if (array_key_exists('access', $body)) {
$merged['access'] = is_array($body['access']) ? $body['access'] : [];
}
// Renames are out of scope — groupname is the storage key.
$merged['groupname'] = (string) $name;
$groups[$name] = $merged;
$this->saveGroupsArray($groups);
$this->fireEvent('onApiGroupUpdated', ['groupname' => $name, 'group' => $merged]);
$row = $this->getSerializer()->serializeArray((string) $name, $merged);
return $this->respondWithEtag($row, 200, ['groups:update:' . $name, 'groups:list']);
}
public function delete(ServerRequestInterface $request): ResponseInterface
{
$this->requireSuperOrAdmin($request);
$name = $this->getRouteParam($request, 'name');
$groups = $this->loadGroupsArray();
if (!isset($groups[$name])) {
throw new NotFoundException("Group '{$name}' not found.");
}
unset($groups[$name]);
$this->saveGroupsArray($groups);
$this->fireEvent('onApiGroupDeleted', ['groupname' => $name]);
return ApiResponse::noContent(
$this->invalidationHeaders(['groups:delete:' . $name, 'groups:list']),
);
}
private function loadGroupRow(?string $name): array
{
if ($name === null || $name === '') {
throw new ValidationException('Group name is required.');
}
$directory = $this->getFlexDirectory('user-groups');
if ($directory) {
$group = $directory->getObject($name);
if ($group instanceof UserGroupInterface) {
return $this->getSerializer()->serialize($group);
}
}
$groups = $this->loadGroupsArray();
if (!isset($groups[$name]) || !is_array($groups[$name])) {
throw new NotFoundException("Group '{$name}' not found.");
}
return $this->getSerializer()->serializeArray((string) $name, $groups[$name]);
}
/**
* Load groups from in-memory config (which Grav populates from
* user/config/groups.yaml on bootstrap, with env overlays applied).
*
* @return array<string, array<string, mixed>>
*/
private function loadGroupsArray(): array
{
$raw = $this->config->get('groups', []);
return is_array($raw) ? $raw : [];
}
/**
* Persist groups back to user/config/groups.yaml. Writes to the base
* config file (not an env overlay) so saved groups are visible in every
* environment — mirrors how classic admin's groups page writes.
*
* @param array<string, array<string, mixed>> $groups
*/
private function saveGroupsArray(array $groups): void
{
$grav = Grav::instance();
/** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator $locator */
$locator = $grav['locator'];
$userConfig = $locator->findResource('user://config', true);
if (!$userConfig) {
throw new \RuntimeException('Base user/config directory not found.');
}
$filePath = $userConfig . '/groups.yaml';
file_put_contents($filePath, Yaml::dump($groups, 99, 2));
// Reflect in-memory so subsequent reads in the same request see it.
$this->config->set('groups', $groups);
// Clear the standard cache so the next request rebuilds the config
// tree (and any Flex user-groups index cached against the file mtime).
$grav['cache']->clearCache('standard');
}
/**
* @param array<string, mixed> $body
* @return array<string, mixed>
*/
private function normalizeGroupPayload(array $body): array
{
$entry = [];
foreach (['readableName', 'description', 'icon'] as $field) {
if (isset($body[$field])) {
$entry[$field] = (string) $body[$field];
}
}
$entry['enabled'] = array_key_exists('enabled', $body) ? (bool) $body['enabled'] : true;
$entry['access'] = isset($body['access']) && is_array($body['access']) ? $body['access'] : [];
return $entry;
}
private function getSerializer(): GroupSerializer
{
return $this->serializer ??= new GroupSerializer();
}
/**
* Groups are admin-level governance — match the security@: admin.super
* gate that account.yaml places on the groups/access sections.
*/
private function requireSuperOrAdmin(ServerRequestInterface $request): void
{
$user = $this->getUser($request);
if ($this->isSuperAdmin($user)) {
return;
}
// Fall through to permission check so the error response carries the
// standard "missing permission" shape rather than a bare forbidden.
$this->requirePermission($request, 'admin.super');
}
}
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Serializers\MediaSerializer;
use Grav\Plugin\Api\Services\ThumbnailService;
use Grav\Plugin\Api\Services\UploadFieldSettings;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
/**
* Shared file-upload pipeline for media endpoints: validating uploads,
* moving them into a target folder, and serializing the resulting media.
*
* The logic is storage-agnostic — it only needs a resolved filesystem
* directory to write into. That makes it reusable for any object that can
* yield a media folder: a page (its content folder) or a folder-stored Flex
* object (its storage folder, e.g. user-data://flex-objects/contacts/{id}).
*
* Used by MediaController (pages + site media) and by the flex-objects
* plugin's FlexApiController via the shared AbstractApiController base.
*/
trait HandlesMediaUploads
{
/** Maximum upload size: 64 MB */
private const int MAX_UPLOAD_SIZE = 67_108_864;
private ?MediaSerializer $mediaSerializer = null;
protected function getThumbnailService(): ThumbnailService
{
$cacheDir = $this->grav['locator']->findResource('cache://') . '/api/thumbnails';
return new ThumbnailService($cacheDir);
}
protected function getSerializer(): MediaSerializer
{
if (!$this->mediaSerializer) {
$thumbnailService = $this->getThumbnailService();
$baseUrl = $this->getApiBaseUrl();
$this->mediaSerializer = new MediaSerializer($thumbnailService, $baseUrl);
}
return $this->mediaSerializer;
}
/**
* Extract and validate a safe filename from the route parameters.
*/
protected function getSafeFilename(ServerRequestInterface $request): string
{
$filename = $this->getRouteParam($request, 'filename');
if ($filename === null || $filename === '') {
throw new ValidationException('Filename is required.');
}
$filename = basename($filename);
if (
str_contains($filename, '..')
|| str_contains($filename, "\0")
|| str_starts_with($filename, '.')
) {
throw new ValidationException('Invalid filename.');
}
return $filename;
}
/**
* Parse blueprint file-field upload settings (random_name, avoid_overwriting,
* accept, filesize) from a request's form fields. Absent settings yield an
* inert object, so callers without field context keep current behavior.
*/
protected function parseUploadFieldSettings(ServerRequestInterface $request): UploadFieldSettings
{
$body = $request->getParsedBody();
return is_array($body) ? UploadFieldSettings::fromParams($body) : UploadFieldSettings::none();
}
/**
* Process a single uploaded file: validate it and move to the target directory.
*
* Optional per-field $settings (from a blueprint `type: file` field) layer
* filename randomization, overwrite avoidance, an accept allowlist, and a
* per-field size limit *on top of* the immovable security floor enforced
* here (size cap, traversal guard, dangerous-extension denylist).
*
* Returns the safe filename that was written.
*/
protected function processUploadedFile(
UploadedFileInterface $file,
string $targetDir,
?UploadFieldSettings $settings = null,
): string {
// Check for upload errors
if ($file->getError() !== UPLOAD_ERR_OK) {
$message = match ($file->getError()) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File exceeds maximum upload size.',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.',
UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder.',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
default => 'Unknown upload error.',
};
throw new ValidationException($message);
}
// Validate file size against the hard cap, then the per-field limit.
$size = $file->getSize();
if ($size !== null && $size > self::MAX_UPLOAD_SIZE) {
throw new ValidationException(
sprintf('File exceeds maximum allowed size of %d MB.', self::MAX_UPLOAD_SIZE / 1_048_576)
);
}
$settings?->assertFilesize($size);
// Sanitize the filename
$originalName = $file->getClientFilename() ?? 'upload';
$filename = basename($originalName);
if (
str_contains($filename, '..')
|| str_contains($filename, "\0")
|| str_starts_with($filename, '.')
) {
throw new ValidationException("Invalid filename: '{$filename}'.");
}
// Validate extension against dangerous extensions list, then the
// field's accept allowlist (matched on the original name's extension).
$this->validateFileExtension($filename);
$settings?->assertAccepted($filename);
// Apply random_name / avoid_overwriting last — both preserve the
// already-validated extension, so the floor checks above still hold.
if ($settings !== null) {
$filename = $settings->resolveFilename($filename, $targetDir);
}
// Move the file to the target directory
$targetPath = $targetDir . '/' . $filename;
$file->moveTo($targetPath);
return $filename;
}
/**
* Validate that a filename's extension is not on the dangerous list.
*/
protected function validateFileExtension(string $filename): void
{
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($extension === '') {
throw new ValidationException('Uploaded file must have a file extension.');
}
$dangerousExtensions = $this->config->get('security.uploads_dangerous_extensions', []);
// Normalize to lowercase for comparison
$dangerousExtensions = array_map('strtolower', $dangerousExtensions);
if (in_array($extension, $dangerousExtensions, true)) {
throw new ValidationException(
"File extension '.{$extension}' is not allowed for security reasons."
);
}
}
/**
* Flatten a potentially nested array of uploaded files into a flat list.
*
* PSR-7 allows uploaded files to be nested (e.g. files[avatar], files[gallery][]).
*
* @return UploadedFileInterface[]
*/
protected function flattenUploadedFiles(array $files): array
{
$result = [];
foreach ($files as $file) {
if ($file instanceof UploadedFileInterface) {
$result[] = $file;
} elseif (is_array($file)) {
$result = [...$result, ...$this->flattenUploadedFiles($file)];
}
}
return $result;
}
}
@@ -0,0 +1,420 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\User\Authentication;
use Grav\Common\User\DataUser\User as DataUser;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Exceptions\ConflictException;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Invitations\InviteStore;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* User invitations.
*
* An admin pre-configures a new user's permissions/groups and sends a
* time-limited invite link. The recipient opens the link, chooses their own
* username/fullname/title/password, and the account is created with exactly
* the access the admin pre-set — never more. Because the invitee never picks
* their own access, they cannot make themselves a super admin.
*
* Admin endpoints require api.users.write (list requires api.users.read).
* The accept/validate endpoints live under /auth/ so they are public.
*/
class InvitationsController extends AbstractApiController
{
use ResolvesAdminBaseUrl;
private ?InviteStore $store = null;
private function store(): InviteStore
{
return $this->store ??= new InviteStore();
}
/**
* GET /invitations — list pending (non-expired) invites.
*/
public function index(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.read');
$store = $this->store();
$store->purgeExpired();
$data = [];
foreach ($store->all() as $record) {
$data[] = $this->serializeInvite($record);
}
// Most-recent first.
usort($data, static fn($a, $b) => ($b['created'] ?? 0) <=> ($a['created'] ?? 0));
return ApiResponse::create(['invitations' => $data]);
}
/**
* POST /invitations — create an invite and (if email is configured) send it.
*/
public function create(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.write');
$actor = $this->getUser($request);
$body = $this->getRequestBody($request);
$this->requireFields($body, ['email']);
$email = trim((string) $body['email']);
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new ValidationException(
'Invalid email address.',
[['field' => 'email', 'message' => 'A valid email address is required.']],
);
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$existing = $accounts->find($email, ['email']);
if ($existing && $existing->exists()) {
throw new ConflictException('A user with that email already exists.');
}
// Permissions the invitee will receive. Strip super flags unless the
// inviting admin is itself super — an admin cannot grant authority it
// does not hold, and this is the core "can't make yourself super" gate.
$access = is_array($body['access'] ?? null) ? $body['access'] : [];
if (!$this->isSuperAdmin($actor)) {
$access = $this->stripSuperFlags($access);
}
$groups = [];
if (is_array($body['groups'] ?? null)) {
$groups = array_values(array_filter(
$body['groups'],
static fn($g) => is_string($g) && $g !== '',
));
}
// Expiration: clamp to a sane window; default 7 days.
$default = (int) $this->config->get('plugins.api.invitations.expiration', 604800);
$expiration = (int) ($body['expiration'] ?? $default);
if ($expiration < 300) {
$expiration = $default;
}
$store = $this->store();
// One pending invite per email — replace any prior one.
$prior = $store->getByEmail($email);
if ($prior && isset($prior['token'])) {
$store->remove((string) $prior['token']);
}
$token = $store->generateToken();
$record = [
'token' => $token,
'email' => $email,
'fullname' => trim((string) ($body['fullname'] ?? '')),
'access' => $access,
'groups' => $groups,
'created' => time(),
'created_by' => (string) $actor->username,
'created_by_name' => (string) ($actor->get('fullname') ?: $actor->username),
'expires' => time() + $expiration,
];
$store->add($record);
$link = $this->buildInviteLink($body['admin_base_url'] ?? null, $request, $token);
// Email guard mirrors AuthController::forgotPassword. If email isn't
// configured we still create the invite and hand the link back so the
// admin can deliver it manually — never silently fail.
$emailSent = false;
$warning = null;
if (isset($this->grav['Email']) && !empty($this->config->get('plugins.email.from'))) {
try {
$this->sendInviteEmail($record, $link, $actor, (string) ($body['message'] ?? ''));
$emailSent = true;
} catch (\Throwable $e) {
$this->grav['log']->error('api.invitations: failed to send invite email: ' . $e->getMessage());
$warning = 'The invitation was created but the email could not be sent. Share the link manually.';
}
} else {
$warning = 'Email is not configured, so no invitation email was sent. Share the link manually.';
}
$payload = $this->serializeInvite($record);
$payload['link'] = $link;
$payload['email_sent'] = $emailSent;
if ($warning !== null) {
$payload['warning'] = $warning;
}
return ApiResponse::created(
data: $payload,
location: $this->getApiBaseUrl() . '/invitations/' . $token,
headers: $this->invalidationHeaders(['invitations:list']),
);
}
/**
* POST /invitations/{token}/resend — re-send an existing invite's email.
*/
public function resend(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.write');
$token = (string) $this->getRouteParam($request, 'token');
$record = $this->store()->get($token);
if ($record === null || InviteStore::isExpired($record)) {
throw new NotFoundException('Invitation not found or expired.');
}
$body = $this->getRequestBody($request);
$link = $this->buildInviteLink($body['admin_base_url'] ?? null, $request, $token);
if (!isset($this->grav['Email']) || empty($this->config->get('plugins.email.from'))) {
throw new ApiException(422, 'Unprocessable Entity', 'Email is not configured. Share the invite link manually.');
}
$actor = $this->getUser($request);
$this->sendInviteEmail($record, $link, $actor, (string) ($body['message'] ?? ''));
$payload = $this->serializeInvite($record);
$payload['link'] = $link;
$payload['email_sent'] = true;
return ApiResponse::create($payload);
}
/**
* DELETE /invitations/{token} — revoke an invite.
*/
public function delete(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.write');
$token = (string) $this->getRouteParam($request, 'token');
if (!$this->store()->remove($token)) {
throw new NotFoundException('Invitation not found.');
}
return $this->respondWithInvalidation(null, ['invitations:list'], 204);
}
/**
* GET /auth/invite/{token} — PUBLIC. Validate a token for the accept page.
*
* Returns only what the accept form needs (email to lock, optional
* fullname prefill, validity). Never leaks the pre-set access/groups.
*/
public function validate(ServerRequestInterface $request): ResponseInterface
{
$token = (string) $this->getRouteParam($request, 'token');
$record = $this->store()->get($token);
if ($record === null) {
throw new NotFoundException('This invitation is invalid.');
}
if (InviteStore::isExpired($record)) {
return ApiResponse::create([
'valid' => false,
'expired' => true,
'email' => (string) ($record['email'] ?? ''),
]);
}
return ApiResponse::create([
'valid' => true,
'expired' => false,
'email' => (string) ($record['email'] ?? ''),
'fullname' => (string) ($record['fullname'] ?? ''),
]);
}
/**
* POST /auth/invite/{token} — PUBLIC. Accept an invite: create the account
* with the admin-preset access/groups and auto-login.
*/
public function accept(ServerRequestInterface $request): ResponseInterface
{
$token = (string) $this->getRouteParam($request, 'token');
$store = $this->store();
$record = $store->get($token);
if ($record === null) {
throw new NotFoundException('This invitation is invalid.');
}
if (InviteStore::isExpired($record)) {
$store->remove($token);
throw new ApiException(410, 'Gone', 'This invitation has expired.');
}
$body = $this->getRequestBody($request);
$this->requireFields($body, ['username', 'password']);
$username = (string) $body['username'];
$password = (string) $body['password'];
// Username format — identical rules to UsersController::create.
$length = mb_strlen($username);
if ($length < 3 || $length > 64 || !DataUser::isValidUsername($username)) {
throw new ValidationException(
'Invalid username format.',
[['field' => 'username', 'message' => 'Username must be 3-64 characters and contain only letters, numbers, periods, hyphens, and underscores (and cannot start with a period).']],
);
}
// Password policy — mirror SetupController.
$pwdRegex = (string) $this->config->get('system.pwd_regex', '');
if ($pwdRegex !== '' && !@preg_match('#^(?:' . $pwdRegex . ')$#', $password)) {
throw new ValidationException(
'Password does not meet the required policy.',
[['field' => 'password', 'message' => 'Password does not meet the required policy.']],
);
}
if ($pwdRegex === '' && strlen($password) < 8) {
throw new ValidationException(
'Password is too short.',
[['field' => 'password', 'message' => 'Password must be at least 8 characters.']],
);
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
if ($accounts->load($username)->exists()) {
throw new ConflictException("User '{$username}' already exists.");
}
$user = $accounts->load($username);
// Email is locked to the invited address — the token is bound to it.
$user->set('email', (string) ($record['email'] ?? ''));
$user->set('fullname', trim((string) ($body['fullname'] ?? ($record['fullname'] ?? ''))));
$user->set('title', trim((string) ($body['title'] ?? '')));
$user->set('state', 'enabled');
$user->set('hashed_password', Authentication::create($password));
$user->set('created', time());
$user->set('modified', time());
// Access + groups come from the invite, NOT the request body — the
// invitee can never influence their own permissions.
$user->set('access', is_array($record['access'] ?? null) ? $record['access'] : []);
if (!empty($record['groups']) && is_array($record['groups'])) {
$user->set('groups', array_values($record['groups']));
}
// Fresh-account hygiene (matches SetupController).
$user->set('avatar', []);
$user->set('twofa_enabled', false);
$user->set('twofa_secret', '');
// NOTE: unlike the authenticated UsersController::create, this is a
// PUBLIC endpoint, so the router has not registered the admin proxy
// ($grav['admin']) that onAdminSave/onAdminAfterSave subscribers
// (git-sync, SEO, etc.) rely on — firing them here fatals. We follow
// the same convention as the public SetupController: save the account
// and fire only the API-level events.
$user->save();
$this->fireEvent('onApiUserCreated', ['user' => $user]);
$this->fireEvent('onApiInvitationAccepted', ['user' => $user, 'invitation' => $record]);
$store->remove($token);
// Auto-login the new user (same token pair as /auth/setup).
$jwt = new JwtAuthenticator($this->grav, $this->config);
$response = $this->issueTokenPair($jwt, $user);
return $response->withHeader('X-Invalidates', 'users:list');
}
/**
* Strip super-admin flags from an access tree.
*
* @param array<string, mixed> $access
* @return array<string, mixed>
*/
private function stripSuperFlags(array $access): array
{
foreach (['admin', 'api'] as $scope) {
if (isset($access[$scope]) && is_array($access[$scope])) {
unset($access[$scope]['super']);
}
}
return $access;
}
private function buildInviteLink(mixed $clientBaseUrl, ServerRequestInterface $request, string $token): string
{
$adminBase = $this->resolveAdminBaseUrl($clientBaseUrl, $request, ['/users/invite', '/invite']);
return rtrim($adminBase, '/') . '/invite?token=' . rawurlencode($token);
}
/**
* @param array<string, mixed> $record
*/
private function sendInviteEmail(array $record, string $link, UserInterface $actor, string $message = ''): void
{
if (!isset($this->grav['Email'])) {
throw new \RuntimeException('Email service not available.');
}
$cfg = $this->grav['config'];
$siteHost = (string) ($cfg->get('plugins.login.site_host') ?: ($this->grav['uri']->host() ?? ''));
$context = [
'invite_link' => $link,
'actor' => (string) ($record['created_by_name'] ?? $actor->get('fullname') ?: $actor->username),
'message' => $message,
'site_name' => $cfg->get('site.title', 'Website'),
'site_host' => $siteHost,
'author' => $cfg->get('site.author.name', ''),
];
$params = [
'to' => (string) ($record['email'] ?? ''),
'body' => [
[
'content_type' => 'text/html',
'template' => 'emails/api/invite-user.html.twig',
'body' => '',
],
],
];
/** @var \Grav\Plugin\Email\Email $email */
$email = $this->grav['Email'];
$emailMessage = $email->buildMessage($params, $context);
$email->send($emailMessage);
}
/**
* Public-safe invite representation (no access/groups leakage beyond what
* an authenticated admin endpoint returns).
*
* @param array<string, mixed> $record
* @return array<string, mixed>
*/
private function serializeInvite(array $record): array
{
return [
'token' => (string) ($record['token'] ?? ''),
'email' => (string) ($record['email'] ?? ''),
'fullname' => (string) ($record['fullname'] ?? ''),
'groups' => array_values((array) ($record['groups'] ?? [])),
'created' => (int) ($record['created'] ?? 0),
'created_by' => (string) ($record['created_by'] ?? ''),
'created_by_name' => (string) ($record['created_by_name'] ?? ''),
'expires' => (int) ($record['expires'] ?? 0),
'expired' => InviteStore::isExpired($record),
];
}
}
@@ -0,0 +1,890 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Framework\Psr7\Response;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class MediaController extends AbstractApiController
{
use HandlesMediaUploads;
/**
* GET /pages/{route}/media - List all media for a page.
*/
public function pageMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.read');
$page = $this->findPageOrFail($request);
$pagePath = $page->path();
// Create fresh Media object to avoid stale page cache
$media = new \Grav\Common\Page\Media($pagePath);
$serialized = $this->getSerializer()->serializeCollection($media->all());
return ApiResponse::create($serialized);
}
/**
* POST /pages/{route}/media - Upload file(s) to a page.
*/
public function uploadPageMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$page = $this->findPageOrFail($request);
$pagePath = $page->path();
if (!$pagePath || !is_dir($pagePath)) {
throw new NotFoundException('Page directory does not exist on disk.');
}
$uploadedFiles = $this->flattenUploadedFiles($request->getUploadedFiles());
if ($uploadedFiles === []) {
throw new ValidationException('No files were uploaded.');
}
// Honor per-field upload settings (random_name, accept, ...) when the
// file field forwards them; absent, this is an inert no-op.
$settings = $this->parseUploadFieldSettings($request);
$uploadedNames = [];
foreach ($uploadedFiles as $file) {
// Fire before event — plugins can throw to reject specific files
$this->fireEvent('onApiBeforeMediaUpload', [
'page' => $page,
'filename' => $file->getClientFilename(),
'type' => $file->getClientMediaType(),
'size' => $file->getSize(),
]);
$uploadedNames[] = $this->processUploadedFile($file, $pagePath, $settings);
}
// Create fresh Media object to pick up newly uploaded files
$media = new \Grav\Common\Page\Media($pagePath);
$serialized = $this->getSerializer()->serializeCollection($media->all());
$this->fireAdminEvent('onAdminAfterAddMedia', ['object' => $page, 'page' => $page]);
$this->fireEvent('onApiMediaUploaded', [
'page' => $page,
'filenames' => $uploadedNames,
]);
$baseUrl = $this->getApiBaseUrl();
$route = $this->getRouteParam($request, 'route') ?? '';
$location = "{$baseUrl}/pages/{$route}/media";
return ApiResponse::created(
$serialized,
$location,
$this->invalidationHeaders([
'media:update:pages/' . $route,
'pages:update:/' . $route,
]),
);
}
/**
* DELETE /pages/{route}/media/{filename} - Delete a media file from a page.
*/
public function deletePageMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$page = $this->findPageOrFail($request);
$filename = $this->getSafeFilename($request);
$pagePath = $page->path();
if (!$pagePath) {
throw new NotFoundException('Page directory does not exist on disk.');
}
// Verify the file exists on disk
$filePath = $pagePath . '/' . $filename;
if (!file_exists($filePath)) {
throw new NotFoundException("Media file '{$filename}' not found on this page.");
}
$this->fireEvent('onApiBeforeMediaDelete', ['page' => $page, 'filename' => $filename]);
unlink($filePath);
// Also remove any metadata file (.meta.yaml) if it exists
$metaPath = $filePath . '.meta.yaml';
if (file_exists($metaPath)) {
unlink($metaPath);
}
// Build fresh media object for admin event compatibility
$media = new \Grav\Common\Page\Media($pagePath);
$this->fireAdminEvent('onAdminAfterDelMedia', [
'object' => $page, 'page' => $page,
'media' => $media, 'filename' => $filename,
]);
$this->fireEvent('onApiMediaDeleted', ['page' => $page, 'filename' => $filename]);
$route = $this->getRouteParam($request, 'route') ?? '';
return ApiResponse::noContent(
$this->invalidationHeaders([
'media:delete:pages/' . $route . '/' . $filename,
'media:update:pages/' . $route,
'pages:update:/' . $route,
]),
);
}
/**
* GET /media - List site-level media with folder browsing, search, and type filter.
*/
public function siteMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.read');
$mediaPath = $this->getSiteMediaPath();
$queryParams = $request->getQueryParams();
// Validate optional path parameter
$relativePath = '';
if (!empty($queryParams['path'])) {
$relativePath = $this->validateRelativePath($queryParams['path'], $mediaPath);
}
$currentPath = $relativePath !== '' ? $mediaPath . '/' . $relativePath : $mediaPath;
// Handle search mode
if (!empty($queryParams['search'])) {
return $this->handleMediaSearch($request, $mediaPath, $queryParams);
}
// Verify directory exists
if (!is_dir($currentPath)) {
// Return empty result for non-existent paths
$baseUrl = $this->getApiBaseUrl() . '/media';
return ApiResponse::paginated([], 0, 1, 20, $baseUrl, 200, [], [
'path' => $relativePath,
'folders' => [],
]);
}
$result = $this->scanMediaDirectoryWithFolders($currentPath, $relativePath);
$pagination = $this->getPagination($request);
// Apply type filter
$typeFilter = $queryParams['type'] ?? null;
$files = $result['files'];
if ($typeFilter) {
$files = array_values(array_filter($files, function (string $file) use ($currentPath, $typeFilter) {
$mime = mime_content_type($currentPath . '/' . $file) ?: '';
return match ($typeFilter) {
'image' => str_starts_with($mime, 'image/'),
'video' => str_starts_with($mime, 'video/'),
'audio' => str_starts_with($mime, 'audio/'),
'document' => !str_starts_with($mime, 'image/') && !str_starts_with($mime, 'video/') && !str_starts_with($mime, 'audio/'),
default => true,
};
}));
}
$total = count($files);
$pagedFiles = array_slice($files, $pagination['offset'], $pagination['limit']);
$serialized = array_map(
fn(string $file) => $this->serializeSiteFile($currentPath, $file, $relativePath),
$pagedFiles,
);
$baseUrl = $this->getApiBaseUrl() . '/media';
return ApiResponse::paginated(
$serialized,
$total,
$pagination['page'],
$pagination['per_page'],
$baseUrl,
200,
[],
[
'path' => $relativePath,
'folders' => $result['folders'],
],
);
}
/**
* POST /media - Upload file(s) to the site media folder (with optional subfolder path).
*/
public function uploadSiteMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$queryParams = $request->getQueryParams();
// Validate optional subfolder path
$relativePath = '';
if (!empty($queryParams['path'])) {
$relativePath = $this->validateRelativePath($queryParams['path'], $mediaPath);
}
$targetDir = $relativePath !== '' ? $mediaPath . '/' . $relativePath : $mediaPath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new ValidationException('Unable to create upload directory.');
}
$uploadedFiles = $this->flattenUploadedFiles($request->getUploadedFiles());
if ($uploadedFiles === []) {
throw new ValidationException('No files were uploaded.');
}
$settings = $this->parseUploadFieldSettings($request);
$created = [];
foreach ($uploadedFiles as $file) {
$filename = $this->processUploadedFile($file, $targetDir, $settings);
$created[] = $this->serializeSiteFile($targetDir, $filename, $relativePath);
}
$location = $this->getApiBaseUrl() . '/media';
return ApiResponse::created(
$created,
$location,
$this->invalidationHeaders(['media:update:' . ($relativePath !== '' ? $relativePath : '/'), 'media:list']),
);
}
/**
* DELETE /media/{filename} - Delete a site media file (supports subfolder paths).
*/
public function deleteSiteMedia(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$relativePath = $this->getSafeRelativeFilePath($request, $mediaPath);
$filePath = $mediaPath . '/' . $relativePath;
if (!file_exists($filePath)) {
throw new NotFoundException("Media file not found.");
}
unlink($filePath);
// Also remove any metadata file
$metaPath = $filePath . '.meta.yaml';
if (file_exists($metaPath)) {
unlink($metaPath);
}
$parentDir = ltrim(dirname($relativePath), '.');
return ApiResponse::noContent(
$this->invalidationHeaders([
'media:delete:' . $relativePath,
'media:update:' . ($parentDir !== '' ? $parentDir : '/'),
'media:list',
]),
);
}
/**
* POST /media/folders - Create a new folder.
*/
public function createFolder(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$body = json_decode((string) $request->getBody(), true) ?? [];
if (empty($body['path'])) {
throw new ValidationException('Folder path is required.');
}
$relativePath = $this->validateRelativePath($body['path'], $mediaPath);
$absolutePath = $mediaPath . '/' . $relativePath;
if (is_dir($absolutePath)) {
throw new ValidationException('Folder already exists.');
}
if (!mkdir($absolutePath, 0775, true)) {
throw new ValidationException('Unable to create folder.');
}
$name = basename($relativePath);
$data = [
'name' => $name,
'path' => $relativePath,
'children_count' => 0,
'file_count' => 0,
];
return ApiResponse::created(
$data,
$this->getApiBaseUrl() . '/media?path=' . urlencode($relativePath),
$this->invalidationHeaders(['media:create:' . $relativePath, 'media:list']),
);
}
/**
* DELETE /media/folders/{path} - Delete an empty folder.
*/
public function deleteFolder(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$path = $this->getRouteParam($request, 'path');
if ($path === null || $path === '') {
throw new ValidationException('Folder path is required.');
}
$relativePath = $this->validateRelativePath($path, $mediaPath);
$absolutePath = $mediaPath . '/' . $relativePath;
if (!is_dir($absolutePath)) {
throw new NotFoundException('Folder not found.');
}
// Check if folder is empty (only . and ..)
$isEmpty = true;
foreach (new \DirectoryIterator($absolutePath) as $item) {
if (!$item->isDot()) {
$isEmpty = false;
break;
}
}
if (!$isEmpty) {
throw new ValidationException('Folder is not empty. Delete all files first.');
}
if (!rmdir($absolutePath)) {
throw new ValidationException('Unable to delete folder.');
}
return ApiResponse::noContent(
$this->invalidationHeaders(['media:delete:' . $relativePath, 'media:list']),
);
}
/**
* POST /media/rename - Rename or move a media file.
*/
public function renameFile(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$body = json_decode((string) $request->getBody(), true) ?? [];
if (empty($body['from']) || empty($body['to'])) {
throw new ValidationException("Both 'from' and 'to' paths are required.");
}
$from = $this->validateRelativePath($body['from'], $mediaPath);
$to = $this->validateRelativePath($body['to'], $mediaPath);
$fromAbsolute = $mediaPath . '/' . $from;
$toAbsolute = $mediaPath . '/' . $to;
if (!file_exists($fromAbsolute)) {
throw new NotFoundException("Source file not found.");
}
if (file_exists($toAbsolute)) {
throw new ValidationException("A file already exists at the destination.");
}
// Ensure target directory exists
$targetDir = dirname($toAbsolute);
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new ValidationException('Unable to create destination directory.');
}
if (!rename($fromAbsolute, $toAbsolute)) {
throw new ValidationException('Unable to rename file.');
}
// Also rename metadata sidecar if it exists
$fromMeta = $fromAbsolute . '.meta.yaml';
$toMeta = $toAbsolute . '.meta.yaml';
if (file_exists($fromMeta)) {
rename($fromMeta, $toMeta);
}
$toDir = ltrim(dirname($to) === '.' ? '' : dirname($to), '/');
$toFilename = basename($to);
$targetPath = $toDir !== '' ? $mediaPath . '/' . $toDir : $mediaPath;
return ApiResponse::ok(
$this->serializeSiteFile($targetPath, $toFilename, $toDir),
$this->invalidationHeaders([
'media:delete:' . $from,
'media:create:' . $to,
'media:list',
]),
);
}
/**
* POST /media/folders/rename - Rename a folder.
*/
public function renameFolder(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.write');
$mediaPath = $this->getSiteMediaPath();
$body = json_decode((string) $request->getBody(), true) ?? [];
if (empty($body['from']) || empty($body['to'])) {
throw new ValidationException("Both 'from' and 'to' paths are required.");
}
$from = $this->validateRelativePath($body['from'], $mediaPath);
$to = $this->validateRelativePath($body['to'], $mediaPath);
$fromAbsolute = $mediaPath . '/' . $from;
$toAbsolute = $mediaPath . '/' . $to;
if (!is_dir($fromAbsolute)) {
throw new NotFoundException("Source folder not found.");
}
if (file_exists($toAbsolute)) {
throw new ValidationException("A folder already exists at the destination.");
}
if (!rename($fromAbsolute, $toAbsolute)) {
throw new ValidationException('Unable to rename folder.');
}
$name = basename($to);
$data = [
'name' => $name,
'path' => $to,
'children_count' => 0,
'file_count' => 0,
];
return ApiResponse::ok(
$data,
$this->invalidationHeaders([
'media:delete:' . $from,
'media:create:' . $to,
'media:list',
]),
);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* GET /thumbnails/{hash}.{ext} - Serve a cached thumbnail image.
*/
public function thumbnail(ServerRequestInterface $request): ResponseInterface
{
$file = $this->getRouteParam($request, 'file');
if (!$file) {
throw new NotFoundException('Thumbnail not found.');
}
$cacheDir = $this->grav['locator']->findResource('cache://') . '/api/thumbnails';
$cachePath = $cacheDir . '/' . basename($file);
if (!file_exists($cachePath)) {
throw new NotFoundException('Thumbnail not found.');
}
$mime = mime_content_type($cachePath) ?: 'application/octet-stream';
$content = file_get_contents($cachePath);
return new Response(
200,
[
'Content-Type' => $mime,
'Content-Length' => (string) strlen($content),
'Cache-Control' => 'public, max-age=31536000, immutable',
],
$content
);
}
/**
* Resolve a page from the route parameter or throw a 404.
*/
private function findPageOrFail(ServerRequestInterface $request): PageInterface
{
$route = $this->getRouteParam($request, 'route');
if ($route === null || $route === '') {
throw new NotFoundException('Page route is required.');
}
$pages = $this->grav['pages'];
// Enable pages if they were disabled (e.g. in admin context)
if (method_exists($pages, 'enablePages')) {
$pages->enablePages();
}
$page = $pages->find('/' . ltrim($route, '/'));
if (!$page) {
throw new NotFoundException("Page '/{$route}' not found.");
}
return $page;
}
/**
* Validate a relative path is safe and within the media directory.
* Returns the sanitized relative path.
*/
private function validateRelativePath(string $path, string $basePath): string
{
// Normalize separators
$path = str_replace('\\', '/', $path);
$path = trim($path, '/');
if ($path === '') {
return '';
}
// Check each segment
foreach (explode('/', $path) as $segment) {
if (
$segment === '' ||
$segment === '.' ||
$segment === '..' ||
str_contains($segment, "\0") ||
str_starts_with($segment, '.')
) {
throw new ValidationException("Invalid path: '{$path}'.");
}
}
// Verify resolved path is within base
$absolute = $basePath . '/' . $path;
// For existing paths, use realpath
if (file_exists($absolute)) {
$real = realpath($absolute);
$realBase = realpath($basePath);
if ($real === false || $realBase === false || !str_starts_with($real, $realBase . '/')) {
throw new ValidationException("Invalid path: '{$path}'.");
}
}
return $path;
}
/**
* Extract and validate a relative file path from route parameters.
* Unlike getSafeFilename() which strips directories with basename(),
* this preserves path components for subfolder support.
*/
private function getSafeRelativeFilePath(ServerRequestInterface $request, string $basePath): string
{
$filename = $this->getRouteParam($request, 'filename');
if ($filename === null || $filename === '') {
throw new ValidationException('Filename is required.');
}
// Normalize
$filename = str_replace('\\', '/', $filename);
$filename = trim($filename, '/');
// Validate each path segment
foreach (explode('/', $filename) as $segment) {
if (
$segment === '' ||
$segment === '.' ||
$segment === '..' ||
str_contains($segment, "\0") ||
str_starts_with($segment, '.')
) {
throw new ValidationException('Invalid filename.');
}
}
// Verify resolved path is within base
$absolute = $basePath . '/' . $filename;
if (file_exists($absolute)) {
$real = realpath($absolute);
$realBase = realpath($basePath);
if ($real === false || $realBase === false || !str_starts_with($real, $realBase . '/')) {
throw new ValidationException('Invalid filename.');
}
}
return $filename;
}
/**
* Resolve the absolute path to the site-level media directory.
*/
private function getSiteMediaPath(): string
{
/** @var \Grav\Common\Locator $locator */
$locator = $this->grav['locator'];
$path = $locator->findResource('user://media', true, true);
if (!$path) {
throw new NotFoundException('Site media directory could not be resolved.');
}
return $path;
}
/**
* Handle recursive media search across all subfolders.
*/
private function handleMediaSearch(
ServerRequestInterface $request,
string $mediaPath,
array $queryParams
): ResponseInterface {
$search = strtolower($queryParams['search']);
$typeFilter = $queryParams['type'] ?? null;
$pagination = $this->getPagination($request);
$matches = [];
if (is_dir($mediaPath)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($mediaPath, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir()) {
continue;
}
$name = $item->getFilename();
// Skip hidden and metadata files
if (str_starts_with($name, '.') || str_ends_with($name, '.meta.yaml')) {
continue;
}
// Match filename
if (!str_contains(strtolower($name), $search)) {
continue;
}
// Apply type filter
if ($typeFilter) {
$mime = mime_content_type($item->getPathname()) ?: '';
$passesFilter = match ($typeFilter) {
'image' => str_starts_with($mime, 'image/'),
'video' => str_starts_with($mime, 'video/'),
'audio' => str_starts_with($mime, 'audio/'),
'document' => !str_starts_with($mime, 'image/') && !str_starts_with($mime, 'video/') && !str_starts_with($mime, 'audio/'),
default => true,
};
if (!$passesFilter) {
continue;
}
}
// Calculate relative path
$fullPath = $item->getPathname();
$relDir = ltrim(str_replace($mediaPath, '', dirname($fullPath)), '/');
$matches[] = ['filename' => $name, 'dir' => $relDir, 'fullPath' => $fullPath];
}
}
// Sort matches
usort($matches, fn($a, $b) => strnatcasecmp($a['filename'], $b['filename']));
$total = count($matches);
$paged = array_slice($matches, $pagination['offset'], $pagination['limit']);
$serialized = array_map(function (array $match) {
return $this->serializeSiteFile(dirname($match['fullPath']), $match['filename'], $match['dir']);
}, $paged);
$baseUrl = $this->getApiBaseUrl() . '/media';
return ApiResponse::paginated(
$serialized,
$total,
$pagination['page'],
$pagination['per_page'],
$baseUrl,
200,
[],
[
'path' => '',
'folders' => [],
'search' => $queryParams['search'],
],
);
}
/**
* Scan a directory for media files, returning just the filenames sorted alphabetically.
*
* @return string[]
*/
private function scanMediaDirectory(string $path): array
{
if (!is_dir($path)) {
return [];
}
$files = [];
/** @var \SplFileInfo $item */
foreach (new \DirectoryIterator($path) as $item) {
if ($item->isDot() || $item->isDir()) {
continue;
}
// Skip hidden files and metadata files
$name = $item->getFilename();
if (str_starts_with($name, '.') || str_ends_with($name, '.meta.yaml')) {
continue;
}
$files[] = $name;
}
sort($files, SORT_NATURAL | SORT_FLAG_CASE);
return $files;
}
/**
* Scan a directory for media files and subdirectories.
*
* @return array{files: string[], folders: array<array{name: string, path: string, children_count: int, file_count: int}>}
*/
private function scanMediaDirectoryWithFolders(string $absolutePath, string $relativePath = ''): array
{
$files = [];
$folders = [];
if (!is_dir($absolutePath)) {
return ['files' => $files, 'folders' => $folders];
}
foreach (new \DirectoryIterator($absolutePath) as $item) {
if ($item->isDot()) {
continue;
}
$name = $item->getFilename();
// Skip hidden files/dirs
if (str_starts_with($name, '.')) {
continue;
}
if ($item->isDir()) {
$folderPath = $relativePath !== '' ? $relativePath . '/' . $name : $name;
$childPath = $absolutePath . '/' . $name;
// Count immediate children
$childrenCount = 0;
$fileCount = 0;
if (is_dir($childPath)) {
foreach (new \DirectoryIterator($childPath) as $child) {
if ($child->isDot() || str_starts_with($child->getFilename(), '.')) {
continue;
}
if ($child->isDir()) {
$childrenCount++;
} elseif (!str_ends_with($child->getFilename(), '.meta.yaml')) {
$fileCount++;
}
}
}
$folders[] = [
'name' => $name,
'path' => $folderPath,
'children_count' => $childrenCount,
'file_count' => $fileCount,
];
} else {
// Skip metadata files
if (str_ends_with($name, '.meta.yaml')) {
continue;
}
$files[] = $name;
}
}
sort($files, SORT_NATURAL | SORT_FLAG_CASE);
usort($folders, fn(array $a, array $b) => strnatcasecmp($a['name'], $b['name']));
return ['files' => $files, 'folders' => $folders];
}
/**
* Build a serialized array for a raw file in the site media directory.
* Used when we don't have Grav Medium objects available.
*/
private function serializeSiteFile(string $basePath, string $filename, string $relativePath = ''): array
{
$filePath = $basePath . '/' . $filename;
$mime = mime_content_type($filePath) ?: 'application/octet-stream';
$fullRelativePath = $relativePath !== '' ? $relativePath . '/' . $filename : $filename;
$data = [
'filename' => $filename,
'path' => $relativePath,
'url' => '/user/media/' . $fullRelativePath,
'type' => $mime,
'size' => (int) filesize($filePath),
];
if (str_starts_with($mime, 'image/') && $mime !== 'image/svg+xml') {
if ($imageSize = @getimagesize($filePath)) {
$data['dimensions'] = [
'width' => $imageSize[0],
'height' => $imageSize[1],
];
}
// Generate thumbnail
try {
$thumbnailService = $this->getThumbnailService();
$hash = $thumbnailService->getOrCreate($filePath);
if ($hash) {
$data['thumbnail_url'] = $this->getApiBaseUrl() . '/thumbnails/' . $hash;
}
} catch (\Throwable) {
// Thumbnail generation failed — skip it
}
}
$mtime = filemtime($filePath);
$data['modified'] = date(\DateTimeInterface::ATOM, $mtime ?: time());
return $data;
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
/**
* Menubar API — lets plugins register toolbar items with executable actions.
*
* Plugins listen for `onApiMenubarItems` to register items and
* `onApiMenubarAction` to handle action execution.
*
* Item format:
* [
* 'id' => 'warm-cache', // unique identifier
* 'plugin' => 'warm-cache', // owning plugin slug
* 'label' => 'Warm Cache', // tooltip / display name
* 'icon' => 'fa-tachometer', // FA icon class
* 'action' => 'warm', // action key for POST
* 'confirm' => 'Warm the cache?', // optional confirmation prompt
* 'authorize' => 'api.some.permission', // optional — string or array (any-of)
* ]
*
* `authorize` follows the same string-or-array semantics as the sidebar API.
* Items without `authorize` are visible to every authenticated user.
*/
class MenubarController extends AbstractApiController
{
/**
* GET /menubar/items — Collect menu items from plugins, filtered by the
* current user's permissions.
*/
public function items(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$event = new Event(['items' => [], 'user' => $user]);
$this->grav->fireEvent('onApiMenubarItems', $event);
$isSuperAdmin = $this->isSuperAdmin($user);
$filtered = [];
foreach ($event['items'] as $item) {
if (!$this->userPassesAuthorize($user, $item['authorize'] ?? null, $isSuperAdmin)) {
continue;
}
// Strip the authorize field — it's a server-side annotation, not client data
unset($item['authorize']);
$filtered[] = $item;
}
return ApiResponse::create($filtered);
}
/**
* POST /menubar/actions/{plugin}/{action} — Execute a plugin action.
*/
public function executeAction(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$plugin = $this->getRouteParam($request, 'plugin');
$action = $this->getRouteParam($request, 'action');
$body = $this->getRequestBody($request);
$sentinel = "__no_handler_{$plugin}_{$action}__";
$event = new Event([
'plugin' => $plugin,
'action' => $action,
'body' => $body,
'user' => $this->getUser($request),
'result' => [
'status' => 'error',
'message' => $sentinel,
],
]);
$this->grav->fireEvent('onApiMenubarAction', $event);
$result = $event['result'];
// Distinguish "no plugin registered for this action" from a handler
// that ran and reported a domain-level failure (e.g. auth error from
// Cloudflare). The former is a 404; the latter is a successful API
// call that the client will toast as an error based on result.status.
if (($result['message'] ?? null) === $sentinel) {
throw new NotFoundException("No handler registered for action '{$plugin}/{$action}'.");
}
return ApiResponse::create($result, 200);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\PasswordPolicyService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Public endpoint that exposes the configured password policy so the
* setup, password-reset and user-creation flows can render matching
* client-side validation and a strength meter.
*/
class PasswordPolicyController extends AbstractApiController
{
public function show(ServerRequestInterface $request): ResponseInterface
{
return ApiResponse::create(PasswordPolicyService::build($this->config));
}
}
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\PreferencesResolver;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Admin-next UI preferences endpoints.
*
* GET /admin-next/preferences — full resolved payload
* PATCH /admin-next/preferences/user — patch current user overrides
* DELETE /admin-next/preferences/user — clear all current-user overrides
* PATCH /admin-next/preferences/site — super-admin: write site defaults
* PATCH /admin-next/branding — super-admin: write logo mode + text
* POST /admin-next/branding/logo — super-admin: upload logo file
* DELETE /admin-next/branding/logo — super-admin: delete a logo file
*
* The SPA fetches once on boot, then PATCHes deltas as the user changes
* preferences. See PreferencesResolver for storage layout (Tier A/B/C).
*/
class PreferencesController extends AbstractApiController
{
/** Whitelist of MIME types accepted for logo uploads. */
private const LOGO_MIMES = [
'image/svg+xml' => 'svg',
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
];
/** 4 MB cap — logos shouldn't be anywhere near this. */
private const LOGO_MAX_SIZE = 4_194_304;
public function show(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$resolver = $this->getResolver();
$payload = $resolver->resolve($user, $this->canEditSite($user));
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return $this->respondWithEtag($payload);
}
public function saveUser(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$body = $this->getRequestBody($request);
$user = $this->getUser($request);
$resolver = $this->getResolver();
$resolver->saveUserPreferences($user, $body);
$payload = $resolver->resolve($user, $this->canEditSite($user));
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload);
}
public function resetUser(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$resolver = $this->getResolver();
$resolver->clearUserPreferences($user);
$payload = $resolver->resolve($user, $this->canEditSite($user));
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload);
}
public function saveSite(ServerRequestInterface $request): ResponseInterface
{
$this->requireSiteEditor($request);
$body = $this->getRequestBody($request);
$resolver = $this->getResolver();
// Route a flat payload into the two yaml destinations: Tier B keys
// go to `ui.defaults` (overridable per-user), Tier A2 keys go to
// `ui.settings` (site-only behavioral). Anything else is ignored.
$tierB = array_intersect_key($body, $resolver->defaultPreferences());
$tierA2 = array_intersect_key($body, $resolver->defaultSiteSettings());
if ($tierB !== []) {
$resolver->saveSitePreferences($tierB);
}
if ($tierA2 !== []) {
$resolver->saveSiteSettings($tierA2);
}
$user = $this->getUser($request);
$payload = $resolver->resolve($user, true);
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload);
}
public function saveBranding(ServerRequestInterface $request): ResponseInterface
{
$this->requireSiteEditor($request);
$body = $this->getRequestBody($request);
$resolver = $this->getResolver();
// Branding is replace-all: merge with current so callers can PATCH
// just `mode` or just `text` without wiping the saved file paths.
$merged = array_replace($resolver->siteBranding(), $body);
$resolver->saveSiteBranding($merged);
$user = $this->getUser($request);
$payload = $resolver->resolve($user, true);
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload);
}
public function uploadLogo(ServerRequestInterface $request): ResponseInterface
{
$this->requireSiteEditor($request);
$variant = $this->getLogoVariant($request);
$uploaded = $request->getUploadedFiles();
$file = $uploaded['file'] ?? $uploaded['logo'] ?? null;
if ($file === null || $file->getError() !== UPLOAD_ERR_OK) {
throw new ValidationException('No logo file uploaded.');
}
$size = $file->getSize();
if ($size !== null && $size > self::LOGO_MAX_SIZE) {
throw new ValidationException(
sprintf('Logo exceeds maximum size of %d MB.', self::LOGO_MAX_SIZE / 1_048_576)
);
}
$mime = strtolower((string) ($file->getClientMediaType() ?? ''));
if (!isset(self::LOGO_MIMES[$mime])) {
throw new ValidationException(
'Logo must be SVG, PNG, JPEG, or WebP. Received: ' . ($mime === '' ? '(unknown)' : $mime)
);
}
$ext = self::LOGO_MIMES[$mime];
$resolver = $this->getResolver();
$dir = $resolver->brandingMediaDir(createDir: true);
if ($dir === null) {
throw new \RuntimeException('Unable to resolve user://media/admin-next/.');
}
// Timestamp+rand keeps writes idempotent on filesystems with second-resolution mtime.
$stamp = substr(md5(uniqid('logo', true)), 0, 10);
$filename = "logo-{$variant}-{$stamp}.{$ext}";
$filepath = $dir . '/' . $filename;
$file->moveTo($filepath);
// Replace the path for this variant; preserve everything else.
$branding = $resolver->siteBranding();
$previous = $branding[$variant === 'light' ? 'logoLight' : 'logoDark'] ?? '';
$branding[$variant === 'light' ? 'logoLight' : 'logoDark'] = $filename;
// If a custom logo file was uploaded, auto-flip mode to `custom` unless the
// operator has explicitly set `text` mode (text trumps both default + custom).
if (($branding['mode'] ?? 'default') !== 'text') {
$branding['mode'] = 'custom';
}
$resolver->saveSiteBranding($branding);
// Clean up the previous file for this variant if it's different.
if ($previous && $previous !== $filename) {
$oldPath = $dir . '/' . basename($previous);
if (is_file($oldPath)) {
@unlink($oldPath);
}
}
$user = $this->getUser($request);
$payload = $resolver->resolve($user, true);
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload, 201);
}
public function deleteLogo(ServerRequestInterface $request): ResponseInterface
{
$this->requireSiteEditor($request);
$variant = $this->getLogoVariant($request);
$resolver = $this->getResolver();
$branding = $resolver->siteBranding();
$key = $variant === 'light' ? 'logoLight' : 'logoDark';
$existing = $branding[$key] ?? '';
if ($existing) {
$dir = $resolver->brandingMediaDir();
if ($dir && is_file($dir . '/' . basename($existing))) {
@unlink($dir . '/' . basename($existing));
}
}
$branding[$key] = '';
// If both variants are now empty, revert to default mode so the SPA
// falls back to the built-in Grav logo rather than rendering nothing.
if ($branding['logoLight'] === '' && $branding['logoDark'] === '' && ($branding['mode'] ?? '') === 'custom') {
$branding['mode'] = 'default';
}
$resolver->saveSiteBranding($branding);
$user = $this->getUser($request);
$payload = $resolver->resolve($user, true);
$payload['branding_urls'] = $this->resolveBrandingUrls($payload['branding'] ?? [], $resolver);
return ApiResponse::create($payload);
}
private function getLogoVariant(ServerRequestInterface $request): string
{
$variant = $request->getQueryParams()['variant'] ?? null;
if ($variant === null) {
$body = $request->getParsedBody();
if (is_array($body)) {
$variant = $body['variant'] ?? null;
}
}
$variant = is_string($variant) ? strtolower($variant) : '';
if ($variant !== 'light' && $variant !== 'dark') {
throw new ValidationException("Query parameter 'variant' must be 'light' or 'dark'.");
}
return $variant;
}
private function requireSiteEditor(ServerRequestInterface $request): void
{
$user = $this->getUser($request);
if (!$this->canEditSite($user)) {
throw new ForbiddenException('Only super-admins can edit site-wide admin preferences.');
}
}
private function canEditSite(\Grav\Common\User\Interfaces\UserInterface $user): bool
{
return $this->isSuperAdmin($user);
}
/**
* Project filename-only branding paths into URL fragments the SPA can use directly.
*
* @param array<string, mixed> $branding
* @return array{light: string, dark: string}
*/
private function resolveBrandingUrls(array $branding, PreferencesResolver $resolver): array
{
return [
'light' => $resolver->brandingMediaUrl((string) ($branding['logoLight'] ?? '')),
'dark' => $resolver->brandingMediaUrl((string) ($branding['logoDark'] ?? '')),
];
}
private function getResolver(): PreferencesResolver
{
return new PreferencesResolver($this->grav);
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Helpers\YamlLinter;
use Grav\Common\Page\Pages;
use Grav\Common\Security;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
class ReportsController extends AbstractApiController
{
private const PERMISSION_READ = 'api.reports.read';
/**
* GET /reports - Generate plugin-extensible reports.
*
* Built-in reports: Security Check, YAML Linter.
* Plugins can add reports via the onApiGenerateReports event.
*/
public function index(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$reports = [];
// Built-in: Grav Security Check (XSS scan)
$reports[] = $this->securityReport();
// Built-in: YAML Linter
$reports[] = $this->yamlLinterReport();
// Fire event for plugins to add their own reports
$event = new Event(['reports' => $reports]);
$this->grav->fireEvent('onApiGenerateReports', $event);
$reports = $event['reports'];
return ApiResponse::create($reports);
}
/**
* Scan all pages for potential XSS vulnerabilities.
*/
private function securityReport(): array
{
/** @var Pages $pages */
$pages = $this->grav['pages'];
$pages->enablePages();
$result = Security::detectXssFromPages($pages, true);
$items = [];
foreach ($result as $route => $fields) {
foreach ($fields as $field) {
$items[] = [
'route' => $route,
'field' => $field,
];
}
}
$issueCount = count($items);
return [
'id' => 'security-check',
'title' => 'Grav Security Check',
'provider' => 'core',
'component' => null,
'status' => $issueCount === 0 ? 'success' : 'warning',
'message' => $issueCount === 0
? 'Security Scan complete: No issues found.'
: "Security Scan complete: {$issueCount} potential XSS issue" . ($issueCount > 1 ? 's' : '') . ' found...',
'items' => $items,
];
}
/**
* Lint all YAML files for syntax errors.
*/
private function yamlLinterReport(): array
{
$result = YamlLinter::lint();
$items = [];
foreach ($result as $file => $error) {
$items[] = [
'file' => $file,
'error' => $error,
];
}
$errorCount = count($items);
return [
'id' => 'yaml-linter',
'title' => 'Grav Yaml Linter',
'provider' => 'core',
'component' => null,
'status' => $errorCount === 0 ? 'success' : 'error',
'message' => $errorCount === 0
? 'YAML Linting: No errors found.'
: "YAML Linting: {$errorCount} error" . ($errorCount > 1 ? 's' : '') . ' found.',
'items' => $items,
];
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Psr\Http\Message\ServerRequestInterface;
/**
* Resolves the admin-next frontend base URL (scheme + host + port + any base
* path) for building self-referential links inside emails (password reset,
* invitations, …). Shared by AuthController and InvitationsController.
*
* Resolution priority:
* 1. Explicit admin_base_url from the request body — the admin-next client
* sends `window.location.origin + base`, always correct for browsers.
* 2. Referer header — fallback when the body field is missing.
* 3. Origin header + Grav base path — last resort.
*
* Any accepted value is sanity-checked: only http(s) URLs are allowed so a
* link can't be coerced into producing something like a javascript: or
* data: URL.
*/
trait ResolvesAdminBaseUrl
{
/**
* @param string[] $stripSuffixes path suffixes to trim off the Referer
* path (e.g. ['/forgot', '/invite']) so we
* land at the admin-next root.
*/
protected function resolveAdminBaseUrl(
mixed $clientBaseUrl,
ServerRequestInterface $request,
array $stripSuffixes = ['/forgot'],
): string {
if (is_string($clientBaseUrl) && $clientBaseUrl !== '') {
$normalized = $this->sanitizeHttpUrl($clientBaseUrl);
if ($normalized !== null) {
return $normalized;
}
}
$referer = $request->getHeaderLine('Referer');
if ($referer !== '') {
$parts = parse_url($referer);
if (!empty($parts['scheme']) && !empty($parts['host'])) {
$origin = $parts['scheme'] . '://' . $parts['host'];
if (!empty($parts['port'])) {
$origin .= ':' . $parts['port'];
}
$path = $parts['path'] ?? '';
foreach ($stripSuffixes as $suffix) {
if ($suffix !== '' && str_ends_with($path, $suffix)) {
$path = substr($path, 0, -\strlen($suffix));
break;
}
}
$normalized = $this->sanitizeHttpUrl($origin . rtrim($path, '/'));
if ($normalized !== null) {
return $normalized;
}
}
}
$origin = $request->getHeaderLine('Origin');
if ($origin !== '') {
$basePath = (string) $this->grav['uri']->rootUrl(false);
$normalized = $this->sanitizeHttpUrl(rtrim($origin, '/') . $basePath);
if ($normalized !== null) {
return $normalized;
}
}
// Last resort: Grav's own root URL. Wrong in dev when admin-next runs
// on a separate origin, but at least a valid URL.
return rtrim((string) $this->grav['uri']->rootUrl(true), '/');
}
protected function sanitizeHttpUrl(string $url): ?string
{
$url = trim($url);
if ($url === '') {
return null;
}
$parts = parse_url($url);
if (empty($parts['scheme']) || empty($parts['host'])) {
return null;
}
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
return null;
}
return rtrim($url, '/');
}
}
@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Scheduler\Scheduler;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
class SchedulerController extends AbstractApiController
{
private const PERMISSION_READ = 'api.scheduler.read';
private const PERMISSION_WRITE = 'api.scheduler.write';
/**
* GET /scheduler/jobs - List all registered scheduler jobs with status.
*/
public function jobs(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
/** @var Scheduler $scheduler */
$scheduler = $this->grav['scheduler'];
// Fire onSchedulerInitialized so plugins register their system jobs
// (cache-purge, cache-clear, backups, etc.)
$this->grav->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler]));
$allJobs = $scheduler->getAllJobs();
$states = $scheduler->getJobStates()->content();
$data = [];
foreach ($allJobs as $job) {
$id = $job->getId();
$command = $job->getCommand();
$state = $states[$id] ?? null;
$data[] = [
'id' => $id,
'command' => is_string($command) ? $command : '(closure)',
'expression' => $job->getAt(),
'enabled' => $job->getEnabled(),
'status' => $state['state'] ?? 'pending',
'last_run' => isset($state['last-run']) ? date('c', $state['last-run']) : null,
'error' => $state['error'] ?? null,
];
}
return ApiResponse::create($data);
}
/**
* GET /scheduler/status - Get scheduler cron status.
*/
public function status(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
/** @var Scheduler $scheduler */
$scheduler = $this->grav['scheduler'];
// Fire onSchedulerInitialized so health status sees system jobs
$this->grav->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler]));
$crontabStatus = $scheduler->isCrontabSetup();
$statusMap = [0 => 'not_installed', 1 => 'installed', 2 => 'error'];
// Health status and active triggers
$health = method_exists($scheduler, 'getHealthStatus') ? $scheduler->getHealthStatus() : [];
$triggers = method_exists($scheduler, 'getActiveTriggers') ? $scheduler->getActiveTriggers() : [];
// Webhook plugin status
$webhookInstalled = class_exists('Grav\\Plugin\\SchedulerWebhookPlugin')
|| is_dir($this->grav['locator']->findResource('plugin://scheduler-webhook') ?: '');
$webhookEnabled = method_exists($scheduler, 'isWebhookEnabled') && $scheduler->isWebhookEnabled();
$data = [
'crontab_status' => $statusMap[$crontabStatus] ?? 'unknown',
'cron_command' => $scheduler->getCronCommand(),
'scheduler_command' => $scheduler->getSchedulerCommand(),
'whoami' => $scheduler->whoami(),
'health' => $health,
'triggers' => $triggers,
'webhook_installed' => $webhookInstalled,
'webhook_enabled' => $webhookEnabled,
];
return ApiResponse::create($data);
}
/**
* GET /scheduler/history - Job execution history (paginated).
*/
public function history(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$pagination = $this->getPagination($request);
/** @var Scheduler $scheduler */
$scheduler = $this->grav['scheduler'];
$states = $scheduler->getJobStates()->content();
// Convert states to array sorted by last-run desc
$history = [];
foreach ($states as $jobId => $state) {
$history[] = [
'job_id' => $jobId,
'status' => $state['state'] ?? 'unknown',
'last_run' => isset($state['last-run']) ? date('c', $state['last-run']) : null,
'last_run_timestamp' => $state['last-run'] ?? 0,
'error' => $state['error'] ?? null,
];
}
// Sort by last_run descending
usort($history, fn($a, $b) => ($b['last_run_timestamp'] ?? 0) <=> ($a['last_run_timestamp'] ?? 0));
// Remove the timestamp helper field
$history = array_map(function ($item) {
unset($item['last_run_timestamp']);
return $item;
}, $history);
$total = count($history);
$slice = array_slice($history, $pagination['offset'], $pagination['limit']);
$baseUrl = $this->getApiBaseUrl() . '/scheduler/history';
return ApiResponse::paginated(
data: $slice,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $baseUrl,
);
}
/**
* POST /scheduler/run - Trigger scheduler run manually.
*/
public function run(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
/** @var Scheduler $scheduler */
$scheduler = $this->grav['scheduler'];
$body = $this->getRequestBody($request);
$force = filter_var($body['force'] ?? false, FILTER_VALIDATE_BOOLEAN);
$scheduler->run(null, $force);
// Collect results
$states = $scheduler->getJobStates()->content();
return ApiResponse::create([
'message' => 'Scheduler run completed.',
'forced' => $force,
'job_states' => $states,
]);
}
/**
* GET /systeminfo - Generate system info overview.
*/
public function systemInfo(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$reports = [];
// PHP info
$reports['php'] = [
'version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'extensions' => get_loaded_extensions(),
'memory_limit' => ini_get('memory_limit'),
'max_execution_time' => ini_get('max_execution_time'),
'upload_max_filesize' => ini_get('upload_max_filesize'),
'post_max_size' => ini_get('post_max_size'),
];
// Grav info
$reports['grav'] = [
'version' => GRAV_VERSION,
'php_version' => PHP_VERSION,
];
// Disk usage
$rootPath = GRAV_ROOT;
$reports['disk'] = [
'free_space' => disk_free_space($rootPath),
'total_space' => disk_total_space($rootPath),
];
// Plugin status
$plugins = $this->grav['plugins']->all();
$enabledPlugins = 0;
$disabledPlugins = 0;
foreach ($plugins as $name => $plugin) {
if ($this->grav['config']->get("plugins.{$name}.enabled", false)) {
$enabledPlugins++;
} else {
$disabledPlugins++;
}
}
$reports['plugins'] = [
'total' => count($plugins),
'enabled' => $enabledPlugins,
'disabled' => $disabledPlugins,
];
// Cache status
$cacheDriver = $this->grav['config']->get('system.cache.driver', 'auto');
$cacheEnabled = $this->grav['config']->get('system.cache.enabled', true);
$reports['cache'] = [
'enabled' => $cacheEnabled,
'driver' => $cacheDriver,
];
return ApiResponse::create($reports);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
/**
* Settings API — lets plugins register admin-next settings panels that
* render as sections inside the Settings page, instead of as standalone
* sidebar entries via the plugin-page mechanism.
*
* Plugins listen for `onApiAdminSettingsPanels` to register panels.
*
* Panel format (same shape as plugin-page definitions, blueprint mode only):
* [
* 'id' => 'login-settings', // unique identifier
* 'plugin' => 'api', // plugin owning the blueprint
* 'label' => 'Login & Security', // card title
* 'description' => 'Authentication …', // optional sub-label
* 'icon' => 'fa-shield-alt', // optional FA icon
* 'blueprint' => 'login-settings', // blueprint file name
* 'data_endpoint' => '/login-settings/data', // GET endpoint
* 'save_endpoint' => '/login-settings/save', // PATCH endpoint
* 'priority' => 0, // sort order (higher = earlier)
* ]
*
* Panels are gated by the registering plugin — the user is passed in the
* event so listeners can skip adding the panel when permissions aren't met.
*/
class SettingsController extends AbstractApiController
{
/**
* GET /settings/panels — Collect admin-next settings panels from plugins.
*/
public function panels(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$event = new Event(['panels' => [], 'user' => $this->getUser($request)]);
$this->grav->fireEvent('onApiAdminSettingsPanels', $event);
$panels = $event['panels'] ?? [];
// Sort by priority descending (higher priority first), preserving
// insertion order among equal-priority panels.
usort($panels, fn($a, $b) => ($b['priority'] ?? 0) <=> ($a['priority'] ?? 0));
return ApiResponse::create($panels);
}
}
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\User\Authentication;
use Grav\Common\User\DataUser\User as DataUser;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Exceptions\ConflictException;
use Grav\Plugin\Api\Exceptions\TooManyRequestsException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\PasswordPolicyService;
use Grav\Plugin\Login\Login;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Handles the one-time first-run setup for fresh Grav 2.0 installs that use
* Admin-Next + API. Active only while user/accounts/ is empty; once any user
* is created the endpoints 409.
*/
class SetupController extends AbstractApiController
{
public function status(ServerRequestInterface $request): ResponseInterface
{
return ApiResponse::create([
'setup_required' => $this->noAccountsExist(),
'password_policy' => PasswordPolicyService::build($this->config),
]);
}
public function create(ServerRequestInterface $request): ResponseInterface
{
$this->enforceSetupRateLimit($request);
if (!$this->noAccountsExist()) {
throw new ConflictException('Setup has already been completed.');
}
$body = $this->getRequestBody($request);
$this->requireFields($body, ['username', 'password', 'email']);
$username = (string) $body['username'];
$password = (string) $body['password'];
$email = (string) $body['email'];
// Validate username format. Delegate the character rules to the core
// helper (Grav\Common\User\DataUser\User::isValidUsername) so setup
// accepts exactly what admin-classic does: letters, numbers, periods,
// hyphens and underscores, while still blocking path traversal,
// leading dots and filesystem-dangerous characters. Keep a 3-64 length
// bound for a friendlier message and to match the admin-next UI hint.
$length = mb_strlen($username);
if ($length < 3 || $length > 64 || !DataUser::isValidUsername($username)) {
throw new ValidationException(
'Invalid username format.',
[['field' => 'username', 'message' => 'Username must be 3-64 characters and contain only letters, numbers, periods, hyphens, and underscores (and cannot start with a period).']],
);
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new ValidationException(
'Invalid email address.',
[['field' => 'email', 'message' => 'A valid email address is required.']],
);
}
$pwdRegex = (string) $this->config->get('system.pwd_regex', '');
if ($pwdRegex !== '' && !@preg_match('#^(?:' . $pwdRegex . ')$#', $password)) {
throw new ValidationException(
'Password does not meet the required policy.',
[['field' => 'password', 'message' => 'Password does not meet the required policy.']],
);
} elseif ($pwdRegex === '' && strlen($password) < 8) {
throw new ValidationException(
'Password is too short.',
[['field' => 'password', 'message' => 'Password must be at least 8 characters.']],
);
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
// Second race-guard check after acquiring accounts: another concurrent
// setup call may have completed between the first check and now.
if ($accounts->count() > 0) {
throw new ConflictException('Setup has already been completed.');
}
$user = $accounts->load($username);
$user->set('email', $email);
$user->set('fullname', $body['fullname'] ?? $username);
$user->set('title', $body['title'] ?? 'Administrator');
$user->set('state', 'enabled');
$user->set('access', [
'site' => ['login' => true],
'api' => ['super' => true],
]);
$user->set('hashed_password', Authentication::create($password));
$user->set('created', time());
$user->set('modified', time());
// Flex user-accounts storage may still hold cached state for this
// username from a previous account (avatar, 2FA, content editor, …).
// Zero them out so the new super-admin is genuinely fresh.
$user->set('avatar', []);
$user->set('twofa_enabled', false);
$user->set('twofa_secret', '');
$user->save();
$this->fireEvent('onApiUserCreated', ['user' => $user]);
$this->fireEvent('onApiSetupComplete', ['user' => $user]);
$jwt = new JwtAuthenticator($this->grav, $this->config);
return $this->issueTokenPair($jwt, $user);
}
private function noAccountsExist(): bool
{
/** @var UserCollectionInterface|null $accounts */
$accounts = $this->grav['accounts'] ?? null;
return $accounts !== null && $accounts->count() === 0;
}
/**
* Defense-in-depth: even though this endpoint is self-disabling once any
* user exists, rate-limit by IP to blunt rapid brute-force probing during
* the eligible window. Reuses the login plugin's rate limiter keyed by a
* synthetic "__api_setup__:{ip}" string.
*/
private function enforceSetupRateLimit(ServerRequestInterface $request): void
{
if (!class_exists(Login::class) || !isset($this->grav['login'])) {
return;
}
$server = $request->getServerParams();
$ip = (string) ($server['REMOTE_ADDR'] ?? 'unknown');
$key = '__api_setup__:' . $ip;
/** @var Login $login */
$login = $this->grav['login'];
$interval = $login->checkLoginRateLimit($key);
if ($interval > 0) {
throw new TooManyRequestsException(
sprintf('Too many setup attempts. Try again in %d minutes.', $interval),
$interval * 60,
);
}
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
/**
* Sidebar API — lets plugins register navigation items in the admin sidebar.
*
* Plugins listen for `onApiSidebarItems` to register items.
*
* Item format:
* [
* 'id' => 'license-manager', // unique identifier
* 'plugin' => 'license-manager', // owning plugin slug
* 'label' => 'License Manager', // display name
* 'icon' => 'fa-key', // FA icon class
* 'route' => '/plugin/license-manager', // admin-next route
* 'priority' => 0, // sort order (higher = earlier)
* 'badge' => null, // optional static badge text/count
* 'badgeEndpoint' => '/my-plugin/badge', // optional — API path returning { count: N }, refreshed live
* 'authorize' => 'api.some.permission', // optional — single permission, or array for any-of
* ]
*
* When `badgeEndpoint` is set, admin-next fetches it on load and re-fetches on
* content/config/plugin/theme changes; a plugin can also push an update live by
* dispatching `grav:sidebar:badge` ({ id, count }). The live count overrides the
* static `badge`.
*
* `authorize` accepts either a string or an array of permissions. An array is
* treated as an any-of test, matching admin-classic's nav-quick-tray template.
* Items without `authorize` are shown to every authenticated user (anyone past
* the api.access gate).
*/
class SidebarController extends AbstractApiController
{
/**
* GET /sidebar/items — Collect sidebar items from plugins, filtered by
* the current user's permissions.
*/
public function items(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.access');
$user = $this->getUser($request);
$event = new Event(['items' => [], 'user' => $user]);
$this->grav->fireEvent('onApiSidebarItems', $event);
$isSuperAdmin = $this->isSuperAdmin($user);
$filtered = [];
foreach ($event['items'] as $item) {
if (!$this->userPassesAuthorize($user, $item['authorize'] ?? null, $isSuperAdmin)) {
continue;
}
// Strip the authorize field — it's a server-side annotation, not client data
unset($item['authorize']);
$filtered[] = $item;
}
usort($filtered, fn($a, $b) => ($b['priority'] ?? 0) <=> ($a['priority'] ?? 0));
return ApiResponse::create($filtered);
}
}
@@ -0,0 +1,767 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Backup\Backups;
use Grav\Common\Language\LanguageCodes;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\DisabledPluginLangIndex;
use Grav\Plugin\Api\Services\EnvironmentService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class SystemController extends AbstractApiController
{
/**
* GET /system/environments — list writable environment targets.
*
* Response shape:
* {
* detected: "host.example", // what Grav inferred from the URL
* environments: [
* { name: "", label: "Default", exists: true, hasOverrides: true|false },
* { name: "staging", exists: true, hasOverrides: true }
* ]
* }
*
* `name: ""` represents the base user/config target. Any other entry is an
* existing user/env/<name>/ folder that can be selected as a write target.
* Legacy user/<host>/config/ layouts (Grav 1.6 fallback) are included too.
*/
public function environments(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$envService = new EnvironmentService($this->grav);
$list = [[
'name' => '',
'label' => 'Default',
'exists' => true,
'hasOverrides' => false,
]];
foreach ($envService->listEnvironments() as $name) {
$list[] = [
'name' => $name,
'label' => $name,
'exists' => true,
'hasOverrides' => $envService->envHasOverrides($name),
];
}
return ApiResponse::create([
'detected' => $this->grav['uri']->environment(),
'environments' => $list,
]);
}
/**
* POST /system/environments — create a new env folder.
*
* Body: { "name": "staging.foo.com" }
* Creates user/env/<name>/config/ (and user/env/ if missing).
*/
public function createEnvironment(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.write');
$body = $this->getRequestBody($request);
$name = trim((string)($body['name'] ?? ''));
$envService = new EnvironmentService($this->grav);
try {
$envService->createEnvironment($name);
} catch (\InvalidArgumentException $e) {
throw new ValidationException($e->getMessage());
}
return ApiResponse::create([
'name' => $name,
'label' => $name,
'exists' => true,
'hasOverrides' => false,
], 201, ['X-Invalidates' => 'system:environments']);
}
/**
* DELETE /system/environments/{name} — remove a user/env/<name>/ folder.
*
* Refuses to delete the env that Grav resolved for the current request, and
* refuses to act on legacy user/<name>/ layouts. See EnvironmentService for
* the full safety rules.
*/
public function deleteEnvironment(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.config.write');
$name = (string) $this->getRouteParam($request, 'name');
$envService = new EnvironmentService($this->grav);
try {
$envService->deleteEnvironment($name);
} catch (\InvalidArgumentException $e) {
throw new ValidationException($e->getMessage());
}
return ApiResponse::noContent(['X-Invalidates' => 'system:environments']);
}
public function info(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$plugins = $this->getPluginsInfo();
$themes = $this->getThemesInfo();
$data = [
'grav_version' => GRAV_VERSION,
'php_version' => PHP_VERSION,
'php_extensions' => get_loaded_extensions(),
'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown',
'environment' => $this->config->get('system.environment') ?? $this->grav['uri']->environment(),
'plugins' => $plugins,
'themes' => $themes,
'php_config' => $this->getPhpConfig(),
];
return ApiResponse::create($data);
}
private function getPhpConfig(): array
{
$ini = function (string $key): string {
return (string) ini_get($key);
};
return [
'Upload & POST' => [
'file_uploads' => $ini('file_uploads'),
'upload_max_filesize' => $ini('upload_max_filesize'),
'max_file_uploads' => $ini('max_file_uploads'),
'post_max_size' => $ini('post_max_size'),
],
'Memory & Execution' => [
'memory_limit' => $ini('memory_limit'),
'max_execution_time' => $ini('max_execution_time') . 's',
'max_input_time' => $ini('max_input_time') . 's',
'max_input_vars' => $ini('max_input_vars'),
],
'Error Handling' => [
'display_errors' => $ini('display_errors'),
'error_reporting' => (string) error_reporting(),
'log_errors' => $ini('log_errors'),
'error_log' => $ini('error_log') ?: '(none)',
],
'Paths & Environment' => [
'open_basedir' => $ini('open_basedir') ?: '(none)',
'sys_temp_dir' => sys_get_temp_dir(),
'doc_root' => $_SERVER['DOCUMENT_ROOT'] ?? '(unknown)',
'include_path' => $ini('include_path'),
],
'Session' => [
'session.save_handler' => $ini('session.save_handler'),
'session.save_path' => $ini('session.save_path') ?: '(default)',
'session.gc_maxlifetime' => $ini('session.gc_maxlifetime') . 's',
'session.cookie_lifetime' => $ini('session.cookie_lifetime') . 's',
'session.cookie_secure' => $ini('session.cookie_secure'),
'session.cookie_httponly' => $ini('session.cookie_httponly'),
],
'OPcache' => function_exists('opcache_get_status') ? [
'opcache.enable' => $ini('opcache.enable'),
'opcache.memory_consumption' => $ini('opcache.memory_consumption') . 'MB',
'opcache.max_accelerated_files' => $ini('opcache.max_accelerated_files'),
'opcache.validate_timestamps' => $ini('opcache.validate_timestamps'),
'opcache.revalidate_freq' => $ini('opcache.revalidate_freq') . 's',
] : ['opcache.enable' => '0'],
'Security' => [
'allow_url_fopen' => $ini('allow_url_fopen'),
'allow_url_include' => $ini('allow_url_include'),
'disable_functions' => $ini('disable_functions') ?: '(none)',
'expose_php' => $ini('expose_php'),
],
'Date & Locale' => [
'date.timezone' => $ini('date.timezone') ?: date_default_timezone_get(),
'default_charset' => $ini('default_charset'),
'mbstring.internal_encoding' => $ini('mbstring.internal_encoding') ?: '(default)',
],
];
}
/**
* GET /ping - Lightweight keep-alive endpoint.
* Health/connectivity check. No auth required — session keep-alive
* is handled by proactive token refresh on the client side.
*/
public function ping(ServerRequestInterface $request): ResponseInterface
{
return ApiResponse::create(['pong' => true]);
}
public function clearCache(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.write');
$query = $request->getQueryParams();
$scope = $query['scope'] ?? 'standard';
$allowedScopes = ['all', 'standard', 'images', 'assets', 'tmp'];
if (!in_array($scope, $allowedScopes, true)) {
throw new ValidationException(
"Invalid cache scope '{$scope}'. Allowed: " . implode(', ', $allowedScopes),
);
}
$results = $this->grav['cache']->clearCache($scope);
return ApiResponse::create([
'scope' => $scope,
'message' => "Cache cleared successfully (scope: {$scope}).",
'details' => $results,
]);
}
/**
* GET /system/logs/files — list log files registered for the admin viewer.
*
* Seeds with grav.log / email.log / scheduler.log, then fires
* onApiLogFiles so plugins can append their own. The file names returned
* here are the only values accepted by GET /system/logs?file=...
*/
public function logFiles(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$files = $this->getRegisteredLogFiles();
return ApiResponse::create([
'files' => array_values($files),
'default' => 'grav.log',
]);
}
public function logs(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
$levelFilter = $query['level'] ?? null;
$search = $query['search'] ?? null;
// Validate ?file= against the registered whitelist. Without this an
// attacker could read any file the locator can resolve.
$registered = $this->getRegisteredLogFiles();
$allowed = array_column($registered, 'file');
$requested = $query['file'] ?? 'grav.log';
if (!in_array($requested, $allowed, true)) {
throw new ValidationException('Unknown log file: ' . $requested, [
['field' => 'file', 'message' => 'Must be one of: ' . implode(', ', $allowed)],
]);
}
$logFile = $this->grav['locator']->findResource('log://' . $requested);
if (!$logFile || !file_exists($logFile)) {
return ApiResponse::paginated([], 0, $pagination['page'], $pagination['per_page'], $this->getApiBaseUrl() . '/system/logs');
}
$content = file_get_contents($logFile);
$lines = explode("\n", $content);
$entries = [];
foreach ($lines as $line) {
if ($line === '' || $line[0] !== '[') {
continue;
}
// Extract date
$closeBracket = strpos($line, ']');
if ($closeBracket === false) {
continue;
}
$date = substr($line, 1, $closeBracket - 1);
// Extract logger.LEVEL: message
$rest = ltrim(substr($line, $closeBracket + 1));
$colonPos = strpos($rest, ':');
if ($colonPos === false) {
continue;
}
$loggerLevel = substr($rest, 0, $colonPos);
$dotPos = strpos($loggerLevel, '.');
if ($dotPos === false) {
continue;
}
$logger = substr($loggerLevel, 0, $dotPos);
$level = strtoupper(substr($loggerLevel, $dotPos + 1));
$message = trim(substr($rest, $colonPos + 1));
// Strip trailing [] []
$message = preg_replace('/\s*\[\]\s*\[\]\s*$/', '', $message);
if ($levelFilter !== null && $level !== strtoupper($levelFilter)) {
continue;
}
if ($search !== null && $search !== '' && stripos($message, $search) === false) {
continue;
}
$entries[] = [
'date' => $date,
'logger' => $logger,
'level' => $level,
'message' => $message,
];
}
$entries = array_reverse($entries);
$total = count($entries);
$paged = array_slice($entries, $pagination['offset'], $pagination['limit']);
return ApiResponse::paginated($paged, $total, $pagination['page'], $pagination['per_page'], $this->getApiBaseUrl() . '/system/logs');
}
/**
* Build the list of log files available to the admin viewer.
*
* Seeded with the core logs Grav writes itself, then plugins can append
* via onApiLogFiles. Result is deduped by `file` (first wins) so plugins
* cannot shadow core log labels.
*
* @return array<int, array{file: string, label: string}>
*/
private function getRegisteredLogFiles(): array
{
$files = [
['file' => 'grav.log', 'label' => 'Grav System Log'],
['file' => 'email.log', 'label' => 'Email Log'],
['file' => 'scheduler.log', 'label' => 'Scheduler Log'],
];
$event = $this->fireEvent('onApiLogFiles', ['files' => $files]);
$merged = $event['files'] ?? $files;
// Dedupe by file name; first occurrence wins so core entries above
// are preserved even if a plugin tries to re-register the same name.
$seen = [];
$result = [];
foreach ($merged as $entry) {
if (!is_array($entry) || empty($entry['file'])) {
continue;
}
$name = (string) $entry['file'];
if (isset($seen[$name])) {
continue;
}
// Strip path components defensively — log names must be simple
// basenames so they resolve through the log:// stream.
if ($name !== basename($name)) {
continue;
}
$seen[$name] = true;
$result[] = [
'file' => $name,
'label' => (string) ($entry['label'] ?? $name),
];
}
return $result;
}
public function backup(ServerRequestInterface $request): ResponseInterface
{
// Backups archive the full Grav root, including user/accounts (admin
// password hashes) and user/config secrets. Gate creation, listing,
// download and deletion behind a dedicated api.system.backup permission
// (or api.super) rather than the broader read/write tiers, so only
// operators explicitly trusted with the credential-bearing archive can
// touch it (GHSA-2f86-9cp8-6hcf).
$this->requirePermission($request, 'api.system.backup');
// Ensure backup directory is initialized
$backups = $this->grav['backups'] ?? new Backups();
if (method_exists($backups, 'init')) {
$backups->init();
}
$result = Backups::backup();
$filename = basename($result);
$size = file_exists($result) ? filesize($result) : 0;
return ApiResponse::created(
data: [
'filename' => $filename,
'path' => $result,
'size' => $size,
'date' => date('c'),
],
location: $this->getApiBaseUrl() . '/system/backups',
);
}
public function backups(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.backup');
// Ensure backup directory is initialized before listing
$backups = $this->grav['backups'] ?? new Backups();
if (method_exists($backups, 'init')) {
$backups->init();
}
$list = Backups::getAvailableBackups(true);
$items = [];
foreach ($list as $backup) {
// getAvailableBackups returns stdClass objects, not arrays
$b = is_object($backup) ? $backup : (object) $backup;
$items[] = [
'filename' => $b->filename ?? basename($b->path ?? ''),
'title' => $b->title ?? null,
'date' => $b->date ?? null,
'size' => $b->size ?? 0,
];
}
// Include purge config for storage usage display
$purge = Backups::getPurgeConfig();
return ApiResponse::create([
'backups' => $items,
'purge' => $purge,
'profiles_count' => count(Backups::getBackupProfiles() ?? []),
]);
}
/**
* DELETE /system/backups/{filename} - Delete a backup file.
*/
public function deleteBackup(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.backup');
$b = $this->grav['backups'] ?? new Backups();
if (method_exists($b, 'init')) { $b->init(); }
$filename = $this->getRouteParam($request, 'filename');
// Validate filename (no path traversal)
if (!$filename || $filename !== basename($filename) || !str_ends_with($filename, '.zip')) {
throw new ValidationException(['filename' => ['Invalid backup filename.']]);
}
$backupDir = $this->grav['locator']->findResource('backup://', true);
$filepath = $backupDir . '/' . $filename;
if (!file_exists($filepath)) {
throw new NotFoundException("Backup '{$filename}' not found.");
}
unlink($filepath);
return ApiResponse::noContent();
}
/**
* GET /system/backups/{filename}/download - Download a backup file.
*/
public function downloadBackup(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.backup');
$b = $this->grav['backups'] ?? new Backups();
if (method_exists($b, 'init')) { $b->init(); }
$filename = $this->getRouteParam($request, 'filename');
if (!$filename || $filename !== basename($filename) || !str_ends_with($filename, '.zip')) {
throw new ValidationException(['filename' => ['Invalid backup filename.']]);
}
$backupDir = $this->grav['locator']->findResource('backup://', true);
$filepath = $backupDir . '/' . $filename;
if (!file_exists($filepath)) {
throw new NotFoundException("Backup '{$filename}' not found.");
}
$stream = fopen($filepath, 'rb');
return new \Grav\Framework\Psr7\Response(
200,
[
'Content-Type' => 'application/zip',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => (string) filesize($filepath),
],
$stream,
);
}
/**
* GET /translations/{lang} - Get all translation strings for a language.
*
* Returns a flat key-value object of all translation strings for efficient
* client-side caching. Optionally filter by prefix (e.g., ?prefix=PLUGIN_ADMIN).
*/
public function translations(ServerRequestInterface $request): ResponseInterface
{
// No auth required — translation strings are not sensitive
$lang = $this->getRouteParam($request, 'lang');
$prefix = $request->getQueryParams()['prefix'] ?? null;
/** @var \Grav\Common\Language\Language $language */
$language = $this->grav['language'];
// Validate language code shape only — admin UI languages are a
// different concept from site content languages, so we DO NOT gate
// on $language->getLanguages() (which lists languages configured in
// system.yaml for site content). Any plugin shipping a `languages/
// <lang>.yaml` should be loadable here, even if the site itself only
// serves English content.
if (!is_string($lang) || !preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z]{2,4})?$/', $lang)) {
$lang = $language->getDefault() ?: 'en-US';
}
// Coerce legacy short codes to their BCP 47 canonical form so a request
// for `/translations/en` resolves to admin2's `en-US.yaml`.
$lang = self::normalizeLangCode($lang);
/** @var \Grav\Common\Config\Languages $languages */
$languages = $this->grav['languages'];
try {
$translations = $languages->flattenByLang($lang);
} catch (\Throwable) {
$translations = [];
}
// Strip strings contributed only by disabled plugins. Grav core's
// `flattenByLang()` reads every plugin's lang yaml regardless of enabled
// state — fine for the legacy admin, broken for admin2: a disabled plugin
// would still influence what admin2 renders. The service walks each
// plugin's lang yaml to determine provenance and returns keys unique to
// disabled plugins. Keys also shipped by enabled sources stay.
if (is_array($translations)) {
$disabledIndex = new DisabledPluginLangIndex($this->grav);
foreach ($disabledIndex->disabledOnlyKeys($lang) as $key) {
unset($translations[$key]);
}
}
// Drop flat `<key>` entries when an `ICU.<key>` shadow exists. Admin2 ships
// the canonical PLUGIN_ADMIN.* vocabulary under ICU; if a 3rd-party plugin
// still using the Grav 1 flat convention is also installed, its values
// would otherwise leak into the dictionary served to the client. Keeping
// only the ICU side guarantees admin2 is the source of truth.
if (is_array($translations)) {
foreach (array_keys($translations) as $key) {
if (is_string($key) && !str_starts_with($key, 'ICU.') && isset($translations['ICU.' . $key])) {
unset($translations[$key]);
}
}
}
// Filter by prefix if requested
if ($prefix && is_array($translations)) {
$prefixLower = strtolower($prefix) . '.';
$translations = array_filter(
$translations,
fn($key) => str_starts_with(strtolower($key), $prefixLower),
ARRAY_FILTER_USE_KEY
);
}
// Include a checksum for cache invalidation
$checksum = md5(json_encode($translations));
return ApiResponse::create([
'lang' => $lang,
'dir' => LanguageCodes::getOrientation(self::primarySubtag($lang)),
'count' => count($translations),
'checksum' => $checksum,
'strings' => $translations,
]);
}
/**
* GET /admin/languages - Locales the admin UI itself can be rendered in.
*
* Distinct from GET /languages, which returns *site content* languages
* configured in system.yaml. This endpoint returns locales for which a
* translation file exists in the admin2 plugin's languages directory —
* i.e. languages a user can pick for their admin interface.
*/
public function adminLanguages(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$dir = GRAV_ROOT . '/user/plugins/admin2/languages';
$languages = [];
if (is_dir($dir)) {
foreach (glob($dir . '/*.yaml') ?: [] as $file) {
$code = basename($file, '.yaml');
$languages[] = [
'code' => $code,
'name' => LanguageCodes::getName($code) ?: $code,
'native_name' => LanguageCodes::getNativeName($code) ?: $code,
'rtl' => LanguageCodes::isRtl(self::primarySubtag($code)),
];
}
}
// Stable sort by native name so the dropdown order doesn't depend on
// filesystem readdir order.
usort($languages, fn($a, $b) => strcmp($a['native_name'], $b['native_name']));
return ApiResponse::create([
'languages' => $languages,
]);
}
private function getPluginsInfo(): array
{
$plugins = [];
$gpm = $this->grav['plugins'];
foreach ($gpm as $plugin) {
$name = $plugin->name;
// Plugin::getBlueprint() asserts the plugin's metadata is in
// the Plugins manager. On Grav 2.0-rc.2 a number of registered
// plugin instances have no companion entry there (login, form,
// error, several first-party + side-car plugins), and the
// assert blows up for the whole /system/info request. Fall
// back to a read-from-disk path so partial info still ships.
$bpName = null;
$bpVersion = null;
if ($gpm->get($name) !== null) {
try {
$blueprint = $plugin->getBlueprint();
$bpName = $blueprint->get('name');
$bpVersion = $blueprint->get('version');
} catch (\Throwable $e) {
// Defensive: even past the null check, blueprint
// hydration can throw on malformed yaml. Treat as
// metadata-unavailable.
}
} else {
// Direct file read — bypasses Plugin::loadBlueprint() entirely.
$file = GRAV_ROOT . "/user/plugins/{$name}/blueprints.yaml";
if (is_file($file)) {
try {
$raw = \Symfony\Component\Yaml\Yaml::parseFile($file);
if (is_array($raw)) {
$bpName = $raw['name'] ?? null;
$bpVersion = $raw['version'] ?? null;
}
} catch (\Throwable $e) {
// ignore — leave metadata blank
}
}
}
$plugins[] = [
'name' => $bpName ?? $name,
'version' => $bpVersion ?? '0.0.0',
'enabled' => $this->config->get("plugins.{$name}.enabled", false),
];
}
return $plugins;
}
private function getThemesInfo(): array
{
$themes = [];
$activeTheme = $this->config->get('system.pages.theme');
$themesDir = $this->grav['locator']->findResource('themes://');
if (!$themesDir || !is_dir($themesDir)) {
return $themes;
}
$iterator = new \DirectoryIterator($themesDir);
foreach ($iterator as $item) {
if ($item->isDot() || !$item->isDir()) {
continue;
}
$blueprintFile = $item->getPathname() . '/blueprints.yaml';
if (!file_exists($blueprintFile)) {
continue;
}
$blueprint = \Grav\Common\Yaml::parse(file_get_contents($blueprintFile));
$themeName = $item->getFilename();
$themes[] = [
'name' => $blueprint['name'] ?? $themeName,
'version' => $blueprint['version'] ?? '0.0.0',
'active' => $themeName === $activeTheme,
];
}
return $themes;
}
/**
* Map a raw lang code (`en`, `fr`, `zh-hans`) to its BCP 47 canonical form
* (`en-US`, `fr-FR`, `zh-Hans`). Admin2 + admin-next standardize on BCP 47
* for their UI surfaces, so any short or lowercase variant arriving on the
* wire is coerced here before disk lookup. Anything not in the alias map
* (or already in canonical region/script casing) passes through.
*/
/**
* Primary language subtag of a BCP 47 code. `he-IL` → `he`, `zh-Hans` →
* `zh`. Grav core's `LanguageCodes` table is keyed by short codes only,
* so any lookup against it has to go through here when the input might
* be region/script-qualified.
*/
private static function primarySubtag(string $code): string
{
return strtolower(explode('-', $code, 2)[0]);
}
private static function normalizeLangCode(string $code): string
{
static $aliases = [
'en' => 'en-US',
'ar' => 'ar-SA',
'cs' => 'cs-CZ',
'de' => 'de-DE',
'es' => 'es-ES',
'es-mx' => 'es-MX',
'fi' => 'fi-FI',
'fr' => 'fr-FR',
'fr-ca' => 'fr-CA',
'he' => 'he-IL',
'it' => 'it-IT',
'nl' => 'nl-NL',
'pt' => 'pt-PT',
'ru' => 'ru-RU',
'sv' => 'sv-SE',
'uk' => 'uk-UA',
'zh-hans' => 'zh-Hans',
'zh-hant' => 'zh-Hant',
];
$key = strtolower(str_replace('_', '-', trim($code)));
if (isset($aliases[$key])) {
return $aliases[$key];
}
if (preg_match('/^([a-z]{2,3})-([a-z0-9]{2,4})$/i', $code, $m)) {
$tag = strlen($m[2]) === 4
? ucfirst(strtolower($m[2]))
: strtoupper($m[2]);
return strtolower($m[1]) . '-' . $tag;
}
return $code;
}
}
@@ -0,0 +1,933 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\User\Authentication;
use Grav\Common\User\DataUser\User as DataUser;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Framework\Flex\FlexDirectory;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Grav\Plugin\Api\Exceptions\ConflictException;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\FlexBackend;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\UserSerializer;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class UsersController extends AbstractApiController
{
use FlexBackend;
private ?UserSerializer $serializer = null;
public function index(ServerRequestInterface $request): ResponseInterface
{
// Without api.users.read a caller can still see *their own* row —
// we auto-filter the listing to self rather than 403 the request.
// Anything beyond that requires api.users.read.
$currentUser = $this->getUser($request);
$canSeeAll = $this->isSuperAdmin($currentUser)
|| $this->hasPermission($currentUser, 'api.users.read');
if (!$canSeeAll) {
return $this->indexSelfOnly($request, $currentUser);
}
$directory = $this->getFlexDirectory('user-accounts');
if ($directory) {
return $this->indexViaFlex($request, $directory);
}
return $this->indexViaAccounts($request);
}
/**
* Single-row "listing" for callers without api.users.read. Matches the
* paginated envelope of the full listing so the client doesn't need a
* special-case branch.
*/
private function indexSelfOnly(ServerRequestInterface $request, UserInterface $currentUser): ResponseInterface
{
$pagination = $this->getPagination($request);
$data = [$this->serializeUser($currentUser)];
return ApiResponse::paginated(
data: $data,
total: 1,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $this->getApiBaseUrl() . '/users',
);
}
/**
* List users using the Flex-Objects backend (indexed, searchable).
*/
private function indexViaFlex(ServerRequestInterface $request, FlexDirectory $directory): ResponseInterface
{
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
$search = $query['search'] ?? null;
$filters = $this->getListFilters($request);
// Grav's Flex FileStorage indexes every file in user/accounts/ without
// filtering by extension — any stray file left there by another plugin
// (e.g. revisions-pro's `name.yaml.<timestamp>.rev` snapshots) surfaces
// as a phantom user. Constrain to keys that look like actual usernames
// before the collection is built so downstream search/sort/pagination
// operate on real accounts only.
//
// Usernames may legitimately contain periods (DataUser::isValidUsername
// allows them, and so does POST /users), so we can't simply reject dots
// — that hid accounts like `bill.bailey`. Instead accept anything that
// is a valid username but drop keys that embed a stored-file extension
// (`.yaml`/`.json`), which is the tell-tale of a revision/backup stray.
$index = $directory->getIndex();
$validKeys = array_values(array_filter(
$index->getKeys(),
static fn($k) => is_string($k)
&& DataUser::isValidUsername($k)
&& !preg_match('/\.(ya?ml|json)(\.|$)/i', $k),
));
$collection = $directory->getCollection($validKeys);
// Apply search (searches username, email, fullname per blueprint config)
if ($search && $search !== '') {
$collection = $collection->search($search);
}
// Sort by username by default
$collection = $collection->sort(['username' => 'asc']);
if ($filters['access'] === '' && $filters['group'] === '') {
// No permission/group filter — keep the lazy, indexed fast path that
// only materializes the requested page.
$total = $collection->count();
$slice = $collection->slice($pagination['offset'], $pagination['limit']);
$data = [];
foreach ($slice as $flexUser) {
if ($flexUser instanceof UserInterface) {
$data[] = $this->serializeUser($flexUser);
}
}
} else {
// Permission/group filtering can't be expressed as an indexed query
// (it depends on effective access, including group inheritance and
// the superuser fallback), so materialize the ordered users and
// filter in PHP before paginating. Search above already narrowed
// the set.
$users = [];
foreach ($collection as $flexUser) {
if ($flexUser instanceof UserInterface && $this->userMatchesFilters($flexUser, $filters)) {
$users[] = $flexUser;
}
}
$total = count($users);
$data = [];
foreach (array_slice($users, $pagination['offset'], $pagination['limit']) as $flexUser) {
$data[] = $this->serializeUser($flexUser);
}
}
return ApiResponse::paginated(
data: $data,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $this->getApiBaseUrl() . '/users',
);
}
/**
* List users using filesystem scan (fallback).
*/
private function indexViaAccounts(ServerRequestInterface $request): ResponseInterface
{
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
$search = isset($query['search']) ? trim((string) $query['search']) : '';
$filters = $this->getListFilters($request);
$allUsers = [];
foreach ($this->getAllUsernames() as $username) {
$user = $this->grav['accounts']->load($username);
if (!$user->exists()) {
continue;
}
if ($search !== '' && !$this->userMatchesSearch($user, $search)) {
continue;
}
if (!$this->userMatchesFilters($user, $filters)) {
continue;
}
$allUsers[] = $this->serializeUser($user);
}
$total = count($allUsers);
$paged = array_slice($allUsers, $pagination['offset'], $pagination['limit']);
return ApiResponse::paginated(
data: $paged,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $this->getApiBaseUrl() . '/users',
);
}
public function show(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
// Self-access mirrors update(): a user can fetch their own record
// with just api.access. Otherwise api.users.read is required to see
// someone else's account.
$currentUser = $this->getUser($request);
if ($currentUser->username !== $username) {
$this->requirePermission($request, 'api.users.read');
} else {
$this->requirePermission($request, 'api.access');
}
$user = $this->loadUserOrFail($username);
$data = $this->serializeUser($user);
// ETag is computed from the user data only — system capability flags
// like twofa_global_enabled are not part of the resource state and
// shouldn't cause spurious 409s on PATCH when the admin flips the
// global setting between fetch and save.
$etag = $this->generateEtag($data);
$data['twofa_global_enabled'] = (bool) $this->config->get('plugins.login.twofa_enabled', false);
return ApiResponse::create($data, 200, ['ETag' => '"' . $etag . '"']);
}
public function create(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.write');
$body = $this->getRequestBody($request);
$this->requireFields($body, ['username', 'password', 'email']);
$username = $body['username'];
// Validate username format. Delegate the character rules to the core
// helper (Grav\Common\User\DataUser\User::isValidUsername) so the API
// accepts exactly what admin-classic does: letters, numbers, periods,
// hyphens and underscores, while still blocking path traversal,
// leading dots and filesystem-dangerous characters. Keep a 3-64 length
// bound for a friendlier message and to match the admin-next UI hint.
$length = mb_strlen((string) $username);
if ($length < 3 || $length > 64 || !DataUser::isValidUsername((string) $username)) {
throw new ValidationException(
'Invalid username format.',
[['field' => 'username', 'message' => 'Username must be 3-64 characters and contain only letters, numbers, periods, hyphens, and underscores (and cannot start with a period).']],
);
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$existing = $accounts->load($username);
if ($existing->exists()) {
throw new ConflictException("User '{$username}' already exists.");
}
// Create new user
$user = $accounts->load($username);
$user->set('email', $body['email']);
$user->set('fullname', $body['fullname'] ?? '');
$user->set('title', $body['title'] ?? '');
$user->set('state', $body['state'] ?? 'enabled');
$user->set('hashed_password', Authentication::create($body['password']));
$user->set('created', time());
$user->set('modified', time());
if (isset($body['access'])) {
$user->set('access', $body['access']);
}
// `groups` is super-admin-only (see update()): group membership can grant
// access, so a non-super creator must not seed group assignments.
if (isset($body['groups']) && $this->isSuperAdmin($this->getUser($request))) {
$user->set('groups', $body['groups']);
}
// Allow plugins to modify the user before save
$this->fireAdminEvent('onAdminSave', ['object' => &$user]);
// Validate the submitted fields against the account blueprint before
// writing to disk (admin2#30) — e.g. a password that fails the
// configured pwd_regex, or a required field sent empty, now returns 422.
$this->validateChangedFields($body, method_exists($user, 'getBlueprint') ? $user->getBlueprint() : null);
$user->save();
$this->fireAdminEvent('onAdminAfterSave', ['object' => $user]);
$this->fireEvent('onApiUserCreated', ['user' => $user]);
return ApiResponse::created(
data: $this->serializeUser($user),
location: $this->getApiBaseUrl() . '/users/' . $username,
headers: $this->invalidationHeaders(['users:create:' . $username, 'users:list']),
);
}
public function update(ServerRequestInterface $request): ResponseInterface
{
$currentUser = $this->getUser($request);
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
// Users can update themselves with just api.access, otherwise need api.users.write
$isSelf = $currentUser->username === $username;
$canManageUsers = $this->isSuperAdmin($currentUser)
|| $this->hasPermission($currentUser, 'api.users.write');
if (!$isSelf) {
$this->requirePermission($request, 'api.users.write');
} else {
// Self-edit only requires api.access (already checked by auth middleware)
$this->requirePermission($request, 'api.access');
}
// ETag validation
$currentHash = $this->generateEtag($this->serializeUser($user));
$this->validateEtag($request, $currentHash);
$body = $this->getRequestBody($request);
if (empty($body)) {
throw new ValidationException('Request body must contain fields to update.');
}
// Privilege-sensitive fields are gated on api.users.write. Without this
// split a self-edit (api.access only) could PATCH `access` and grant
// itself api.super / admin.super — see GHSA-r945-h4vm-h736.
$selfFields = ['email', 'fullname', 'title', 'language', 'content_editor', 'twofa_enabled'];
$adminFields = ['state', 'access'];
// `groups` is marked `security@: admin.super` in the account blueprint:
// group membership can confer access, so only super admins may change it
// — a plain api.users.write manager must not assign users into groups.
$superFields = ['groups'];
$isSuper = $this->isSuperAdmin($currentUser);
if (!$canManageUsers) {
foreach ($adminFields as $field) {
if (array_key_exists($field, $body)) {
throw new ForbiddenException(
"Modifying '{$field}' requires the 'api.users.write' permission."
);
}
}
}
if (!$isSuper) {
foreach ($superFields as $field) {
if (array_key_exists($field, $body)) {
throw new ForbiddenException(
"Modifying '{$field}' requires super-admin privileges."
);
}
}
}
$allowedFields = $selfFields;
if ($canManageUsers) {
$allowedFields = array_merge($allowedFields, $adminFields);
}
if ($isSuper) {
$allowedFields = array_merge($allowedFields, $superFields);
}
foreach ($allowedFields as $field) {
if (array_key_exists($field, $body)) {
$user->set($field, $body[$field]);
}
}
// Hash password if provided
if (isset($body['password']) && $body['password'] !== '') {
$user->set('hashed_password', Authentication::create($body['password']));
}
$user->set('modified', time());
// Allow plugins to modify the user before save
$this->fireAdminEvent('onAdminSave', ['object' => &$user]);
// Validate the submitted fields against the account blueprint before
// writing to disk (admin2#30).
$this->validateChangedFields($body, method_exists($user, 'getBlueprint') ? $user->getBlueprint() : null);
$user->save();
$this->fireAdminEvent('onAdminAfterSave', ['object' => $user]);
$this->fireEvent('onApiUserUpdated', ['user' => $user]);
return $this->respondWithEtag(
$this->serializeUser($user),
200,
['users:update:' . $username, 'users:list'],
);
}
public function delete(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.users.write');
$currentUser = $this->getUser($request);
$username = $this->getRouteParam($request, 'username');
if ($currentUser->username === $username) {
throw new ForbiddenException('You cannot delete your own account.');
}
$user = $this->loadUserOrFail($username);
$this->fireEvent('onApiBeforeUserDelete', ['user' => $user]);
// Remove user file
$file = $user->file();
if ($file) {
$file->delete();
}
$this->fireEvent('onApiUserDeleted', ['username' => $username]);
return ApiResponse::noContent(
$this->invalidationHeaders(['users:delete:' . $username, 'users:list']),
);
}
/**
* POST /users/{username}/avatar - Upload a custom avatar image.
*/
public function uploadAvatar(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$currentUser = $this->getUser($request);
if ($currentUser->username !== $username) {
$this->requirePermission($request, 'api.users.write');
}
$uploadedFiles = $request->getUploadedFiles();
$file = $uploadedFiles['avatar'] ?? $uploadedFiles['file'] ?? null;
if (!$file || $file->getError() !== UPLOAD_ERR_OK) {
throw new ValidationException('No avatar file uploaded.');
}
$mime = $file->getClientMediaType() ?? '';
if (!str_starts_with($mime, 'image/')) {
throw new ValidationException('Avatar must be an image file.');
}
// Save to account://avatars/
$locator = $this->grav['locator'];
$avatarDir = $locator->findResource('account://', true) . '/avatars';
if (!is_dir($avatarDir)) {
mkdir($avatarDir, 0755, true);
}
$ext = match ($mime) {
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => 'jpg',
};
$filename = $username . '-' . substr(md5((string) time()), 0, 8) . '.' . $ext;
$filepath = $avatarDir . '/' . $filename;
$file->moveTo($filepath);
// Build path relative to Grav root (e.g. user/accounts/avatars/filename.jpg)
// to match the format used by the old admin plugin.
$relativeBase = $locator->findResource('account://', false);
$relativePath = $relativeBase . '/avatars/' . $filename;
// Update user's avatar reference
$user->set('avatar', [
$relativePath => [
'name' => $filename,
'type' => $mime,
'size' => filesize($filepath),
'path' => $relativePath,
],
]);
$user->save();
return ApiResponse::create(
$this->serializeUser($user),
201,
$this->invalidationHeaders(['users:update:' . $username]),
);
}
/**
* DELETE /users/{username}/avatar - Remove the custom avatar.
*/
public function deleteAvatar(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$currentUser = $this->getUser($request);
if ($currentUser->username !== $username) {
$this->requirePermission($request, 'api.users.write');
}
// Delete avatar file(s)
$avatar = $user->get('avatar');
if (is_array($avatar)) {
foreach ($avatar as $entry) {
if (is_array($entry) && isset($entry['path'])) {
// path is relative to Grav root (e.g. user/accounts/avatars/file.jpg)
$filePath = GRAV_ROOT . '/' . $entry['path'];
if (file_exists($filePath)) {
@unlink($filePath);
}
}
}
}
$user->set('avatar', []);
$user->save();
return ApiResponse::create(
$this->serializeUser($user),
200,
$this->invalidationHeaders(['users:update:' . $username]),
);
}
/**
* POST /users/{username}/2fa - Generate or regenerate 2FA secret and return QR code.
*/
public function generate2fa(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
// Self or admin
$currentUser = $this->getUser($request);
if ($currentUser->username !== $username) {
$this->requirePermission($request, 'api.users.write');
}
if (!class_exists(\Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth::class)) {
throw new \Grav\Plugin\Api\Exceptions\ApiException(
500,
'2FA Not Available',
'The Login plugin with 2FA support must be installed.'
);
}
$twoFa = new \Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth();
$secret = $twoFa->createSecret();
// Format secret with spaces for readability
$formattedSecret = trim(chunk_split($secret, 4, ' '));
// Save to user
$user->set('twofa_secret', $formattedSecret);
// Generating/regenerating a secret resets the enabled flag — the user
// must verify a code against the new secret to re-enable.
$user->set('twofa_enabled', false);
$user->save();
// Generate QR code data URI
$qrImage = $twoFa->getQrImageData($username, $secret);
return ApiResponse::create([
'secret' => $formattedSecret,
'qr_code' => $qrImage,
]);
}
/**
* POST /users/{username}/2fa/enable - Verify a code against the stored
* secret and set twofa_enabled=true. Self-only: only the account owner
* can enable their own 2FA, because enabling requires proving you hold
* the secret (otherwise an attacker could lock a user out by enabling
* 2FA with a secret they don't control).
*/
public function enable2fa(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$currentUser = $this->getUser($request);
if ($currentUser->username !== $username) {
throw new ForbiddenException('Only the account owner can enable 2FA.');
}
$body = $this->getRequestBody($request);
$this->requireFields($body, ['code']);
if (!class_exists(\Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth::class)) {
throw new \Grav\Plugin\Api\Exceptions\ApiException(
500,
'2FA Not Available',
'The Login plugin with 2FA support must be installed.',
);
}
$secret = (string) $user->get('twofa_secret');
if ($secret === '') {
throw new ValidationException('2FA secret has not been generated. POST /users/{username}/2fa first.');
}
$twoFa = new \Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth();
if (!$twoFa->verifyCode($secret, (string) $body['code'])) {
throw new ValidationException('Invalid 2FA code.');
}
$user->set('twofa_enabled', true);
$user->save();
$this->fireEvent('onApiUser2faEnabled', ['user' => $user]);
return ApiResponse::create(['twofa_enabled' => true]);
}
/**
* POST /users/{username}/2fa/disable - Disable 2FA for a user.
*
* Self-disable requires a valid current TOTP code so that a stolen
* session cannot unilaterally remove 2FA. Admins with api.users.write
* (or superadmin) can force-disable without a code — used for lost-
* device recovery. Both paths clear twofa_secret.
*/
public function disable2fa(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$currentUser = $this->getUser($request);
$isSelf = $currentUser->username === $username;
$isAdmin = $this->isSuperAdmin($currentUser) || $this->hasPermission($currentUser, 'api.users.write');
if (!$isSelf && !$isAdmin) {
throw new ForbiddenException('You do not have permission to disable 2FA for this user.');
}
if ($isSelf && !$isAdmin) {
// Self-disable without admin privilege requires code verification.
$body = $this->getRequestBody($request);
$this->requireFields($body, ['code']);
if (!class_exists(\Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth::class)) {
throw new \Grav\Plugin\Api\Exceptions\ApiException(
500,
'2FA Not Available',
'The Login plugin with 2FA support must be installed.',
);
}
$secret = (string) $user->get('twofa_secret');
$twoFa = new \Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth();
if (!$secret || !$twoFa->verifyCode($secret, (string) $body['code'])) {
throw new ValidationException('Invalid 2FA code.');
}
}
$user->set('twofa_enabled', false);
$user->set('twofa_secret', '');
$user->save();
$this->fireEvent('onApiUser2faDisabled', [
'user' => $user,
'forced_by_admin' => !$isSelf,
]);
return ApiResponse::create(['twofa_enabled' => false]);
}
public function apiKeys(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$this->requireApiKeyPermission($request, $username);
$manager = new ApiKeyManager();
$keys = $manager->listKeys($user);
return ApiResponse::create($keys);
}
public function createApiKey(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$this->requireApiKeyPermission($request, $username, write: true);
$body = $this->getRequestBody($request);
$name = $body['name'] ?? '';
$scopes = $body['scopes'] ?? [];
$expiryDays = isset($body['expiry_days']) ? (int) $body['expiry_days'] : null;
$manager = new ApiKeyManager();
$result = $manager->generateKey($user, $name, $scopes, $expiryDays);
// Return the raw key (shown ONCE only) along with key metadata
$keys = $manager->listKeys($user);
$keyMeta = null;
foreach ($keys as $key) {
if ($key['id'] === $result['id']) {
$keyMeta = $key;
break;
}
}
$data = array_merge($keyMeta ?? [], ['api_key' => $result['key']]);
return ApiResponse::created(
data: $data,
location: $this->getApiBaseUrl() . '/users/' . $username . '/api-keys',
headers: $this->invalidationHeaders(['users:update:' . $username . ':api-keys']),
);
}
public function deleteApiKey(ServerRequestInterface $request): ResponseInterface
{
$username = $this->getRouteParam($request, 'username');
$user = $this->loadUserOrFail($username);
$this->requireApiKeyPermission($request, $username, write: true);
$keyId = $this->getRouteParam($request, 'keyId');
$manager = new ApiKeyManager();
$revoked = $manager->revokeKey($user, $keyId);
if (!$revoked) {
throw new NotFoundException("API key '{$keyId}' not found for user '{$username}'.");
}
return ApiResponse::noContent(
$this->invalidationHeaders(['users:update:' . $username . ':api-keys']),
);
}
/**
* Check permission for API key operations. Own user with api.access is sufficient,
* otherwise require api.users.read (or api.users.write for mutations).
*/
private function requireApiKeyPermission(
ServerRequestInterface $request,
string $targetUsername,
bool $write = false,
): void {
$currentUser = $this->getUser($request);
$isSelf = $currentUser->username === $targetUsername;
if ($isSelf) {
// Self-access only requires api.access
$this->requirePermission($request, 'api.access');
} else {
$this->requirePermission($request, $write ? 'api.users.write' : 'api.users.read');
}
}
private function loadUserOrFail(?string $username): UserInterface
{
if ($username === null || $username === '') {
throw new ValidationException('Username is required.');
}
/** @var UserCollectionInterface $accounts */
$accounts = $this->grav['accounts'];
$user = $accounts->load($username);
if (!$user->exists()) {
throw new NotFoundException("User '{$username}' not found.");
}
return $user;
}
private function serializeUser(UserInterface $user): array
{
return $this->getSerializer()->serialize($user);
}
/**
* Extract the access/group list filters from the request query string.
*
* `access` is the canonical permission filter (e.g. `admin.login`,
* `api.super`); `permission` is accepted as an alias. `group` filters by
* group membership.
*
* @return array{access: string, group: string}
*/
private function getListFilters(ServerRequestInterface $request): array
{
$query = $request->getQueryParams();
$access = $query['access'] ?? $query['permission'] ?? '';
$group = $query['group'] ?? '';
return [
'access' => is_string($access) ? trim($access) : '',
'group' => is_string($group) ? trim($group) : '',
];
}
/**
* @param array{access: string, group: string} $filters
*/
private function userMatchesFilters(UserInterface $user, array $filters): bool
{
if ($filters['group'] !== '') {
$groups = array_map('strval', (array) $user->get('groups', []));
if (!in_array($filters['group'], $groups, true)) {
return false;
}
}
if ($filters['access'] !== '' && !$this->userHasEffectiveAccess($user, $filters['access'])) {
return false;
}
return true;
}
/**
* Test whether a user is effectively granted a permission, independent of
* login state (so it works against accounts loaded from storage).
*
* Resolves the action against the merged access map (group access overlaid
* by the user's own access) with parent-key inheritance — `api.pages`
* covers `api.pages.read` — and treats super admins (api.super or the
* legacy admin.super) as authorized for everything, so "find all admins"
* catches either authority.
*/
private function userHasEffectiveAccess(UserInterface $user, string $action): bool
{
if ($action === '') {
return true;
}
$flat = $this->effectiveAccessMap($user);
if ($action !== 'admin.super' && $action !== 'api.super') {
if ($this->isPositiveFlat($flat, 'api.super') || $this->isPositiveFlat($flat, 'admin.super')) {
return true;
}
}
// Walk up the dot-path; the closest explicitly-set key wins.
$key = $action;
while ($key !== '') {
if (array_key_exists($key, $flat)) {
return Utils::isPositive($flat[$key]);
}
$pos = strrpos($key, '.');
$key = $pos !== false ? substr($key, 0, $pos) : '';
}
return false;
}
/**
* Build a flattened (dot-notation) access map for the user: each group's
* access first, then the user's own access on top so direct grants
* override inherited ones.
*
* @return array<string, mixed>
*/
private function effectiveAccessMap(UserInterface $user): array
{
$map = [];
foreach ((array) $user->get('groups', []) as $group) {
if (!is_string($group)) {
continue;
}
$groupAccess = $this->config->get("groups.{$group}.access");
if (is_array($groupAccess)) {
$map = array_merge($map, Utils::arrayFlattenDotNotation($groupAccess));
}
}
$own = $user->get('access');
if (is_array($own)) {
$map = array_merge($map, Utils::arrayFlattenDotNotation($own));
}
return $map;
}
/**
* @param array<string, mixed> $flat
*/
private function isPositiveFlat(array $flat, string $key): bool
{
return array_key_exists($key, $flat) && Utils::isPositive($flat[$key]);
}
/**
* Case-insensitive substring match across the searchable user fields,
* mirroring the Flex backend's blueprint-configured search.
*/
private function userMatchesSearch(UserInterface $user, string $search): bool
{
$needle = mb_strtolower($search);
$haystacks = [
(string) $user->username,
(string) $user->get('email', ''),
(string) $user->get('fullname', ''),
(string) $user->get('title', ''),
];
foreach ($haystacks as $value) {
if ($value !== '' && str_contains(mb_strtolower($value), $needle)) {
return true;
}
}
return false;
}
private function getSerializer(): UserSerializer
{
return $this->serializer ??= new UserSerializer();
}
/**
* Get all usernames by scanning account files.
*/
private function getAllUsernames(): array
{
$locator = $this->grav['locator'];
$accountDir = $locator->findResource('account://', true)
?: $locator->findResource('user://accounts', true);
if (!$accountDir || !is_dir($accountDir)) {
return [];
}
$usernames = [];
foreach (new \DirectoryIterator($accountDir) as $file) {
if ($file->isDot() || !$file->isFile() || $file->getExtension() !== 'yaml') {
continue;
}
$usernames[] = $file->getBasename('.yaml');
}
sort($usernames);
return $usernames;
}
}
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Webhooks\WebhookDispatcher;
use Grav\Plugin\Api\Webhooks\WebhookManager;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class WebhookController extends AbstractApiController
{
private const PERMISSION_READ = 'api.webhooks.read';
private const PERMISSION_WRITE = 'api.webhooks.write';
private const VALID_EVENTS = [
'*',
'page.created', 'page.updated', 'page.deleted', 'page.moved', 'page.translated',
'pages.reordered',
'media.uploaded', 'media.deleted',
'user.created', 'user.updated', 'user.deleted',
'config.updated',
'gpm.installed', 'gpm.removed', 'grav.upgraded',
];
private readonly WebhookManager $manager;
public function __construct(\Grav\Common\Grav $grav, \Grav\Common\Config\Config $config)
{
parent::__construct($grav, $config);
$this->manager = new WebhookManager();
}
/**
* GET /webhooks - List all configured webhooks.
*/
public function index(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$webhooks = $this->manager->getAll();
// Redact secrets in listing
$data = array_map(function ($webhook) {
$webhook['secret'] = $this->redactSecret($webhook['secret'] ?? '');
return $webhook;
}, $webhooks);
return ApiResponse::create($data);
}
/**
* POST /webhooks - Create a new webhook.
*/
public function create(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$body = $this->getRequestBody($request);
$this->requireFields($body, ['url']);
$url = $body['url'];
if (!filter_var($url, FILTER_VALIDATE_URL)) {
throw new ValidationException("Invalid webhook URL: {$url}");
}
// Validate events if provided
if (isset($body['events'])) {
$this->validateEvents($body['events']);
}
$webhook = $this->manager->create($body);
$location = $this->getApiBaseUrl() . '/webhooks/' . $webhook['id'];
return ApiResponse::created($webhook, $location);
}
/**
* GET /webhooks/{id} - Get webhook details.
*/
public function show(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$id = $this->getRouteParam($request, 'id');
$webhook = $this->manager->get($id);
if (!$webhook) {
throw new NotFoundException("Webhook '{$id}' not found.");
}
// Redact secret
$webhook['secret'] = $this->redactSecret($webhook['secret'] ?? '');
return $this->respondWithEtag($webhook);
}
/**
* PATCH /webhooks/{id} - Update a webhook.
*/
public function update(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$id = $this->getRouteParam($request, 'id');
$body = $this->getRequestBody($request);
if (isset($body['url']) && !filter_var($body['url'], FILTER_VALIDATE_URL)) {
throw new ValidationException("Invalid webhook URL: {$body['url']}");
}
if (isset($body['events'])) {
$this->validateEvents($body['events']);
}
$webhook = $this->manager->update($id, $body);
if (!$webhook) {
throw new NotFoundException("Webhook '{$id}' not found.");
}
// Redact secret
$webhook['secret'] = $this->redactSecret($webhook['secret'] ?? '');
return $this->respondWithEtag($webhook);
}
/**
* DELETE /webhooks/{id} - Delete a webhook.
*/
public function delete(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$id = $this->getRouteParam($request, 'id');
$deleted = $this->manager->delete($id);
if (!$deleted) {
throw new NotFoundException("Webhook '{$id}' not found.");
}
return ApiResponse::noContent();
}
/**
* GET /webhooks/{id}/deliveries - Get delivery log for a webhook.
*/
public function deliveries(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$id = $this->getRouteParam($request, 'id');
if (!$this->manager->get($id)) {
throw new NotFoundException("Webhook '{$id}' not found.");
}
$pagination = $this->getPagination($request);
$result = $this->manager->getDeliveries($id, $pagination['limit'], $pagination['offset']);
$baseUrl = $this->getApiBaseUrl() . '/webhooks/' . $id . '/deliveries';
return ApiResponse::paginated(
data: $result['deliveries'],
total: $result['total'],
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $baseUrl,
);
}
/**
* POST /webhooks/{id}/test - Send a test payload.
*/
public function test(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$id = $this->getRouteParam($request, 'id');
$webhook = $this->manager->get($id);
if (!$webhook) {
throw new NotFoundException("Webhook '{$id}' not found.");
}
$dispatcher = new WebhookDispatcher($this->manager);
$delivery = $dispatcher->sendTest($webhook);
return ApiResponse::create($delivery, $delivery['success'] ? 200 : 502);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private function validateEvents(array $events): void
{
foreach ($events as $event) {
if (!in_array($event, self::VALID_EVENTS, true)) {
$valid = implode(', ', self::VALID_EVENTS);
throw new ValidationException("Invalid event '{$event}'. Valid events: {$valid}");
}
}
}
private function redactSecret(string $secret): string
{
if (strlen($secret) <= 10) {
return str_repeat('*', strlen($secret));
}
return substr($secret, 0, 6) . str_repeat('*', strlen($secret) - 10) . substr($secret, -4);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
use RuntimeException;
class ApiException extends RuntimeException
{
public function __construct(
protected readonly int $statusCode,
protected readonly string $errorTitle,
string $detail = '',
protected readonly array $headers = [],
?\Throwable $previous = null,
) {
parent::__construct($detail, $statusCode, $previous);
}
public function getStatusCode(): int
{
return $this->statusCode;
}
public function getErrorTitle(): string
{
return $this->errorTitle;
}
public function getHeaders(): array
{
return $this->headers;
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class ConflictException extends ApiException
{
public function __construct(string $detail = 'The resource has been modified. Refresh and try again.', ?\Throwable $previous = null)
{
parent::__construct(409, 'Conflict', $detail, [], $previous);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class ForbiddenException extends ApiException
{
public function __construct(string $detail = 'You do not have permission to perform this action.', ?\Throwable $previous = null)
{
parent::__construct(403, 'Forbidden', $detail, [], $previous);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class NotFoundException extends ApiException
{
public function __construct(string $detail = 'The requested resource was not found.', ?\Throwable $previous = null)
{
parent::__construct(404, 'Not Found', $detail, [], $previous);
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class TooManyRequestsException extends ApiException
{
public function __construct(string $detail = 'Too many requests.', int $retryAfter = 0, ?\Throwable $previous = null)
{
$headers = [];
if ($retryAfter > 0) {
$headers['Retry-After'] = (string) $retryAfter;
}
parent::__construct(429, 'Too Many Requests', $detail, $headers, $previous);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
/**
* 403 forbidden, dedicated to the `security.twig_content.*` gate. The
* `errorTitle` field carries a stable machine-readable reason code that
* Admin Next can switch on to render the right toast.
*/
class TwigContentForbiddenException extends ApiException
{
/** Site-wide gate is off; nobody can enable Twig in content. */
public const REASON_DISABLED = 'TWIG_CONTENT_DISABLED';
/** Gate is on, but the current user is not allowed to toggle Twig on pages. */
public const REASON_FORBIDDEN = 'TWIG_CONTENT_FORBIDDEN';
/** Page already has process.twig:true; the current user cannot edit it. */
public const REASON_PAGE_FORBIDDEN = 'TWIG_CONTENT_PAGE_FORBIDDEN';
public function __construct(string $reason, string $detail = '', ?\Throwable $previous = null)
{
if ($detail === '') {
$detail = match ($reason) {
self::REASON_DISABLED => 'Twig processing in page content is disabled site-wide. An administrator can enable it under Configuration > Security > Twig in Content.',
self::REASON_FORBIDDEN => "You don't have permission to enable Twig processing on pages.",
self::REASON_PAGE_FORBIDDEN => "This page has Twig processing enabled in its content. You don't have permission to edit pages with Twig enabled.",
default => 'Twig in content is not allowed.',
};
}
parent::__construct(403, $reason, $detail, [], $previous);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class UnauthorizedException extends ApiException
{
public function __construct(string $detail = 'Authentication is required.', ?\Throwable $previous = null)
{
parent::__construct(401, 'Unauthorized', $detail, ['WWW-Authenticate' => 'Bearer'], $previous);
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Exceptions;
class ValidationException extends ApiException
{
public function __construct(
string $detail = 'The request data is invalid.',
protected readonly array $errors = [],
?\Throwable $previous = null,
) {
parent::__construct(422, 'Unprocessable Entity', $detail, [], $previous);
}
public function getValidationErrors(): array
{
return $this->errors;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api;
use Grav\Framework\Flex\FlexDirectory;
/**
* Trait for controllers that optionally use Flex-Objects backend
* for listing/search operations.
*
* When enabled (default), listing endpoints use flex directories for
* indexed search, filtering, sorting, and pagination. When disabled
* or unavailable, controllers fall back to regular Grav services.
*
* Config keys: plugins.api.flex_backend.pages, plugins.api.flex_backend.accounts
*/
trait FlexBackend
{
/**
* Map flex directory types to their config keys.
*/
private const FLEX_CONFIG_MAP = [
'pages' => 'pages',
'user-accounts' => 'accounts',
];
protected function getFlexDirectory(string $type): ?FlexDirectory
{
$configKey = self::FLEX_CONFIG_MAP[$type] ?? $type;
if (!$this->config->get('plugins.api.flex_backend.' . $configKey, true)) {
return null;
}
if (!isset($this->grav['flex_objects'])) {
return null;
}
$flex = $this->grav['flex_objects'];
$directory = $flex->getDirectory($type);
return ($directory && $directory->isEnabled()) ? $directory : null;
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Invitations;
use Grav\Common\File\CompiledYamlFile;
/**
* Self-contained store for user invitations.
*
* Invites are persisted to user-data://accounts/invites.yaml, keyed by token.
* Each record carries the recipient email plus the access/groups the inviting
* admin pre-configured — those are applied verbatim when the invite is
* accepted, so the invitee never gets to choose their own permissions.
*
* Deliberately independent of the Login plugin's own Invitations classes so
* the API plugin has no hard dependency on Login being installed.
*
* Record shape:
* token string the secret token (also the array key)
* email string recipient address (locked at acceptance)
* fullname string optional pre-fill for the accept form
* access array permission tree applied on acceptance
* groups array group names applied on acceptance
* created int unix timestamp
* created_by string inviting user's username
* created_by_name string inviting user's fullname (email "actor")
* expires int unix timestamp after which the token is invalid
*/
class InviteStore
{
private const FILE = 'user-data://accounts/invites.yaml';
/** @var array<string, array<string, mixed>>|null */
private ?array $items = null;
private function getFile(): CompiledYamlFile
{
return CompiledYamlFile::instance(self::FILE);
}
/**
* @return array<string, array<string, mixed>>
*/
private function load(): array
{
if ($this->items === null) {
$content = $this->getFile()->content();
$this->items = is_array($content) ? $content : [];
}
return $this->items;
}
private function persist(): void
{
$file = $this->getFile();
$file->save($this->items ?? []);
$file->free();
}
/**
* Generate a unique, URL-safe token (40 hex chars).
*/
public function generateToken(): string
{
$items = $this->load();
do {
try {
$token = bin2hex(random_bytes(20));
} catch (\Exception) {
$token = md5(uniqid((string) mt_rand(), true)) . md5(uniqid((string) mt_rand(), true));
}
} while (isset($items[$token]));
return $token;
}
/**
* @return array<string, array<string, mixed>> token => record
*/
public function all(): array
{
return $this->load();
}
/**
* @return array<string, mixed>|null
*/
public function get(string $token): ?array
{
$items = $this->load();
return $items[$token] ?? null;
}
public function getByEmail(string $email): ?array
{
$email = strtolower(trim($email));
foreach ($this->load() as $record) {
if (strtolower((string) ($record['email'] ?? '')) === $email) {
return $record;
}
}
return null;
}
/**
* @param array<string, mixed> $record must contain a 'token' key
*/
public function add(array $record): void
{
$token = (string) ($record['token'] ?? '');
if ($token === '') {
throw new \InvalidArgumentException('Invite record requires a token.');
}
$this->load();
$this->items[$token] = $record;
$this->persist();
}
public function remove(string $token): bool
{
$this->load();
if (!isset($this->items[$token])) {
return false;
}
unset($this->items[$token]);
$this->persist();
return true;
}
/**
* Drop any invites whose expiry has passed.
*
* @return int number of invites removed
*/
public function purgeExpired(): int
{
$this->load();
$removed = 0;
foreach ($this->items as $token => $record) {
if (self::isExpired($record)) {
unset($this->items[$token]);
$removed++;
}
}
if ($removed > 0) {
$this->persist();
}
return $removed;
}
/**
* @param array<string, mixed> $record
*/
public static function isExpired(array $record): bool
{
$expires = (int) ($record['expires'] ?? 0);
return $expires > 0 && time() > $expires;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Middleware;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Plugin\Api\Auth\ApiKeyAuthenticator;
use Grav\Plugin\Api\Auth\AuthenticatorInterface;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Auth\SessionAuthenticator;
use Grav\Plugin\Api\Exceptions\UnauthorizedException;
use Psr\Http\Message\ServerRequestInterface;
class AuthMiddleware
{
/** @var AuthenticatorInterface[] */
protected array $authenticators = [];
public function __construct(
protected readonly Grav $grav,
protected readonly Config $config,
) {
$this->buildAuthenticatorChain();
}
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
// Try each authenticator in order
foreach ($this->authenticators as $authenticator) {
$user = $authenticator->authenticate($request);
if ($user !== null) {
return $request->withAttribute('api_user', $user);
}
}
throw new UnauthorizedException(
'No valid authentication credentials provided. Use an API key, JWT token, or active session.'
);
}
/**
* Optimistic authentication for public routes: attach api_user when valid
* credentials are supplied, continue as guest otherwise. Lets public
* endpoints return richer, permission-filtered responses to logged-in
* callers without requiring auth from anonymous ones.
*/
public function processOptional(ServerRequestInterface $request): ServerRequestInterface
{
foreach ($this->authenticators as $authenticator) {
$user = $authenticator->authenticate($request);
if ($user !== null) {
return $request->withAttribute('api_user', $user);
}
}
return $request;
}
protected function buildAuthenticatorChain(): void
{
// API Key is fastest to check - try first
if ($this->config->get('plugins.api.auth.api_keys_enabled', true)) {
$this->authenticators[] = new ApiKeyAuthenticator($this->grav);
}
// JWT is next
if ($this->config->get('plugins.api.auth.jwt_enabled', true)) {
$this->authenticators[] = new JwtAuthenticator($this->grav, $this->config);
}
// Session passthrough is last (requires existing session)
if ($this->config->get('plugins.api.auth.session_enabled', true)) {
$this->authenticators[] = new SessionAuthenticator($this->grav);
}
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Middleware;
use Grav\Common\Config\Config;
use Grav\Framework\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class CorsMiddleware
{
public function __construct(
protected readonly Config $config,
) {}
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
// Nothing to modify on the request, CORS is response-side
return $request;
}
public function addHeaders(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
if (!$this->config->get('plugins.api.cors.enabled', true)) {
return $response;
}
$origin = $request->getHeaderLine('Origin');
if (!$origin) {
return $response;
}
$allowedOrigins = (array) $this->config->get('plugins.api.cors.origins', ['*']);
if (in_array('*', $allowedOrigins, true)) {
$response = $response->withHeader('Access-Control-Allow-Origin', '*');
} elseif (in_array($origin, $allowedOrigins, true)) {
$response = $response
->withHeader('Access-Control-Allow-Origin', $origin)
->withHeader('Vary', 'Origin');
} else {
return $response;
}
$credentials = $this->config->get('plugins.api.cors.credentials', false);
if ($credentials) {
$response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
}
$exposeHeaders = (array) $this->config->get('plugins.api.cors.expose_headers', []);
// Always expose X-Invalidates so the client can read cache invalidation tags
if (!in_array('X-Invalidates', $exposeHeaders)) {
$exposeHeaders[] = 'X-Invalidates';
}
$response = $response->withHeader('Access-Control-Expose-Headers', implode(', ', $exposeHeaders));
return $response;
}
public function createPreflightResponse(): ResponseInterface
{
$headers = [];
$allowedOrigins = (array) $this->config->get('plugins.api.cors.origins', ['*']);
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
if (in_array('*', $allowedOrigins, true)) {
$headers['Access-Control-Allow-Origin'] = '*';
} elseif (in_array($origin, $allowedOrigins, true)) {
$headers['Access-Control-Allow-Origin'] = $origin;
$headers['Vary'] = 'Origin';
}
$methods = (array) $this->config->get('plugins.api.cors.methods', ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS']);
$headers['Access-Control-Allow-Methods'] = implode(', ', $methods);
$allowHeaders = (array) $this->config->get('plugins.api.cors.headers', []);
if ($allowHeaders) {
$headers['Access-Control-Allow-Headers'] = implode(', ', $allowHeaders);
}
$maxAge = $this->config->get('plugins.api.cors.max_age', 86400);
$headers['Access-Control-Max-Age'] = (string) $maxAge;
$credentials = $this->config->get('plugins.api.cors.credentials', false);
if ($credentials) {
$headers['Access-Control-Allow-Credentials'] = 'true';
}
$headers['Content-Length'] = '0';
return new Response(204, $headers);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Middleware;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Psr\Http\Message\ServerRequestInterface;
class JsonBodyParserMiddleware
{
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
$contentType = $request->getHeaderLine('Content-Type');
if (!str_contains($contentType, 'application/json')) {
return $request;
}
$body = (string) $request->getBody();
if ($body === '') {
return $request->withAttribute('json_body', []);
}
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new ValidationException('Invalid JSON in request body: ' . json_last_error_msg());
}
return $request->withAttribute('json_body', $decoded);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Middleware;
use Psr\Http\Message\ServerRequestInterface;
/**
* Transparent POST → {DELETE,PATCH,PUT} rewrite for clients behind restrictive
* reverse proxies that reject non-standard HTTP verbs.
*
* Some managed nginx configurations (notably shared-hosting providers) strip
* or 405 DELETE/PATCH before the request reaches PHP. This middleware lets the
* admin-next client keep using semantic methods internally but fall back to
* `POST + X-HTTP-Method-Override: <METHOD>` when it detects a proxy block. The
* header is only honored on POST (other methods pass through untouched), and
* only for the safelisted mutation verbs — no route should ever see an
* "overridden GET", which would sidestep CSRF-shaped assumptions baked into
* the routing layer.
*/
class MethodOverrideMiddleware
{
private const ALLOWED_OVERRIDES = ['DELETE', 'PATCH', 'PUT'];
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
if (strtoupper($request->getMethod()) !== 'POST') {
return $request;
}
$override = strtoupper(trim($request->getHeaderLine('X-HTTP-Method-Override')));
if ($override === '' || !in_array($override, self::ALLOWED_OVERRIDES, true)) {
return $request;
}
return $request->withMethod($override);
}
}
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Middleware;
use Grav\Common\Config\Config;
use Psr\Http\Message\ServerRequestInterface;
/**
* File-based token bucket rate limiter.
* Cloud-safe: each Grav instance has its own cache directory.
*/
class RateLimitMiddleware
{
public function __construct(
protected readonly Config $config,
) {}
/**
* Check rate limit for the current request.
*
* @return array{limited: bool, limit: int, remaining: int, reset: int}
*/
public function check(ServerRequestInterface $request): array
{
$enabled = $this->config->get('plugins.api.rate_limit.enabled', true);
$limit = (int) $this->config->get('plugins.api.rate_limit.requests', 120);
$window = (int) $this->config->get('plugins.api.rate_limit.window', 60);
if (!$enabled) {
return [
'limited' => false,
'limit' => $limit,
'remaining' => $limit,
'reset' => time() + $window,
];
}
// Path-prefix exclusions. Used to keep high-frequency API
// surfaces (collab polling, etc.) out of the per-user bucket so a
// single typing user doesn't trip the global anti-abuse limit.
// Defaults to excluding the sync plugin's endpoints; operators can
// override via plugins.api.rate_limit.excluded_paths.
$excluded = (array) $this->config->get('plugins.api.rate_limit.excluded_paths', ['/sync/']);
$path = $request->getUri()->getPath();
foreach ($excluded as $prefix) {
if (!is_string($prefix) || $prefix === '') continue;
if (str_contains($path, $prefix)) {
return [
'limited' => false,
'limit' => $limit,
'remaining' => $limit,
'reset' => time() + $window,
];
}
}
$identifier = $this->getIdentifier($request);
$storageDir = $this->getStorageDir();
if (!is_dir($storageDir)) {
@mkdir($storageDir, 0775, true);
}
$file = $storageDir . '/' . md5($identifier) . '.json';
return $this->checkLimit($file, $limit, $window);
}
protected function getIdentifier(ServerRequestInterface $request): string
{
// Use authenticated user if available, otherwise fall back to IP
$user = $request->getAttribute('api_user');
if ($user) {
return 'user:' . $user->username;
}
return 'ip:' . ($request->getServerParams()['REMOTE_ADDR'] ?? 'unknown');
}
protected function checkLimit(string $file, int $limit, int $window): array
{
$now = time();
$data = ['tokens' => $limit, 'last_refill' => $now];
// Use file locking for concurrency safety
$fp = fopen($file, 'c+');
if (!$fp) {
// If we can't open the file, allow the request
return ['limited' => false, 'limit' => $limit, 'remaining' => $limit, 'reset' => $now + $window];
}
flock($fp, LOCK_EX);
$contents = stream_get_contents($fp);
if ($contents) {
$data = json_decode($contents, true) ?: $data;
}
// Refill tokens based on elapsed time
$elapsed = $now - ($data['last_refill'] ?? $now);
$refillRate = $limit / $window;
$data['tokens'] = min($limit, ($data['tokens'] ?? $limit) + ($elapsed * $refillRate));
$data['last_refill'] = $now;
// Try to consume a token
$limited = $data['tokens'] < 1;
if (!$limited) {
$data['tokens'] -= 1;
}
// Write back
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data));
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
$remaining = max(0, (int) floor($data['tokens']));
$reset = $now + (int) ceil(($limit - $data['tokens']) / $refillRate);
return [
'limited' => $limited,
'limit' => $limit,
'remaining' => $remaining,
'reset' => $reset,
];
}
protected function getStorageDir(): string
{
$locator = \Grav\Common\Grav::instance()['locator'];
return $locator->findResource('cache://api/ratelimit', true, true);
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Framework\Acl\Permissions;
/**
* Hierarchical permission resolver for the API layer.
*
* Grav's User::authorize() requires admin context, so the API uses direct
* access-array lookups. This class adds parent-key inheritance so granting
* "api.pages" implicitly covers "api.pages.read", matching how Grav's
* core Access::get() resolves permissions.
*/
class PermissionResolver
{
/** @var array<string, mixed>|null Lazy-flattened user access map (one per instance). */
private ?array $flatAccess = null;
/** @var UserInterface|null The user whose access was flattened — used to invalidate cache. */
private ?UserInterface $flatAccessUser = null;
public function __construct(private readonly Permissions $permissions) {}
/**
* Resolve a single permission for a user with parent-key inheritance.
*
* Walks up the dot-path (api.pages.read → api.pages → api) and returns
* the first explicitly set value, or null if nothing is set at any level.
*/
public function resolve(UserInterface $user, string $permission): ?bool
{
$flat = $this->getFlatAccess($user);
$key = $permission;
while ($key !== '') {
if (array_key_exists($key, $flat)) {
$value = $flat[$key];
if (is_bool($value)) {
return $value;
}
if ($value === 1 || $value === '1' || $value === 'true') {
return true;
}
if ($value === 0 || $value === '0' || $value === 'false' || $value === null) {
return false;
}
}
$pos = strrpos($key, '.');
$key = $pos !== false ? substr($key, 0, $pos) : '';
}
return null;
}
/**
* Build a flat map of all registered api.* permissions with resolved
* true/false values. Super-admins receive true for everything.
*
* @return array<string, bool>
*/
public function resolvedMap(UserInterface $user, bool $isSuperAdmin): array
{
$allInstances = $this->permissions->getInstances();
$result = [];
foreach ($allInstances as $name => $action) {
if (!str_starts_with($name, 'api.')) {
continue;
}
$result[$name] = $isSuperAdmin ? true : (bool) $this->resolve($user, $name);
}
return $result;
}
/**
* Lazily flatten $user->get('access') from nested array to dot-notation keys.
* Cached per user instance within this resolver.
*/
private function getFlatAccess(UserInterface $user): array
{
if ($this->flatAccess === null || $this->flatAccessUser !== $user) {
$nested = $user->get('access');
$this->flatAccess = is_array($nested)
? Utils::arrayFlattenDotNotation($nested)
: [];
$this->flatAccessUser = $user;
}
return $this->flatAccess;
}
}
@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Popularity;
use Grav\Common\Grav;
/**
* Single-file flat-JSON storage for page-view popularity data.
*
* Replaces admin-classic's four-JSON-file scheme (daily.json, monthly.json,
* totals.json, visitors.json) with one combined `popularity.json` guarded
* by an exclusive flock(). Wins vs. the old design:
*
* - One file open + one lock per hit, vs. four uncoordinated read/writes
* that could race and silently corrupt each other.
* - `pages` (formerly `totals.json`) is capped at PAGES_CAP entries, so
* it can no longer grow unbounded with every URL ever visited.
* - ISO date keys (YYYY-MM-DD / YYYY-MM) sort lexicographically and are
* locale-stable, fixing the old `d-m-Y` ordering bug.
*
* On first construction in a site that still has the old four JSONs but no
* combined file yet, the store imports them once and renames them to
* `*.migrated` so nothing is lost and a re-run won't double-count.
*/
class PopularityStore
{
private const SCHEMA_VERSION = 2;
private const COMBINED_FILE = 'popularity.json';
private const PAGES_CAP = 500;
private const LEGACY_FILES = [
'daily' => 'daily.json',
'monthly' => 'monthly.json',
'totals' => 'totals.json',
'visitors' => 'visitors.json',
];
private string $dataDir;
private string $filePath;
public function __construct(?string $dataDir = null)
{
$this->dataDir = $dataDir ?? Grav::instance()['locator']
->findResource('log://popularity', true, true);
$this->filePath = $this->dataDir . '/' . self::COMBINED_FILE;
}
/**
* Record a single page hit. All four counters update inside one locked
* read-modify-write cycle, so a concurrent hit can't tear the file or
* lose updates.
*/
public function recordHit(
string $route,
string $ipHash,
?int $now = null,
int $dailyHistory = 30,
int $monthlyHistory = 12,
int $visitorHistory = 20,
): void {
$now ??= time();
$today = date('Y-m-d', $now);
$month = date('Y-m', $now);
$this->withLock(function (array $data) use (
$route, $ipHash, $now, $today, $month,
$dailyHistory, $monthlyHistory, $visitorHistory,
): array {
$data['daily'][$today] = ($data['daily'][$today] ?? 0) + 1;
$data['monthly'][$month] = ($data['monthly'][$month] ?? 0) + 1;
$data['pages'][$route] = ($data['pages'][$route] ?? 0) + 1;
$data['visitors'][$ipHash] = $now;
return $this->prune($data, $dailyHistory, $monthlyHistory, $visitorHistory);
});
}
public function getDaily(int $limit = 365): array
{
$data = $this->read();
$daily = $data['daily'] ?? [];
ksort($daily);
return array_slice($daily, -$limit, $limit, true);
}
public function getMonthly(int $limit = 24): array
{
$data = $this->read();
$monthly = $data['monthly'] ?? [];
ksort($monthly);
return array_slice($monthly, -$limit, $limit, true);
}
public function getTopPages(int $limit = 10): array
{
$data = $this->read();
$pages = $data['pages'] ?? [];
arsort($pages);
return array_slice($pages, 0, $limit, true);
}
public function getRecentVisitors(int $limit = 20): array
{
$data = $this->read();
$visitors = $data['visitors'] ?? [];
arsort($visitors);
return array_slice($visitors, 0, $limit, true);
}
public function flush(): void
{
$this->withLock(fn() => $this->emptyData());
}
/**
* Trim each section to its configured retention. Daily/monthly are
* trimmed by date threshold (not just count) so an old, never-pruned
* file gets cleaned up promptly. `pages` is capped to PAGES_CAP by
* descending views — pages with no recent traffic naturally fall off.
*/
private function prune(
array $data,
int $dailyHistory,
int $monthlyHistory,
int $visitorHistory,
): array {
$cutDay = date('Y-m-d', strtotime("-{$dailyHistory} days"));
$data['daily'] = array_filter(
$data['daily'] ?? [],
static fn($_, $k) => $k >= $cutDay,
ARRAY_FILTER_USE_BOTH,
);
$cutMonth = date('Y-m', strtotime("-{$monthlyHistory} months"));
$data['monthly'] = array_filter(
$data['monthly'] ?? [],
static fn($_, $k) => $k >= $cutMonth,
ARRAY_FILTER_USE_BOTH,
);
$pages = $data['pages'] ?? [];
if (count($pages) > self::PAGES_CAP) {
arsort($pages);
$pages = array_slice($pages, 0, self::PAGES_CAP, true);
}
$data['pages'] = $pages;
$visitors = $data['visitors'] ?? [];
if (count($visitors) > $visitorHistory) {
arsort($visitors);
$visitors = array_slice($visitors, 0, $visitorHistory, true);
}
$data['visitors'] = $visitors;
return $data;
}
/**
* Acquire exclusive lock, read current state (importing legacy files
* the first time), apply the mutator, write atomically.
*/
private function withLock(callable $mutator): void
{
if (!is_dir($this->dataDir)) {
mkdir($this->dataDir, 0755, true);
}
$fp = fopen($this->filePath, 'c+');
if ($fp === false) {
return;
}
try {
if (!flock($fp, LOCK_EX)) {
return;
}
$contents = stream_get_contents($fp);
$data = $this->decodeOrMigrate($contents);
$data = $mutator($data);
$data['version'] = self::SCHEMA_VERSION;
$encoded = json_encode($data, JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return;
}
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, $encoded);
fflush($fp);
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}
private function read(): array
{
if (!is_file($this->filePath)) {
// Trigger migration if legacy files exist but combined doesn't
if ($this->legacyFilesExist()) {
$this->withLock(static fn(array $d) => $d);
} else {
return $this->emptyData();
}
}
$fp = @fopen($this->filePath, 'r');
if ($fp === false) {
return $this->emptyData();
}
try {
if (!flock($fp, LOCK_SH)) {
return $this->emptyData();
}
$contents = stream_get_contents($fp);
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
return $this->decodeOrMigrate($contents);
}
private function decodeOrMigrate(string $contents): array
{
$data = $contents !== '' ? json_decode($contents, true) : null;
if (is_array($data) && isset($data['version'])) {
return $this->ensureSections($data);
}
// Either empty file, malformed JSON, or unversioned legacy state.
// Try to import legacy four-file data once.
return $this->importLegacy();
}
private function importLegacy(): array
{
$data = $this->emptyData();
$imported = false;
foreach (self::LEGACY_FILES as $type => $name) {
$path = $this->dataDir . '/' . $name;
if (!is_file($path)) {
continue;
}
$raw = @file_get_contents($path);
$legacy = $raw === false ? null : json_decode($raw, true);
if (!is_array($legacy)) {
@rename($path, $path . '.migrated');
continue;
}
switch ($type) {
case 'daily':
foreach ($legacy as $key => $count) {
$iso = self::convertDailyKey((string) $key);
if ($iso !== null) {
$data['daily'][$iso] = (int) $count;
}
}
break;
case 'monthly':
foreach ($legacy as $key => $count) {
$iso = self::convertMonthlyKey((string) $key);
if ($iso !== null) {
$data['monthly'][$iso] = (int) $count;
}
}
break;
case 'totals':
foreach ($legacy as $route => $count) {
$data['pages'][(string) $route] = (int) $count;
}
break;
case 'visitors':
foreach ($legacy as $hash => $ts) {
$data['visitors'][(string) $hash] = (int) $ts;
}
break;
}
@rename($path, $path . '.migrated');
$imported = true;
}
if ($imported) {
ksort($data['daily']);
ksort($data['monthly']);
}
return $data;
}
private function legacyFilesExist(): bool
{
foreach (self::LEGACY_FILES as $name) {
if (is_file($this->dataDir . '/' . $name)) {
return true;
}
}
return false;
}
private function emptyData(): array
{
return [
'version' => self::SCHEMA_VERSION,
'daily' => [],
'monthly' => [],
'pages' => [],
'visitors' => [],
];
}
private function ensureSections(array $data): array
{
return array_merge($this->emptyData(), $data);
}
private static function convertDailyKey(string $key): ?string
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $key)) {
return $key;
}
// Legacy d-m-Y → Y-m-d
if (preg_match('/^(\d{2})-(\d{2})-(\d{4})$/', $key, $m)) {
return $m[3] . '-' . $m[2] . '-' . $m[1];
}
return null;
}
private static function convertMonthlyKey(string $key): ?string
{
if (preg_match('/^\d{4}-\d{2}$/', $key)) {
return $key;
}
// Legacy m-Y → Y-m
if (preg_match('/^(\d{2})-(\d{4})$/', $key, $m)) {
return $m[2] . '-' . $m[1];
}
return null;
}
}
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Popularity;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\Yaml;
/**
* Records page views into PopularityStore. Mirrors the behaviour of
* admin-classic's tracker (bot/DNT respect, configurable ignore globs)
* but writes to a SQLite database instead of four JSON files.
*/
class PopularityTracker
{
private Config $config;
private PopularityStore $store;
public function __construct(?PopularityStore $store = null)
{
$this->config = Grav::instance()['config'];
$this->store = $store ?? new PopularityStore();
}
public function trackHit(): void
{
if (!$this->config->get('plugins.api.popularity.enabled', true)) {
return;
}
$grav = Grav::instance();
if (!$grav['browser']->isHuman()) {
return;
}
if (!$grav['browser']->isTrackable()) {
return;
}
/** @var \Grav\Common\Page\Interfaces\PageInterface|null $page */
$page = $grav['page'] ?? null;
if ($page === null || !$page->route()) {
return;
}
if ($page->template() === 'error') {
return;
}
$route = $page->route();
$url = (string) str_replace($grav['base_url_relative'], '', $page->url());
foreach ((array) $this->config->get('plugins.api.popularity.ignore', []) as $ignore) {
if (fnmatch((string) $ignore, $url)) {
return;
}
}
try {
// Keyed HMAC over the visitor IP with a server-private salt.
// GDPR Recital 26 / Art. 4(1): plain sha1(ip) is reversible via
// a precomputed rainbow table of the ~4.3B IPv4 space (trivial
// on a modern GPU), so the hash remains personal data. Keying
// with a per-install secret the attacker can't compute against
// breaks that re-identification path while preserving stable
// bucketing for the unique-visitor counter.
$ipHash = hash_hmac('sha256', (string) $grav['uri']->ip(), $this->getSalt());
// Pruning happens inside recordHit() under the same lock — every
// write trims to the configured retention window, so the file
// can never grow beyond bounded size between hits.
$this->store->recordHit(
$route,
$ipHash,
null,
(int) $this->config->get('plugins.api.popularity.history.daily', 30),
(int) $this->config->get('plugins.api.popularity.history.monthly', 12),
(int) $this->config->get('plugins.api.popularity.history.visitors', 20),
);
} catch (\Throwable) {
// Tracking must never break the page response — swallow.
}
}
/**
* Read the popularity HMAC salt from config, auto-generating + persisting
* one on first use. The salt MUST stay stable across requests so the
* unique-visitor bucket for a given IP stays the same; regenerating per
* request would balloon the visitors map with duplicate entries.
*
* Stored under plugins.api.popularity.salt in user/config/plugins/api.yaml.
* Never shipped with a default — a committed salt would be globally known
* and defeat the keyed-hash protection entirely.
*/
private function getSalt(): string
{
$salt = (string) $this->config->get('plugins.api.popularity.salt', '');
if ($salt !== '') {
return $salt;
}
$salt = bin2hex(random_bytes(32));
$this->config->set('plugins.api.popularity.salt', $salt);
// Persist so subsequent requests reuse the same salt. If we can't
// write the file (perms, missing config stream), fall through with
// the in-memory salt — tracking still works for this request and we
// retry on the next hit.
$grav = Grav::instance();
$locator = $grav['locator'];
$file = $locator->findResource('config://plugins/api.yaml');
if (!$file) {
$configDir = $locator->findResource('config://', true);
if (!$configDir) {
if (isset($grav['log'])) {
$grav['log']->warning('api.popularity: could not resolve config:// stream to persist popularity salt; visitor counts may double until salt is configured.');
}
return $salt;
}
$file = $configDir . '/plugins/api.yaml';
}
$dir = dirname($file);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
if (isset($grav['log'])) {
$grav['log']->warning(sprintf('api.popularity: could not create %s to persist popularity salt.', $dir));
}
return $salt;
}
$yaml = Yaml::parse(file_exists($file) ? (string) file_get_contents($file) : '') ?? [];
$yaml['popularity']['salt'] = $salt;
if (@file_put_contents($file, Yaml::dump($yaml)) === false) {
if (isset($grav['log'])) {
$grav['log']->warning(sprintf('api.popularity: could not write popularity salt to %s — visitor counts may double until next successful write.', $file));
}
}
return $salt;
}
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Response;
use Grav\Framework\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
class ApiResponse
{
/**
* Create a standard JSON response with the data envelope.
*/
public static function create(mixed $data, int $status = 200, array $headers = [], ?array $meta = null): ResponseInterface
{
$body = [
'data' => $data,
];
if ($meta !== null) {
$body['meta'] = $meta;
}
$headers = array_merge($headers, [
'Content-Type' => 'application/json',
'Cache-Control' => 'no-store, max-age=0',
]);
return new Response($status, $headers, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
/**
* Create a paginated response with meta and links.
*/
public static function paginated(
array $data,
int $total,
int $page,
int $perPage,
string $baseUrl,
int $status = 200,
array $headers = [],
array $extraMeta = [],
?int $locatedAtIndex = null,
): ResponseInterface {
$totalPages = $perPage > 0 ? (int) ceil($total / $perPage) : 1;
$pagination = [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => $totalPages,
];
if ($locatedAtIndex !== null) {
$pagination['located_at_index'] = $locatedAtIndex;
}
$meta = [
'pagination' => $pagination,
];
if ($extraMeta !== []) {
$meta = array_merge($meta, $extraMeta);
}
$body = [
'data' => $data,
'meta' => $meta,
'links' => [
'self' => $baseUrl . '?' . http_build_query(['page' => $page, 'per_page' => $perPage]),
],
];
if ($page > 1) {
$body['links']['first'] = $baseUrl . '?' . http_build_query(['page' => 1, 'per_page' => $perPage]);
$body['links']['prev'] = $baseUrl . '?' . http_build_query(['page' => $page - 1, 'per_page' => $perPage]);
}
if ($page < $totalPages) {
$body['links']['next'] = $baseUrl . '?' . http_build_query(['page' => $page + 1, 'per_page' => $perPage]);
$body['links']['last'] = $baseUrl . '?' . http_build_query(['page' => $totalPages, 'per_page' => $perPage]);
}
$headers = array_merge($headers, [
'Content-Type' => 'application/json',
'Cache-Control' => 'no-store, max-age=0',
]);
return new Response($status, $headers, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
/**
* 200 OK with data envelope.
*/
public static function ok(mixed $data, array $headers = []): ResponseInterface
{
return self::create($data, 200, $headers);
}
/**
* 201 Created with Location header.
*/
public static function created(mixed $data, string $location, array $headers = []): ResponseInterface
{
return self::create($data, 201, array_merge($headers, ['Location' => $location]));
}
/**
* 204 No Content.
*/
public static function noContent(array $headers = []): ResponseInterface
{
return new Response(204, $headers);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Response;
use Grav\Framework\Psr7\Response;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Psr\Http\Message\ResponseInterface;
/**
* RFC 7807 Problem Details response builder.
*/
class ErrorResponse
{
/**
* @param array<string,mixed> $headers
* @param array<string,mixed>|null $toast Optional toast hint honored by Admin
* Next: { message?, type?, duration?, dismissible? }. `duration` is in ms;
* use 0 (or dismissible:true) for a toast that stays until manually closed.
*/
public static function create(int $status, string $title, string $detail, array $headers = [], ?array $toast = null): ResponseInterface
{
$body = [
'status' => $status,
'title' => $title,
'detail' => $detail,
];
if ($toast !== null) {
$body['toast'] = $toast;
}
$headers = array_merge($headers, [
'Content-Type' => 'application/problem+json',
'Cache-Control' => 'no-store, max-age=0',
]);
return new Response($status, $headers, json_encode($body, JSON_UNESCAPED_SLASHES));
}
public static function fromException(ApiException $e): ResponseInterface
{
$body = [
'status' => $e->getStatusCode(),
'title' => $e->getErrorTitle(),
'detail' => $e->getMessage(),
];
if ($e instanceof ValidationException && $e->getValidationErrors()) {
$body['errors'] = $e->getValidationErrors();
}
$headers = array_merge($e->getHeaders(), [
'Content-Type' => 'application/problem+json',
'Cache-Control' => 'no-store, max-age=0',
]);
return new Response($e->getStatusCode(), $headers, json_encode($body, JSON_UNESCAPED_SLASHES));
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use Grav\Common\Flex\Types\UserGroups\UserGroupObject;
class GroupSerializer implements SerializerInterface
{
public function serialize(object $resource, array $options = []): array
{
/** @var UserGroupObject $resource */
return [
'groupname' => (string) $resource->getProperty('groupname', ''),
'readableName' => (string) ($resource->getProperty('readableName') ?? ''),
'description' => (string) ($resource->getProperty('description') ?? ''),
'icon' => (string) ($resource->getProperty('icon') ?? ''),
'enabled' => (bool) $resource->getProperty('enabled', true),
'access' => $resource->getProperty('access') ?? [],
];
}
/**
* Serialize a plain array entry from groups.yaml (used by the fallback
* non-Flex listing path, where there is no UserGroupObject yet).
*
* @param array<string,mixed> $entry
*/
public function serializeArray(string $groupname, array $entry): array
{
return [
'groupname' => $groupname,
'readableName' => (string) ($entry['readableName'] ?? ''),
'description' => (string) ($entry['description'] ?? ''),
'icon' => (string) ($entry['icon'] ?? ''),
'enabled' => (bool) ($entry['enabled'] ?? true),
'access' => $entry['access'] ?? [],
];
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use Grav\Plugin\Api\Services\ThumbnailService;
class MediaSerializer implements SerializerInterface
{
public function __construct(
private ?ThumbnailService $thumbnailService = null,
private string $thumbnailBaseUrl = '',
) {}
/**
* Serialize a single Grav Medium object to an API response array.
*/
public function serialize(object $medium, array $options = []): array
{
$mime = $medium->get('mime') ?? 'application/octet-stream';
$data = [
'filename' => $medium->filename,
'url' => $medium->url(),
'type' => $mime,
'size' => (int) ($medium->get('size') ?? 0),
];
if (str_starts_with($mime, 'image/')) {
$width = $medium->get('width');
$height = $medium->get('height');
if ($width !== null && $height !== null) {
$data['dimensions'] = [
'width' => (int) $width,
'height' => (int) $height,
];
}
// Generate thumbnail URL for images
if ($this->thumbnailService) {
$sourcePath = $this->resolveSourcePath($medium);
if ($sourcePath) {
$thumbFilename = $this->thumbnailService->getThumbnailFilename($sourcePath);
if ($thumbFilename) {
// Trigger thumbnail generation
$this->thumbnailService->getThumbnail($sourcePath);
$data['thumbnail_url'] = $this->thumbnailBaseUrl . '/thumbnails/' . $thumbFilename;
}
}
}
}
$data['modified'] = $this->resolveModifiedTime($medium);
return $data;
}
/**
* Serialize an iterable collection of Medium objects.
*/
public function serializeCollection(iterable $media, array $options = []): array
{
$items = [];
foreach ($media as $medium) {
$items[] = $this->serialize($medium, $options);
}
return $items;
}
/**
* Resolve the physical file path for a medium.
*/
private function resolveSourcePath(object $medium): ?string
{
if (method_exists($medium, 'path')) {
$path = $medium->path();
if ($path && file_exists($path)) {
return $path;
}
}
return null;
}
/**
* Resolve the last-modified timestamp for a medium, returning an ISO 8601 string.
*/
private function resolveModifiedTime(object $medium): string
{
$timestamp = null;
if (method_exists($medium, 'modified')) {
$timestamp = $medium->modified();
}
if (!$timestamp && method_exists($medium, 'path')) {
$path = $medium->path();
if ($path && file_exists($path)) {
$timestamp = filemtime($path);
}
}
$timestamp = $timestamp ?: time();
return date(\DateTimeInterface::ATOM, (int) $timestamp);
}
}
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use Grav\Common\GPM\Licenses;
use Parsedown;
class PackageSerializer implements SerializerInterface
{
private static ?Parsedown $parsedown = null;
public function serialize(object $resource, array $options = []): array
{
$description = $resource->description ?? null;
$data = [
'slug' => $resource->slug ?? null,
'name' => $resource->name ?? null,
'version' => $resource->version ?? null,
'type' => $options['type'] ?? null,
'description' => $description,
'description_html' => $this->renderMarkdown($description),
'author' => $this->serializeAuthor($resource),
'homepage' => $resource->homepage ?? $resource->url ?? null,
];
// Include enabled status + symlink detection for installed packages
if ($options['installed'] ?? false) {
$data['enabled'] = $this->isEnabled($resource, $options);
$data['is_symlink'] = $this->isSymlinked($resource, $options);
}
// Include update info if available
if (isset($resource->available)) {
$data['available_version'] = $resource->available;
$data['updatable'] = !empty($resource->available);
}
// Include premium status and purchase info
if (!empty($resource->premium)) {
$slug = $resource->slug ?? $options['slug_key'] ?? '';
$premium = $resource->premium;
$permalink = is_object($premium) ? ($premium->permalink ?? null) : ($premium['permalink'] ?? null);
$data['premium'] = true;
$data['licensed'] = !empty(Licenses::get($slug));
if ($permalink) {
$data['purchase_url'] = 'https://licensing.getgrav.org/buy/' . $permalink;
}
}
// Include dependencies
if (!empty($resource->dependencies)) {
$data['dependencies'] = $resource->dependencies;
}
// Include compatibility metadata. Grav core resolves
// `compatibility.grav` / `compatibility.api` (and infers grav from the
// dependencies array as a fallback). Any keys core doesn't currently
// resolve (e.g. a future `compatibility.php`) come straight from the
// blueprint via the `compatibility_raw` fallback below.
$compatibility = $this->normalizeCompatibility($resource->compatibility ?? null);
$rawCompat = is_object($resource) && method_exists($resource, 'toArray')
? ($resource->toArray()['compatibility'] ?? null)
: null;
if (is_array($rawCompat)) {
foreach ($rawCompat as $key => $value) {
if (!isset($compatibility[$key])) {
$compatibility[$key] = is_array($value) ? array_map('strval', $value) : (string) $value;
}
}
}
if (!empty($compatibility)) {
$data['compatibility'] = $compatibility;
}
// Include keywords/tags
if (!empty($resource->keywords)) {
$data['keywords'] = $resource->keywords;
}
// Include icon
if (!empty($resource->icon)) {
$data['icon'] = $resource->icon;
}
// Include screenshot URL for themes (from GPM repository data)
if (!empty($resource->screenshot)) {
$screenshot = $resource->screenshot;
// GPM returns just a filename — resolve to full URL
if (!str_starts_with($screenshot, 'http')) {
$screenshot = 'https://getgrav.org/images/' . $screenshot;
}
$data['screenshot'] = $screenshot;
}
return $data;
}
/**
* Serialize a collection of packages.
*/
public function serializeCollection(iterable $packages, array $options = []): array
{
$result = [];
foreach ($packages as $slug => $package) {
$opts = array_merge($options, ['slug_key' => $slug]);
$serialized = $this->serialize($package, $opts);
// Ensure slug is set (some iterators use slug as key)
if ($serialized['slug'] === null && is_string($slug)) {
$serialized['slug'] = $slug;
}
$result[] = $serialized;
}
return $result;
}
private function serializeAuthor(object $resource): ?array
{
$author = $resource->author ?? null;
if ($author === null) {
return null;
}
if (is_object($author)) {
return [
'name' => $author->name ?? null,
'email' => $author->email ?? null,
'url' => $author->url ?? null,
];
}
if (is_array($author)) {
return [
'name' => $author['name'] ?? null,
'email' => $author['email'] ?? null,
'url' => $author['url'] ?? null,
];
}
return null;
}
private function isEnabled(object $resource, array $options): bool
{
$type = $options['type'] ?? 'plugin';
$slug = $resource->slug ?? $options['slug_key'] ?? '';
if ($type === 'plugin') {
return (bool) (\Grav\Common\Grav::instance()['config']->get("plugins.{$slug}.enabled", false));
}
// For themes, check if it's the active theme
$activeTheme = \Grav\Common\Grav::instance()['config']->get('system.pages.theme');
return $slug === $activeTheme;
}
/**
* Render a plugin/theme description as safe HTML. Descriptions are
* YAML-authored and routinely contain inline markdown (links, bold,
* emphasis) that renders as literal syntax in UIs without processing.
* Returns null for empty input so clients can trivially fall back.
*/
private function renderMarkdown(?string $markdown): ?string
{
if ($markdown === null || $markdown === '') {
return null;
}
if (self::$parsedown === null) {
self::$parsedown = new Parsedown();
// Untrusted YAML input — sanitize any inline HTML and disable unsafe protocols.
self::$parsedown->setSafeMode(true);
self::$parsedown->setBreaksEnabled(false);
}
return self::$parsedown->text($markdown);
}
/**
* Normalize Grav's resolved compatibility array into a stable client shape.
* Strips empty keys so consumers don't render `Grav: ` with nothing after.
*
* @param mixed $compatibility
* @return array<string, mixed>
*/
private function normalizeCompatibility($compatibility): array
{
if (!is_array($compatibility)) {
return [];
}
$out = [];
foreach ($compatibility as $key => $value) {
if (is_array($value)) {
$value = array_values(array_filter(array_map('strval', $value), 'strlen'));
if (!empty($value)) {
$out[(string) $key] = $value;
}
} elseif ($value !== null && $value !== '') {
$out[(string) $key] = (string) $value;
}
}
return $out;
}
private function isSymlinked(object $resource, array $options): bool
{
$type = $options['type'] ?? 'plugin';
$slug = $resource->slug ?? $options['slug_key'] ?? '';
if (!$slug) {
return false;
}
$scheme = $type === 'theme' ? 'themes' : 'plugins';
$path = \Grav\Common\Grav::instance()['locator']->findResource("{$scheme}://{$slug}", true);
return $path ? is_link($path) : false;
}
}
@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use DateTimeImmutable;
use DateTimeZone;
use Grav\Common\Page\Interfaces\PageInterface;
class PageSerializer implements SerializerInterface
{
public function __construct(
private ?MediaSerializer $mediaSerializer = null,
) {}
public function serialize(object $resource, array $options = []): array
{
/** @var PageInterface $resource */
$includeContent = $options['include_content'] ?? true;
$renderContent = $options['render_content'] ?? false;
$includeChildren = $options['include_children'] ?? false;
$childrenDepth = $options['children_depth'] ?? 1;
$includeMedia = $options['include_media'] ?? true;
$includeTranslations = $options['include_translations'] ?? false;
$headerArr = $this->serializeHeader($resource->header());
// Flex-indexed PageObject instances expose EMPTY headers during
// listing (the index only materializes summary fields). That makes
// $resource->published() / visible() fall back to Grav's default
// "true" even when the frontmatter explicitly says false. Swap in the
// fully-loaded legacy Page so every downstream field reads correctly.
// Flex-indexed PageObject instances expose EMPTY headers during
// listing (the index only materializes summary fields). Read the
// frontmatter directly from the .md file so published/visible and
// everything else in the header are accurate regardless of which
// controller path we came through.
if (empty($headerArr) && $resource instanceof \Grav\Framework\Flex\Pages\FlexPageObject) {
$path = method_exists($resource, 'path') ? $resource->path() : null;
$template = $resource->template();
if ($path && $template) {
$candidates = [];
// Prefer the page's own language, then the active language,
// then the untyped default, then any matching {template}*.md.
$pageLang = $resource->language();
if ($pageLang) {
$candidates[] = $path . '/' . $template . '.' . $pageLang . '.md';
}
$grav = \Grav\Common\Grav::instance();
$lang = $grav['language'] ?? null;
if ($lang && method_exists($lang, 'getLanguage')) {
$active = $lang->getLanguage();
if ($active) {
$candidates[] = $path . '/' . $template . '.' . $active . '.md';
}
}
$candidates[] = $path . '/' . $template . '.md';
foreach ($candidates as $file) {
if (is_file($file)) {
$parsed = $this->parseFrontmatter($file);
if (!empty($parsed)) {
$headerArr = $parsed;
break;
}
}
}
// Fallback: glob for any {template}*.md file in the directory
if (empty($headerArr)) {
foreach (glob($path . '/' . $template . '*.md') ?: [] as $file) {
$parsed = $this->parseFrontmatter($file);
if (!empty($parsed)) {
$headerArr = $parsed;
break;
}
}
}
}
}
// For flex-indexed PageObject listings the in-memory header is empty
// (we re-parsed the .md file into $headerArr above). $resource->title()
// and ->menu() in that mode fall back to a slug-derived label
// ("Contact-us") even when the frontmatter has a real title.
// Prefer the parsed-header value so the listing reads the same as
// the detail endpoint.
$headerTitle = $headerArr['title'] ?? null;
$headerMenu = $headerArr['menu'] ?? null;
$data = [
'route' => $resource->route(),
// Structural route — for the home page, route() returns the
// public alias '/' but rawRoute() returns the actual page like
// '/home'. Clients editing/finding pages should prefer this.
'raw_route' => $resource->rawRoute(),
'slug' => $resource->slug(),
// The on-disk folder basename, including any numeric ordering
// prefix (e.g. `01.consulting`). `slug` is the prefix-stripped
// name; admin UIs need the real folder to show/diagnose ordering.
'folder' => $resource->folder(),
'title' => is_string($headerTitle) && $headerTitle !== '' ? $headerTitle : $resource->title(),
'menu' => is_string($headerMenu) && $headerMenu !== '' ? $headerMenu : (is_string($headerTitle) && $headerTitle !== '' ? $headerTitle : $resource->menu()),
'template' => $resource->template(),
'language' => $resource->language(),
'header' => $headerArr,
'taxonomy' => $resource->taxonomy(),
// Prefer the explicit frontmatter value for published/visible over
// the object method. During flex-indexed collection listings
// (GET /pages) the indexed PageObject::published() can return a
// stale/default "true" when the header isn't fully materialized,
// while GET /pages/{route} goes through enablePages() and reads a
// legacy Page where the method is correct. Reading the serialized
// header array (same one we return to the client) gives the same
// answer in both paths.
'published' => array_key_exists('published', $headerArr) ? (bool)$headerArr['published'] : $resource->published(),
'visible' => array_key_exists('visible', $headerArr) ? (bool)$headerArr['visible'] : $resource->visible(),
'routable' => $resource->routable(),
'date' => $this->formatTimestamp($resource->date()),
'modified' => $this->formatTimestamp($resource->modified()),
'order' => $resource->order(),
'has_children' => count($resource->children()) > 0,
];
if ($includeTranslations) {
$data['translated_languages'] = $resource->translatedLanguages();
$data['untranslated_languages'] = $resource->untranslatedLanguages();
// Disambiguate Grav's translated_languages response: when the page
// has an untyped base file (e.g. default.md), Grav reports every
// site language as "translated" because default.md acts as a
// fallback for any active lang. These two fields let admin UIs
// tell whether each language is backed by an EXPLICIT file
// (default.<lang>.md) or by the implicit default.md fallback.
$pagePath = $resource->path();
$template = $resource->template();
$data['has_default_file'] = $pagePath && $template
? is_file($pagePath . '/' . $template . '.md')
: false;
// List of language codes that have a concrete `{template}.{lang}.md`
// file on disk. Everything else in translated_languages is falling
// back to default.md. Empty array when multilang is off.
$explicit = [];
if ($pagePath && $template) {
$lang = \Grav\Common\Grav::instance()['language'] ?? null;
$langCodes = $lang && method_exists($lang, 'getLanguages')
? (array) $lang->getLanguages()
: [];
foreach ($langCodes as $code) {
if (is_file($pagePath . '/' . $template . '.' . $code . '.md')) {
$explicit[] = $code;
}
}
}
$data['explicit_language_files'] = $explicit;
}
if ($includeContent) {
$data['content'] = $resource->rawMarkdown();
}
if ($renderContent) {
$data['content_html'] = $resource->content();
}
$includeSummary = $options['include_summary'] ?? false;
if ($includeSummary) {
$summarySize = $options['summary_size'] ?? null;
// summary() runs the page through the full Twig / shortcode pipeline,
// so any page with a plugin shortcode whose dependencies aren't
// available in the API request context (e.g. a `[poll]` that wants
// the frontend theme's Twig env) can throw — we don't want that to
// take down the whole response for something the client is treating
// as a preview. Fall back to a plain-text rendering of the raw
// markdown, trimmed to the requested size.
try {
$data['summary'] = $summarySize
? $resource->summary($summarySize)
: $resource->summary();
} catch (\Throwable $e) {
$raw = (string) $resource->rawMarkdown();
// Strip frontmatter artifacts, shortcodes, markdown syntax.
$plain = preg_replace('/\[[^\]]+\s*\/?\]/', '', $raw) ?? $raw;
$plain = preg_replace('/[#*_`>]/', '', $plain) ?? $plain;
$plain = trim(preg_replace('/\s+/', ' ', $plain) ?? $plain);
$max = $summarySize ?: 300;
$data['summary'] = mb_strlen($plain) > $max
? rtrim(mb_substr($plain, 0, $max)) . '…'
: $plain;
}
}
if ($includeMedia) {
$data['media'] = $this->serializeMedia($resource);
}
if ($includeChildren && $childrenDepth > 0) {
$data['children'] = $this->serializeChildren(
$resource,
$options,
$childrenDepth,
);
}
return $data;
}
/**
* Parse the YAML frontmatter from a Grav .md file. Returns the header
* array, or empty array if there's no frontmatter / on parse failure.
*/
private function parseFrontmatter(string $file): array
{
$contents = @file_get_contents($file);
if ($contents === false) {
return [];
}
// Grav frontmatter: content between leading `---\n` and the next `---\n`.
if (!preg_match('/^---\r?\n(.*?)\r?\n---\r?\n/s', $contents, $m)) {
return [];
}
try {
$parsed = \Symfony\Component\Yaml\Yaml::parse($m[1]);
return is_array($parsed) ? $parsed : [];
} catch (\Throwable) {
return [];
}
}
/**
* Serialize a collection of pages.
*/
public function serializeCollection(iterable $pages, array $options = []): array
{
$result = [];
foreach ($pages as $page) {
$result[] = $this->serialize($page, $options);
}
return $result;
}
/**
* Convert page header object to an associative array.
*/
private function serializeHeader(object|null $header): array
{
if ($header === null) {
return [];
}
return json_decode(json_encode($header), true) ?: [];
}
/**
* Serialize the media collection attached to a page.
*/
private function serializeMedia(PageInterface $page): array
{
$media = $page->media();
if ($this->mediaSerializer) {
return $this->mediaSerializer->serializeCollection($media->all());
}
$result = [];
foreach ($media->all() as $filename => $medium) {
$result[] = [
'filename' => $medium->filename,
'type' => $medium->get('mime'),
'size' => $medium->get('size'),
];
}
return $result;
}
/**
* Recursively serialize children pages up to the specified depth.
*/
private function serializeChildren(PageInterface $page, array $options, int $depth): array
{
$childOptions = array_merge($options, [
'include_children' => $depth > 1,
'children_depth' => $depth - 1,
]);
$result = [];
foreach ($page->children() as $child) {
$result[] = $this->serialize($child, $childOptions);
}
return $result;
}
/**
* Format a Unix timestamp as ISO 8601.
*/
private function formatTimestamp(int|null $timestamp): ?string
{
if ($timestamp === null || $timestamp === 0) {
return null;
}
return (new DateTimeImmutable('@' . $timestamp))
->setTimezone(new DateTimeZone('UTC'))
->format(DateTimeImmutable::ATOM);
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
interface SerializerInterface
{
public function serialize(object $resource, array $options = []): array;
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Serializers;
use DateTimeImmutable;
use DateTimeZone;
use Grav\Common\User\Interfaces\UserInterface;
class UserSerializer implements SerializerInterface
{
public function serialize(object $resource, array $options = []): array
{
/** @var UserInterface $resource */
return [
'username' => $resource->username,
'email' => $resource->get('email'),
'fullname' => $resource->get('fullname'),
'title' => $resource->get('title'),
'state' => $resource->get('state', 'enabled'),
'language' => $resource->get('language', ''),
'content_editor' => $resource->get('content_editor', ''),
'access' => $resource->get('access', []),
'groups' => array_values(array_filter(
(array) $resource->get('groups', []),
'is_string',
)),
'avatar_url' => self::resolveAvatarUrl($resource),
'twofa_enabled' => (bool) $resource->get('twofa_enabled', false),
'twofa_secret' => $resource->get('twofa_secret') ? true : false,
'created' => $this->formatTimestamp($resource->get('created')),
'modified' => $this->formatTimestamp($resource->get('modified')),
];
}
/**
* Resolve the avatar URL for a user.
* Returns the URL to an uploaded avatar, or null if none exists.
*/
public static function resolveAvatarUrl(UserInterface $resource): ?string
{
$avatar = $resource->get('avatar');
// Avatar is stored as { filename: { name, type, size, path } } or similar
if (is_array($avatar) && !empty($avatar)) {
$first = reset($avatar);
if (is_array($first) && isset($first['path'])) {
// path is relative to Grav root (e.g. user/accounts/avatars/file.jpg)
$filePath = GRAV_ROOT . '/' . $first['path'];
if (file_exists($filePath)) {
// Generate a thumbnail URL via the thumbnail service
$locator = \Grav\Common\Grav::instance()['locator'];
$cacheDir = $locator->findResource('cache://', true, true) . '/api/thumbnails';
$thumbService = new \Grav\Plugin\Api\Services\ThumbnailService($cacheDir, 200);
$filename = $thumbService->getThumbnailFilename($filePath);
if ($filename) {
$thumbService->getThumbnail($filePath);
$config = \Grav\Common\Grav::instance()['config'];
$route = $config->get('plugins.api.route', '/api');
$prefix = $config->get('plugins.api.version_prefix', 'v1');
return $route . '/' . $prefix . '/thumbnails/' . $filename;
}
}
}
}
return null;
}
private function formatTimestamp(mixed $timestamp): ?string
{
if ($timestamp === null || $timestamp === 0) {
return null;
}
return (new DateTimeImmutable('@' . (int) $timestamp))
->setTimezone(new DateTimeZone('UTC'))
->format(DateTimeImmutable::ATOM);
}
}
@@ -0,0 +1,332 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\PermissionResolver;
/**
* Resolves blueprint-field path inputs (destination / folder) to an absolute
* filesystem directory, mirroring admin-classic's logic in
* AdminBaseController::taskFilesUpload / taskGetFilesInFolder.
*
* Inputs supported:
* - `self@:subpath`, `@self:subpath` — relative to the scope owner
* (plugins/<slug>, themes/<slug>, pages/<route>, users/<username>)
* - Grav streams: `user://`, `theme://`, `themes://`, `plugins://`,
* `account://`, `image://`, `asset://`, `page://`, etc.
* - Plain relative paths — resolved under `user/`, confined to it.
*
* Extracted from BlueprintUploadController so the same resolution is used
* by the read-only browse endpoint (BlueprintFilesController). All security
* gates that previously lived on the upload controller remain there; this
* service is the path-resolution primitive only.
*/
class BlueprintPathResolver
{
public function __construct(
private readonly Grav $grav,
) {}
/**
* Reject traversal / null-byte / backslash strings before stream resolution.
* Mirrors BlueprintUploadController::assertSafeDestination.
*/
public function assertSafe(string $input): void
{
if (str_contains($input, "\0") || str_contains($input, '\\')) {
throw new ValidationException('Invalid path.');
}
$path = $input;
if (preg_match('/^(?:self@|@self)(?::(.*))?$/', $input, $m)) {
$path = $m[1] ?? '';
} elseif (preg_match('#^[A-Za-z][A-Za-z0-9+.-]*://(.*)$#', $input, $m)) {
$path = $m[1] ?? '';
}
foreach (explode('/', trim($path, '/')) as $segment) {
if ($segment === '') {
continue;
}
if ($segment === '.' || $segment === '..') {
throw new ValidationException('Traversal not allowed.');
}
}
}
/**
* Detect a `@self` / `self@` / `@self@` literal (no subpath). The browse
* endpoint treats these specially — they mean "use the page's own media"
* which is served via /pages/{route}/media, not a generic folder browse.
*/
public function isSelfLiteral(string $input): bool
{
return in_array($input, ['@self', 'self@', '@self@', '@self/', 'self@/'], true);
}
/**
* Resolve a blueprint destination/folder + scope to an absolute filesystem
* directory.
*
* Streams and `self@:` owner roots are trusted as-is — Grav's resource
* locator is the authority on where they point. Plain relative paths are
* gated to stay under `user/`.
*
* @param UserInterface|null $caller Required to resolve `users/<username>` scope.
*/
public function resolve(string $input, string $scope, ?UserInterface $caller = null): string
{
$locator = $this->locator();
// `self@:subpath` / `@self:subpath` — relative to the blueprint owner.
if (preg_match('/^(?:self@|@self)(?::(.*))?$/', $input, $m)) {
$sub = $m[1] ?? '';
if (str_contains($sub, '..')) {
throw new ValidationException('Traversal not allowed in self@: subpath.');
}
$base = $this->resolveScopeRoot($scope, $caller);
if ($base === null) {
throw new ValidationException(
"Cannot resolve 'self@:' path: scope '{$scope}' is not a supported owner."
);
}
return $sub === '' ? $base : $base . '/' . ltrim($sub, '/');
}
// Grav stream — user://, theme://, account://, etc.
if ($locator->isStream($input)) {
$resolved = $locator->findResource($input, true, true);
if ($resolved === false || !is_string($resolved)) {
throw new ValidationException("Stream not resolvable: '{$input}'.");
}
return $resolved;
}
// Plain path — must be relative to user root and stay inside it.
if (str_starts_with($input, '/') || str_contains($input, '..')) {
throw new ValidationException('Absolute or traversal paths are not allowed.');
}
$userRoot = $this->userRoot();
if ($userRoot === null) {
throw new ValidationException('User root is not available.');
}
return $this->assertInsideUserRoot($userRoot . '/' . $input);
}
/**
* Map a scope (plugins/<slug>, themes/<slug>, pages/<route>, users/<username>)
* to its filesystem root. Returns null for unsupported scope types.
*/
public function resolveScopeRoot(string $scope, ?UserInterface $caller = null): ?string
{
if ($scope === '') return null;
$parts = explode('/', $scope, 2);
$type = $parts[0];
$name = $parts[1] ?? '';
$locator = $this->locator();
return match ($type) {
'plugins' => $this->resolveStreamOrNull($locator, 'plugins://', $name),
'themes' => $this->resolveStreamOrNull($locator, 'themes://', $name),
'pages' => $this->resolvePageScope($name),
'users' => $name !== '' ? $this->resolveUserScope($name, $caller) : null,
default => null,
};
}
/**
* Compute the Grav-root-relative directory path for a destination, used to
* produce stable round-trip identifiers (returned by upload, accepted by
* delete). Survives symlinks because it's derived from the logical input,
* not the realpath.
*/
public function logicalParent(string $destination, string $scope): ?string
{
// self@:sub — resolve relative to scope owner
if (preg_match('/^(?:self@|@self)(?::(.*))?$/', $destination, $m)) {
$sub = ltrim($m[1] ?? '', '/');
[$type, $name] = array_pad(explode('/', $scope, 2), 2, '');
$parent = match ($type) {
'plugins' => $name ? "plugins/{$name}" : null,
'themes' => $name ? "themes/{$name}" : null,
'users' => 'accounts',
'pages' => $name ? "pages/{$name}" : null,
default => null,
};
if ($parent === null) return null;
return $sub === '' ? $parent : $parent . '/' . $sub;
}
// Known Grav streams that map 1:1 to user/ subdirs.
$streamMap = [
'user://' => '',
'theme://' => $this->activeThemeDir(),
'themes://' => 'themes',
'plugins://' => 'plugins',
'account://' => 'accounts',
'image://' => 'images',
'asset://' => 'assets',
'page://' => 'pages',
];
foreach ($streamMap as $prefix => $replace) {
if ($replace !== null && str_starts_with($destination, $prefix)) {
$rest = ltrim(substr($destination, strlen($prefix)), '/');
$parts = array_filter([$replace, $rest], static fn($p) => $p !== '' && $p !== null);
return implode('/', $parts);
}
}
// Plain relative path — treated as user-rooted already.
if (!str_starts_with($destination, '/') && !str_contains($destination, '..')) {
return trim($destination, '/');
}
return null;
}
public function userRoot(): ?string
{
$locator = $this->locator();
$root = $locator->findResource('user://', true, true);
if ($root === false || !is_string($root)) return null;
$real = realpath($root);
return $real === false ? null : $real;
}
/**
* Classify a resolved directory against the config-bearing dirs under
* `user/`. Returns 'accounts', 'config', 'env', or null.
*
* Used by upload-side guards. Browse callers can ignore this since
* Media::all() filters non-media files anyway and reading config is
* harmless — but exposing the same method here keeps the security
* logic centralized.
*/
public function classifyTargetDir(string $absoluteDir): ?string
{
$userRoot = $this->userRoot();
if ($userRoot === null) return null;
$probe = $absoluteDir;
while ($probe !== '' && !file_exists($probe)) {
$parent = dirname($probe);
if ($parent === $probe) break;
$probe = $parent;
}
$real = realpath($probe !== '' ? $probe : $absoluteDir);
if ($real === false) {
$real = $absoluteDir;
}
$normalizedTarget = rtrim(str_replace('\\', '/', $absoluteDir), '/');
$map = [
'accounts' => $userRoot . '/accounts',
'config' => $userRoot . '/config',
'env' => $userRoot . '/env',
];
foreach ($map as $label => $forbidden) {
$normalizedForbidden = rtrim(str_replace('\\', '/', $forbidden), '/');
if (
$real === $forbidden
|| str_starts_with($real, $forbidden . '/')
|| $normalizedTarget === $normalizedForbidden
|| str_starts_with($normalizedTarget, $normalizedForbidden . '/')
) {
return $label;
}
}
return null;
}
public function assertInsideUserRoot(string $path): string
{
$userRoot = $this->userRoot();
if ($userRoot === null) {
throw new ValidationException('User root is not available.');
}
$probe = $path;
while ($probe !== '' && !file_exists($probe)) {
$parent = dirname($probe);
if ($parent === $probe) break;
$probe = $parent;
}
$real = realpath($probe !== '' ? $probe : $userRoot);
if ($real === false || (!str_starts_with($real, $userRoot . '/') && $real !== $userRoot)) {
throw new ValidationException('Path escapes the user directory.');
}
return rtrim($path, '/');
}
private function resolveStreamOrNull($locator, string $stream, string $name): ?string
{
if ($name === '') return null;
$resolved = $locator->findResource($stream . $name, true, true);
return is_string($resolved) ? $resolved : null;
}
private function resolvePageScope(string $route): ?string
{
if ($route === '') return null;
$pages = $this->grav['pages'];
if (method_exists($pages, 'enablePages')) {
$pages->enablePages();
}
/** @var PageInterface|null $page */
$page = $pages->find('/' . ltrim($route, '/'));
return $page?->path() ?: null;
}
/**
* Resolve `users/<username>` scope to the accounts directory.
*
* Tight gating: the caller must be editing their own account OR hold
* `api.users.write`. Without this, any holder of `api.media.write` could
* target other users' avatar slots — see GHSA-6xx2-m8wv-756h.
*/
private function resolveUserScope(string $name, ?UserInterface $caller): ?string
{
if (!preg_match('/^[A-Za-z0-9_.-]+$/', $name)) {
throw new ValidationException("Invalid users scope: '{$name}'.");
}
if ($caller === null) {
throw new ForbiddenException("The 'users/{$name}' scope requires an authenticated caller.");
}
$isSelf = strcasecmp($caller->username, $name) === 0;
$resolver = new PermissionResolver($this->grav['permissions']);
$isSuper = (bool) $caller->get('access.api.super');
$hasUsersWrite = (bool) $resolver->resolve($caller, 'api.users.write');
if (!$isSelf && !$isSuper && !$hasUsersWrite) {
throw new ForbiddenException(
"The 'users/{$name}' scope requires editing your own account or holding the 'api.users.write' permission."
);
}
$accounts = $this->locator()->findResource('account://', true, true);
return is_string($accounts) ? $accounts : null;
}
private function activeThemeDir(): ?string
{
$theme = (string)($this->grav['config']->get('system.pages.theme') ?? '');
return $theme === '' ? null : 'themes/' . $theme;
}
private function locator()
{
return $this->grav['locator'];
}
}
@@ -0,0 +1,506 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
use Grav\Common\Yaml;
use Grav\Plugin\Api\Services\EnvironmentService;
/**
* Differential config-save support.
*
* Admin writes should only persist values that actually override the parent
* matching how developers hand-edit Grav configs. The parent of each config
* scope is:
*
* system / site / media / security / scheduler / backups
* system/config/<scope>.yaml (Grav core defaults)
*
* plugins/<name>
* user/plugins/<name>/<name>.yaml (plugin's own defaults)
*
* themes/<name>
* user/themes/<name>/<name>.yaml (theme's own defaults)
*
* For env-targeted writes the parent is defaults merged with the current
* user/config/<scope>.yaml, so env files store only values that differ from
* the effective base config.
*
* Note: we deliberately use the raw YAML files as the source of defaults, not
* blueprint defaults. Blueprints describe the admin form; they can diverge
* from what the yaml actually supplies at load time.
*/
class ConfigDiffer
{
private const CORE_SCOPES = ['system', 'site', 'media', 'security', 'scheduler', 'backups'];
public function __construct(private Grav $grav)
{
}
/**
* Return the subset of $current that differs from $parent.
*
* Associative arrays recurse; sequential arrays are treated as atomic
* values (any difference the whole new list is retained). This avoids
* the classic admin-classic trap where shortening a list silently merged
* removed entries back in.
*
* @param array<mixed> $current
* @param array<mixed> $parent
* @return array<mixed>
*/
public function diff(array $current, array $parent): array
{
$out = [];
foreach ($current as $key => $value) {
if (!array_key_exists($key, $parent)) {
$out[$key] = $value;
continue;
}
$parentValue = $parent[$key];
if (self::valuesEqual($value, $parentValue)) {
continue;
}
if (is_array($value) && is_array($parentValue)
&& self::isAssoc($value) && self::isAssoc($parentValue)) {
$sub = $this->diff($value, $parentValue);
if ($sub !== []) {
$out[$key] = $sub;
}
continue;
}
// Scalar change, sequential-array change, or shape change (assoc↔list).
$out[$key] = $value;
}
return $out;
}
/**
* Parent config for a scope + optional env target.
* See class docblock for parent resolution rules.
*
* @return array<mixed>
*/
public function parent(string $scope, ?string $targetEnv): array
{
$defaults = $this->loadYamlAtPath($this->defaultsPath($scope)) ?? [];
if ($targetEnv === null || $targetEnv === '') {
return $defaults;
}
$base = $this->loadYamlAtPath($this->baseFilePath($scope)) ?? [];
if ($base === []) return $defaults;
return $this->deepMergeAssoc($defaults, $base);
}
/**
* The effective merged config for $scope under $targetEnv, computed purely
* from YAML files:
*
* defaults user/config user/env/<targetEnv>/config (when targetEnv set)
*
* then with GRAV_CONFIG__* environment overrides re-applied so the result
* matches what Grav resolves at runtime. Used as the baseline the admin
* reads and edits when the requested target differs from the environment
* Grav booted under notably base/"Default" while a hostname overlay is
* active. Grav can't re-resolve its environment mid-request, so we resolve
* the files ourselves; this is what stops "Default" from showing and a
* save from inheriting the env overlay.
*
* @return array<mixed>
*/
public function effective(string $scope, ?string $targetEnv): array
{
$merged = $this->loadYamlAtPath($this->defaultsPath($scope)) ?? [];
$base = $this->loadYamlAtPath($this->baseFilePath($scope)) ?? [];
if ($base !== []) {
$merged = $this->deepMergeAssoc($merged, $base);
}
if ($targetEnv !== null && $targetEnv !== '') {
$overlay = $this->loadYamlAtPath($this->envFilePath($scope, $targetEnv)) ?? [];
if ($overlay !== []) {
$merged = $this->deepMergeAssoc($merged, $overlay);
}
}
return $this->applyEnvironmentOverrides($merged, $scope);
}
/**
* Re-apply GRAV_CONFIG__* overrides for $scope on top of $data, mirroring
* the runtime layering Grav core does (InitializeProcessor), so a file-based
* effective() shows the same value Grav serves. Values are read from the
* live config env-var overrides are environment-agnostic, so they apply
* identically regardless of the target. The inverse of
* stripEnvironmentOverrides(), which removes these on save.
*
* @param array<mixed> $data
* @return array<mixed>
*/
public function applyEnvironmentOverrides(array $data, string $scope): array
{
$envKeys = $this->environmentOverrideKeys();
if ($envKeys === [] || $scope === '') {
return $data;
}
$prefix = str_replace('/', '.', $scope);
$config = $this->grav['config'] ?? null;
foreach ($envKeys as $key) {
$isWholeScope = $key === $prefix;
if (!$isWholeScope && !str_starts_with($key, $prefix . '.')) {
continue;
}
$value = is_object($config) && method_exists($config, 'get') ? $config->get($key) : null;
if ($value === null) {
continue;
}
if ($isWholeScope) {
return is_array($value) ? $value : $data;
}
$data = $this->setDotPath($data, substr($key, strlen($prefix) + 1), $value);
}
return $data;
}
/**
* Set a dotted path in a nested array, creating intermediate maps. The
* counterpart to unsetDotPath().
*
* @param array<mixed> $data
* @return array<mixed>
*/
private function setDotPath(array $data, string $path, mixed $value): array
{
$parts = explode('.', $path);
$ref = &$data;
foreach ($parts as $i => $part) {
if ($i === array_key_last($parts)) {
$ref[$part] = $value;
break;
}
if (!isset($ref[$part]) || !is_array($ref[$part])) {
$ref[$part] = [];
}
$ref = &$ref[$part];
}
unset($ref);
return $data;
}
/**
* Remove from $data any values that are currently supplied by GRAV_CONFIG__*
* environment variables for this scope, pruning subtrees that empty out.
*
* Those overrides are layered onto the compiled config at runtime by Grav
* core (InitializeProcessor) and always win, so they must never be written
* back to a YAML file on save doing so would persist a secret provided
* through `.env` (or the server environment) into the config on disk. This
* is scope-agnostic: it works for system/site/plugins/themes and any other
* config namespace because a scope maps to its config key by turning the
* `/` separator into a `.`.
*
* @param array<mixed> $data
* @return array<mixed>
*/
public function stripEnvironmentOverrides(array $data, string $scope): array
{
$envKeys = $this->environmentOverrideKeys();
if ($envKeys === [] || $scope === '') {
return $data;
}
$prefix = str_replace('/', '.', $scope);
foreach ($envKeys as $key) {
if ($key === $prefix) {
// The entire scope is provided by the environment.
return [];
}
if (str_starts_with($key, $prefix . '.')) {
$data = $this->unsetDotPath($data, substr($key, strlen($prefix) + 1));
}
}
return $data;
}
/**
* Dotted config keys currently supplied via GRAV_CONFIG__* environment
* variables, with GRAV_CONFIG_ALIAS__ substitution applied. Mirrors the
* resolution in Grav core's InitializeProcessor::initializeConfig() so the
* keys we skip on save are exactly the keys core injects at runtime. Empty
* when the GRAV_CONFIG switch is off.
*
* @return list<string>
*/
public function environmentOverrideKeys(): array
{
if (!getenv('GRAV_CONFIG')) {
return [];
}
$prefix = 'GRAV_CONFIG';
$cPrefix = $prefix . '__';
$aPrefix = $prefix . '_ALIAS__';
$cLen = strlen($cPrefix);
$aLen = strlen($aPrefix);
$keys = [];
$aliases = [];
foreach ($_ENV + $_SERVER as $name => $value) {
$name = (string) $name;
if (!str_starts_with($name, $prefix)) {
continue;
}
if (str_starts_with($name, $cPrefix)) {
$keys[] = str_replace('__', '.', substr($name, $cLen));
} elseif (str_starts_with($name, $aPrefix)) {
$aliases[substr($name, $aLen)] = (string) $value;
}
}
foreach ($keys as $i => $key) {
foreach ($aliases as $alias => $real) {
$key = str_replace($alias, $real, $key);
}
$keys[$i] = $key;
}
return $keys;
}
/**
* Flatten a nested config delta to its dotted leaf paths. A "leaf" is a
* scalar, a sequential (list) array treated atomically, matching diff()
* or an empty array; only associative maps recurse. Used to map a persisted
* override delta onto blueprint field names for the override indicators.
*
* @param array<mixed> $data
* @return list<string>
*/
public static function flattenLeaves(array $data, string $prefix = ''): array
{
$out = [];
foreach ($data as $key => $value) {
$path = $prefix === '' ? (string) $key : $prefix . '.' . $key;
if (is_array($value) && self::isAssoc($value)) {
$out = array_merge($out, self::flattenLeaves($value, $path));
} else {
$out[] = $path;
}
}
return $out;
}
/**
* Dig a dotted path out of a nested array, or null if any segment is
* missing. Callers treat "absent in the parent" as "reverts to the
* blueprint default / unset".
*
* @param array<mixed> $data
*/
public static function valueAtPath(array $data, string $path): mixed
{
$ref = $data;
foreach (explode('.', $path) as $part) {
if (!is_array($ref) || !array_key_exists($part, $ref)) {
return null;
}
$ref = $ref[$part];
}
return $ref;
}
/**
* Unset a dotted path from a nested array, pruning parents left empty.
*
* @param array<mixed> $data
* @return array<mixed>
*/
public function unsetDotPath(array $data, string $path): array
{
$parts = explode('.', $path);
$key = array_shift($parts);
if (!array_key_exists($key, $data)) {
return $data;
}
if ($parts === []) {
unset($data[$key]);
return $data;
}
if (is_array($data[$key])) {
$data[$key] = $this->unsetDotPath($data[$key], implode('.', $parts));
if ($data[$key] === []) {
unset($data[$key]);
}
}
return $data;
}
/**
* Recursive merge: $override wins, assoc subtrees recurse, sequential
* arrays are REPLACED (not concatenated).
*
* @param array<mixed> $base
* @param array<mixed> $override
* @return array<mixed>
*/
public function deepMergeAssoc(array $base, array $override): array
{
foreach ($override as $k => $v) {
if (is_array($v) && isset($base[$k]) && is_array($base[$k])
&& self::isAssoc($v) && self::isAssoc($base[$k])) {
$base[$k] = $this->deepMergeAssoc($base[$k], $v);
} else {
$base[$k] = $v;
}
}
return $base;
}
/**
* Path to the defaults file for $scope, or null if none resolvable.
*/
private function defaultsPath(string $scope): ?string
{
$locator = $this->grav['locator'];
if (in_array($scope, self::CORE_SCOPES, true)) {
$p = $locator->findResource('system://config/' . $scope . '.yaml', true);
return $p ?: null;
}
if (str_starts_with($scope, 'plugins/')) {
$name = substr($scope, 8);
$p = $locator->findResource('plugins://' . $name . '/' . $name . '.yaml', true);
return $p ?: null;
}
if (str_starts_with($scope, 'themes/')) {
$name = substr($scope, 7);
$p = $locator->findResource('themes://' . $name . '/' . $name . '.yaml', true);
return $p ?: null;
}
return null;
}
/**
* Path to the base user/config file for $scope, or null if missing.
*/
private function baseFilePath(string $scope): ?string
{
$userConfig = $this->grav['locator']->findResource('user://config', true);
if (!$userConfig) return null;
$relative = $this->scopeRelativeFile($scope);
if ($relative === null) return null;
$full = $userConfig . '/' . $relative;
return is_file($full) ? $full : null;
}
/**
* Path to an env overlay file for $scope under $targetEnv, or null if the
* env (or file) doesn't exist. Resolves user/env/<env>/config first, then
* the legacy user/<env>/config layout same as EnvironmentService.
*/
private function envFilePath(string $scope, string $targetEnv): ?string
{
$root = (new EnvironmentService($this->grav))->envConfigRoot($targetEnv);
if ($root === null) return null;
$relative = $this->scopeRelativeFile($scope);
if ($relative === null) return null;
$full = $root . '/' . $relative;
return is_file($full) ? $full : null;
}
/**
* The config filename for $scope relative to a config dir
* (e.g. 'system.yaml', 'plugins/foo.yaml'), or null for unknown scopes.
*/
private function scopeRelativeFile(string $scope): ?string
{
return match (true) {
in_array($scope, self::CORE_SCOPES, true) => $scope . '.yaml',
str_starts_with($scope, 'plugins/') => 'plugins/' . substr($scope, 8) . '.yaml',
str_starts_with($scope, 'themes/') => 'themes/' . substr($scope, 7) . '.yaml',
// Site-authored top-level config: a flat user/config/<scope>.yaml,
// so base + env overlay reads resolve like the core scopes.
ConfigScopes::isCustom($this->grav, $scope) => $scope . '.yaml',
default => null,
};
}
/**
* @return array<mixed>|null
*/
private function loadYamlAtPath(?string $path): ?array
{
if ($path === null || !is_file($path)) return null;
try {
$content = Yaml::parse((string)file_get_contents($path));
} catch (\Throwable) {
return null;
}
return is_array($content) ? $content : null;
}
/**
* @param array<mixed> $arr
*/
public static function isAssoc(array $arr): bool
{
if ($arr === []) return false;
return !array_is_list($arr);
}
/**
* Deep value equality with canonical key order for associative arrays so
* the same logical config hashes equal regardless of key insertion order.
*/
public static function valuesEqual(mixed $a, mixed $b): bool
{
if (is_array($a) && is_array($b)) {
return self::canonicalize($a) === self::canonicalize($b);
}
return $a === $b;
}
/**
* Recursively sort associative arrays by key so the same logical config
* serializes (and therefore hashes) identically regardless of key order.
* Sequential arrays keep their order.
*
* @param array<mixed> $arr
* @return array<mixed>
*/
public static function canonicalize(array $arr): array
{
if (self::isAssoc($arr)) {
ksort($arr);
}
foreach ($arr as $k => $v) {
if (is_array($v)) {
$arr[$k] = self::canonicalize($v);
}
}
return $arr;
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
/**
* Decides which config scopes the generic /config and /blueprints/config
* endpoints accept.
*
* Core scopes (system, site, media, security, scheduler, backups) are handled
* by explicit arms in ConfigController / BlueprintController. Beyond those,
* site authors can drop a top-level config in via the cookbook "add a custom
* yaml file" recipe — a `user/blueprints/config/<scope>.yaml` paired with a
* `user/config/<scope>.yaml`. Admin-classic showed those as config tabs
* automatically; admin2's API used to reject them because every downstream
* handler hardcoded the 6-scope whitelist.
*
* {@see isCustom()} is the single gate those handlers now share. It deliberately
* keys off a *user/environment-authored* blueprint, NOT the merged
* `blueprints://config` stream: core ships system blueprints there too (e.g.
* `streams.yaml`), and those must never become writable through the generic
* config permission. Requiring the blueprint to live under user:// or
* environment:// limits custom scopes to ones the site itself defined.
*/
final class ConfigScopes
{
/**
* Config scopes the API handles with explicit, individually-guarded arms.
* Custom scopes can never collide with these the explicit arms win first.
*/
public const CORE = ['system', 'site', 'media', 'security', 'scheduler', 'backups'];
/**
* True when $scope is a site-authored top-level config (the cookbook custom
* yaml recipe).
*
* A valid custom scope is a flat slug (no slashes or dots this also blocks
* path traversal through the `/config/{scope:.+}` route), is not one of the
* explicitly-handled CORE scopes, and has its config blueprint under the
* user:// or environment:// blueprints stream.
*/
public static function isCustom(Grav $grav, ?string $scope): bool
{
if ($scope === null || !preg_match('/^[a-z0-9][a-z0-9_-]*$/', $scope)) {
return false;
}
if (in_array($scope, self::CORE, true)) {
return false;
}
$locator = $grav['locator'];
foreach (['user://blueprints/config/', 'environment://blueprints/config/'] as $base) {
if ($locator->findResource($base . $scope . '.yaml', true)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\PermissionResolver;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\YamlFile;
/**
* Resolves the final dashboard widget list for a given user by merging:
* 1. Built-in core widget registry
* 2. Plugin-contributed widgets via onApiDashboardWidgets
* 3. Site layout (super-admin defaults visibility floor)
* 4. User layout (per-user overrides order, size, visible)
*
* Site-hidden widgets are stripped before user layout is applied; users can
* never re-enable a widget the site admin has turned off.
*/
class DashboardLayoutResolver
{
public const SITE_CONFIG_FILE = 'admin-next.yaml';
public const VALID_SIZES = ['xs', 'sm', 'md', 'lg', 'xl'];
public function __construct(
private readonly Grav $grav,
private readonly PermissionResolver $permissions,
) {}
/**
* Built-in core widgets shipped with admin-next.
*
* @return array<int, array<string, mixed>>
*/
public function coreRegistry(): array
{
return [
[
'id' => 'core.stats',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.STATS',
'icon' => 'BarChart3',
'sizes' => ['md', 'lg', 'xl'],
'defaultSize' => 'xl',
'authorize' => 'api.system.read',
'priority' => 100,
],
[
'id' => 'core.popularity',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.POPULARITY',
'icon' => 'TrendingUp',
'sizes' => ['md', 'lg', 'xl'],
'defaultSize' => 'lg',
'authorize' => 'api.system.read',
'priority' => 90,
],
[
'id' => 'core.system-health',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.SYSTEM_HEALTH',
'icon' => 'Activity',
'sizes' => ['sm', 'md', 'lg'],
'defaultSize' => 'sm',
'authorize' => 'api.system.read',
'priority' => 80,
],
[
'id' => 'core.recent-pages',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.RECENT_PAGES',
'icon' => 'FileText',
'sizes' => ['sm', 'md'],
'defaultSize' => 'md',
'authorize' => 'api.pages.read',
'priority' => 70,
],
[
'id' => 'core.top-pages',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.TOP_PAGES',
'icon' => 'Flame',
'sizes' => ['sm', 'md'],
'defaultSize' => 'sm',
'authorize' => 'api.system.read',
'priority' => 60,
],
[
'id' => 'core.backups',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.BACKUPS',
'icon' => 'Archive',
'sizes' => ['sm', 'md'],
'defaultSize' => 'sm',
'authorize' => 'api.system.read',
'priority' => 50,
],
[
'id' => 'core.notifications',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.NOTIFICATIONS',
'icon' => 'Bell',
'sizes' => ['sm', 'md', 'lg'],
'defaultSize' => 'md',
'authorize' => 'api.system.read',
'priority' => 40,
],
[
'id' => 'core.news-feed',
'source' => 'core',
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.NEWS_FEED',
'icon' => 'Rss',
'sizes' => ['sm', 'md', 'lg'],
'defaultSize' => 'md',
'authorize' => 'api.system.read',
'priority' => 30,
],
];
}
/**
* Collect plugin-contributed widgets via the onApiDashboardWidgets event.
*
* @return array<int, array<string, mixed>>
*/
public function pluginRegistry(UserInterface $user): array
{
$event = new Event(['widgets' => [], 'user' => $user]);
$this->grav->fireEvent('onApiDashboardWidgets', $event);
$items = [];
foreach ($event['widgets'] as $widget) {
if (!is_array($widget) || empty($widget['id'])) {
continue;
}
$widget['source'] = 'plugin';
$items[] = $widget;
}
return $items;
}
/**
* Read the site-wide dashboard layout from user/config/admin-next.yaml.
*
* @return array<string, mixed>
*/
public function siteLayout(): array
{
$path = $this->siteConfigFilePath();
if (!$path || !is_file($path)) {
return [];
}
$content = (array) YamlFile::instance($path)->content();
$layout = $content['dashboard']['site_layout'] ?? [];
return is_array($layout) ? $layout : [];
}
/**
* Read this user's saved dashboard layout from their account YAML.
*
* Storage location is the top-level `admin_next.dashboard` key. Older
* builds wrote to `state.admin_next.dashboard`, which collided with
* Grav's account-state string (`state: enabled` / `state: disabled`)
* and caused affected users to render as Disabled in user lists.
* `migrateLegacyState()` lifts that legacy data out and restores the
* account-state string on first read.
*
* @return array<string, mixed>
*/
public function userLayout(UserInterface $user): array
{
$this->migrateLegacyState($user);
$adminNext = $user->get('admin_next');
if (!is_array($adminNext)) {
return [];
}
$layout = $adminNext['dashboard'] ?? [];
return is_array($layout) ? $layout : [];
}
/**
* One-time migration: if `state` was clobbered by an older build with a
* map containing `admin_next.dashboard`, lift the dashboard layout out
* to the top-level `admin_next.dashboard` key and restore `state` to
* the standard `enabled` / `disabled` string.
*/
private function migrateLegacyState(UserInterface $user): bool
{
$state = $user->get('state');
if (!is_array($state)) {
return false;
}
$legacyDashboard = $state['admin_next']['dashboard'] ?? null;
if (is_array($legacyDashboard)) {
$adminNext = $user->get('admin_next');
$adminNext = is_array($adminNext) ? $adminNext : [];
// New location wins if both are present (shouldn't happen, but
// be defensive — the new write path is authoritative).
if (!isset($adminNext['dashboard'])) {
$adminNext['dashboard'] = $legacyDashboard;
$user->set('admin_next', $adminNext);
}
}
// Restore the account-state string. If a legacy install ever wrote
// an explicit `state.enabled: false`, honor it; otherwise default
// to `enabled` since the account exists and was being used.
$restored = ($state['enabled'] ?? null) === false ? 'disabled' : 'enabled';
$user->set('state', $restored);
$user->save();
return true;
}
/**
* Resolve the final widget list for a user.
*
* Returns the merged list with each widget annotated with its effective
* `visible`, `size`, and `order`, plus a flag indicating whether the
* widget was hidden by the site admin (in which case the user cannot
* override).
*
* @return array{
* widgets: array<int, array<string, mixed>>,
* user_layout: array<string, mixed>,
* site_layout: array<string, mixed>,
* can_edit_site: bool
* }
*/
public function resolve(UserInterface $user, bool $isSuperAdmin): array
{
$registry = array_merge($this->coreRegistry(), $this->pluginRegistry($user));
// Permission filter
$available = [];
foreach ($registry as $widget) {
$authorize = $widget['authorize'] ?? null;
if ($authorize !== null && !$isSuperAdmin && !(bool) $this->permissions->resolve($user, $authorize)) {
continue;
}
$available[$widget['id']] = $widget;
}
$siteLayout = $this->siteLayout();
$userLayout = $this->userLayout($user);
$siteEntries = $this->indexEntries($siteLayout['widgets'] ?? []);
$userEntries = $this->indexEntries($userLayout['widgets'] ?? []);
$merged = [];
$defaultOrder = 0;
foreach ($available as $id => $widget) {
$siteEntry = $siteEntries[$id] ?? null;
$userEntry = $userEntries[$id] ?? null;
$siteHidden = $siteEntry !== null && ($siteEntry['visible'] ?? true) === false;
// If site admin hid this widget, drop it entirely from the user's view.
if ($siteHidden) {
continue;
}
$size = $userEntry['size'] ?? $siteEntry['size'] ?? $widget['defaultSize'];
if (!in_array($size, self::VALID_SIZES, true)) {
$size = $widget['defaultSize'];
}
// Coerce to a size the widget supports
if (!in_array($size, $widget['sizes'], true)) {
$size = $widget['defaultSize'];
}
$visible = $userEntry !== null
? (bool) ($userEntry['visible'] ?? true)
: (bool) ($siteEntry['visible'] ?? true);
$order = $userEntry['order']
?? $siteEntry['order']
?? (1000 - (int) ($widget['priority'] ?? 0)) * 10 + $defaultOrder++;
$widget['visible'] = $visible;
$widget['size'] = $size;
$widget['order'] = (int) $order;
// Strip server-only annotation
unset($widget['authorize']);
$merged[] = $widget;
}
usort($merged, static fn($a, $b) => $a['order'] <=> $b['order']);
return [
'widgets' => $merged,
'user_layout' => $userLayout,
'site_layout' => $siteLayout,
'can_edit_site' => $isSuperAdmin,
];
}
/**
* Persist a user's layout to their account YAML under admin_next.dashboard.
*
* Note: this used to write to `state.admin_next.dashboard`, which
* collided with Grav's `state: enabled|disabled` account-state field.
* Legacy data is migrated on read by `migrateLegacyState()`.
*
* @param array<string, mixed> $layout
*/
public function saveUserLayout(UserInterface $user, array $layout): void
{
$this->migrateLegacyState($user);
$adminNext = $user->get('admin_next');
$adminNext = is_array($adminNext) ? $adminNext : [];
$adminNext['dashboard'] = $this->normalizeLayout($layout);
$user->set('admin_next', $adminNext);
$user->save();
}
/**
* Persist the site-wide layout to user/config/admin-next.yaml.
*
* @param array<string, mixed> $layout
*/
public function saveSiteLayout(array $layout): void
{
$path = $this->siteConfigFilePath(true);
if (!$path) {
throw new \RuntimeException('Unable to resolve user/config path for admin-next.yaml.');
}
$file = YamlFile::instance($path);
$content = (array) $file->content();
$content['dashboard'] = is_array($content['dashboard'] ?? null) ? $content['dashboard'] : [];
$content['dashboard']['site_layout'] = $this->normalizeLayout($layout);
$file->content($content);
$file->save();
// Make the saved layout visible to the running config in this request
$config = $this->grav['config'] ?? null;
if ($config) {
$config->set('admin-next.dashboard.site_layout', $content['dashboard']['site_layout']);
}
}
/**
* Normalize a layout payload, dropping unknown keys and bad types.
*
* @param array<string, mixed> $layout
* @return array<string, mixed>
*/
public function normalizeLayout(array $layout): array
{
$out = [];
$preset = $layout['preset'] ?? 'custom';
if (is_string($preset) && in_array($preset, ['default', 'minimal', 'compact', 'custom'], true)) {
$out['preset'] = $preset;
} else {
$out['preset'] = 'custom';
}
$widgets = [];
foreach ((array) ($layout['widgets'] ?? []) as $entry) {
if (!is_array($entry) || empty($entry['id']) || !is_string($entry['id'])) {
continue;
}
$size = $entry['size'] ?? null;
$widgets[] = [
'id' => $entry['id'],
'visible' => array_key_exists('visible', $entry) ? (bool) $entry['visible'] : true,
'size' => is_string($size) && in_array($size, self::VALID_SIZES, true) ? $size : null,
'order' => isset($entry['order']) ? (int) $entry['order'] : 0,
];
}
$out['widgets'] = $widgets;
return $out;
}
/**
* @param array<int, array<string, mixed>> $entries
* @return array<string, array<string, mixed>>
*/
private function indexEntries(mixed $entries): array
{
if (!is_array($entries)) {
return [];
}
$indexed = [];
foreach ($entries as $entry) {
if (is_array($entry) && !empty($entry['id']) && is_string($entry['id'])) {
$indexed[$entry['id']] = $entry;
}
}
return $indexed;
}
/**
* Resolve the absolute path to user/config/admin-next.yaml.
*/
private function siteConfigFilePath(bool $createDir = false): ?string
{
$locator = $this->grav['locator'] ?? null;
if ($locator === null) {
return null;
}
$userConfigDir = $locator->findResource('user://config', true) ?: null;
if ($userConfigDir === null) {
$userPath = $locator->findResource('user://', true);
if ($userPath && $createDir) {
$userConfigDir = $userPath . '/config';
if (!is_dir($userConfigDir)) {
mkdir($userConfigDir, 0775, true);
}
}
}
if (!$userConfigDir) {
return null;
}
return $userConfigDir . '/' . self::SITE_CONFIG_FILE;
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
use Grav\Common\Utils;
use Grav\Common\Yaml;
/**
* Index of translation keys contributed exclusively by disabled plugins, keyed
* by language code.
*
* Grav core's `Languages::flattenByLang()` reads every plugin's lang yaml
* regardless of whether the plugin is enabled fine for legacy admin, broken
* for admin2 where a disabled plugin (most notably admin classic, mid-migration
* on Grav 2 sites) would otherwise leak its strings into both the dictionary
* served to the SPA and the server-side blueprint label resolver.
*
* This service walks `user/plugins/<name>/languages/<lang>.yaml` and
* `user/plugins/<name>/languages.yaml` (single-file multi-lang format), buckets
* keys by enabled-vs-disabled provenance, and returns the keys present only in
* the disabled bucket. Keys also contributed by an enabled plugin are kept
* the enabled plugin owns them, even if a disabled plugin happens to ship the
* same key.
*
* The result is cached per-language for the request lifecycle since the
* underlying YAML files don't change mid-request.
*/
final class DisabledPluginLangIndex
{
/** @var array<string, array<int, string>> */
private array $cache = [];
public function __construct(private readonly Grav $grav)
{
}
/**
* @return array<int, string> flat translation keys (e.g. `PLUGIN_ADMIN.ADD_FOLDER`)
*/
public function disabledOnlyKeys(string $lang): array
{
if (isset($this->cache[$lang])) {
return $this->cache[$lang];
}
$plugins = $this->grav['plugins'];
$config = $this->grav['config'];
$locator = $this->grav['locator'];
$enabled = [];
$disabled = [];
foreach ($plugins as $plugin) {
$name = $plugin->name;
$resolved = $locator->findResource("plugin://{$name}");
if (!$resolved || !is_dir($resolved)) {
continue;
}
$keys = $this->collectPluginLangKeys($resolved, $lang);
if (empty($keys)) {
continue;
}
$isEnabled = (bool) $config->get("plugins.{$name}.enabled", false);
foreach ($keys as $k) {
if ($isEnabled) {
$enabled[$k] = true;
} else {
$disabled[$k] = true;
}
}
}
$result = array_keys(array_diff_key($disabled, $enabled));
$this->cache[$lang] = $result;
return $result;
}
/**
* True if `$key` is contributed only by disabled plugins for `$lang`.
*/
public function isDisabledOnly(string $key, string $lang): bool
{
return in_array($key, $this->disabledOnlyKeys($lang), true);
}
/**
* @return array<int, string>
*/
private function collectPluginLangKeys(string $pluginDir, string $lang): array
{
$keys = [];
$perLangFile = "{$pluginDir}/languages/{$lang}.yaml";
if (is_file($perLangFile)) {
$data = $this->safeParseYaml($perLangFile);
if (is_array($data)) {
foreach (array_keys(Utils::arrayFlattenDotNotation($data)) as $k) {
$keys[$k] = true;
}
}
}
$singleFile = "{$pluginDir}/languages.yaml";
if (is_file($singleFile)) {
$data = $this->safeParseYaml($singleFile);
$langData = is_array($data) ? ($data[$lang] ?? null) : null;
if (is_array($langData)) {
foreach (array_keys(Utils::arrayFlattenDotNotation($langData)) as $k) {
$keys[$k] = true;
}
}
}
return array_keys($keys);
}
private function safeParseYaml(string $file): mixed
{
try {
return Yaml::parse(file_get_contents($file));
} catch (\Throwable) {
return null;
}
}
}
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
/**
* Resolves environment folders for config writes.
*
* The base write target is always user/config/. Named environments live in
* user/env/<name>/ (preferred) or legacy user/<name>/ layouts from Grav 1.6.
* We never auto-create env folders they must be opted into via the
* environments API.
*/
class EnvironmentService
{
private const RESERVED_USER_DIRS = [
'accounts', 'blueprints', 'config', 'data', 'env',
'images', 'languages', 'media', 'pages', 'plugins', 'themes',
];
/**
* Names the admin uses as the "base / no overlay" sentinel. The admin-next
* environment switcher maps its base ("Default") selection to the env name
* `default` for X-Grav-Environment, relying on there being no
* user/env/default/ folder so Grav resolves config base-only (Setup empties
* the environment:// stream for a non-existent env dir). Allowing an env
* folder with one of these names would let an overlay silently shadow the
* base-only view, so we refuse to create them.
*/
private const RESERVED_ENV_NAMES = ['default', 'base'];
public function __construct(private Grav $grav)
{
}
/**
* Absolute path to an env's config dir, or null if it doesn't exist.
* Checks user/env/<name>/config first, then legacy user/<name>/config.
*/
public function envConfigRoot(string $name): ?string
{
$userRoot = $this->userRoot();
if ($userRoot === null) return null;
foreach ([
$userRoot . '/env/' . $name . '/config',
$userRoot . '/' . $name . '/config',
] as $dir) {
if (is_dir($dir)) return $dir;
}
return null;
}
/**
* List existing env folder names user/env/* plus legacy user/<host>/
* that have a config/ subdir. Sorted, case-insensitive natural order.
*
* @return string[]
*/
public function listEnvironments(): array
{
$names = [];
$userRoot = $this->userRoot();
if ($userRoot === null) return $names;
$envDir = $userRoot . '/env';
if (is_dir($envDir)) {
foreach (new \DirectoryIterator($envDir) as $item) {
if ($item->isDot() || !$item->isDir()) continue;
$names[$item->getFilename()] = true;
}
}
foreach (new \DirectoryIterator($userRoot) as $item) {
if ($item->isDot() || !$item->isDir()) continue;
$n = $item->getFilename();
if (in_array($n, self::RESERVED_USER_DIRS, true) || str_starts_with($n, '.')) continue;
if (is_dir($item->getPathname() . '/config')) {
$names[$n] = true;
}
}
$names = array_keys($names);
sort($names, SORT_NATURAL | SORT_FLAG_CASE);
return $names;
}
/**
* The environment Grav is currently loading config under, if any, AND only
* when that env has a config dir on disk. Used by the config-write path so
* saves land where reads come from otherwise an active env overlay can
* silently shadow a write to base.
*
* The env Grav actually booted its overlay under (`Setup::$environment`) is
* authoritative. Behind a reverse proxy that is the REAL connection host
* e.g. `localhost` via `SERVER_NAME` captured at boot, whereas
* `$uri->environment()` reflects the FORWARDED host (e.g.
* `translations.rhuk.net`) and so names an env whose overlay was never
* loaded. We therefore trust the booted env first: if it has a config dir
* that overlay is live, so return it; if it doesn't, no overlay is active
* and base is correct (return null) we must NOT fall through to a
* forwarded-host env that isn't actually loaded. The Uri is consulted only
* when the booted env is unknown (non-standard bootstrap, or unit tests).
*
* Returns null when no env is active, the env name is malformed, or the
* active env has no config dir (in which case base writes are correct).
*/
public function activeEnvironment(): ?string
{
$booted = $this->bootedEnvironment();
if ($booted !== null) {
return $this->envConfigRoot($booted) !== null ? $booted : null;
}
$name = $this->uriEnvironment();
if ($name === null) {
return null;
}
return $this->envConfigRoot($name) !== null ? $name : null;
}
/**
* The environment Grav resolved at boot (`Setup::$environment`), normalized.
* This is the env whose config overlay is actually loaded for the request.
* Null when the static is unset/malformed or Grav core isn't available.
*/
private function bootedEnvironment(): ?string
{
if (!class_exists(\Grav\Common\Config\Setup::class)) {
return null;
}
$name = \Grav\Common\Config\Setup::$environment;
return is_string($name) && $name !== '' && self::isValidName($name) ? $name : null;
}
/**
* The environment derived from the Grav Uri service (the request host, with
* forwarded-host handling applied). Defensive fallback only see
* {@see activeEnvironment()}.
*/
private function uriEnvironment(): ?string
{
$uri = $this->grav['uri'] ?? null;
if (!is_object($uri) || !method_exists($uri, 'environment')) {
return null;
}
$name = $uri->environment();
return is_string($name) && $name !== '' && self::isValidName($name) ? $name : null;
}
public function envHasOverrides(string $name): bool
{
$root = $this->envConfigRoot($name);
if ($root === null) return false;
foreach (new \FilesystemIterator($root) as $_) {
return true;
}
return false;
}
/**
* Create a new env/<name>/config/ folder. Returns the created config dir.
* Throws \InvalidArgumentException on invalid names and \RuntimeException on fs failure.
*/
public function createEnvironment(string $name): string
{
if (!self::isValidName($name)) {
throw new \InvalidArgumentException("Invalid environment name '{$name}'.");
}
if (in_array(strtolower($name), self::RESERVED_ENV_NAMES, true)) {
throw new \InvalidArgumentException("Environment name '{$name}' is reserved for the base configuration.");
}
if (in_array($name, $this->listEnvironments(), true)) {
throw new \InvalidArgumentException("Environment '{$name}' already exists.");
}
$userRoot = $this->userRoot();
if ($userRoot === null) {
throw new \RuntimeException('user:// path not resolvable.');
}
$configDir = $userRoot . '/env/' . $name . '/config';
if (!mkdir($configDir, 0775, true) && !is_dir($configDir)) {
throw new \RuntimeException("Failed to create environment directory: {$configDir}");
}
return $configDir;
}
/**
* Delete an env folder (user/env/<name>/) and everything under it.
*
* Refuses to act on legacy user/<name>/ layouts (Grav 1.6 fallback) because
* those directory names overlap freely with user-managed paths, so removing
* them carries too much blast radius. Operators must clean those up by hand.
* Refuses to delete the env Grav resolved for the current request so the
* running session can't have its config yanked out from under it.
*
* Throws \InvalidArgumentException on validation failures and \RuntimeException
* on filesystem failures.
*/
public function deleteEnvironment(string $name): void
{
if (!self::isValidName($name)) {
throw new \InvalidArgumentException("Invalid environment name '{$name}'.");
}
if ($name === $this->activeEnvironment()) {
throw new \InvalidArgumentException(
"Cannot delete environment '{$name}': it is the active environment for this request."
);
}
$userRoot = $this->userRoot();
if ($userRoot === null) {
throw new \RuntimeException('user:// path not resolvable.');
}
$modernDir = $userRoot . '/env/' . $name;
$legacyDir = $userRoot . '/' . $name;
if (!is_dir($modernDir)) {
if (is_dir($legacyDir) && is_dir($legacyDir . '/config')) {
throw new \InvalidArgumentException(
"Environment '{$name}' uses the legacy user/{$name}/ layout. "
. "Remove it manually so unrelated files are not deleted."
);
}
throw new \InvalidArgumentException("Environment '{$name}' does not exist.");
}
// Guard against symlink escape: the resolved path must still live under
// user/env/. If something has replaced user/env/<name>/ with a symlink
// pointing elsewhere, we refuse rather than recursively delete outside
// the user tree.
$real = realpath($modernDir);
$envRootReal = realpath($userRoot . '/env');
if ($real === false || $envRootReal === false || !str_starts_with($real, $envRootReal . DIRECTORY_SEPARATOR)) {
throw new \RuntimeException("Refusing to delete '{$modernDir}': path resolves outside user/env/.");
}
self::rmrf($real);
}
public static function isValidName(string $name): bool
{
return $name !== '' && (bool)preg_match('/^[a-z0-9][a-z0-9._-]*$/i', $name);
}
/**
* Whether a name is the admin's base/"no overlay" sentinel (`default` /
* `base`). Such names are refused as env folders, and the config write path
* treats them as a base (user/config) write target.
*/
public static function isReservedName(string $name): bool
{
return in_array(strtolower($name), self::RESERVED_ENV_NAMES, true);
}
private static function rmrf(string $dir): void
{
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iter as $entry) {
/** @var \SplFileInfo $entry */
if ($entry->isDir() && !$entry->isLink()) {
rmdir($entry->getPathname());
} else {
unlink($entry->getPathname());
}
}
rmdir($dir);
}
private function userRoot(): ?string
{
$root = $this->grav['locator']->findResource('user://', true);
return $root !== false && is_string($root) ? $root : null;
}
}
@@ -0,0 +1,514 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Cache;
use Grav\Common\Filesystem\Folder;
use Grav\Common\GPM\Common\Package;
use Grav\Common\GPM\GPM as GravGPM;
use Grav\Common\GPM\Installer;
use Grav\Common\GPM\Licenses;
use Grav\Common\GPM\Upgrader;
use Grav\Common\Grav;
use Grav\Common\HTTP\Response;
use Grav\Common\Utils;
/**
* GpmService GPM write operations (install / update / remove / direct-install / self-upgrade).
*
* This is a port of Grav\Plugin\Admin\Gpm that removes the dependency on the
* classic admin plugin so admin-next / admin2 users can manage packages
* without needing the old admin plugin installed.
*
* Admin-specific callsites (Admin::translate, Admin::getTempDir) have been
* replaced with inlined English strings and a local temp-dir resolver.
*/
class GpmService
{
/** @var GravGPM|null */
protected static ?GravGPM $GPM = null;
/** @var string|null Raw installer error captured during the last selfupgrade(). */
protected static ?string $lastError = null;
/** @var array<string, mixed>|null Preflight report captured during the last selfupgrade(). */
protected static ?array $lastPreflightReport = null;
/**
* Default options for install operations.
*
* @var array<string, mixed>
*/
protected static array $options = [
'destination' => GRAV_ROOT,
'overwrite' => true,
'ignore_symlinks' => true,
'skip_invalid' => true,
'install_deps' => false,
'theme' => false,
];
public static function GPM(): GravGPM
{
if (self::$GPM === null) {
self::$GPM = new GravGPM();
}
return self::$GPM;
}
/**
* Install one or more packages.
*
* @param Package[]|string[]|string $packages
* @param array<string, mixed> $options
* @return string|bool
*/
public static function install($packages, array $options)
{
$options = array_merge(self::$options, $options);
if (!Installer::isGravInstance($options['destination']) || !Installer::isValidDestination($options['destination'],
[Installer::EXISTS, Installer::IS_LINK])
) {
return false;
}
$packages = is_array($packages) ? $packages : [$packages];
$count = count($packages);
$packages = array_filter(array_map(static function ($p) {
return !is_string($p) ? ($p instanceof Package ? $p : false) : self::GPM()->findPackage($p);
}, $packages));
if (!$options['skip_invalid'] && $count !== count($packages)) {
return false;
}
$messages = '';
foreach ($packages as $package) {
// Dependency resolution is the caller's responsibility (see
// GpmController::install / ::update which use GPM::getDependencies()).
// The blueprint `dependencies` structure is a list of
// ['name' => slug, 'version' => constraint] entries, not slugs or
// Package objects, so it can't be passed back into install().
Installer::isValidDestination($options['destination'] . DS . $package->install_path);
if (!$options['overwrite'] && Installer::lastErrorCode() === Installer::EXISTS) {
return false;
}
if (!$options['ignore_symlinks'] && Installer::lastErrorCode() === Installer::IS_LINK) {
return false;
}
$license = Licenses::get($package->slug);
$local = static::download($package, $license);
Installer::install(
$local,
$options['destination'],
['install_path' => $package->install_path, 'theme' => $options['theme']]
);
Folder::delete(dirname($local));
$errorCode = Installer::lastErrorCode();
if ($errorCode) {
throw new \RuntimeException(Installer::lastErrorMsg());
}
if (count($packages) === 1) {
$message = Installer::getMessage();
if ($message) {
return $message;
}
$messages .= $message;
}
}
Cache::clearCache();
return $messages !== '' ? $messages : true;
}
/**
* Update one or more packages.
*
* @param Package[]|string[]|string $packages
* @param array<string, mixed> $options
* @return string|bool
*/
public static function update($packages, array $options)
{
$options['overwrite'] = true;
return static::install($packages, $options);
}
/**
* Uninstall one or more packages.
*
* @param Package[]|string[]|string $packages
* @param array<string, mixed> $options
* @return string|bool
*/
public static function uninstall($packages, array $options)
{
$options = array_merge(self::$options, $options);
$packages = (array) $packages;
$count = count($packages);
$packages = array_filter(array_map(static function ($p) {
if (is_string($p)) {
$p = strtolower($p);
$plugin = self::GPM()->getInstalledPlugin($p);
$p = $plugin ?: self::GPM()->getInstalledTheme($p);
}
return $p instanceof Package ? $p : false;
}, $packages));
if (!$options['skip_invalid'] && $count !== count($packages)) {
return false;
}
foreach ($packages as $package) {
$location = Grav::instance()['locator']->findResource($package->package_type . '://' . $package->slug);
Installer::isValidDestination($location);
if (!$options['ignore_symlinks'] && Installer::lastErrorCode() === Installer::IS_LINK) {
return false;
}
Installer::uninstall($location);
$errorCode = Installer::lastErrorCode();
if ($errorCode && $errorCode !== Installer::IS_LINK && $errorCode !== Installer::EXISTS) {
throw new \RuntimeException(Installer::lastErrorMsg());
}
if (count($packages) === 1) {
$message = Installer::getMessage();
if ($message) {
return $message;
}
}
}
Cache::clearCache();
return true;
}
/**
* Install a package directly from a local zip or remote URL.
*
* @param string $packageFile
* @return string|bool
*/
public static function directInstall(string $packageFile)
{
if ($packageFile === '') {
return 'No package file provided.';
}
$tmpDir = static::getTempDir();
$tmpZip = $tmpDir . '/Grav-' . uniqid('', false);
if (Response::isRemote($packageFile)) {
$zip = GravGPM::downloadPackage($packageFile, $tmpZip);
} else {
$zip = GravGPM::copyPackage($packageFile, $tmpZip);
}
if (!file_exists($zip)) {
return 'Zip package not found.';
}
$tmpSource = $tmpDir . '/Grav-' . uniqid('', false);
$extracted = Installer::unZip($zip, $tmpSource);
if (!$extracted) {
Folder::delete($tmpSource);
Folder::delete($tmpZip);
return 'Package extraction failed.';
}
$type = GravGPM::getPackageType($extracted);
if (!$type) {
Folder::delete($tmpSource);
Folder::delete($tmpZip);
return 'Not a valid Grav package.';
}
if ($type === 'grav') {
Installer::isValidDestination(GRAV_ROOT . '/system');
if (Installer::IS_LINK === Installer::lastErrorCode()) {
Folder::delete($tmpSource);
Folder::delete($tmpZip);
return 'Cannot overwrite symlinks.';
}
static::upgradeGrav($zip, $extracted);
} else {
$name = GravGPM::getPackageName($extracted);
if (!$name) {
Folder::delete($tmpSource);
Folder::delete($tmpZip);
return 'Package name could not be determined.';
}
$installPath = GravGPM::getInstallPath($type, $name);
$isUpdate = file_exists($installPath);
Installer::isValidDestination(GRAV_ROOT . DS . $installPath);
if (Installer::lastErrorCode() === Installer::IS_LINK) {
Folder::delete($tmpSource);
Folder::delete($tmpZip);
return 'Cannot overwrite symlinks.';
}
Installer::install(
$zip,
GRAV_ROOT,
['install_path' => $installPath, 'theme' => $type === 'theme', 'is_update' => $isUpdate],
$extracted
);
}
Folder::delete($tmpSource);
if (Installer::lastErrorCode()) {
return Installer::lastErrorMsg();
}
Folder::delete($tmpZip);
Cache::clearCache();
return true;
}
/**
* Self-upgrade Grav core to the latest release.
*
* @param array<string, mixed> $options Supported: 'override' (bool) to bypass
* blocking preflight checks, mirroring the CLI.
* @return bool
*/
public static function selfupgrade(array $options = []): bool
{
static::$lastError = null;
static::$lastPreflightReport = null;
$upgrader = new Upgrader();
if (!Installer::isGravInstance(GRAV_ROOT)) {
static::$lastError = 'Target directory is not a valid Grav instance.';
return false;
}
if (is_link(GRAV_ROOT . DS . 'index.php')) {
Installer::setError(Installer::IS_LINK);
static::$lastError = 'Cannot self-upgrade: index.php is a symlink.';
return false;
}
if (method_exists($upgrader, 'meetsRequirements') &&
method_exists($upgrader, 'minPHPVersion') &&
!$upgrader->meetsRequirements()) {
$error = [];
$error[] = '<p>Grav has increased the minimum PHP requirement.<br />';
$error[] = 'You are currently running PHP <strong>' . phpversion() . '</strong>';
$error[] = ', but PHP <strong>' . $upgrader->minPHPVersion() . '</strong> is required.</p>';
Installer::setError(implode("\n", $error));
static::$lastError = sprintf(
'PHP %s or higher is required; this server runs PHP %s.',
$upgrader->minPHPVersion(),
phpversion()
);
return false;
}
$update = $upgrader->getAssets()['grav-update'];
$tmp = static::getTempDir() . '/Grav-' . uniqid('', false);
$file = static::downloadSelfupgrade($update, $tmp);
$folder = Installer::unZip($file, $tmp . '/zip');
static::upgradeGrav($file, $folder, false, $options);
$errorCode = Installer::lastErrorCode();
Folder::delete($tmp);
$success = !(is_string($errorCode) || ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR)));
// Capture the real reason so the controller can surface it instead of a generic 500.
if (!$success && null === static::$lastError) {
$msg = Installer::lastErrorMsg();
static::$lastError = ('' !== $msg && 'No Error' !== $msg) ? $msg : 'Failed to upgrade Grav core.';
}
return $success;
}
/**
* The raw installer error from the last selfupgrade() attempt, if any.
*/
public static function getLastError(): ?string
{
return static::$lastError;
}
/**
* The preflight report from the last selfupgrade() attempt, if one was generated.
*
* @return array<string, mixed>|null
*/
public static function getLastPreflightReport(): ?array
{
return static::$lastPreflightReport;
}
/**
* Download a GPM package zip into a temp directory.
*/
private static function download(Package $package, ?string $license = null): string
{
$query = '';
if ($package->premium) {
$query = \json_encode(array_merge($package->premium, [
'slug' => $package->slug,
'license_key' => $license,
'sid' => md5(GRAV_ROOT),
]));
$query = '?d=' . base64_encode($query);
}
try {
$contents = Response::get($package->zipball_url . $query, []);
} catch (\Exception $e) {
throw new \RuntimeException($e->getMessage());
}
$tmpDir = static::getTempDir() . '/Grav-' . uniqid('', false);
Folder::mkdir($tmpDir);
$badChars = array_merge(array_map('chr', range(0, 31)), ['<', '>', ':', '"', '/', '\\', '|', '?', '*']);
$filename = $package->slug . str_replace($badChars, '', Utils::basename($package->zipball_url));
$filename = preg_replace('/[\\\\\/:"*?&<>|]+/m', '-', $filename);
file_put_contents($tmpDir . DS . $filename . '.zip', $contents);
return $tmpDir . DS . $filename . '.zip';
}
/**
* Download the Grav self-upgrade zip.
*
* @param array<string, mixed> $package
*/
private static function downloadSelfupgrade(array $package, string $tmp): string
{
$output = Response::get($package['download'], []);
Folder::mkdir($tmp);
file_put_contents($tmp . DS . $package['name'], $output);
return $tmp . DS . $package['name'];
}
/**
* Run the Grav core upgrade install script against an extracted zip.
*/
private static function upgradeGrav(string $zip, string $folder, bool $keepFolder = false, array $options = []): void
{
static $ignores = [
'backup',
'cache',
'images',
'logs',
'tmp',
'user',
'.htaccess',
'robots.txt',
];
if (!is_dir($folder)) {
Installer::setError('Invalid source folder');
return;
}
try {
$script = $folder . '/system/install.php';
if ((file_exists($script) && $install = include $script) && is_callable($install)) {
// Preflight parity with `bin/gpm self-upgrade`: inspect the blocking checks
// and honor an explicit override, rather than failing with an opaque error.
if (is_object($install) && method_exists($install, 'generatePreflightReport')) {
$report = $install->generatePreflightReport();
static::$lastPreflightReport = $report;
if (!empty($report['blocking'] ?? [])) {
if (!empty($options['override'])) {
if (method_exists($install, 'allowIncompatibleOverride')) {
$install::allowIncompatibleOverride(true);
}
if (method_exists($install, 'allowPendingOverride')) {
$install::allowPendingOverride(true);
}
// Recompute so install() reuses an unblocked, cached report.
$report = $install->generatePreflightReport();
static::$lastPreflightReport = $report;
}
if (!empty($report['blocking'] ?? [])) {
Installer::setError('Upgrade preflight checks failed.');
return;
}
}
}
$install($zip);
} else {
Installer::install(
$zip,
GRAV_ROOT,
['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $ignores],
$folder,
$keepFolder
);
Cache::clearCache();
}
} catch (\Throwable $e) {
Installer::setError($e->getMessage());
static::$lastError = $e->getMessage();
}
}
/**
* Resolve a writable temporary directory, falling back to cache/tmp if tmp://
* isn't configured.
*/
private static function getTempDir(): string
{
try {
$tmpDir = Grav::instance()['locator']->findResource('tmp://', true, true);
} catch (\Exception $e) {
$tmpDir = Grav::instance()['locator']->findResource('cache://', true, true) . '/tmp';
}
return $tmpDir;
}
}
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Config\Config;
/**
* Builds a structured password policy from Grav's single `system.pwd_regex`
* string. Admin-next uses the result to render a live rule checklist and
* strength meter without baking assumptions into the UI.
*
* Source of truth order:
* 1. system.pwd_rules (optional, admin-supplied list of labeled rules)
* 2. Heuristic parse of system.pwd_regex (handles the common lookahead form)
* 3. Opaque fallback one generic "must match policy" rule
*
* The combined regex is always returned unchanged for server-side validation.
*/
class PasswordPolicyService
{
public static function build(Config $config): array
{
$regex = (string) $config->get('system.pwd_regex', '');
$rules = self::configuredRules($config);
if ($rules === null) {
$rules = self::parseRules($regex);
}
return [
'regex' => $regex,
'min_length' => self::extractMinLength($regex),
'rules' => $rules,
];
}
/**
* @return list<array{id:string,label:string,pattern:string}>|null
*/
private static function configuredRules(Config $config): ?array
{
$raw = $config->get('system.pwd_rules');
if (!is_array($raw) || $raw === []) {
return null;
}
$out = [];
foreach ($raw as $i => $entry) {
if (!is_array($entry)) continue;
$pattern = (string) ($entry['pattern'] ?? '');
$label = (string) ($entry['label'] ?? '');
if ($pattern === '' || $label === '') continue;
$out[] = [
'id' => (string) ($entry['id'] ?? ('rule_' . $i)),
'label' => $label,
'pattern' => $pattern,
];
}
return $out === [] ? null : $out;
}
/**
* Heuristic parse of the common lookahead form used by Grav's default
* pwd_regex: `(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}`.
*
* @return list<array{id:string,label:string,pattern:string}>
*/
private static function parseRules(string $regex): array
{
$rules = [];
$min = self::extractMinLength($regex);
if ($min > 0) {
$rules[] = [
'id' => 'length',
'label' => sprintf('At least %d characters', $min),
'pattern' => '.{' . $min . ',}',
];
}
$lookaheads = [];
if (preg_match_all('/\(\?=([^)]+)\)/', $regex, $m)) {
$lookaheads = $m[1];
}
$seen = [];
foreach ($lookaheads as $inner) {
$rule = self::classifyLookahead($inner);
if ($rule === null) continue;
if (isset($seen[$rule['id']])) continue;
$seen[$rule['id']] = true;
$rules[] = $rule;
}
if ($rules === []) {
$rules[] = [
'id' => 'policy',
'label' => 'Must match the configured password policy',
'pattern' => $regex !== '' ? $regex : '.+',
];
}
return $rules;
}
/**
* @return array{id:string,label:string,pattern:string}|null
*/
private static function classifyLookahead(string $inner): ?array
{
// Strip the `.*` prefix that typically precedes the character class.
$body = preg_replace('/^\.\*/', '', $inner) ?? $inner;
// Digit: \d or [0-9]
if ($body === '\\d' || preg_match('/^\[0-9\]$/', $body)) {
return ['id' => 'digit', 'label' => 'At least one number', 'pattern' => '\\d'];
}
if ($body === '[a-z]') {
return ['id' => 'lowercase', 'label' => 'At least one lowercase letter', 'pattern' => '[a-z]'];
}
if ($body === '[A-Z]') {
return ['id' => 'uppercase', 'label' => 'At least one uppercase letter', 'pattern' => '[A-Z]'];
}
// Special char — a handful of common forms
$specialForms = ['\\W', '[^\\w]', '[^a-zA-Z0-9]', '[^\\w\\s]'];
if (in_array($body, $specialForms, true) || preg_match('/^\[[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?`~\s]+\]$/', $body)) {
return ['id' => 'symbol', 'label' => 'At least one symbol', 'pattern' => '[^a-zA-Z0-9]'];
}
return null;
}
private static function extractMinLength(string $regex): int
{
if (preg_match('/\.\{(\d+),?\d*\}/', $regex, $m)) {
return (int) $m[1];
}
return 0;
}
}
@@ -0,0 +1,544 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserInterface;
use RocketTheme\Toolbox\File\YamlFile;
/**
* Resolves admin-next UI preferences across three storage tiers:
*
* Tier A Site branding (logo + text), stored in user/config/admin-next.yaml
* under `ui.branding`. No per-user override (uniform brand).
*
* Tier B Site default + per-user override (theme, accent, fonts, editor
* mode, auto-save, collab, language, page-list size). Defaults in
* `ui.defaults`; user overrides under `admin_next.preferences` in
* the account YAML. A user override of `null` removes that key.
*
* Tier C Per-user synced (currently `menubarLinks`). No site default;
* same per-user storage as Tier B.
*
* Device-local UI state (sidebar collapse, page list view mode, etc.) is NOT
* managed here; the SPA keeps that in localStorage.
*/
class PreferencesResolver
{
public const SITE_CONFIG_FILE = 'admin-next.yaml';
private const VALID_COLOR_MODE = ['', 'light', 'dark'];
private const VALID_FONT_FAMILY = ['inter', 'google-sans', 'public-sans', 'nunito-sans', 'jost'];
private const VALID_FONT_SIZE = ['small', 'normal', 'large', 'xlarge'];
private const VALID_EDITOR_MODE = ['normal', 'expert'];
private const VALID_LOGO_MODE = ['default', 'text', 'custom'];
private const VALID_PAGES_VIEW_MODE = ['tree', 'list', 'miller'];
private const VALID_ACCOUNTS_VIEW_MODE = ['cards', 'table'];
public function __construct(
private readonly Grav $grav,
) {}
/**
* Tier B built-in baseline keys that can be overridden per-user. Used
* when neither site nor user has set a value.
*
* @return array<string, mixed>
*/
public function defaultPreferences(): array
{
return [
'colorMode' => '',
'accentHue' => 271,
'accentSaturation' => 91,
'fontFamily' => 'google-sans',
'fontSize' => 'normal',
'editorMode' => 'normal',
'editorStickyToolbar' => true,
'editorFixedHeight' => 0,
'adminLanguage' => 'en',
'pagesPerPage' => 20,
'pagesViewMode' => 'tree',
'usersViewMode' => 'cards',
'groupsViewMode' => 'cards',
'pluginsViewMode' => 'cards',
'themesViewMode' => 'cards',
];
}
/**
* Tier A2 built-in baseline site-only behavioral settings that are
* not user-overridable (auto-save, real-time collab, menubar links).
* The admin sets these once for everyone.
*
* @return array<string, mixed>
*/
public function defaultSiteSettings(): array
{
return [
'autoSaveEnabled' => false,
'autoSaveToolbarUndo' => true,
'autoSaveBatchWindowMs' => 0,
'collabEnabled' => true,
'menubarLinks' => [],
];
}
/**
* @return array<string, mixed>
*/
public function defaultBranding(): array
{
return [
'mode' => 'default',
'text' => 'Grav',
'logoLight' => '',
'logoDark' => '',
];
}
/**
* @return array<string, mixed>
*/
public function siteBranding(): array
{
$ui = $this->readSiteUiBlock();
$raw = is_array($ui['branding'] ?? null) ? $ui['branding'] : [];
return $this->normalizeBranding($raw, $this->defaultBranding());
}
/**
* @return array<string, mixed>
*/
public function sitePreferences(): array
{
$ui = $this->readSiteUiBlock();
$raw = is_array($ui['defaults'] ?? null) ? $ui['defaults'] : [];
return $this->normalizePreferences($raw, $this->defaultPreferences(), strict: false);
}
/**
* @return array<string, mixed>
*/
public function siteSettings(): array
{
$ui = $this->readSiteUiBlock();
$raw = is_array($ui['settings'] ?? null) ? $ui['settings'] : [];
return $this->normalizeSiteSettings($raw, $this->defaultSiteSettings(), strict: false);
}
/**
* Read the user's saved overrides from their account YAML.
*
* Stored under `admin_next.preferences`. Sits next to `admin_next.dashboard`
* which is owned by DashboardLayoutResolver the two are independent.
*
* @return array<string, mixed>
*/
public function userPreferences(UserInterface $user): array
{
$adminNext = $user->get('admin_next');
if (!is_array($adminNext)) {
return [];
}
$prefs = $adminNext['preferences'] ?? [];
return is_array($prefs) ? $prefs : [];
}
/**
* Return the full resolved preferences payload for the SPA.
*
* @return array{
* branding: array<string, mixed>,
* site: array<string, mixed>,
* user: array<string, mixed>,
* effective: array<string, mixed>,
* can_edit_site: bool
* }
*/
public function resolve(UserInterface $user, bool $canEditSite): array
{
$defaults = $this->defaultPreferences();
$site = $this->sitePreferences();
$userPrefs = $this->userPreferences($user);
$siteSettings = $this->siteSettings();
// Tier B resolution: built-in defaults ⊕ site defaults ⊕ user overrides.
$effective = array_replace($defaults, $site);
foreach ($userPrefs as $key => $value) {
if ($value === null || !array_key_exists($key, $defaults)) {
continue;
}
$effective[$key] = $value;
}
// Tier A2 site-only behavioral settings are applied last and are not
// user-overridable. Merging them into `effective` lets the SPA read
// every applicable value from one map.
$effective = array_replace($effective, $siteSettings);
return [
'branding' => $this->siteBranding(),
'site' => $site,
'site_settings' => $siteSettings,
'user' => $userPrefs,
'effective' => $effective,
'can_edit_site' => $canEditSite,
];
}
/**
* Persist site-wide defaults. Replaces the entire `ui.defaults` block.
*
* @param array<string, mixed> $payload
*/
public function saveSitePreferences(array $payload): void
{
$normalized = $this->normalizePreferences($payload, $this->defaultPreferences(), strict: true);
$this->writeSiteUiKey('defaults', $normalized);
}
/**
* Persist site branding. Replaces the entire `ui.branding` block.
*
* @param array<string, mixed> $payload
*/
public function saveSiteBranding(array $payload): void
{
$normalized = $this->normalizeBranding($payload, $this->defaultBranding());
$this->writeSiteUiKey('branding', $normalized);
}
/**
* Persist site-only Tier A2 settings (auto-save, collab, menubar links).
* Patch semantics: only keys present in the payload are written; others
* are read from the existing yaml so callers can update a subset.
*
* @param array<string, mixed> $payload
*/
public function saveSiteSettings(array $payload): void
{
$merged = array_replace($this->siteSettings(), $payload);
$normalized = $this->normalizeSiteSettings($merged, $this->defaultSiteSettings(), strict: true);
$this->writeSiteUiKey('settings', $normalized);
}
/**
* Patch the current user's overrides.
*
* Semantics: keys with `null` values are removed from the override map
* (i.e. "reset to site default"). Keys not present in the payload are
* left alone. Pass an explicit empty array to clear an override list
* (e.g. `menubarLinks: []`).
*
* @param array<string, mixed> $payload
*/
public function saveUserPreferences(UserInterface $user, array $payload): void
{
$current = $this->userPreferences($user);
$whitelist = $this->userKeyWhitelist();
foreach ($payload as $key => $value) {
if (!in_array($key, $whitelist, true)) {
continue;
}
if ($value === null) {
unset($current[$key]);
continue;
}
$coerced = $this->coerceValue($key, $value);
if ($coerced === null) {
// Invalid input — silently drop rather than corrupt the file.
continue;
}
$current[$key] = $coerced;
}
$adminNext = $user->get('admin_next');
$adminNext = is_array($adminNext) ? $adminNext : [];
if ($current === []) {
unset($adminNext['preferences']);
} else {
$adminNext['preferences'] = $current;
}
$user->set('admin_next', $adminNext);
$user->save();
}
/**
* Clear ALL user overrides used by "Reset to site defaults" in the UI.
*/
public function clearUserPreferences(UserInterface $user): void
{
$adminNext = $user->get('admin_next');
if (!is_array($adminNext)) {
return;
}
unset($adminNext['preferences']);
$user->set('admin_next', $adminNext);
$user->save();
}
/**
* Resolve `user://media/admin-next/` and ensure it exists if requested.
*/
public function brandingMediaDir(bool $createDir = false): ?string
{
$locator = $this->grav['locator'] ?? null;
if ($locator === null) {
return null;
}
$base = $locator->findResource('user://', true);
if (!$base) {
return null;
}
$dir = $base . '/media/admin-next';
if (!is_dir($dir)) {
if (!$createDir) {
return $dir;
}
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
return null;
}
}
return $dir;
}
/**
* Public URL fragment a logo path resolves to, relative to the site root.
* Returns empty string for empty/missing paths so the SPA can treat that
* as "fall back to built-in logo".
*/
public function brandingMediaUrl(string $filename): string
{
$filename = trim($filename);
if ($filename === '') {
return '';
}
// Strip any leading slashes / path traversal; we only store basenames.
$filename = basename($filename);
return '/user/media/admin-next/' . $filename;
}
/**
* Whitelist of keys the user may override (Tier B only Tier A2 are
* site-only and rejected here).
*
* @return array<int, string>
*/
private function userKeyWhitelist(): array
{
return array_keys($this->defaultPreferences());
}
/**
* @param array<string, mixed> $input
* @param array<string, mixed> $defaults
* @return array<string, mixed>
*/
private function normalizePreferences(array $input, array $defaults, bool $strict): array
{
$out = $strict ? [] : $defaults;
foreach ($defaults as $key => $defaultValue) {
if (!array_key_exists($key, $input)) {
continue;
}
$coerced = $this->coerceValue($key, $input[$key]);
if ($coerced === null) {
// Bad value — fall back to default in non-strict mode, drop in strict.
if (!$strict) {
$out[$key] = $defaultValue;
}
continue;
}
$out[$key] = $coerced;
}
return $out;
}
/**
* @param array<string, mixed> $input
* @param array<string, mixed> $defaults
* @return array<string, mixed>
*/
private function normalizeSiteSettings(array $input, array $defaults, bool $strict): array
{
$out = $strict ? [] : $defaults;
foreach ($defaults as $key => $defaultValue) {
if (!array_key_exists($key, $input)) {
continue;
}
if ($key === 'menubarLinks') {
$out[$key] = $this->normalizeMenubarLinks($input[$key]);
continue;
}
$coerced = $this->coerceValue($key, $input[$key]);
if ($coerced === null) {
if (!$strict) {
$out[$key] = $defaultValue;
}
continue;
}
$out[$key] = $coerced;
}
return $out;
}
/**
* Coerce a single Tier-B key to its valid type, or return null if the
* value cannot be coerced. `null` from this method always means "reject".
*/
private function coerceValue(string $key, mixed $value): mixed
{
return match ($key) {
'colorMode' => is_string($value) && in_array($value, self::VALID_COLOR_MODE, true) ? $value : null,
'accentHue' => is_numeric($value) ? max(0, min(360, (int) $value)) : null,
'accentSaturation' => is_numeric($value) ? max(0, min(100, (int) $value)) : null,
'fontFamily' => is_string($value) && in_array($value, self::VALID_FONT_FAMILY, true) ? $value : null,
'fontSize' => is_string($value) && in_array($value, self::VALID_FONT_SIZE, true) ? $value : null,
'editorMode' => is_string($value) && in_array($value, self::VALID_EDITOR_MODE, true) ? $value : null,
'editorStickyToolbar', 'autoSaveEnabled', 'autoSaveToolbarUndo', 'collabEnabled' => is_bool($value) ? $value : (is_scalar($value) ? (bool) $value : null),
// 0 = auto-grow (disabled); any other value is clamped to a sane fixed-height range.
'editorFixedHeight' => is_numeric($value) ? (((int) $value) <= 0 ? 0 : max(300, min(1200, (int) $value))) : null,
'autoSaveBatchWindowMs' => is_numeric($value) ? max(0, (int) $value) : null,
'adminLanguage' => is_string($value) && $value !== '' ? substr($value, 0, 32) : null,
'pagesPerPage' => is_numeric($value) ? max(1, min(200, (int) $value)) : null,
'pagesViewMode' => is_string($value) && in_array($value, self::VALID_PAGES_VIEW_MODE, true) ? $value : null,
'usersViewMode', 'groupsViewMode', 'pluginsViewMode', 'themesViewMode' => is_string($value) && in_array($value, self::VALID_ACCOUNTS_VIEW_MODE, true) ? $value : null,
default => null,
};
}
/**
* @param array<string, mixed> $input
* @param array<string, mixed> $defaults
* @return array<string, mixed>
*/
private function normalizeBranding(array $input, array $defaults): array
{
$mode = $input['mode'] ?? $defaults['mode'];
if (!is_string($mode) || !in_array($mode, self::VALID_LOGO_MODE, true)) {
$mode = $defaults['mode'];
}
$text = $input['text'] ?? $defaults['text'];
if (!is_string($text)) {
$text = $defaults['text'];
}
$text = trim($text);
if ($text === '') {
$text = $defaults['text'];
}
return [
'mode' => $mode,
'text' => substr($text, 0, 64),
'logoLight' => $this->sanitizeLogoPath($input['logoLight'] ?? ''),
'logoDark' => $this->sanitizeLogoPath($input['logoDark'] ?? ''),
];
}
private function sanitizeLogoPath(mixed $value): string
{
if (!is_string($value) || $value === '') {
return '';
}
// Store only the basename; resolver controls the directory.
$name = basename(trim($value));
if (str_contains($name, '..') || str_contains($name, "\0") || str_starts_with($name, '.')) {
return '';
}
return $name;
}
/**
* @param mixed $value
* @return array<int, array<string, mixed>>
*/
private function normalizeMenubarLinks(mixed $value): array
{
if (!is_array($value)) {
return [];
}
$out = [];
foreach ($value as $entry) {
if (!is_array($entry)) {
continue;
}
$label = is_string($entry['label'] ?? null) ? trim($entry['label']) : '';
$url = is_string($entry['url'] ?? null) ? trim($entry['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$link = ['label' => substr($label, 0, 64), 'url' => substr($url, 0, 512)];
if (isset($entry['icon']) && is_string($entry['icon']) && $entry['icon'] !== '') {
$link['icon'] = substr($entry['icon'], 0, 64);
}
if (isset($entry['external'])) {
$link['external'] = (bool) $entry['external'];
}
$out[] = $link;
}
return $out;
}
/**
* @return array<string, mixed>
*/
private function readSiteUiBlock(): array
{
$path = $this->siteConfigFilePath();
if (!$path || !is_file($path)) {
return [];
}
$content = (array) YamlFile::instance($path)->content();
return is_array($content['ui'] ?? null) ? $content['ui'] : [];
}
/**
* @param array<string, mixed> $value
*/
private function writeSiteUiKey(string $key, array $value): void
{
$path = $this->siteConfigFilePath(true);
if (!$path) {
throw new \RuntimeException('Unable to resolve user/config path for admin-next.yaml.');
}
$file = YamlFile::instance($path);
$content = (array) $file->content();
$content['ui'] = is_array($content['ui'] ?? null) ? $content['ui'] : [];
$content['ui'][$key] = $value;
$file->content($content);
$file->save();
$config = $this->grav['config'] ?? null;
if ($config) {
$config->set('admin-next.ui.' . $key, $value);
}
}
/**
* Mirror of DashboardLayoutResolver::siteConfigFilePath() so the two
* resolvers stay loosely coupled. Resolves to user/config/admin-next.yaml.
*/
private function siteConfigFilePath(bool $createDir = false): ?string
{
$locator = $this->grav['locator'] ?? null;
if ($locator === null) {
return null;
}
$userConfigDir = $locator->findResource('user://config', true) ?: null;
if ($userConfigDir === null) {
$userPath = $locator->findResource('user://', true);
if ($userPath && $createDir) {
$userConfigDir = $userPath . '/config';
if (!is_dir($userConfigDir)) {
mkdir($userConfigDir, 0775, true);
}
}
}
if (!$userConfigDir) {
return null;
}
return $userConfigDir . '/' . self::SITE_CONFIG_FILE;
}
}
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
class ThumbnailService
{
private string $cacheDir;
private int $maxSize;
private int $quality;
public function __construct(string $cacheDir, int $maxSize = 500, int $quality = 85)
{
$this->cacheDir = rtrim($cacheDir, '/');
$this->maxSize = $maxSize;
$this->quality = $quality;
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
}
/**
* Get the hash for a thumbnail based on source path and modification time.
*/
public function getHash(string $sourcePath): string
{
$mtime = file_exists($sourcePath) ? filemtime($sourcePath) : 0;
return md5($sourcePath . '|' . $mtime . '|' . $this->maxSize);
}
/**
* Get the thumbnail filename (hash.ext) for a source image.
* Returns null if not a supported image.
*/
public function getThumbnailFilename(string $sourcePath): ?string
{
if (!file_exists($sourcePath)) {
return null;
}
$mime = mime_content_type($sourcePath);
if (!$mime || !str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') {
return null;
}
return $this->getHash($sourcePath) . '.' . $this->getOutputExtension($mime);
}
/**
* Get the cached thumbnail path, generating it if needed.
* Returns null if the source is not a supported image.
*/
public function getThumbnail(string $sourcePath): ?string
{
if (!file_exists($sourcePath)) {
return null;
}
$mime = mime_content_type($sourcePath);
if (!$mime || !str_starts_with($mime, 'image/')) {
return null;
}
// Skip SVGs — serve as-is
if ($mime === 'image/svg+xml') {
return null;
}
$hash = $this->getHash($sourcePath);
$ext = $this->getOutputExtension($mime);
$cachePath = $this->cacheDir . '/' . $hash . '.' . $ext;
if (file_exists($cachePath)) {
return $cachePath;
}
return $this->generate($sourcePath, $cachePath, $mime);
}
/**
* Generate a thumbnail and save to cache.
*/
private function generate(string $sourcePath, string $cachePath, string $mime): ?string
{
$sourceImage = $this->loadImage($sourcePath, $mime);
if (!$sourceImage) {
return null;
}
$origWidth = imagesx($sourceImage);
$origHeight = imagesy($sourceImage);
// Already small enough — cache as-is so we don't re-check every time
if ($origWidth <= $this->maxSize && $origHeight <= $this->maxSize) {
return $this->saveImage($sourceImage, $cachePath, $mime, $origWidth, $origHeight);
}
// Calculate new dimensions maintaining aspect ratio
if ($origWidth >= $origHeight) {
$newWidth = $this->maxSize;
$newHeight = (int) round($origHeight * ($this->maxSize / $origWidth));
} else {
$newHeight = $this->maxSize;
$newWidth = (int) round($origWidth * ($this->maxSize / $origHeight));
}
$thumb = imagecreatetruecolor($newWidth, $newHeight);
if (!$thumb) {
imagedestroy($sourceImage);
return null;
}
// Preserve transparency for PNG/WebP
if ($mime === 'image/png' || $mime === 'image/webp') {
imagealphablending($thumb, false);
imagesavealpha($thumb, true);
$transparent = imagecolorallocatealpha($thumb, 0, 0, 0, 127);
imagefill($thumb, 0, 0, $transparent);
}
imagecopyresampled($thumb, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $origWidth, $origHeight);
imagedestroy($sourceImage);
return $this->saveImage($thumb, $cachePath, $mime, $newWidth, $newHeight);
}
/**
* Load an image resource from file.
*/
private function loadImage(string $path, string $mime): ?\GdImage
{
return match ($mime) {
'image/jpeg' => @imagecreatefromjpeg($path) ?: null,
'image/png' => @imagecreatefrompng($path) ?: null,
'image/gif' => @imagecreatefromgif($path) ?: null,
'image/webp' => @imagecreatefromwebp($path) ?: null,
'image/avif' => function_exists('imagecreatefromavif') ? (@imagecreatefromavif($path) ?: null) : null,
default => null,
};
}
/**
* Save an image resource to the cache path.
*/
private function saveImage(\GdImage $image, string $cachePath, string $mime, int $width, int $height): ?string
{
$result = match ($mime) {
'image/png' => imagepng($image, $cachePath, 6),
'image/gif' => imagegif($image, $cachePath),
'image/webp' => imagewebp($image, $cachePath, $this->quality),
'image/avif' => function_exists('imageavif') ? imageavif($image, $cachePath, $this->quality) : false,
default => imagejpeg($image, $cachePath, $this->quality),
};
imagedestroy($image);
return $result ? $cachePath : null;
}
/**
* Get the output file extension for a MIME type.
*/
private function getOutputExtension(string $mime): string
{
return match ($mime) {
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/avif' => 'avif',
default => 'jpg',
};
}
}
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Utils;
use Grav\Plugin\Api\Exceptions\ValidationException;
use function in_array;
use function is_array;
use function is_bool;
use function is_numeric;
use function is_string;
/**
* Per-field upload settings for blueprint `type: file` fields.
*
* Carries the subset of Grav's core upload settings (MediaUploadTrait's
* `$_upload_defaults` and the form plugin's per-field schema) that the API
* honors, so admin-next file fields behave like admin-classic ones:
*
* - random_name randomize the stored filename
* - avoid_overwriting datetime-prefix on a name conflict instead of clobbering
* - accept mime / extension allowlist
* - filesize per-field maximum size in MB
*
* TRUST MODEL: these values arrive from the client (the blueprint the SPA
* renders), exactly as `destination`/`scope` already do on the blueprint-upload
* endpoint. They can only *further restrict* an upload (`accept`, `filesize`)
* or change the *output filename* (`random_name`, `avoid_overwriting`) never
* relax the immovable server-side security floor (dangerous/forbidden
* extensions, accounts image-only, the hard size cap, traversal guards), which
* each controller enforces separately and never delegates to the client.
*/
final class UploadFieldSettings
{
/**
* @param string[] $accept
*/
private function __construct(
public readonly bool $randomName = false,
public readonly bool $avoidOverwriting = false,
public readonly array $accept = [],
public readonly ?float $filesizeMb = null,
) {
}
/**
* A settings object with nothing active every upload behaves as before.
*/
public static function none(): self
{
return new self();
}
/**
* Build from an associative array of request parameters (parsed body /
* uploaded-file metadata). Missing or unrecognized keys fall back to the
* inert default, so a request that carries no field settings is a no-op.
*
* @param array<string, mixed> $params
*/
public static function fromParams(array $params): self
{
return new self(
randomName: self::toBool($params['random_name'] ?? false),
avoidOverwriting: self::toBool($params['avoid_overwriting'] ?? false),
accept: self::toAcceptList($params['accept'] ?? null),
filesizeMb: self::toFilesize($params['filesize'] ?? null),
);
}
/**
* Whether any field-level setting is active.
*/
public function isEmpty(): bool
{
return !$this->randomName
&& !$this->avoidOverwriting
&& $this->accept === []
&& $this->filesizeMb === null;
}
/**
* Enforce the per-field maximum filesize (MB). The endpoint's own hard cap
* is applied separately and always wins; this only tightens it.
*/
public function assertFilesize(?int $size): void
{
if ($this->filesizeMb === null || $this->filesizeMb <= 0 || $size === null) {
return;
}
$max = (int) round($this->filesizeMb * 1_048_576);
if ($size > $max) {
$label = rtrim(rtrim(number_format($this->filesizeMb, 2), '0'), '.');
throw new ValidationException(
sprintf('File exceeds the maximum allowed size of %s MB for this field.', $label)
);
}
}
/**
* Enforce the field's `accept` allowlist (mime types such as `image/*`, or
* extensions such as `.pdf`). Mirrors the form plugin's matching, including
* deriving the mime from the filename rather than trusting the browser.
*/
public function assertAccepted(string $filename): void
{
if ($this->accept === []) {
return;
}
$mime = Utils::getMimeByFilename($filename);
foreach ($this->accept as $type) {
if ($type === '') {
continue;
}
if ($type === '*') {
return;
}
$isMime = str_contains($type, '/');
$pattern = '#' . str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type) . '$#';
$subject = $isMime ? $mime : $filename;
if (preg_match($pattern, $subject)) {
return;
}
}
throw new ValidationException(
sprintf("File '%s' does not match the accepted types for this field.", $filename)
);
}
/**
* Decide the final stored filename, applying `random_name` then
* `avoid_overwriting` against the resolved target directory.
*
* Both transforms preserve the file extension (random names re-append it;
* the conflict guard only prepends a datetime), so a caller that already
* validated the extension on the incoming name does not need to re-check.
*/
public function resolveFilename(string $filename, string $targetDir): string
{
if ($this->randomName) {
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$random = Utils::generateRandomString(15);
$filename = strtolower($extension !== '' ? "{$random}.{$extension}" : $random);
}
if ($this->avoidOverwriting && is_file($targetDir . '/' . $filename)) {
$filename = date('YmdHis') . '-' . $filename;
}
return $filename;
}
private static function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_string($value)) {
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
return (bool) $value;
}
/**
* Accept may arrive as an array (JSON body) or a comma-separated string
* (multipart meta). Normalize to a trimmed list of non-empty entries.
*
* @return string[]
*/
private static function toAcceptList(mixed $value): array
{
if (is_string($value)) {
$value = $value === '' ? [] : explode(',', $value);
}
if (!is_array($value)) {
return [];
}
$out = [];
foreach ($value as $item) {
$item = trim((string) $item);
if ($item !== '') {
$out[] = $item;
}
}
return $out;
}
private static function toFilesize(mixed $value): ?float
{
if ($value === null || $value === '' || !is_numeric($value)) {
return null;
}
$filesize = (float) $value;
return $filesize > 0 ? $filesize : null;
}
}
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Webhooks;
use Grav\Common\HTTP\Response;
class WebhookDispatcher
{
/**
* Map of internal event names to webhook event names.
*/
private const EVENT_MAP = [
'onApiPageCreated' => 'page.created',
'onApiPageUpdated' => 'page.updated',
'onApiPageDeleted' => 'page.deleted',
'onApiPageMoved' => 'page.moved',
'onApiPageTranslated' => 'page.translated',
'onApiPagesReordered' => 'pages.reordered',
'onApiMediaUploaded' => 'media.uploaded',
'onApiMediaDeleted' => 'media.deleted',
'onApiUserCreated' => 'user.created',
'onApiUserUpdated' => 'user.updated',
'onApiUserDeleted' => 'user.deleted',
'onApiConfigUpdated' => 'config.updated',
'onApiPackageInstalled' => 'gpm.installed',
'onApiPackageRemoved' => 'gpm.removed',
'onApiGravUpgraded' => 'grav.upgraded',
];
private WebhookManager $manager;
public function __construct(?WebhookManager $manager = null)
{
$this->manager = $manager ?? new WebhookManager();
}
/**
* Get the list of subscribed events for the plugin.
*/
public static function getSubscribedEvents(): array
{
$events = [];
foreach (array_keys(self::EVENT_MAP) as $eventName) {
$events[$eventName] = ['dispatch', -100]; // Low priority - run after main handlers
}
return $events;
}
/**
* Dispatch webhooks for an event.
*/
public function dispatch(string $internalEvent, array $eventData): void
{
$webhookEvent = self::EVENT_MAP[$internalEvent] ?? null;
if (!$webhookEvent) {
return;
}
$webhooks = $this->manager->getForEvent($webhookEvent);
if (empty($webhooks)) {
return;
}
$payload = $this->buildPayload($webhookEvent, $eventData);
foreach ($webhooks as $webhook) {
$this->send($webhook, $payload);
}
}
/**
* Send a test payload to a webhook.
*/
public function sendTest(array $webhook): array
{
$payload = $this->buildPayload('test', [
'message' => 'This is a test webhook delivery.',
]);
return $this->send($webhook, $payload);
}
/**
* Build the webhook payload.
*/
private function buildPayload(string $event, array $data): array
{
// Serialize objects in data to arrays
$cleanData = $this->serializeEventData($data);
return [
'event' => $event,
'timestamp' => date('c'),
'data' => $cleanData,
];
}
/**
* Send a webhook HTTP request and record the delivery.
*/
private function send(array $webhook, array $payload): array
{
$payload['webhook_id'] = $webhook['id'];
$jsonPayload = json_encode($payload, JSON_UNESCAPED_SLASHES);
// Generate HMAC signature
$signature = hash_hmac('sha256', $jsonPayload, $webhook['secret'] ?? '');
$headers = array_merge(
[
'Content-Type' => 'application/json',
'X-Grav-Signature' => $signature,
'X-Grav-Event' => $payload['event'],
'X-Grav-Delivery' => 'dlv_' . bin2hex(random_bytes(8)),
'User-Agent' => 'Grav-Webhook/1.0',
],
$webhook['headers'] ?? []
);
$delivery = [
'id' => $headers['X-Grav-Delivery'],
'event' => $payload['event'],
'url' => $webhook['url'],
'request_headers' => $headers,
'request_body' => $payload,
'created' => time(),
];
$startTime = microtime(true);
try {
$response = $this->httpPost($webhook['url'], $jsonPayload, $headers);
$duration = (int) ((microtime(true) - $startTime) * 1000);
$delivery['status_code'] = $response['status_code'];
$delivery['response_body'] = mb_substr($response['body'] ?? '', 0, 1000);
$delivery['duration_ms'] = $duration;
$delivery['success'] = $response['status_code'] >= 200 && $response['status_code'] < 300;
if ($delivery['success']) {
$this->manager->resetFailureCount($webhook['id']);
} else {
$this->manager->recordFailure($webhook['id']);
}
} catch (\Exception $e) {
$duration = (int) ((microtime(true) - $startTime) * 1000);
$delivery['status_code'] = 0;
$delivery['error'] = $e->getMessage();
$delivery['duration_ms'] = $duration;
$delivery['success'] = false;
$this->manager->recordFailure($webhook['id']);
}
$this->manager->recordDelivery($webhook['id'], $delivery);
return $delivery;
}
/**
* Make an HTTP POST request.
*/
private function httpPost(string $url, string $body, array $headers): array
{
$ch = curl_init($url);
if ($ch === false) {
throw new \RuntimeException('Failed to initialize cURL');
}
$headerLines = [];
foreach ($headers as $key => $value) {
$headerLines[] = "{$key}: {$value}";
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => false,
]);
$responseBody = curl_exec($ch);
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($responseBody === false) {
throw new \RuntimeException('Webhook request failed: ' . $error);
}
return [
'status_code' => $statusCode,
'body' => is_string($responseBody) ? $responseBody : '',
];
}
/**
* Convert event data objects to serializable arrays.
*/
private function serializeEventData(array $data): array
{
$result = [];
foreach ($data as $key => $value) {
if (is_object($value)) {
// Try common serialization methods
if (method_exists($value, 'route')) {
$result[$key] = [
'route' => $value->route(),
'title' => method_exists($value, 'title') ? $value->title() : null,
'slug' => method_exists($value, 'slug') ? $value->slug() : null,
];
} elseif (method_exists($value, 'toArray')) {
$result[$key] = $value->toArray();
} elseif (method_exists($value, 'jsonSerialize')) {
$result[$key] = $value->jsonSerialize();
} else {
$result[$key] = '(object)';
}
} elseif (is_array($value)) {
$result[$key] = $this->serializeEventData($value);
} else {
$result[$key] = $value;
}
}
return $result;
}
}
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Webhooks;
use Grav\Common\Grav;
use RocketTheme\Toolbox\File\YamlFile;
class WebhookManager
{
private string $storagePath;
private string $deliveryPath;
private ?array $webhooksCache = null;
public function __construct()
{
$grav = Grav::instance();
$this->storagePath = $grav['locator']->findResource('user://data/api', true, true);
$this->deliveryPath = $this->storagePath . '/webhook-deliveries';
}
/**
* Get all webhooks.
*
* @return array
*/
public function getAll(): array
{
return $this->load();
}
/**
* Get a webhook by ID.
*/
public function get(string $id): ?array
{
$webhooks = $this->load();
foreach ($webhooks as $webhook) {
if ($webhook['id'] === $id) {
return $webhook;
}
}
return null;
}
/**
* Create a new webhook.
*/
public function create(array $data): array
{
$webhook = [
'id' => 'wh_' . bin2hex(random_bytes(12)),
'url' => $data['url'],
'secret' => 'whsec_' . bin2hex(random_bytes(24)),
'events' => $data['events'] ?? ['*'],
'enabled' => $data['enabled'] ?? true,
'headers' => $data['headers'] ?? [],
'created' => time(),
'failure_count' => 0,
];
$webhooks = $this->load();
$webhooks[] = $webhook;
$this->save($webhooks);
return $webhook;
}
/**
* Update a webhook.
*/
public function update(string $id, array $data): ?array
{
$webhooks = $this->load();
foreach ($webhooks as &$webhook) {
if ($webhook['id'] === $id) {
if (isset($data['url'])) {
$webhook['url'] = $data['url'];
}
if (isset($data['events'])) {
$webhook['events'] = $data['events'];
}
if (isset($data['enabled'])) {
$webhook['enabled'] = (bool) $data['enabled'];
}
if (isset($data['headers'])) {
$webhook['headers'] = $data['headers'];
}
$this->save($webhooks);
return $webhook;
}
}
return null;
}
/**
* Delete a webhook.
*/
public function delete(string $id): bool
{
$webhooks = $this->load();
$filtered = array_values(array_filter($webhooks, fn($w) => $w['id'] !== $id));
if (count($filtered) === count($webhooks)) {
return false;
}
$this->save($filtered);
// Clean up delivery logs
$deliveryDir = $this->deliveryPath . '/' . $id;
if (is_dir($deliveryDir)) {
$files = glob($deliveryDir . '/*.yaml');
foreach ($files as $file) {
@unlink($file);
}
@rmdir($deliveryDir);
}
return true;
}
/**
* Record a delivery log entry.
*/
public function recordDelivery(string $webhookId, array $delivery): void
{
$dir = $this->deliveryPath . '/' . $webhookId;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$file = $dir . '/' . $delivery['id'] . '.yaml';
$yamlFile = YamlFile::instance($file);
$yamlFile->content($delivery);
$yamlFile->save();
// Keep only last 50 deliveries
$this->pruneDeliveries($webhookId, 50);
}
/**
* Get delivery history for a webhook.
*/
public function getDeliveries(string $webhookId, int $limit = 20, int $offset = 0): array
{
$dir = $this->deliveryPath . '/' . $webhookId;
if (!is_dir($dir)) {
return ['deliveries' => [], 'total' => 0];
}
$files = glob($dir . '/*.yaml');
// Sort by modification time descending
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
$total = count($files);
$slice = array_slice($files, $offset, $limit);
$deliveries = [];
foreach ($slice as $file) {
$yamlFile = YamlFile::instance($file);
$deliveries[] = $yamlFile->content();
}
return ['deliveries' => $deliveries, 'total' => $total];
}
/**
* Get webhooks matching a specific event.
*/
public function getForEvent(string $event): array
{
$webhooks = $this->load();
return array_filter($webhooks, function ($webhook) use ($event) {
if (!($webhook['enabled'] ?? true)) {
return false;
}
$events = $webhook['events'] ?? ['*'];
return in_array('*', $events, true) || in_array($event, $events, true);
});
}
/**
* Increment failure count and auto-disable if threshold reached.
*/
public function recordFailure(string $id): void
{
$webhooks = $this->load();
foreach ($webhooks as &$webhook) {
if ($webhook['id'] === $id) {
$webhook['failure_count'] = ($webhook['failure_count'] ?? 0) + 1;
if ($webhook['failure_count'] >= 5) {
$webhook['enabled'] = false;
$webhook['disabled_reason'] = 'Auto-disabled after 5 consecutive failures';
}
break;
}
}
$this->save($webhooks);
}
/**
* Reset failure count on successful delivery.
*/
public function resetFailureCount(string $id): void
{
$webhooks = $this->load();
foreach ($webhooks as &$webhook) {
if ($webhook['id'] === $id) {
$webhook['failure_count'] = 0;
unset($webhook['disabled_reason']);
break;
}
}
$this->save($webhooks);
}
private function load(): array
{
if ($this->webhooksCache !== null) {
return $this->webhooksCache;
}
$file = YamlFile::instance($this->storagePath . '/webhooks.yaml');
$content = $file->content();
$this->webhooksCache = $content['webhooks'] ?? [];
return $this->webhooksCache;
}
private function save(array $webhooks): void
{
$file = YamlFile::instance($this->storagePath . '/webhooks.yaml');
$file->content(['webhooks' => array_values($webhooks)]);
$file->save();
$this->webhooksCache = array_values($webhooks);
}
private function pruneDeliveries(string $webhookId, int $keep): void
{
$dir = $this->deliveryPath . '/' . $webhookId;
if (!is_dir($dir)) {
return;
}
$files = glob($dir . '/*.yaml');
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
$toDelete = array_slice($files, $keep);
foreach ($toDelete as $file) {
@unlink($file);
}
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Console;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Console\ConsoleCommand;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
class KeysGenerateCommand extends ConsoleCommand
{
protected function configure(): void
{
$this
->setName('keys:generate')
->setAliases(['keys:gen', 'keys:create'])
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'The username to generate the key for')
->addOption('name', 'N', InputOption::VALUE_OPTIONAL, 'A name/label for the API key', 'CLI Generated Key')
->addOption('expiry', 'e', InputOption::VALUE_OPTIONAL, 'Key expiry in days (default: never expires)')
->setDescription('Generate a new API key for a user')
->setHelp('The <info>keys:generate</info> command creates a new API key for the specified user.');
}
protected function serve(): int
{
include __DIR__ . '/../vendor/autoload.php';
$io = new SymfonyStyle($this->input, $this->output);
$grav = Grav::instance();
$this->initializePlugins();
/** @var UserCollectionInterface $accounts */
$accounts = $grav['accounts'];
// Get username
$username = $this->input->getOption('user');
if (!$username) {
$helper = $this->getHelper('question');
$question = new Question('Enter the <yellow>username</yellow>: ');
$question->setValidator(function ($value) use ($accounts) {
if (!$value) {
throw new \RuntimeException('Username is required.');
}
$user = $accounts->load($value);
if (!$user->exists()) {
throw new \RuntimeException("User '{$value}' does not exist.");
}
return $value;
});
$username = $helper->ask($this->input, $this->output, $question);
}
$user = $accounts->load($username);
if (!$user->exists()) {
$io->error("User '{$username}' does not exist.");
return 1;
}
$name = $this->input->getOption('name');
$expiryDays = $this->input->getOption('expiry') !== null ? (int) $this->input->getOption('expiry') : null;
$manager = new ApiKeyManager();
$result = $manager->generateKey($user, $name, [], $expiryDays);
$io->newLine();
$io->success("API key generated for user '{$username}'");
$io->newLine();
$io->writeln('<yellow>API Key:</yellow> <cyan>' . $result['key'] . '</cyan>');
$io->writeln('<yellow>Key ID:</yellow> ' . $result['id']);
if ($expiryDays) {
$io->writeln('<yellow>Expires:</yellow> ' . date('Y-m-d H:i', time() + ($expiryDays * 86400)));
} else {
$io->writeln('<yellow>Expires:</yellow> Never');
}
$io->newLine();
$io->warning('Save this key now — it cannot be retrieved later.');
return 0;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Console;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Console\ConsoleCommand;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
class KeysListCommand extends ConsoleCommand
{
protected function configure(): void
{
$this
->setName('keys:list')
->setAliases(['keys:ls'])
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'The username to list keys for')
->setDescription('List API keys for a user')
->setHelp('The <info>keys:list</info> command shows all API keys for the specified user.');
}
protected function serve(): int
{
include __DIR__ . '/../vendor/autoload.php';
$io = new SymfonyStyle($this->input, $this->output);
$grav = Grav::instance();
$this->initializePlugins();
/** @var UserCollectionInterface $accounts */
$accounts = $grav['accounts'];
// Get username
$username = $this->input->getOption('user');
if (!$username) {
$helper = $this->getHelper('question');
$question = new Question('Enter the <yellow>username</yellow>: ');
$username = $helper->ask($this->input, $this->output, $question);
}
$user = $accounts->load($username);
if (!$user->exists()) {
$io->error("User '{$username}' does not exist.");
return 1;
}
$manager = new ApiKeyManager();
$keys = $manager->listKeys($user);
if (empty($keys)) {
$io->writeln("No API keys found for user '<cyan>{$username}</cyan>'.");
return 0;
}
$io->writeln("API keys for user '<cyan>{$username}</cyan>':");
$io->newLine();
$rows = [];
foreach ($keys as $key) {
$expires = 'Never';
if ($key['expires']) {
$expires = $key['expires'] < time()
? '<red>Expired ' . date('Y-m-d', $key['expires']) . '</red>'
: date('Y-m-d', $key['expires']);
}
$rows[] = [
$key['id'],
$key['name'],
$key['prefix'],
$key['active'] ? '<green>Active</green>' : '<red>Inactive</red>',
$expires,
$key['created'] ? date('Y-m-d H:i', $key['created']) : 'N/A',
$key['last_used'] ? date('Y-m-d H:i', $key['last_used']) : 'Never',
];
}
$io->table(
['ID', 'Name', 'Prefix', 'Status', 'Expires', 'Created', 'Last Used'],
$rows
);
return 0;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Console;
use Grav\Common\Grav;
use Grav\Console\ConsoleCommand;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Symfony\Component\Console\Style\SymfonyStyle;
class KeysMigrateCommand extends ConsoleCommand
{
protected function configure(): void
{
$this
->setName('keys:migrate')
->setDescription('Migrate API keys from user accounts to centralized storage')
->setHelp('Moves API keys from individual user account YAML files to the centralized user/data/api-keys.yaml file.');
}
protected function serve(): int
{
include __DIR__ . '/../vendor/autoload.php';
$io = new SymfonyStyle($this->input, $this->output);
$grav = Grav::instance();
$this->initializePlugins();
$manager = new ApiKeyManager();
$migrated = $manager->migrateFromAccounts();
if ($migrated > 0) {
$io->success("Migrated {$migrated} API key(s) to user/data/api-keys.yaml");
} else {
$io->writeln('No API keys found in user accounts to migrate.');
}
return 0;
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Console;
use Grav\Common\Grav;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Console\ConsoleCommand;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
class KeysRevokeCommand extends ConsoleCommand
{
protected function configure(): void
{
$this
->setName('keys:revoke')
->setAliases(['keys:remove', 'keys:delete', 'keys:rm'])
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'The username')
->addArgument('key-id', InputArgument::OPTIONAL, 'The key ID to revoke')
->setDescription('Revoke an API key')
->setHelp('The <info>keys:revoke</info> command revokes an API key for the specified user. If no key ID is given, you can select from a list.');
}
protected function serve(): int
{
include __DIR__ . '/../vendor/autoload.php';
$io = new SymfonyStyle($this->input, $this->output);
$grav = Grav::instance();
$this->initializePlugins();
/** @var UserCollectionInterface $accounts */
$accounts = $grav['accounts'];
$helper = $this->getHelper('question');
// Get username
$username = $this->input->getOption('user');
if (!$username) {
$question = new Question('Enter the <yellow>username</yellow>: ');
$username = $helper->ask($this->input, $this->output, $question);
}
$user = $accounts->load($username);
if (!$user->exists()) {
$io->error("User '{$username}' does not exist.");
return 1;
}
$manager = new ApiKeyManager();
$keys = $manager->listKeys($user);
if (empty($keys)) {
$io->writeln("No API keys found for user '<cyan>{$username}</cyan>'.");
return 0;
}
// Get key ID
$keyId = $this->input->getArgument('key-id');
if (!$keyId) {
// Let user pick from a list
$choices = [];
foreach ($keys as $key) {
$choices[$key['id']] = sprintf('%s (%s) - %s', $key['name'], $key['prefix'], $key['id']);
}
$question = new ChoiceQuestion(
'Select the key to revoke:',
$choices
);
$selected = $helper->ask($this->input, $this->output, $question);
// Extract the key ID from the selected choice
foreach ($keys as $key) {
$label = sprintf('%s (%s) - %s', $key['name'], $key['prefix'], $key['id']);
if ($label === $selected) {
$keyId = $key['id'];
break;
}
}
}
if (!$keyId) {
$io->error('No key ID provided.');
return 1;
}
// Find key name for confirmation
$keyName = $keyId;
foreach ($keys as $key) {
if ($key['id'] === $keyId) {
$keyName = sprintf('%s (%s)', $key['name'], $key['prefix']);
break;
}
}
$confirm = new ConfirmationQuestion(
"Revoke key <yellow>{$keyName}</yellow> for user <cyan>{$username}</cyan>? [y/N] ",
false
);
if (!$helper->ask($this->input, $this->output, $confirm)) {
$io->writeln('Cancelled.');
return 0;
}
$revoked = $manager->revokeKey($user, $keyId);
if ($revoked) {
$io->success("API key '{$keyId}' revoked for user '{$username}'.");
} else {
$io->error("API key '{$keyId}' not found for user '{$username}'.");
return 1;
}
return 0;
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"name": "getgrav/grav-plugin-api",
"type": "grav-plugin",
"description": "RESTful API plugin for Grav CMS",
"keywords": ["api", "rest", "headless", "plugin"],
"homepage": "https://github.com/getgrav/grav-plugin-api",
"license": "MIT",
"authors": [
{
"name": "Team Grav",
"email": "devs@getgrav.org",
"homepage": "https://getgrav.org",
"role": "Developer"
}
],
"support": {
"issues": "https://github.com/getgrav/grav-plugin-api/issues",
"docs": "https://learn.getgrav.org/api"
},
"require": {
"php": "^8.3",
"ext-json": "*",
"nikic/fast-route": "^1.3",
"firebase/php-jwt": "^6.10 || ^7.0"
},
"replace": {
"symfony/yaml": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"psr/http-message": "^2.0"
},
"autoload": {
"psr-4": {
"Grav\\Plugin\\Api\\": "classes/Api"
},
"classmap": [
"api.php"
]
},
"autoload-dev": {
"psr-4": {
"Grav\\Plugin\\Api\\Tests\\": "tests"
}
},
"config": {
"platform": {
"php": "8.3"
}
},
"scripts": {
"test": "vendor/bin/phpunit"
}
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
PLUGIN_API:
# Permission labels (independent of admin plugin)
ACCESS_SITE: "Site"
ACCESS_SITE_LOGIN: "Login to Site"
ACCESS_ADMIN: "Admin"
ACCESS_ADMIN_LOGIN: "Login to Admin"
ACCESS_ADMIN_SUPER: "Super User"
ACCESS_ADMIN_CACHE: "Clear Cache"
ACCESS_ADMIN_CONFIGURATION: "Configuration"
ACCESS_ADMIN_CONFIGURATION_SYSTEM: "Manage System Configuration"
ACCESS_ADMIN_CONFIGURATION_SITE: "Manage Site Configuration"
ACCESS_ADMIN_CONFIGURATION_MEDIA: "Manage Media Configuration"
ACCESS_ADMIN_CONFIGURATION_SECURITY: "Manage Security Configuration"
ACCESS_ADMIN_CONFIGURATION_INFO: "See Server Information"
ACCESS_ADMIN_SETTINGS: "Settings"
ACCESS_ADMIN_PAGES: "Manage Pages"
ACCESS_ADMIN_MAINTENANCE: "Site Maintenance"
ACCESS_ADMIN_STATISTICS: "Site Statistics"
ACCESS_ADMIN_PLUGINS: "Manage Plugins"
ACCESS_ADMIN_THEMES: "Manage Themes"
ACCESS_ADMIN_TOOLS: "Access to Tools"
ACCESS_ADMIN_USERS: "Manage Users"
# User account form labels (fallbacks for PLUGIN_ADMIN.* keys)
ACCOUNT: "Account"
USERNAME: "Username"
EMAIL: "Email"
EMAIL_VALIDATION_MESSAGE: "Must be a valid email address"
PASSWORD: "Password"
PASSWORD_VALIDATION_MESSAGE: "Password must match the configured pattern"
FULL_NAME: "Full Name"
TITLE: "Title"
LANGUAGE: "Language"
LANGUAGE_HELP: "Set the language for this user"
CONTENT_EDITOR: "Content Editor"
CONTENT_EDITOR_HELP: "Override the content editor for this user"
2FA_TITLE: "2-Factor Authentication"
2FA_ENABLED: "2FA Enabled"
2FA_SECRET: "2FA Secret"
2FA_SECRET_HELP: "Scan the QR code with your Authenticator app"
YUBIKEY_ID: "YubiKey ID"
YUBIKEY_HELP: "Insert your YubiKey and click to generate an OTP. The first 12 chars are your client ID."
YES: "Yes"
NO: "No"
PERMISSIONS: "Permissions"
GROUPS: "Groups"
GROUPS_HELP: "Assign user to groups for permission inheritance"
ACCESS_LEVELS: "Access Levels"
AVATAR_HASH: "Enter a unique identifier for your multiavatar"
File diff suppressed because it is too large Load Diff
+1802
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
{
"private": true,
"scripts": {
"test:api": "./tests/newman/run.sh",
"test:api:verbose": "./tests/newman/run.sh --reporter-cli-no-summary false"
},
"devDependencies": {
"newman": "^6.2.2"
}
}
+89
View File
@@ -0,0 +1,89 @@
actions:
api.super:
label: API Super User
type: access
api.access:
label: API Access
type: access
api.pages:
type: access
label: Pages API
actions:
read:
label: Read Pages
write:
label: Create/Update/Delete Pages
api.media:
type: access
label: Media API
actions:
read:
label: Read Media
write:
label: Upload/Delete Media
api.config:
type: access
label: Configuration API
actions:
read:
label: Read Configuration
write:
label: Update Configuration
api.users:
type: access
label: Users API
actions:
read:
label: Read Users
write:
label: Create/Update/Delete Users
api.system:
type: access
label: System API
actions:
read:
label: Read System Info/Logs
write:
label: Cache/Updates
backup:
label: Create/Download/Delete Backups
api.gpm:
type: access
label: GPM API
actions:
read:
label: List Packages / Check Updates
write:
label: Install/Remove/Update Packages
api.scheduler:
type: access
label: Scheduler API
actions:
read:
label: View Jobs/Status/History
write:
label: Run Jobs
api.reports:
type: access
label: Reports API
actions:
read:
label: View Reports
api.webhooks:
type: access
label: Webhooks API
actions:
read:
label: View Webhooks
write:
label: Manage Webhooks
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>classes</directory>
</include>
</source>
<groups>
<exclude>
<group>integration</group>
</exclude>
</groups>
</phpunit>
@@ -0,0 +1,43 @@
{% extends 'email/base.html.twig' %}
{%- set subject = 'You\'ve been invited to ' ~ site_name %}
{%- do email.message.setSubject(subject) %}
{%- block content -%}
<h1>You're invited</h1>
<p>
Hi,
</p>
<p>
{{ actor ?: site_name }} has invited you to join <b>{{ site_name }}</b>.
</p>
{%- if message %}
<p style="white-space: pre-line;">{{ message }}</p>
{%- endif %}
<p>
Click the button below to set up your account and choose your password:
</p>
<p>
<br />
<a href="{{ invite_link }}" class="btn-primary">Accept invitation</a>
<br />
<br />
</p>
<p>
Or copy and paste this link into your browser's address bar:
</p>
<p class="word-break" style="word-break: break-all;">
<a href="{{ invite_link }}">{{ invite_link }}</a>
</p>
<p>
This invitation will expire soon, so please accept it promptly.
</p>
<p>
If you weren't expecting this invitation you can safely ignore this email.
</p>
<p>
<br />
{{ author ?: site_name }}
</p>
{%- endblock content -%}
@@ -0,0 +1,40 @@
{% extends 'email/base.html.twig' %}
{%- set subject = 'PLUGIN_LOGIN.FORGOT_EMAIL_SUBJECT'|t(site_name) %}
{%- do email.message.setSubject(subject) %}
{%- block content -%}
<h1>Password Reset</h1>
<p>
Hi {{ user.fullname ?? user.username }},
</p>
<p>
A password reset was requested for your admin account at <b>{{ site_name }}</b>.
</p>
<p>
Click the button below to choose a new password:
</p>
<p>
<br />
<a href="{{ reset_link }}" class="btn-primary">Reset my password</a>
<br />
<br />
</p>
<p>
Or copy and paste this link into your browser's address bar:
</p>
<p class="word-break" style="word-break: break-all;">
<a href="{{ reset_link }}">{{ reset_link }}</a>
</p>
<p>
This link will expire in 24 hours.
</p>
<p>
If you did not request this reset you can safely ignore this email — your password will not change.
</p>
<p>
<br />
{{ author ?: site_name }}
</p>
{%- endblock content -%}

Some files were not shown because too many files have changed in this diff Show More