333 lines
12 KiB
PHP
333 lines
12 KiB
PHP
<?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'];
|
|
}
|
|
}
|