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

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