feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user