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