initial commit
This commit is contained in:
commit
5210d78d7d
969 changed files with 223828 additions and 0 deletions
622
kirby/src/Api/Api.php
Normal file
622
kirby/src/Api/Api.php
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Api;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Exception\Exception as ExceptionException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Http\Response;
|
||||
use Kirby\Http\Route;
|
||||
use Kirby\Http\Router;
|
||||
use Kirby\Toolkit\Collection as BaseCollection;
|
||||
use Kirby\Toolkit\Pagination;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The API class is a generic container
|
||||
* for API routes, models and collections and is used
|
||||
* to run our REST API. You can find our API setup
|
||||
* in `kirby/config/api.php`.
|
||||
*
|
||||
* @package Kirby Api
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Api
|
||||
{
|
||||
/**
|
||||
* Authentication callback
|
||||
*/
|
||||
protected Closure|null $authentication = null;
|
||||
|
||||
/**
|
||||
* Debugging flag
|
||||
*/
|
||||
protected bool $debug = false;
|
||||
|
||||
/**
|
||||
* Collection definition
|
||||
*/
|
||||
protected array $collections = [];
|
||||
|
||||
/**
|
||||
* Injected data/dependencies
|
||||
*/
|
||||
protected array $data = [];
|
||||
|
||||
/**
|
||||
* Model definitions
|
||||
*/
|
||||
protected array $models = [];
|
||||
|
||||
/**
|
||||
* The current route
|
||||
*/
|
||||
protected Route|null $route = null;
|
||||
|
||||
/**
|
||||
* The Router instance
|
||||
*/
|
||||
protected Router|null $router = null;
|
||||
|
||||
/**
|
||||
* Route definition
|
||||
*/
|
||||
protected array $routes = [];
|
||||
|
||||
/**
|
||||
* Request data
|
||||
* [query, body, files]
|
||||
*/
|
||||
protected array $requestData = [];
|
||||
|
||||
/**
|
||||
* The applied request method
|
||||
* (GET, POST, PATCH, etc.)
|
||||
*/
|
||||
protected string|null $requestMethod = null;
|
||||
|
||||
/**
|
||||
* Creates a new API instance
|
||||
*/
|
||||
public function __construct(array $props)
|
||||
{
|
||||
$this->authentication = $props['authentication'] ?? null;
|
||||
$this->data = $props['data'] ?? [];
|
||||
$this->routes = $props['routes'] ?? [];
|
||||
$this->debug = $props['debug'] ?? false;
|
||||
|
||||
if ($collections = $props['collections'] ?? null) {
|
||||
$this->collections = array_change_key_case($collections);
|
||||
}
|
||||
|
||||
if ($models = $props['models'] ?? null) {
|
||||
$this->models = array_change_key_case($models);
|
||||
}
|
||||
|
||||
$this->setRequestData($props['requestData'] ?? null);
|
||||
$this->setRequestMethod($props['requestMethod'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic accessor for any given data
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
*/
|
||||
public function __call(string $method, array $args = [])
|
||||
{
|
||||
return $this->data($method, ...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the authentication method
|
||||
* if set
|
||||
*/
|
||||
public function authenticate()
|
||||
{
|
||||
return $this->authentication()?->call($this) ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authentication callback
|
||||
*/
|
||||
public function authentication(): Closure|null
|
||||
{
|
||||
return $this->authentication;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an API call for the given path,
|
||||
* request method and optional request data
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function call(
|
||||
string|null $path = null,
|
||||
string $method = 'GET',
|
||||
array $requestData = []
|
||||
): mixed {
|
||||
$path = rtrim($path ?? '', '/');
|
||||
|
||||
$this->setRequestMethod($method);
|
||||
$this->setRequestData($requestData);
|
||||
|
||||
$this->router = new Router($this->routes());
|
||||
$this->route = $this->router->find($path, $method);
|
||||
$auth = $this->route?->attributes()['auth'] ?? true;
|
||||
|
||||
if ($auth !== false) {
|
||||
$user = $this->authenticate();
|
||||
|
||||
// set PHP locales based on *user* language
|
||||
// so that e.g. strftime() gets formatted correctly
|
||||
if ($user instanceof User) {
|
||||
$language = $user->language();
|
||||
|
||||
// get the locale from the translation
|
||||
$locale = $user->kirby()->translation($language)->locale();
|
||||
|
||||
// provide some variants as fallbacks to be
|
||||
// compatible with as many systems as possible
|
||||
$locales = [
|
||||
$locale . '.UTF-8',
|
||||
$locale . '.UTF8',
|
||||
$locale . '.ISO8859-1',
|
||||
$locale,
|
||||
$language,
|
||||
setlocale(LC_ALL, 0) // fall back to the previously defined locale
|
||||
];
|
||||
|
||||
// set the locales that are relevant for string formatting
|
||||
// *don't* set LC_CTYPE to avoid breaking other parts of the system
|
||||
setlocale(LC_MONETARY, $locales);
|
||||
setlocale(LC_NUMERIC, $locales);
|
||||
setlocale(LC_TIME, $locales);
|
||||
}
|
||||
}
|
||||
|
||||
// don't throw pagination errors if pagination
|
||||
// page is out of bounds
|
||||
$validate = Pagination::$validate;
|
||||
Pagination::$validate = false;
|
||||
|
||||
$output = $this->route?->action()->call(
|
||||
$this,
|
||||
...$this->route->arguments()
|
||||
);
|
||||
|
||||
// restore old pagination validation mode
|
||||
Pagination::$validate = $validate;
|
||||
|
||||
if (
|
||||
is_object($output) === true &&
|
||||
$output instanceof Response === false
|
||||
) {
|
||||
return $this->resolve($output)->toResponse();
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance while
|
||||
* merging initial and new properties
|
||||
*/
|
||||
public function clone(array $props = []): static
|
||||
{
|
||||
return new static([
|
||||
'autentication' => $this->authentication,
|
||||
'data' => $this->data,
|
||||
'routes' => $this->routes,
|
||||
'debug' => $this->debug,
|
||||
'collections' => $this->collections,
|
||||
'models' => $this->models,
|
||||
'requestData' => $this->requestData,
|
||||
'requestMethod' => $this->requestMethod,
|
||||
...$props
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter and getter for an API collection
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If no collection for `$name` exists
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function collection(
|
||||
string $name,
|
||||
array|BaseCollection|null $collection = null
|
||||
): Collection {
|
||||
if (isset($this->collections[$name]) === false) {
|
||||
throw new NotFoundException(
|
||||
message: sprintf('The collection "%s" does not exist', $name)
|
||||
);
|
||||
}
|
||||
|
||||
return new Collection($this, $collection, $this->collections[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collections definition
|
||||
*/
|
||||
public function collections(): array
|
||||
{
|
||||
return $this->collections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the injected data array
|
||||
* or certain parts of it by key
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If no data for `$key` exists
|
||||
*/
|
||||
public function data(string|null $key = null, ...$args): mixed
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
if ($this->hasData($key) === false) {
|
||||
throw new NotFoundException(
|
||||
message: sprintf('Api data for "%s" does not exist', $key)
|
||||
);
|
||||
}
|
||||
|
||||
// lazy-load data wrapped in Closures
|
||||
if ($this->data[$key] instanceof Closure) {
|
||||
return $this->data[$key]->call($this, ...$args);
|
||||
}
|
||||
|
||||
return $this->data[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the debugging flag
|
||||
*/
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->debug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if injected data exists for the given key
|
||||
*/
|
||||
public function hasData(string $key): bool
|
||||
{
|
||||
return isset($this->data[$key]) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches an object with an array item
|
||||
* based on the `type` field
|
||||
*
|
||||
* @param array models or collections
|
||||
* @return string|null key of match
|
||||
*/
|
||||
protected function match(
|
||||
array $array,
|
||||
$object = null
|
||||
): string|null {
|
||||
foreach ($array as $definition => $model) {
|
||||
if ($object instanceof $model['type']) {
|
||||
return $definition;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an API model instance by name
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If no model for `$name` exists
|
||||
*/
|
||||
public function model(
|
||||
string|null $name = null,
|
||||
$object = null
|
||||
): Model {
|
||||
// Try to auto-match object with API models
|
||||
$name ??= $this->match($this->models, $object);
|
||||
|
||||
if (isset($this->models[$name]) === false) {
|
||||
throw new NotFoundException(
|
||||
message: sprintf('The model "%s" does not exist', $name ?? 'NULL')
|
||||
);
|
||||
}
|
||||
|
||||
return new Model($this, $object, $this->models[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all model definitions
|
||||
*/
|
||||
public function models(): array
|
||||
{
|
||||
return $this->models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter for request data
|
||||
* Can either get all the data
|
||||
* or certain parts of it.
|
||||
*/
|
||||
public function requestData(
|
||||
string|null $type = null,
|
||||
string|null $key = null,
|
||||
mixed $default = null
|
||||
): mixed {
|
||||
if ($type === null) {
|
||||
return $this->requestData;
|
||||
}
|
||||
|
||||
if ($key === null) {
|
||||
return $this->requestData[$type] ?? [];
|
||||
}
|
||||
|
||||
$data = array_change_key_case($this->requestData($type));
|
||||
$key = strtolower($key);
|
||||
|
||||
return $data[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request body if available
|
||||
*/
|
||||
public function requestBody(
|
||||
string|null $key = null,
|
||||
mixed $default = null
|
||||
): mixed {
|
||||
return $this->requestData('body', $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the files from the request if available
|
||||
*/
|
||||
public function requestFiles(
|
||||
string|null $key = null,
|
||||
mixed $default = null
|
||||
): mixed {
|
||||
return $this->requestData('files', $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all headers from the request if available
|
||||
*/
|
||||
public function requestHeaders(
|
||||
string|null $key = null,
|
||||
mixed $default = null
|
||||
): mixed {
|
||||
return $this->requestData('headers', $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request method
|
||||
*/
|
||||
public function requestMethod(): string|null
|
||||
{
|
||||
return $this->requestMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request query if available
|
||||
*/
|
||||
public function requestQuery(
|
||||
string|null $key = null,
|
||||
mixed $default = null
|
||||
): mixed {
|
||||
return $this->requestData('query', $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a Kirby object into an
|
||||
* API model or collection representation
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If `$object` cannot be resolved
|
||||
*/
|
||||
public function resolve($object): Model|Collection
|
||||
{
|
||||
if (
|
||||
$object instanceof Model ||
|
||||
$object instanceof Collection
|
||||
) {
|
||||
return $object;
|
||||
}
|
||||
|
||||
if ($model = $this->match($this->models, $object)) {
|
||||
return $this->model($model, $object);
|
||||
}
|
||||
|
||||
if ($collection = $this->match($this->collections, $object)) {
|
||||
return $this->collection($collection, $object);
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
message: sprintf('The object "%s" cannot be resolved', $object::class)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all defined routes
|
||||
*/
|
||||
public function routes(): array
|
||||
{
|
||||
return $this->routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the API call
|
||||
*/
|
||||
public function render(
|
||||
string $path,
|
||||
string $method = 'GET',
|
||||
array $requestData = []
|
||||
): mixed {
|
||||
try {
|
||||
$result = $this->call($path, $method, $requestData);
|
||||
} catch (Throwable $e) {
|
||||
$result = $this->responseForException($e);
|
||||
}
|
||||
|
||||
$result = match ($result) {
|
||||
null => $this->responseFor404(),
|
||||
false => $this->responseFor400(),
|
||||
true => $this->responseFor200(),
|
||||
default => $result
|
||||
};
|
||||
|
||||
if (is_array($result) === false) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// pretty print json data
|
||||
$pretty = (bool)($requestData['query']['pretty'] ?? false) === true;
|
||||
|
||||
if (($result['status'] ?? 'ok') === 'error') {
|
||||
$code = $result['code'] ?? 400;
|
||||
|
||||
// sanitize the error code
|
||||
if ($code < 400 || $code > 599) {
|
||||
$code = 500;
|
||||
}
|
||||
|
||||
return Response::json($result, $code, $pretty);
|
||||
}
|
||||
|
||||
return Response::json($result, 200, $pretty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 200 - ok
|
||||
* response array.
|
||||
*/
|
||||
public function responseFor200(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => 'ok',
|
||||
'code' => 200
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 400 - bad request
|
||||
* response array.
|
||||
*/
|
||||
public function responseFor400(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => 'bad request',
|
||||
'code' => 400,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 404 - not found
|
||||
* response array.
|
||||
*/
|
||||
public function responseFor404(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => 'not found',
|
||||
'code' => 404,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the response array for
|
||||
* an exception. Kirby exceptions will
|
||||
* have more information
|
||||
*/
|
||||
public function responseForException(Throwable $e): array
|
||||
{
|
||||
if (isset($this->kirby) === true) {
|
||||
$docRoot = $this->kirby->environment()->get('DOCUMENT_ROOT');
|
||||
} else {
|
||||
$docRoot = $_SERVER['DOCUMENT_ROOT'] ?? null;
|
||||
}
|
||||
|
||||
// prepare the result array for all exception types
|
||||
$result = [
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
'code' => empty($e->getCode()) === true ? 500 : $e->getCode(),
|
||||
'exception' => $e::class,
|
||||
'key' => null,
|
||||
'file' => F::relativepath($e->getFile(), $docRoot),
|
||||
'line' => $e->getLine(),
|
||||
'details' => [],
|
||||
'route' => $this->route?->pattern()
|
||||
];
|
||||
|
||||
// extend the information for Kirby Exceptions
|
||||
if ($e instanceof ExceptionException) {
|
||||
$result['key'] = $e->getKey();
|
||||
$result['details'] = $e->getDetails();
|
||||
$result['code'] = $e->getHttpCode();
|
||||
}
|
||||
|
||||
// remove critical info from the result set if
|
||||
// debug mode is switched off
|
||||
if ($this->debug !== true) {
|
||||
unset(
|
||||
$result['file'],
|
||||
$result['exception'],
|
||||
$result['line'],
|
||||
$result['route']
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for the request data
|
||||
* @return $this
|
||||
*/
|
||||
protected function setRequestData(
|
||||
array|null $requestData = []
|
||||
): static {
|
||||
$this->requestData = [
|
||||
'query' => [],
|
||||
'body' => [],
|
||||
'files' => [],
|
||||
...$requestData ?? []
|
||||
];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for the request method
|
||||
* @return $this
|
||||
*/
|
||||
protected function setRequestMethod(
|
||||
string|null $requestMethod = null
|
||||
): static {
|
||||
$this->requestMethod = $requestMethod ?? 'GET';
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload helper method
|
||||
*
|
||||
* move_uploaded_file() not working with unit test
|
||||
* Added debug parameter for testing purposes as we did in the Email class
|
||||
*
|
||||
* @throws \Exception If request has no files or there was an error with the upload
|
||||
*/
|
||||
public function upload(
|
||||
Closure $callback,
|
||||
bool $single = false,
|
||||
bool $debug = false
|
||||
): array {
|
||||
return (new Upload($this, $single, $debug))->process($callback);
|
||||
}
|
||||
}
|
||||
153
kirby/src/Api/Collection.php
Normal file
153
kirby/src/Api/Collection.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Api;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Kirby\Toolkit\Collection as BaseCollection;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The Collection class is a wrapper
|
||||
* around our Kirby Collections and handles
|
||||
* stuff like pagination and proper JSON output
|
||||
* for collections in REST calls.
|
||||
*
|
||||
* @package Kirby Api
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Collection
|
||||
{
|
||||
protected string|null $model;
|
||||
protected array|null $select = null;
|
||||
protected string|null $view;
|
||||
|
||||
/**
|
||||
* Collection constructor
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct(
|
||||
protected Api $api,
|
||||
protected BaseCollection|array|null $data,
|
||||
array $schema
|
||||
) {
|
||||
$this->model = $schema['model'] ?? null;
|
||||
$this->view = $schema['view'] ?? null;
|
||||
|
||||
if ($data === null) {
|
||||
if (($schema['default'] ?? null) instanceof Closure === false) {
|
||||
throw new Exception(message: 'Missing collection data');
|
||||
}
|
||||
|
||||
$this->data = $schema['default']->call($this->api);
|
||||
}
|
||||
|
||||
if (
|
||||
isset($schema['type']) === true &&
|
||||
$this->data instanceof $schema['type'] === false
|
||||
) {
|
||||
throw new Exception(message: 'Invalid collection type');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function select($keys = null): static
|
||||
{
|
||||
if ($keys === false) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (is_string($keys)) {
|
||||
$keys = Str::split($keys);
|
||||
}
|
||||
|
||||
if ($keys !== null && is_array($keys) === false) {
|
||||
throw new Exception(message: 'Invalid select keys');
|
||||
}
|
||||
|
||||
$this->select = $keys;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($this->data as $item) {
|
||||
$model = $this->api->model($this->model, $item);
|
||||
|
||||
if ($this->view !== null) {
|
||||
$model = $model->view($this->view);
|
||||
}
|
||||
|
||||
if ($this->select !== null) {
|
||||
$model = $model->select($this->select);
|
||||
}
|
||||
|
||||
$result[] = $model->toArray();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function toResponse(): array
|
||||
{
|
||||
if ($query = $this->api->requestQuery('query')) {
|
||||
$this->data = $this->data->query($query);
|
||||
}
|
||||
|
||||
if (!$this->data->pagination()) {
|
||||
$this->data = $this->data->paginate([
|
||||
'page' => $this->api->requestQuery('page', 1),
|
||||
'limit' => $this->api->requestQuery('limit', 100)
|
||||
]);
|
||||
}
|
||||
|
||||
$pagination = $this->data->pagination();
|
||||
|
||||
if ($select = $this->api->requestQuery('select')) {
|
||||
$this->select($select);
|
||||
}
|
||||
|
||||
if ($view = $this->api->requestQuery('view')) {
|
||||
$this->view($view);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'data' => $this->toArray(),
|
||||
'pagination' => [
|
||||
'page' => $pagination->page(),
|
||||
'total' => $pagination->total(),
|
||||
'offset' => $pagination->offset(),
|
||||
'limit' => $pagination->limit(),
|
||||
],
|
||||
'status' => 'ok',
|
||||
'type' => 'collection'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function view(string $view): static
|
||||
{
|
||||
$this->view = $view;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
156
kirby/src/Api/Controller/Changes.php
Normal file
156
kirby/src/Api/Controller/Changes.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Api\Controller;
|
||||
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Content\Lock;
|
||||
use Kirby\Exception\PermissionException;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Form\Fields;
|
||||
use Kirby\Form\Form;
|
||||
|
||||
/**
|
||||
* The Changes controller takes care of the request logic
|
||||
* to save, discard and publish changes.
|
||||
*
|
||||
* @package Kirby Api
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Changes
|
||||
{
|
||||
/**
|
||||
* Cleans up legacy lock files. The `discard`, `publish` and `save` actions
|
||||
* are perfect for this cleanup job. They will be stopped early if
|
||||
* the lock is still active and otherwise, we can use them to clean
|
||||
* up outdated .lock files to keep the content folders clean. This
|
||||
* can be removed as soon as old .lock files should no longer be around.
|
||||
*
|
||||
* @todo Remove in 6.0.0
|
||||
*/
|
||||
protected static function cleanup(ModelWithContent $model): void
|
||||
{
|
||||
F::remove(Lock::legacyFile($model));
|
||||
}
|
||||
|
||||
/**
|
||||
* Discards unsaved changes by deleting the changes version
|
||||
*/
|
||||
public static function discard(ModelWithContent $model): array
|
||||
{
|
||||
if ($model->permissions()->can('update') === false) {
|
||||
throw new PermissionException(
|
||||
key: 'version.discard.permission',
|
||||
);
|
||||
}
|
||||
|
||||
$model->version('changes')->delete('current');
|
||||
|
||||
// Removes the old .lock file when it is no longer needed
|
||||
// @todo Remove in 6.0.0
|
||||
static::cleanup($model);
|
||||
|
||||
return [
|
||||
'status' => 'ok'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the lastest state of changes first and then publishes them
|
||||
*/
|
||||
public static function publish(ModelWithContent $model, array $input): array
|
||||
{
|
||||
if ($model->permissions()->can('update') === false) {
|
||||
throw new PermissionException(
|
||||
key: 'version.publish.permission',
|
||||
);
|
||||
}
|
||||
|
||||
// save the given changes first
|
||||
static::save(
|
||||
model: $model,
|
||||
input: $input
|
||||
);
|
||||
|
||||
// Removes the old .lock file when it is no longer needed
|
||||
// @todo Remove in 6.0.0
|
||||
static::cleanup($model);
|
||||
|
||||
// get the changes version
|
||||
$changes = $model->version('changes');
|
||||
|
||||
// if the changes version does not exist, we need to return early
|
||||
if ($changes->exists('current') === false) {
|
||||
return [
|
||||
'status' => 'ok',
|
||||
];
|
||||
}
|
||||
|
||||
// publish the changes
|
||||
$changes->publish(
|
||||
language: 'current'
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => 'ok'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves form input in a new or existing `changes` version
|
||||
*/
|
||||
public static function save(ModelWithContent $model, array $input): array
|
||||
{
|
||||
if ($model->permissions()->can('update') === false) {
|
||||
throw new PermissionException(
|
||||
key: 'version.save.permission',
|
||||
);
|
||||
}
|
||||
|
||||
// Removes the old .lock file when it is no longer needed
|
||||
// @todo Remove in 6.0.0
|
||||
static::cleanup($model);
|
||||
|
||||
// get the current language
|
||||
$language = Language::ensure('current');
|
||||
|
||||
// create the fields instance for the model
|
||||
$fields = Fields::for($model, $language);
|
||||
|
||||
// get the changes and latest version for the model
|
||||
$changes = $model->version('changes');
|
||||
$latest = $model->version('latest');
|
||||
|
||||
// get the source version for the existing content
|
||||
$source = $changes->exists($language) === true ? $changes : $latest;
|
||||
$content = $source->content($language)->toArray();
|
||||
|
||||
// fill in the form values and pass through any values that are not
|
||||
// defined as fields, such as uuid, title or similar.
|
||||
$fields->fill(input: $content);
|
||||
|
||||
// submit the new values from the request input
|
||||
$fields->submit(input: $input);
|
||||
|
||||
// save the changes
|
||||
$changes->save(
|
||||
fields: $fields->toStoredValues(),
|
||||
language: $language
|
||||
);
|
||||
|
||||
// if the changes are identical to the latest version,
|
||||
// we can delete the changes version already at this point
|
||||
if ($changes->isIdentical(version: $latest, language: $language)) {
|
||||
$changes->delete(
|
||||
language: $language
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok'
|
||||
];
|
||||
}
|
||||
}
|
||||
227
kirby/src/Api/Model.php
Normal file
227
kirby/src/Api/Model.php
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Api;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The API Model class can be wrapped around any
|
||||
* kind of object. Each model defines a set of properties that
|
||||
* are available in REST calls. Those properties are defined as
|
||||
* simple Closures which are resolved on demand. This is inspired
|
||||
* by GraphQLs architecture and makes it possible to load
|
||||
* only the model data that is needed for the current API call.
|
||||
*
|
||||
* @package Kirby Api
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Model
|
||||
{
|
||||
protected array $fields;
|
||||
protected array|null $select;
|
||||
protected array $views;
|
||||
|
||||
/**
|
||||
* Model constructor
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct(
|
||||
protected Api $api,
|
||||
protected object|array|string|null $data,
|
||||
array $schema
|
||||
) {
|
||||
$this->fields = $schema['fields'] ?? [];
|
||||
$this->select = $schema['select'] ?? null;
|
||||
$this->views = $schema['views'] ?? [];
|
||||
|
||||
if (
|
||||
$this->select === null &&
|
||||
array_key_exists('default', $this->views)
|
||||
) {
|
||||
$this->view('default');
|
||||
}
|
||||
|
||||
if ($data === null) {
|
||||
if (($schema['default'] ?? null) instanceof Closure === false) {
|
||||
throw new Exception(message: 'Missing model data');
|
||||
}
|
||||
|
||||
$this->data = $schema['default']->call($this->api);
|
||||
}
|
||||
|
||||
if (
|
||||
isset($schema['type']) === true &&
|
||||
$this->data instanceof $schema['type'] === false
|
||||
) {
|
||||
$class = match ($this->data) {
|
||||
null => 'null',
|
||||
default => $this->data::class,
|
||||
};
|
||||
throw new Exception(sprintf('Invalid model type "%s" expected: "%s"', $class, $schema['type']));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function select($keys = null): static
|
||||
{
|
||||
if ($keys === false) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (is_string($keys)) {
|
||||
$keys = Str::split($keys);
|
||||
}
|
||||
|
||||
if ($keys !== null && is_array($keys) === false) {
|
||||
throw new Exception(message: 'Invalid select keys');
|
||||
}
|
||||
|
||||
$this->select = $keys;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function selection(): array
|
||||
{
|
||||
$select = $this->select;
|
||||
$select ??= array_keys($this->fields);
|
||||
$selection = [];
|
||||
|
||||
foreach ($select as $key => $value) {
|
||||
if (is_int($key) === true) {
|
||||
$selection[$value] = [
|
||||
'view' => null,
|
||||
'select' => null
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value) === true) {
|
||||
if ($value === 'any') {
|
||||
throw new Exception(message: 'Invalid sub view: "any"');
|
||||
}
|
||||
|
||||
$selection[$key] = [
|
||||
'view' => $value,
|
||||
'select' => null
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($value) === true) {
|
||||
$selection[$key] = [
|
||||
'view' => null,
|
||||
'select' => $value
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $selection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$select = $this->selection();
|
||||
$result = [];
|
||||
|
||||
foreach ($this->fields as $key => $resolver) {
|
||||
if (
|
||||
array_key_exists($key, $select) === false ||
|
||||
$resolver instanceof Closure === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $resolver->call($this->api, $this->data);
|
||||
|
||||
if (is_object($value)) {
|
||||
$value = $this->api->resolve($value);
|
||||
}
|
||||
|
||||
if (
|
||||
$value instanceof Collection ||
|
||||
$value instanceof self
|
||||
) {
|
||||
$selection = $select[$key];
|
||||
|
||||
if ($subview = $selection['view']) {
|
||||
$value->view($subview);
|
||||
}
|
||||
|
||||
if ($subselect = $selection['select']) {
|
||||
$value->select($subselect);
|
||||
}
|
||||
|
||||
$value = $value->toArray();
|
||||
}
|
||||
|
||||
$result[$key] = $value;
|
||||
}
|
||||
|
||||
ksort($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function toResponse(): array
|
||||
{
|
||||
$model = $this;
|
||||
|
||||
if ($select = $this->api->requestQuery('select')) {
|
||||
$model = $model->select($select);
|
||||
}
|
||||
|
||||
if ($view = $this->api->requestQuery('view')) {
|
||||
$model = $model->view($view);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'data' => $model->toArray(),
|
||||
'status' => 'ok',
|
||||
'type' => 'model'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function view(string $name): static
|
||||
{
|
||||
if ($name === 'any') {
|
||||
return $this->select(null);
|
||||
}
|
||||
|
||||
if (isset($this->views[$name]) === false) {
|
||||
$name = 'default';
|
||||
|
||||
// try to fall back to the default view at least
|
||||
if (isset($this->views[$name]) === false) {
|
||||
throw new Exception(sprintf('The view "%s" does not exist', $name));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->select($this->views[$name]);
|
||||
}
|
||||
}
|
||||
436
kirby/src/Api/Upload.php
Normal file
436
kirby/src/Api/Upload.php
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Api;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Cms\FileRules;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Exception\DuplicateException;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The Upload class handles file uploads in the
|
||||
* context of the API. It adds support for chunked
|
||||
* uploads.
|
||||
*
|
||||
* @package Kirby Api
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
readonly class Upload
|
||||
{
|
||||
public function __construct(
|
||||
protected Api $api,
|
||||
protected bool $single = true,
|
||||
protected bool $debug = false
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a clean chunk ID by stripping forbidden characters
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException Too short ID string
|
||||
*/
|
||||
public static function chunkId(string $id): string
|
||||
{
|
||||
$id = Str::slug($id, '', 'a-z0-9');
|
||||
|
||||
if (strlen($id) < 3) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Chunk ID must at least be 3 characters long'
|
||||
);
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ideal size for a file chunk
|
||||
*/
|
||||
public static function chunkSize(): int
|
||||
{
|
||||
$max = [
|
||||
Str::toBytes(ini_get('upload_max_filesize')),
|
||||
Str::toBytes(ini_get('post_max_size'))
|
||||
];
|
||||
|
||||
// consider cloudflare proxy limit, if detected
|
||||
if (isset($_SERVER['HTTP_CF_CONNECTING_IP']) === true) {
|
||||
$max[] = Str::toBytes('100M');
|
||||
}
|
||||
|
||||
// to be sure, only use 95% of the max possible upload size
|
||||
return (int)floor(min($max) * 0.95);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up tmp directory of stale files
|
||||
*/
|
||||
public static function cleanTmpDir(): void
|
||||
{
|
||||
foreach (Dir::files($dir = static::tmpDir(), [], true) as $file) {
|
||||
// remove any file that hasn't been altered
|
||||
// in the last 24 hours
|
||||
if (F::modified($file) < time() - 86400) {
|
||||
F::remove($file);
|
||||
}
|
||||
}
|
||||
|
||||
// remove tmp directory if completely empty
|
||||
if (Dir::isEmpty($dir) === true) {
|
||||
Dir::remove($dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an exception with the appropriate translated error message
|
||||
*
|
||||
* @throws \Exception Any upload error
|
||||
*/
|
||||
public static function error(int $error): never
|
||||
{
|
||||
// get error messages from translation
|
||||
$message = [
|
||||
UPLOAD_ERR_INI_SIZE => I18n::translate('upload.error.iniSize'),
|
||||
UPLOAD_ERR_FORM_SIZE => I18n::translate('upload.error.formSize'),
|
||||
UPLOAD_ERR_PARTIAL => I18n::translate('upload.error.partial'),
|
||||
UPLOAD_ERR_NO_FILE => I18n::translate('upload.error.noFile'),
|
||||
UPLOAD_ERR_NO_TMP_DIR => I18n::translate('upload.error.tmpDir'),
|
||||
UPLOAD_ERR_CANT_WRITE => I18n::translate('upload.error.cantWrite'),
|
||||
UPLOAD_ERR_EXTENSION => I18n::translate('upload.error.extension')
|
||||
];
|
||||
|
||||
throw new Exception(
|
||||
message: $message[$error] ?? I18n::translate('upload.error.default', 'The file could not be uploaded')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the filename and extension
|
||||
* based on the detected mime type
|
||||
*/
|
||||
public static function filename(array $upload): string
|
||||
{
|
||||
// get the extension of the uploaded file
|
||||
$extension = F::extension($upload['name']);
|
||||
|
||||
// try to detect the correct mime and add the extension
|
||||
// accordingly. This will avoid .tmp filenames
|
||||
if (
|
||||
empty($extension) === true ||
|
||||
in_array($extension, ['tmp', 'temp'], true) === true
|
||||
) {
|
||||
$mime = F::mime($upload['tmp_name']);
|
||||
$extension = F::mimeToExtension($mime);
|
||||
$filename = F::name($upload['name']) . '.' . $extension;
|
||||
return $filename;
|
||||
}
|
||||
|
||||
return basename($upload['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the files and call closure for each file
|
||||
*
|
||||
* @throws \Exception Any upload error
|
||||
*/
|
||||
public function process(Closure $callback): array
|
||||
{
|
||||
$files = $this->api->requestFiles();
|
||||
$uploads = [];
|
||||
$errors = [];
|
||||
|
||||
static::validateFiles($files);
|
||||
|
||||
foreach ($files as $upload) {
|
||||
if (
|
||||
isset($upload['tmp_name']) === false &&
|
||||
is_array($upload) === true
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($upload['error'] !== 0) {
|
||||
static::error($upload['error']);
|
||||
}
|
||||
|
||||
$filename = static::filename($upload);
|
||||
$source = $this->source($upload['tmp_name'], $filename);
|
||||
|
||||
// if the file is uploaded in chunks…
|
||||
if ($this->api->requestHeaders('Upload-Length')) {
|
||||
$source = $this->processChunk($source, $filename);
|
||||
}
|
||||
|
||||
// apply callback only to complete uploads
|
||||
// (incomplete chunk request will return empty $source)
|
||||
$data = match ($source) {
|
||||
null => null,
|
||||
default => $callback($source, $filename)
|
||||
};
|
||||
|
||||
$uploads[$upload['name']] = match (true) {
|
||||
is_object($data) => $this->api->resolve($data)->toArray(),
|
||||
default => $data
|
||||
};
|
||||
} catch (Exception $e) {
|
||||
$errors[$upload['name']] = $e->getMessage();
|
||||
|
||||
// clean up file from system tmp directory
|
||||
F::unlink($upload['tmp_name']);
|
||||
}
|
||||
|
||||
if ($this->single === true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return static::response($uploads, $errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle chunked uploads by merging all chunks
|
||||
* in the tmp directory and only returning the new
|
||||
* $source path to the tmp file once complete
|
||||
*
|
||||
* @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
|
||||
* @throws \Kirby\Exception\Exception Chunk offset does not match existing tmp file
|
||||
* @throws \Kirby\Exception\InvalidArgumentException Too short ID string
|
||||
* @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
|
||||
*/
|
||||
public function processChunk(
|
||||
string $source,
|
||||
string $filename
|
||||
): string|null {
|
||||
// ensure the tmp upload directory exists
|
||||
Dir::make($dir = static::tmpDir());
|
||||
|
||||
// create path for file in tmp upload directory;
|
||||
// prefix with id while file isn't completely uploaded yet
|
||||
$id = $this->api->requestHeaders('Upload-Id', '');
|
||||
$id = static::chunkId($id);
|
||||
$total = (int)$this->api->requestHeaders('Upload-Length');
|
||||
$filename = basename($filename);
|
||||
$tmpRoot = $dir . '/' . $id . '-' . $filename;
|
||||
|
||||
// validate various aspects of the request
|
||||
// to ensure the chunk isn't trying to do malicious actions
|
||||
static::validateChunk(
|
||||
source: $source,
|
||||
tmp: $tmpRoot,
|
||||
total: $total,
|
||||
offset: $this->api->requestHeaders('Upload-Offset'),
|
||||
template: $this->api->requestBody('template'),
|
||||
);
|
||||
|
||||
// stream chunk content and append it to partial file
|
||||
stream_copy_to_stream(
|
||||
fopen($source, 'r'),
|
||||
fopen($tmpRoot, 'a')
|
||||
);
|
||||
|
||||
// clear file stat cache so the following call to `F::size`
|
||||
// really returns the updated file size
|
||||
clearstatcache();
|
||||
|
||||
// if file isn't complete yet, return early
|
||||
if (F::size($tmpRoot) < $total) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// remove id from partial filename now the file is complete,
|
||||
// so we can pass the path from the tmp upload directory
|
||||
// as new source path for the file back to the API upload method
|
||||
rename(
|
||||
$tmpRoot,
|
||||
$source = $dir . '/' . $filename
|
||||
);
|
||||
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert uploads and errors in response array for API response
|
||||
*/
|
||||
public static function response(
|
||||
array $uploads,
|
||||
array $errors
|
||||
): array {
|
||||
if (count($uploads) + count($errors) <= 1) {
|
||||
if (count($errors) > 0) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => current($errors)
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'data' => $uploads ? current($uploads) : null
|
||||
];
|
||||
}
|
||||
|
||||
if (count($errors) > 0) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'errors' => $errors
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'data' => $uploads
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the tmp file to a location including the extension,
|
||||
* for better mime detection and return updated source path
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function source(string $source, string $filename): string
|
||||
{
|
||||
if ($this->debug === true) {
|
||||
return $source;
|
||||
}
|
||||
|
||||
$target = dirname($source) . '/' . uniqid() . '.' . $filename;
|
||||
|
||||
if (move_uploaded_file($source, $target)) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
message: I18n::translate('upload.error.cantMove')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns root of directory used for
|
||||
* temporarily storing (incomplete) uploads
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
protected static function tmpDir(): string
|
||||
{
|
||||
return App::instance()->root('cache') . '/.uploads';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the sent chunk is valid
|
||||
*
|
||||
* @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
|
||||
* @throws \Kirby\Exception\InvalidArgumentException Chunk offset does not match existing tmp file
|
||||
* @throws \Kirby\Exception\InvalidArgumentException The maximum file size for this blueprint was exceeded
|
||||
* @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
|
||||
*/
|
||||
protected static function validateChunk(
|
||||
string $source,
|
||||
string $tmp,
|
||||
int $total,
|
||||
int $offset,
|
||||
string|null $template = null
|
||||
): void {
|
||||
$file = new File([
|
||||
'parent' => new Page(['slug' => 'tmp']),
|
||||
'filename' => $filename = basename($tmp),
|
||||
'template' => $template
|
||||
]);
|
||||
|
||||
// if the blueprint `maxsize` option is set,
|
||||
// ensure that the total size communicated in the header
|
||||
// as well as the current tmp size after adding this chunk
|
||||
// do not exceed the max limit
|
||||
if (
|
||||
($max = $file->blueprint()->accept()['maxsize'] ?? null) &&
|
||||
(
|
||||
$total > $max ||
|
||||
(F::size($source) + F::size($tmp)) > $max
|
||||
)
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.maxsize'
|
||||
);
|
||||
}
|
||||
|
||||
// validate the first chunk
|
||||
if ($offset === 0) {
|
||||
// sent chunk is expected to be the first part,
|
||||
// but tmp file already exists
|
||||
if (F::exists($tmp) === true) {
|
||||
throw new DuplicateException(
|
||||
message: 'A tmp file upload with the same filename and upload id already exists: ' . $filename
|
||||
);
|
||||
}
|
||||
|
||||
// validate file (extension, name) for first chunk;
|
||||
// will also be validate again by `$model->createFile()`
|
||||
// when completely uploaded
|
||||
FileRules::validFile($file, false);
|
||||
|
||||
// first chunk is valid
|
||||
return;
|
||||
}
|
||||
|
||||
// validate subsequent chunks:
|
||||
// no tmp in place
|
||||
if (F::exists($tmp) === false) {
|
||||
throw new NotFoundException(
|
||||
message: 'Chunk offset ' . $offset . ' for non-existing tmp file: ' . $filename
|
||||
);
|
||||
}
|
||||
|
||||
// sent chunk's offset is not the continuation of the tmp file
|
||||
if ($offset !== F::size($tmp)) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Chunk offset ' . $offset . ' does not match the existing tmp upload file size of ' . F::size($tmp)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the files array for upload
|
||||
*
|
||||
* @throws \Exception No files were uploaded
|
||||
*/
|
||||
protected static function validateFiles(array $files): void
|
||||
{
|
||||
if ($files === []) {
|
||||
$postMaxSize = Str::toBytes(ini_get('post_max_size'));
|
||||
$uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
if ($postMaxSize < $uploadMaxFileSize) {
|
||||
throw new Exception(
|
||||
message:
|
||||
I18n::translate(
|
||||
'upload.error.iniPostSize',
|
||||
'The uploaded file exceeds the post_max_size directive in php.ini'
|
||||
)
|
||||
);
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
|
||||
throw new Exception(
|
||||
message:
|
||||
I18n::translate(
|
||||
'upload.error.noFiles',
|
||||
'No files were uploaded'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
kirby/src/Cache/ApcuCache.php
Normal file
83
kirby/src/Cache/ApcuCache.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cache;
|
||||
|
||||
use APCUIterator;
|
||||
|
||||
/**
|
||||
* APCu Cache Driver
|
||||
*
|
||||
* @package Kirby Cache
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class ApcuCache extends Cache
|
||||
{
|
||||
/**
|
||||
* Returns whether the cache is ready to
|
||||
* store values
|
||||
*/
|
||||
public function enabled(): bool
|
||||
{
|
||||
return apcu_enabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an item exists in the cache
|
||||
*/
|
||||
public function exists(string $key): bool
|
||||
{
|
||||
return apcu_exists($this->key($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes the entire cache and returns
|
||||
* whether the operation was successful
|
||||
*/
|
||||
public function flush(): bool
|
||||
{
|
||||
if (empty($this->options['prefix']) === false) {
|
||||
return apcu_delete(new APCUIterator('!^' . preg_quote($this->options['prefix']) . '!'));
|
||||
}
|
||||
|
||||
return apcu_clear_cache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from the cache and returns
|
||||
* whether the operation was successful
|
||||
*/
|
||||
public function remove(string $key): bool
|
||||
{
|
||||
return apcu_delete($this->key($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to retrieve the raw cache value;
|
||||
* needs to return a Value object or null if not found
|
||||
*/
|
||||
public function retrieve(string $key): Value|null
|
||||
{
|
||||
$value = apcu_fetch($this->key($key));
|
||||
return Value::fromJson($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an item to the cache for a given number of minutes and
|
||||
* returns whether the operation was successful
|
||||
*
|
||||
* ```php
|
||||
* // put an item in the cache for 15 minutes
|
||||
* $cache->set('value', 'my value', 15);
|
||||
* ```
|
||||
*/
|
||||
public function set(string $key, $value, int $minutes = 0): bool
|
||||
{
|
||||
$key = $this->key($key);
|
||||
$value = (new Value($value, $minutes))->toJson();
|
||||
$expires = $this->expiration($minutes);
|
||||
return apcu_store($key, $value, $expires);
|
||||
}
|
||||
}
|
||||
237
kirby/src/Cache/Cache.php
Normal file
237
kirby/src/Cache/Cache.php
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cache;
|
||||
|
||||
use Closure;
|
||||
|
||||
/**
|
||||
* Cache foundation
|
||||
* This abstract class is used as
|
||||
* foundation for other cache drivers
|
||||
* by extending it
|
||||
*
|
||||
* @package Kirby Cache
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
abstract class Cache
|
||||
{
|
||||
/**
|
||||
* Stores all options for the driver
|
||||
*/
|
||||
protected array $options = [];
|
||||
|
||||
/**
|
||||
* Sets all parameters which are needed to connect to the cache storage
|
||||
*/
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
$this->options = $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks when the cache has been created;
|
||||
* returns the creation timestamp on success
|
||||
* and false if the item does not exist
|
||||
*/
|
||||
public function created(string $key): int|false
|
||||
{
|
||||
// get the Value object
|
||||
$value = $this->retrieve($key);
|
||||
|
||||
// check for a valid Value object
|
||||
if ($value instanceof Value === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// return the expires timestamp
|
||||
return $value->created();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the cache is ready to
|
||||
* store values
|
||||
*/
|
||||
public function enabled(): bool
|
||||
{
|
||||
// TODO: Make this method abstract in a future
|
||||
// release to ensure that cache drivers override it;
|
||||
// until then, we assume that the cache is enabled
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an item exists in the cache
|
||||
*/
|
||||
public function exists(string $key): bool
|
||||
{
|
||||
return $this->expired($key) === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the expiration timestamp
|
||||
*/
|
||||
protected function expiration(int $minutes = 0): int
|
||||
{
|
||||
// 0 = keep forever
|
||||
if ($minutes === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// calculate the time
|
||||
return time() + ($minutes * 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks when an item in the cache expires;
|
||||
* returns the expiry timestamp on success, null if the
|
||||
* item never expires and false if the item does not exist
|
||||
*/
|
||||
public function expires(string $key): int|false|null
|
||||
{
|
||||
// get the Value object
|
||||
$value = $this->retrieve($key);
|
||||
|
||||
// check for a valid Value object
|
||||
if ($value instanceof Value === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// return the expires timestamp
|
||||
return $value->expires();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an item in the cache is expired
|
||||
*/
|
||||
public function expired(string $key): bool
|
||||
{
|
||||
$expires = $this->expires($key);
|
||||
|
||||
if ($expires === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_int($expires) === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return time() >= $expires;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes the entire cache and returns
|
||||
* whether the operation was successful;
|
||||
* this needs to be defined by the driver
|
||||
*/
|
||||
abstract public function flush(): bool;
|
||||
|
||||
/**
|
||||
* Gets an item from the cache
|
||||
*
|
||||
* ```php
|
||||
* // get an item from the cache driver
|
||||
* $value = $cache->get('value');
|
||||
*
|
||||
* // return a default value if the requested item isn't cached
|
||||
* $value = $cache->get('value', 'default value');
|
||||
* ```
|
||||
*/
|
||||
public function get(string $key, $default = null)
|
||||
{
|
||||
// get the Value
|
||||
$value = $this->retrieve($key);
|
||||
|
||||
// check for a valid cache value
|
||||
if ($value instanceof Value === false) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
// remove the item if it is expired
|
||||
if ($value->expires() > 0 && time() >= $value->expires()) {
|
||||
$this->remove($key);
|
||||
return $default;
|
||||
}
|
||||
|
||||
// return the pure value
|
||||
return $value->value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a value by either getting it from the cache
|
||||
* or via the callback function which then is stored in
|
||||
* the cache for future retrieval. This method cannot be
|
||||
* used for `null` as value to be cached.
|
||||
* @since 3.8.0
|
||||
*/
|
||||
public function getOrSet(
|
||||
string $key,
|
||||
Closure $result,
|
||||
int $minutes = 0
|
||||
) {
|
||||
$value = $this->get($key);
|
||||
$result = $value ?? $result();
|
||||
|
||||
if ($value === null) {
|
||||
$this->set($key, $result, $minutes);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the prefix to the key if given
|
||||
*/
|
||||
protected function key(string $key): string
|
||||
{
|
||||
if (empty($this->options['prefix']) === false) {
|
||||
$key = $this->options['prefix'] . '/' . $key;
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternate version for Cache::created($key)
|
||||
*/
|
||||
public function modified(string $key): int|false
|
||||
{
|
||||
return static::created($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all passed cache options
|
||||
*/
|
||||
public function options(): array
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from the cache and returns
|
||||
* whether the operation was successful;
|
||||
* this needs to be defined by the driver
|
||||
*/
|
||||
abstract public function remove(string $key): bool;
|
||||
|
||||
/**
|
||||
* Internal method to retrieve the raw cache value;
|
||||
* needs to return a Value object or null if not found;
|
||||
* this needs to be defined by the driver
|
||||
*/
|
||||
abstract public function retrieve(string $key): Value|null;
|
||||
|
||||
/**
|
||||
* Writes an item to the cache for a given number of minutes and
|
||||
* returns whether the operation was successful;
|
||||
* this needs to be defined by the driver
|
||||
*
|
||||
* ```php
|
||||
* // put an item in the cache for 15 minutes
|
||||
* $cache->set('value', 'my value', 15);
|
||||
* ```
|
||||
*/
|
||||
abstract public function set(string $key, $value, int $minutes = 0): bool;
|
||||
}
|
||||
226
kirby/src/Cache/FileCache.php
Normal file
226
kirby/src/Cache/FileCache.php
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cache;
|
||||
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* File System Cache Driver
|
||||
*
|
||||
* @package Kirby Cache
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class FileCache extends Cache
|
||||
{
|
||||
/**
|
||||
* Full root including prefix
|
||||
*/
|
||||
protected string $root;
|
||||
|
||||
/**
|
||||
* Sets all parameters which are needed for the file cache
|
||||
*
|
||||
* @param array $options 'root' (required)
|
||||
* 'prefix' (default: none)
|
||||
* 'extension' (file extension for cache files, default: none)
|
||||
*/
|
||||
public function __construct(array $options)
|
||||
{
|
||||
parent::__construct([
|
||||
'root' => null,
|
||||
'prefix' => null,
|
||||
'extension' => null,
|
||||
...$options
|
||||
]);
|
||||
|
||||
// build the full root including prefix
|
||||
$this->root = $this->options['root'];
|
||||
|
||||
if (empty($this->options['prefix']) === false) {
|
||||
$this->root .= '/' . $this->options['prefix'];
|
||||
}
|
||||
|
||||
// try to create the directory
|
||||
Dir::make($this->root, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the cache is ready to
|
||||
* store values
|
||||
*/
|
||||
public function enabled(): bool
|
||||
{
|
||||
return is_writable($this->root) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full root including prefix
|
||||
*/
|
||||
public function root(): string
|
||||
{
|
||||
return $this->root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full path to a file for a given key
|
||||
*/
|
||||
protected function file(string $key): string
|
||||
{
|
||||
// strip out invalid characters in each path segment
|
||||
// split by slash or backslash
|
||||
$keyParts = [];
|
||||
foreach (preg_split('#([\/\\\\])#', $key, 0, PREG_SPLIT_DELIM_CAPTURE) as $part) {
|
||||
switch ($part) {
|
||||
case '/':
|
||||
// forward slashes don't need special treatment
|
||||
break;
|
||||
|
||||
case '\\':
|
||||
// backslashes get their own marker in the path
|
||||
// to differentiate the cache key from one with forward slashes
|
||||
$keyParts[] = '_backslash';
|
||||
break;
|
||||
|
||||
case '':
|
||||
// empty part means two slashes in a row;
|
||||
// special marker like for backslashes
|
||||
$keyParts[] = '_empty';
|
||||
break;
|
||||
|
||||
default:
|
||||
// an actual path segment:
|
||||
// check if the segment only contains safe characters;
|
||||
// underscores are *not* safe to guarantee uniqueness
|
||||
// as they are used in the special cases
|
||||
if (preg_match('/^[a-zA-Z0-9-]+$/', $part) === 1) {
|
||||
$keyParts[] = $part;
|
||||
} else {
|
||||
$keyParts[] = Str::slug($part) . '_' . sha1($part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$file = $this->root . '/' . implode('/', $keyParts);
|
||||
|
||||
if (isset($this->options['extension'])) {
|
||||
return $file . '.' . $this->options['extension'];
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an item to the cache for a given number of minutes and
|
||||
* returns whether the operation was successful
|
||||
*
|
||||
* ```php
|
||||
* // put an item in the cache for 15 minutes
|
||||
* $cache->set('value', 'my value', 15);
|
||||
* ```
|
||||
*/
|
||||
public function set(string $key, $value, int $minutes = 0): bool
|
||||
{
|
||||
$file = $this->file($key);
|
||||
|
||||
return F::write($file, (new Value($value, $minutes))->toJson());
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to retrieve the raw cache value;
|
||||
* needs to return a Value object or null if not found
|
||||
*/
|
||||
public function retrieve(string $key): Value|null
|
||||
{
|
||||
$file = $this->file($key);
|
||||
$value = F::read($file);
|
||||
|
||||
return $value ? Value::fromJson($value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks when the cache has been created;
|
||||
* returns the creation timestamp on success
|
||||
* and false if the item does not exist
|
||||
*/
|
||||
public function created(string $key): int|false
|
||||
{
|
||||
// use the modification timestamp
|
||||
// as indicator when the cache has been created/overwritten
|
||||
clearstatcache();
|
||||
|
||||
// get the file for this cache key
|
||||
$file = $this->file($key);
|
||||
return file_exists($file) ? filemtime($file) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from the cache and returns
|
||||
* whether the operation was successful
|
||||
*/
|
||||
public function remove(string $key): bool
|
||||
{
|
||||
$file = $this->file($key);
|
||||
|
||||
if (is_file($file) === true && F::remove($file) === true) {
|
||||
$this->removeEmptyDirectories(dirname($file));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes empty directories safely by checking each directory
|
||||
* up to the root directory
|
||||
*/
|
||||
protected function removeEmptyDirectories(string $dir): void
|
||||
{
|
||||
try {
|
||||
// ensure the path doesn't end with a slash for the next comparison
|
||||
$dir = rtrim($dir, '/\/');
|
||||
|
||||
// checks all directory segments until reaching the root directory
|
||||
while (Str::startsWith($dir, $this->root()) === true && $dir !== $this->root()) {
|
||||
$files = scandir($dir);
|
||||
|
||||
if ($files === false) {
|
||||
$files = []; // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
$files = array_diff($files, ['.', '..']);
|
||||
|
||||
if ($files === [] && Dir::remove($dir) === true) {
|
||||
// continue with the next level up
|
||||
$dir = dirname($dir);
|
||||
} else {
|
||||
// no need to continue with the next level up as `$dir` was not deleted
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception) { // @codeCoverageIgnore
|
||||
// silently stops the process
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes the entire cache and returns
|
||||
* whether the operation was successful
|
||||
*/
|
||||
public function flush(): bool
|
||||
{
|
||||
if (
|
||||
Dir::remove($this->root) === true &&
|
||||
Dir::make($this->root) === true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // @codeCoverageIgnore
|
||||
}
|
||||
}
|
||||
105
kirby/src/Cache/MemCached.php
Normal file
105
kirby/src/Cache/MemCached.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cache;
|
||||
|
||||
use Memcached as MemcachedExt;
|
||||
|
||||
/**
|
||||
* Memcached Driver
|
||||
*
|
||||
* @package Kirby Cache
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class MemCached extends Cache
|
||||
{
|
||||
/**
|
||||
* Store for the memcache connection
|
||||
*/
|
||||
protected MemcachedExt $connection;
|
||||
|
||||
/**
|
||||
* Stores whether the connection was successful
|
||||
*/
|
||||
protected bool $enabled;
|
||||
|
||||
/**
|
||||
* Sets all parameters which are needed to connect to Memcached
|
||||
*
|
||||
* @param array $options 'host' (default: localhost)
|
||||
* 'port' (default: 11211)
|
||||
* 'prefix' (default: null)
|
||||
*/
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
parent::__construct([
|
||||
'host' => 'localhost',
|
||||
'port' => 11211,
|
||||
'prefix' => null,
|
||||
...$options
|
||||
]);
|
||||
|
||||
$this->connection = new MemcachedExt();
|
||||
$this->enabled = $this->connection->addServer(
|
||||
$this->options['host'],
|
||||
$this->options['port']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the cache is ready to
|
||||
* store values
|
||||
*/
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an item to the cache for a given number of minutes and
|
||||
* returns whether the operation was successful
|
||||
*
|
||||
* ```php
|
||||
* // put an item in the cache for 15 minutes
|
||||
* $cache->set('value', 'my value', 15);
|
||||
* ```
|
||||
*/
|
||||
public function set(string $key, $value, int $minutes = 0): bool
|
||||
{
|
||||
$key = $this->key($key);
|
||||
$value = (new Value($value, $minutes))->toJson();
|
||||
$expires = $this->expiration($minutes);
|
||||
return $this->connection->set($key, $value, $expires);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to retrieve the raw cache value;
|
||||
* needs to return a Value object or null if not found
|
||||
*/
|
||||
public function retrieve(string $key): Value|null
|
||||
{
|
||||
$value = $this->connection->get($this->key($key));
|
||||
return Value::fromJson($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from the cache and returns
|
||||
* whether the operation was successful
|
||||
*/
|
||||
public function remove(string $key): bool
|
||||
{
|
||||
return $this->connection->delete($this->key($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes the entire cache and returns
|
||||
* whether the operation was successful;
|
||||
* WARNING: Memcached only supports flushing the whole cache at once!
|
||||
*/
|
||||
public function flush(): bool
|
||||
{
|
||||
return $this->connection->flush();
|
||||
}
|
||||
}
|
||||
77
kirby/src/Cache/MemoryCache.php
Normal file
77
kirby/src/Cache/MemoryCache.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cache;
|
||||
|
||||
/**
|
||||
* Memory Cache Driver (cache in memory for current request only)
|
||||
*
|
||||
* @package Kirby Cache
|
||||
* @author Lukas Bestle <lukas@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class MemoryCache extends Cache
|
||||
{
|
||||
/**
|
||||
* Cache data
|
||||
*/
|
||||
protected array $store = [];
|
||||
|
||||
/**
|
||||
* Returns whether the cache is ready to
|
||||
* store values
|
||||
*/
|
||||
public function enabled(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an item to the cache for a given number of minutes and
|
||||
* returns whether the operation was successful
|
||||
*
|
||||
* ```php
|
||||
* // put an item in the cache for 15 minutes
|
||||
* $cache->set('value', 'my value', 15);
|
||||
* ```
|
||||
*/
|
||||
public function set(string $key, $value, int $minutes = 0): bool
|
||||
{
|
||||
$this->store[$key] = new Value($value, $minutes);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to retrieve the raw cache value;
|
||||
* needs to return a Value object or null if not found
|
||||
*/
|
||||
public function retrieve(string $key): Value|null
|
||||
{
|
||||
return $this->store[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from the cache and returns
|
||||
* whether the operation was successful
|
||||
*/
|
||||
public function remove(string $key): bool
|
||||
{
|
||||
if (isset($this->store[$key])) {
|
||||
unset($this->store[$key]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes the entire cache and returns
|
||||
* whether the operation was successful
|
||||
*/
|
||||
public function flush(): bool
|
||||
{
|
||||
$this->store = [];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
65
kirby/src/Cache/NullCache.php
Normal file
65
kirby/src/Cache/NullCache.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cache;
|
||||
|
||||
/**
|
||||
* Dummy Cache Driver (does not do any caching)
|
||||
*
|
||||
* @package Kirby Cache
|
||||
* @author Lukas Bestle <lukas@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class NullCache extends Cache
|
||||
{
|
||||
/**
|
||||
* Returns whether the cache is ready to
|
||||
* store values
|
||||
*/
|
||||
public function enabled(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an item to the cache for a given number of minutes and
|
||||
* returns whether the operation was successful
|
||||
*
|
||||
* ```php
|
||||
* // put an item in the cache for 15 minutes
|
||||
* $cache->set('value', 'my value', 15);
|
||||
* ```
|
||||
*/
|
||||
public function set(string $key, $value, int $minutes = 0): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to retrieve the raw cache value;
|
||||
* needs to return a Value object or null if not found
|
||||
*/
|
||||
public function retrieve(string $key): Value|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from the cache and returns
|
||||
* whether the operation was successful
|
||||
*/
|
||||
public function remove(string $key): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes the entire cache and returns
|
||||
* whether the operation was successful
|
||||
*/
|
||||
public function flush(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
160
kirby/src/Cache/RedisCache.php
Normal file
160
kirby/src/Cache/RedisCache.php
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cache;
|
||||
|
||||
use Kirby\Cms\Helpers;
|
||||
use Redis;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Redis Cache Driver
|
||||
*
|
||||
* @package Kirby Cache
|
||||
* @author Ahmet Bora <ahmet@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class RedisCache extends Cache
|
||||
{
|
||||
/**
|
||||
* Store for the redis connection
|
||||
*/
|
||||
protected Redis $connection;
|
||||
|
||||
/**
|
||||
* Sets all parameters which are needed to connect to Redis
|
||||
*
|
||||
* @param array $options 'host' (default: 127.0.0.1)
|
||||
* 'port' (default: 6379)
|
||||
*/
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
$options = [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 6379,
|
||||
...$options
|
||||
];
|
||||
|
||||
parent::__construct($options);
|
||||
|
||||
// available options for the redis driver
|
||||
$allowed = [
|
||||
'host',
|
||||
'port',
|
||||
'readTimeout',
|
||||
'connectTimeout',
|
||||
'persistent',
|
||||
'auth',
|
||||
'ssl',
|
||||
'retryInterval',
|
||||
'backoff'
|
||||
];
|
||||
|
||||
// filters only redis supported keys
|
||||
$redisOptions = array_intersect_key($options, array_flip($allowed));
|
||||
|
||||
// creates redis connection
|
||||
$this->connection = new Redis($redisOptions);
|
||||
|
||||
// sets the prefix if defined
|
||||
if ($prefix = $options['prefix'] ?? null) {
|
||||
$this->connection->setOption(Redis::OPT_PREFIX, rtrim($prefix, '/') . '/');
|
||||
}
|
||||
|
||||
// selects the database if defined
|
||||
$database = $options['database'] ?? null;
|
||||
if ($database !== null) {
|
||||
$this->connection->select($database);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the database number
|
||||
*/
|
||||
public function databaseNum(): int
|
||||
{
|
||||
return $this->connection->getDbNum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the cache is ready to store values
|
||||
*/
|
||||
public function enabled(): bool
|
||||
{
|
||||
try {
|
||||
return Helpers::handleErrors(
|
||||
fn () => $this->connection->ping(),
|
||||
fn (int $errno, string $errstr) => true,
|
||||
fn () => false
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an item exists in the cache
|
||||
*/
|
||||
public function exists(string $key): bool
|
||||
{
|
||||
return $this->connection->exists($this->key($key)) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes keys from the database
|
||||
* and returns whether the operation was successful
|
||||
*/
|
||||
public function flush(): bool
|
||||
{
|
||||
return $this->connection->flushDB();
|
||||
}
|
||||
|
||||
/**
|
||||
* The key is not modified, because the prefix is added by the redis driver itself
|
||||
*/
|
||||
protected function key(string $key): string
|
||||
{
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from the cache
|
||||
* and returns whether the operation was successful
|
||||
*/
|
||||
public function remove(string $key): bool
|
||||
{
|
||||
return $this->connection->del($this->key($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to retrieve the raw cache value;
|
||||
* needs to return a Value object or null if not found
|
||||
*/
|
||||
public function retrieve(string $key): Value|null
|
||||
{
|
||||
$value = $this->connection->get($this->key($key));
|
||||
return Value::fromJson($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an item to the cache for a given number of minutes
|
||||
* and returns whether the operation was successful
|
||||
*
|
||||
* ```php
|
||||
* // put an item in the cache for 15 minutes
|
||||
* $cache->set('value', 'my value', 15);
|
||||
* ```
|
||||
*/
|
||||
public function set(string $key, $value, int $minutes = 0): bool
|
||||
{
|
||||
$key = $this->key($key);
|
||||
$value = (new Value($value, $minutes))->toJson();
|
||||
|
||||
if ($minutes > 0) {
|
||||
return $this->connection->setex($key, $minutes * 60, $value);
|
||||
}
|
||||
|
||||
return $this->connection->set($key, $value);
|
||||
}
|
||||
}
|
||||
137
kirby/src/Cache/Value.php
Normal file
137
kirby/src/Cache/Value.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cache;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Cache Value
|
||||
* Stores the value, creation timestamp and expiration timestamp
|
||||
* and makes it possible to store all three with a single cache key
|
||||
*
|
||||
* @package Kirby Cache
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Value
|
||||
{
|
||||
/**
|
||||
* Cached value
|
||||
*/
|
||||
protected mixed $value;
|
||||
|
||||
/**
|
||||
* the number of minutes until the value expires
|
||||
* @todo Rename this property to $expiry to reflect
|
||||
* both minutes and absolute timestamps
|
||||
*/
|
||||
protected int $minutes;
|
||||
|
||||
/**
|
||||
* Creation timestamp
|
||||
*/
|
||||
protected int $created;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param int $minutes the number of minutes until the value expires
|
||||
* or an absolute UNIX timestamp
|
||||
* @param int|null $created the UNIX timestamp when the value has been created
|
||||
* (defaults to the current time)
|
||||
*/
|
||||
public function __construct($value, int $minutes = 0, int|null $created = null)
|
||||
{
|
||||
$this->value = $value;
|
||||
$this->minutes = $minutes;
|
||||
$this->created = $created ?? time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the creation date as UNIX timestamp
|
||||
*/
|
||||
public function created(): int
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the expiration date as UNIX timestamp or
|
||||
* null if the value never expires
|
||||
*/
|
||||
public function expires(): int|null
|
||||
{
|
||||
// 0 = keep forever
|
||||
if ($this->minutes === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->minutes > 1000000000) {
|
||||
// absolute timestamp
|
||||
return $this->minutes;
|
||||
}
|
||||
|
||||
return $this->created + ($this->minutes * 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a value object from an array
|
||||
*/
|
||||
public static function fromArray(array $array): static
|
||||
{
|
||||
return new static(
|
||||
$array['value'] ?? null,
|
||||
$array['minutes'] ?? 0,
|
||||
$array['created'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a value object from a JSON string;
|
||||
* returns null on error
|
||||
*/
|
||||
public static function fromJson(string $json): static|null
|
||||
{
|
||||
try {
|
||||
$array = json_decode($json, true);
|
||||
|
||||
if (is_array($array) === true) {
|
||||
return static::fromArray($array);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the object to a JSON string
|
||||
*/
|
||||
public function toJson(): string
|
||||
{
|
||||
return json_encode($this->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the object to an array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'created' => $this->created,
|
||||
'minutes' => $this->minutes,
|
||||
'value' => $this->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the pure value
|
||||
*/
|
||||
public function value()
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
260
kirby/src/Cms/Api.php
Normal file
260
kirby/src/Cms/Api.php
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Api\Api as BaseApi;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Form\Form;
|
||||
use Kirby\Session\Session;
|
||||
|
||||
/**
|
||||
* Api
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Api extends BaseApi
|
||||
{
|
||||
protected App $kirby;
|
||||
|
||||
public function __construct(array $props)
|
||||
{
|
||||
$this->kirby = $props['kirby'];
|
||||
parent::__construct($props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an API call for the given path,
|
||||
* request method and optional request data
|
||||
*/
|
||||
public function call(
|
||||
string|null $path = null,
|
||||
string $method = 'GET',
|
||||
array $requestData = []
|
||||
): mixed {
|
||||
$this->setRequestMethod($method);
|
||||
$this->setRequestData($requestData);
|
||||
|
||||
$this->kirby->setCurrentLanguage($this->language());
|
||||
|
||||
$allowImpersonation = $this->kirby()->option('api.allowImpersonation', false);
|
||||
|
||||
$translation = $this->kirby->user(null, $allowImpersonation)?->language();
|
||||
$translation ??= $this->kirby->panelLanguage();
|
||||
$this->kirby->setCurrentTranslation($translation);
|
||||
|
||||
return parent::call($path, $method, $requestData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance while
|
||||
* merging initial and new properties
|
||||
*/
|
||||
public function clone(array $props = []): static
|
||||
{
|
||||
return parent::clone([
|
||||
'kirby' => $this->kirby,
|
||||
...$props
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\NotFoundException if the field type cannot be found or the field cannot be loaded
|
||||
*/
|
||||
public function fieldApi(
|
||||
ModelWithContent $model,
|
||||
string $name,
|
||||
string|null $path = null
|
||||
): mixed {
|
||||
$field = Form::for($model)->field($name);
|
||||
|
||||
$fieldApi = $this->clone([
|
||||
'data' => [...$this->data(), 'field' => $field],
|
||||
'routes' => $field->api(),
|
||||
]);
|
||||
|
||||
return $fieldApi->call(
|
||||
$path,
|
||||
$this->requestMethod(),
|
||||
$this->requestData()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file object for the given
|
||||
* parent path and filename
|
||||
*
|
||||
* @param string $path Path to file's parent model
|
||||
* @throws \Kirby\Exception\NotFoundException if the file cannot be found
|
||||
*/
|
||||
public function file(
|
||||
string $path,
|
||||
string $filename
|
||||
): File|null {
|
||||
return Find::file($path, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the all readable files for the parent
|
||||
*
|
||||
* @param string $path Path to file's parent model
|
||||
* @throws \Kirby\Exception\NotFoundException if the file cannot be found
|
||||
*/
|
||||
public function files(string $path): Files
|
||||
{
|
||||
return $this->parent($path)->files()->filter('isAccessible', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model's object for the given path
|
||||
*
|
||||
* @param string $path Path to parent model
|
||||
* @throws \Kirby\Exception\InvalidArgumentException if the model type is invalid
|
||||
* @throws \Kirby\Exception\NotFoundException if the model cannot be found
|
||||
*/
|
||||
public function parent(string $path): ModelWithContent|null
|
||||
{
|
||||
return Find::parent($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Kirby instance
|
||||
*/
|
||||
public function kirby(): App
|
||||
{
|
||||
return $this->kirby;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the language request header
|
||||
*/
|
||||
public function language(): string|null
|
||||
{
|
||||
return
|
||||
$this->requestQuery('language') ??
|
||||
$this->requestHeaders('x-language');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the page object for the given id
|
||||
*
|
||||
* @param string $id Page's id
|
||||
* @throws \Kirby\Exception\NotFoundException if the page cannot be found
|
||||
*/
|
||||
public function page(string $id): Page|null
|
||||
{
|
||||
return Find::page($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subpages for the given
|
||||
* parent. The subpages can be filtered
|
||||
* by status (draft, listed, unlisted, published, all)
|
||||
*/
|
||||
public function pages(
|
||||
string|null $parentId = null,
|
||||
string|null $status = null
|
||||
): Pages {
|
||||
$parent = $parentId === null ? $this->site() : $this->page($parentId);
|
||||
$pages = match ($status) {
|
||||
'all' => $parent->childrenAndDrafts(),
|
||||
'draft', 'drafts' => $parent->drafts(),
|
||||
'listed' => $parent->children()->listed(),
|
||||
'unlisted' => $parent->children()->unlisted(),
|
||||
'published' => $parent->children(),
|
||||
default => $parent->children()
|
||||
};
|
||||
|
||||
return $pages->filter('isAccessible', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for direct subpages of the
|
||||
* given parent
|
||||
*/
|
||||
public function searchPages(string|null $parent = null): Pages
|
||||
{
|
||||
$pages = $this->pages($parent, $this->requestQuery('status'));
|
||||
|
||||
if ($this->requestMethod() === 'GET') {
|
||||
return $pages->search($this->requestQuery('q'));
|
||||
}
|
||||
|
||||
return $pages->query($this->requestBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\NotFoundException if the section type cannot be found or the section cannot be loaded
|
||||
*/
|
||||
public function sectionApi(
|
||||
ModelWithContent $model,
|
||||
string $name,
|
||||
string|null $path = null
|
||||
): mixed {
|
||||
if (!$section = $model->blueprint()?->section($name)) {
|
||||
throw new NotFoundException(
|
||||
message: 'The section "' . $name . '" could not be found'
|
||||
);
|
||||
}
|
||||
|
||||
$sectionApi = $this->clone([
|
||||
'data' => [...$this->data(), 'section' => $section],
|
||||
'routes' => $section->api(),
|
||||
]);
|
||||
|
||||
return $sectionApi->call(
|
||||
$path,
|
||||
$this->requestMethod(),
|
||||
$this->requestData()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current Session instance
|
||||
*
|
||||
* @param array $options Additional options, see the session component
|
||||
*/
|
||||
public function session(array $options = []): Session
|
||||
{
|
||||
return $this->kirby->session(['detect' => true, ...$options]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the site object
|
||||
*/
|
||||
public function site(): Site
|
||||
{
|
||||
return $this->kirby->site();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user object for the given id or
|
||||
* returns the current authenticated user if no
|
||||
* id is passed
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException if the user for the given id cannot be found
|
||||
*/
|
||||
public function user(string|null $id = null): User|null
|
||||
{
|
||||
try {
|
||||
return Find::user($id);
|
||||
} catch (NotFoundException $e) {
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the users collection
|
||||
*/
|
||||
public function users(): Users
|
||||
{
|
||||
return $this->kirby->users();
|
||||
}
|
||||
}
|
||||
1737
kirby/src/Cms/App.php
Normal file
1737
kirby/src/Cms/App.php
Normal file
File diff suppressed because it is too large
Load diff
131
kirby/src/Cms/AppCaches.php
Normal file
131
kirby/src/Cms/AppCaches.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Cache\Cache;
|
||||
use Kirby\Cache\NullCache;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* AppCaches
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait AppCaches
|
||||
{
|
||||
protected array $caches = [];
|
||||
|
||||
/**
|
||||
* Returns a cache instance by key
|
||||
*/
|
||||
public function cache(string $key): Cache
|
||||
{
|
||||
if (isset($this->caches[$key]) === true) {
|
||||
return $this->caches[$key];
|
||||
}
|
||||
|
||||
// get the options for this cache type
|
||||
$options = $this->cacheOptions($key);
|
||||
|
||||
if ($options['active'] === false) {
|
||||
// use a dummy cache that does nothing
|
||||
return $this->caches[$key] = new NullCache();
|
||||
}
|
||||
|
||||
$type = strtolower($options['type']);
|
||||
$types = $this->extensions['cacheTypes'] ?? [];
|
||||
|
||||
if (array_key_exists($type, $types) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'cache.type.invalid',
|
||||
data: ['type' => $type]
|
||||
);
|
||||
}
|
||||
|
||||
$className = $types[$type];
|
||||
|
||||
// initialize the cache class
|
||||
$cache = new $className($options);
|
||||
|
||||
// check if it is a usable cache object
|
||||
if ($cache instanceof Cache === false) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'cache.type.invalid',
|
||||
data: ['type' => $type]
|
||||
);
|
||||
}
|
||||
|
||||
return $this->caches[$key] = $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cache options by key
|
||||
*/
|
||||
protected function cacheOptions(string $key): array
|
||||
{
|
||||
$options = $this->option($this->cacheOptionsKey($key), null);
|
||||
$options ??= $this->core()->caches()[$key] ?? false;
|
||||
|
||||
if ($options === false) {
|
||||
return [
|
||||
'active' => false
|
||||
];
|
||||
}
|
||||
|
||||
$prefix =
|
||||
str_replace(['/', ':'], '_', $this->system()->indexUrl()) .
|
||||
'/' .
|
||||
str_replace(['/', '.'], ['_', '/'], $key);
|
||||
|
||||
$defaults = [
|
||||
'active' => true,
|
||||
'type' => 'file',
|
||||
'extension' => 'cache',
|
||||
'root' => $this->root('cache'),
|
||||
'prefix' => $prefix
|
||||
];
|
||||
|
||||
if ($options === true) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
return [...$defaults, ...$options];
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes care of converting prefixed plugin cache setups
|
||||
* to the right cache key, while leaving regular cache
|
||||
* setups untouched.
|
||||
*/
|
||||
protected function cacheOptionsKey(string $key): string
|
||||
{
|
||||
$prefixedKey = 'cache.' . $key;
|
||||
|
||||
if (isset($this->options[$prefixedKey])) {
|
||||
return $prefixedKey;
|
||||
}
|
||||
|
||||
// plain keys without dots don't need further investigation
|
||||
// since they can never be from a plugin.
|
||||
if (str_contains($key, '.') === false) {
|
||||
return $prefixedKey;
|
||||
}
|
||||
|
||||
// try to extract the plugin name
|
||||
$parts = explode('.', $key);
|
||||
$pluginName = implode('/', array_slice($parts, 0, 2));
|
||||
$pluginPrefix = implode('.', array_slice($parts, 0, 2));
|
||||
$cacheName = implode('.', array_slice($parts, 2));
|
||||
|
||||
// check if such a plugin exists
|
||||
if ($this->plugin($pluginName)) {
|
||||
return empty($cacheName) === true ? $pluginPrefix . '.cache' : $pluginPrefix . '.cache.' . $cacheName;
|
||||
}
|
||||
|
||||
return $prefixedKey;
|
||||
}
|
||||
}
|
||||
228
kirby/src/Cms/AppErrors.php
Normal file
228
kirby/src/Cms/AppErrors.php
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Http\Response;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Throwable;
|
||||
use Whoops\Handler\CallbackHandler;
|
||||
use Whoops\Handler\Handler;
|
||||
use Whoops\Handler\HandlerInterface;
|
||||
use Whoops\Handler\PlainTextHandler;
|
||||
use Whoops\Handler\PrettyPageHandler;
|
||||
use Whoops\Run as Whoops;
|
||||
|
||||
/**
|
||||
* PHP error handling using the Whoops library
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait AppErrors
|
||||
{
|
||||
/**
|
||||
* Allows to disable Whoops globally in CI;
|
||||
* can be overridden by explicitly setting
|
||||
* the `whoops` option to `true` or `false`
|
||||
*/
|
||||
public static bool $enableWhoops = true;
|
||||
|
||||
/**
|
||||
* Whoops instance cache
|
||||
*/
|
||||
protected Whoops $whoops;
|
||||
|
||||
/**
|
||||
* Registers the PHP error handler for CLI usage
|
||||
*/
|
||||
protected function handleCliErrors(): void
|
||||
{
|
||||
$this->setWhoopsHandler(new PlainTextHandler());
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the PHP error handler
|
||||
* based on the environment
|
||||
*/
|
||||
protected function handleErrors(): void
|
||||
{
|
||||
// no matter the environment, exit early if
|
||||
// Whoops was disabled globally
|
||||
// (but continue if the option was explicitly
|
||||
// set to `true` in the config)
|
||||
if (
|
||||
static::$enableWhoops === false &&
|
||||
$this->option('whoops') !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->environment()->cli() === true) {
|
||||
$this->handleCliErrors();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->visitor()->prefersJson() === true) {
|
||||
$this->handleJsonErrors();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handleHtmlErrors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the PHP error handler for HTML output
|
||||
*/
|
||||
protected function handleHtmlErrors(): void
|
||||
{
|
||||
$handler = null;
|
||||
|
||||
if ($this->option('debug') === true) {
|
||||
if ($this->option('whoops', true) !== false) {
|
||||
$handler = new PrettyPageHandler();
|
||||
$handler->setPageTitle('Kirby CMS Debugger');
|
||||
$handler->addResourcePath(dirname(__DIR__, 2) . '/assets');
|
||||
$handler->addCustomCss('whoops.css');
|
||||
|
||||
if ($editor = $this->option('editor')) {
|
||||
$handler->setEditor($editor);
|
||||
}
|
||||
|
||||
if ($blocklist = $this->option('whoops.blocklist')) {
|
||||
foreach ($blocklist as $superglobal => $vars) {
|
||||
foreach ($vars as $var) {
|
||||
$handler->blacklist($superglobal, $var);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$handler = new CallbackHandler(function ($exception, $inspector, $run) {
|
||||
$fatal = $this->option('fatal');
|
||||
|
||||
if ($fatal instanceof Closure) {
|
||||
echo $fatal($this, $exception);
|
||||
} else {
|
||||
include $this->root('kirby') . '/views/fatal.php';
|
||||
}
|
||||
|
||||
return Handler::QUIT;
|
||||
});
|
||||
}
|
||||
|
||||
if ($handler !== null) {
|
||||
$this->setWhoopsHandler($handler);
|
||||
} else {
|
||||
$this->unsetWhoopsHandler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the PHP error handler for JSON output
|
||||
*/
|
||||
protected function handleJsonErrors(): void
|
||||
{
|
||||
$handler = new CallbackHandler(function ($exception, $inspector, $run) {
|
||||
if ($exception instanceof Exception) {
|
||||
$httpCode = $exception->getHttpCode();
|
||||
$code = $exception->getCode();
|
||||
$details = $exception->getDetails();
|
||||
} elseif ($exception instanceof Throwable) {
|
||||
$httpCode = 500;
|
||||
$code = $exception->getCode();
|
||||
$details = null;
|
||||
} else {
|
||||
$httpCode = 500;
|
||||
$code = 500;
|
||||
$details = null;
|
||||
}
|
||||
|
||||
if ($this->option('debug') === true) {
|
||||
echo Response::json([
|
||||
'status' => 'error',
|
||||
'exception' => $exception::class,
|
||||
'code' => $code,
|
||||
'message' => $exception->getMessage(),
|
||||
'details' => $details,
|
||||
'file' => F::relativepath(
|
||||
$exception->getFile(),
|
||||
$this->environment()->get('DOCUMENT_ROOT', '')
|
||||
),
|
||||
'line' => $exception->getLine(),
|
||||
], $httpCode);
|
||||
} else {
|
||||
echo Response::json([
|
||||
'status' => 'error',
|
||||
'code' => $code,
|
||||
'details' => $details,
|
||||
'message' => I18n::translate('error.unexpected'),
|
||||
], $httpCode);
|
||||
}
|
||||
|
||||
return Handler::QUIT;
|
||||
});
|
||||
|
||||
$this->setWhoopsHandler($handler);
|
||||
$this->whoops()->sendHttpCode(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables Whoops with the specified handler
|
||||
*/
|
||||
protected function setWhoopsHandler(callable|HandlerInterface $handler): void
|
||||
{
|
||||
$whoops = $this->whoops();
|
||||
$whoops->clearHandlers();
|
||||
$whoops->pushHandler($handler);
|
||||
$whoops->pushHandler($this->getAdditionalWhoopsHandler());
|
||||
$whoops->register(); // will only do something if not already registered
|
||||
}
|
||||
|
||||
/**
|
||||
* Whoops callback handler for additional error handling
|
||||
* (`system.exception` hook and output to error log)
|
||||
*/
|
||||
protected function getAdditionalWhoopsHandler(): CallbackHandler
|
||||
{
|
||||
return new CallbackHandler(function ($exception, $inspector, $run) {
|
||||
$isLogged = true;
|
||||
|
||||
// allow hook to modify whether the exception should be logged
|
||||
$isLogged = $this->apply(
|
||||
'system.exception',
|
||||
compact('exception', 'isLogged'),
|
||||
'isLogged'
|
||||
);
|
||||
|
||||
if ($isLogged !== false) {
|
||||
error_log($exception);
|
||||
}
|
||||
|
||||
return Handler::DONE;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the Whoops handlers and disables Whoops
|
||||
*/
|
||||
protected function unsetWhoopsHandler(): void
|
||||
{
|
||||
$whoops = $this->whoops();
|
||||
$whoops->clearHandlers();
|
||||
$whoops->unregister(); // will only do something if currently registered
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Whoops error handler instance
|
||||
*/
|
||||
protected function whoops(): Whoops
|
||||
{
|
||||
return $this->whoops ??= new Whoops();
|
||||
}
|
||||
}
|
||||
963
kirby/src/Cms/AppPlugins.php
Normal file
963
kirby/src/Cms/AppPlugins.php
Normal file
|
|
@ -0,0 +1,963 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Content\Field;
|
||||
use Kirby\Exception\DuplicateException;
|
||||
use Kirby\Filesystem\Asset;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Filesystem\Mime;
|
||||
use Kirby\Form\Field as FormField;
|
||||
use Kirby\Image\Image;
|
||||
use Kirby\Plugin\License;
|
||||
use Kirby\Plugin\Plugin;
|
||||
use Kirby\Text\KirbyTag;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Collection as ToolkitCollection;
|
||||
use Kirby\Toolkit\V;
|
||||
|
||||
/**
|
||||
* AppPlugins
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait AppPlugins
|
||||
{
|
||||
/**
|
||||
* A list of all registered plugins
|
||||
*/
|
||||
protected static array $plugins = [];
|
||||
|
||||
/**
|
||||
* The extension registry
|
||||
*/
|
||||
protected array $extensions = [
|
||||
// load options first to make them available for the rest
|
||||
'options' => [],
|
||||
|
||||
// other plugin types
|
||||
'api' => [],
|
||||
'areas' => [],
|
||||
'assetMethods' => [],
|
||||
'authChallenges' => [],
|
||||
'blockMethods' => [],
|
||||
'blockModels' => [],
|
||||
'blocksMethods' => [],
|
||||
'blueprints' => [],
|
||||
'cacheTypes' => [],
|
||||
'collections' => [],
|
||||
'commands' => [],
|
||||
'components' => [],
|
||||
'controllers' => [],
|
||||
'collectionFilters' => [],
|
||||
'collectionMethods' => [],
|
||||
'fieldMethods' => [],
|
||||
'fileMethods' => [],
|
||||
'filePreviews' => [],
|
||||
'fileTypes' => [],
|
||||
'filesMethods' => [],
|
||||
'fields' => [],
|
||||
'hooks' => [],
|
||||
'layoutMethods' => [],
|
||||
'layoutColumnMethods' => [],
|
||||
'layoutsMethods' => [],
|
||||
'pages' => [],
|
||||
'pageMethods' => [],
|
||||
'pagesMethods' => [],
|
||||
'pageModels' => [],
|
||||
'permissions' => [],
|
||||
'routes' => [],
|
||||
'sections' => [],
|
||||
'siteMethods' => [],
|
||||
'snippets' => [],
|
||||
'structureMethods' => [],
|
||||
'structureObjectMethods' => [],
|
||||
'tags' => [],
|
||||
'templates' => [],
|
||||
'thirdParty' => [],
|
||||
'translations' => [],
|
||||
'userMethods' => [],
|
||||
'userModels' => [],
|
||||
'usersMethods' => [],
|
||||
'validators' => [],
|
||||
];
|
||||
|
||||
/**
|
||||
* Flag when plugins have been loaded
|
||||
* to not load them again
|
||||
*/
|
||||
protected bool $pluginsAreLoaded = false;
|
||||
|
||||
/**
|
||||
* Register all given extensions
|
||||
*
|
||||
* @param \Kirby\Plugin\Plugin|null $plugin The plugin which defined those extensions
|
||||
*/
|
||||
public function extend(
|
||||
array $extensions,
|
||||
Plugin|null $plugin = null
|
||||
): array {
|
||||
foreach ($this->extensions as $type => $registered) {
|
||||
if (isset($extensions[$type]) === true) {
|
||||
$this->{'extend' . $type}($extensions[$type], $plugin);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers API extensions
|
||||
*/
|
||||
protected function extendApi(array|bool $api): array
|
||||
{
|
||||
if (is_array($api) === true) {
|
||||
if (($api['routes'] ?? []) instanceof Closure) {
|
||||
$api['routes'] = $api['routes']($this);
|
||||
}
|
||||
|
||||
return $this->extensions['api'] = A::merge(
|
||||
$this->extensions['api'],
|
||||
$api,
|
||||
A::MERGE_APPEND
|
||||
);
|
||||
}
|
||||
|
||||
return $this->extensions['api'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional custom Panel areas
|
||||
*/
|
||||
protected function extendAreas(array $areas): array
|
||||
{
|
||||
foreach ($areas as $id => $area) {
|
||||
$this->extensions['areas'][$id] ??= [];
|
||||
$this->extensions['areas'][$id][] = $area;
|
||||
}
|
||||
|
||||
return $this->extensions['areas'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional asset methods
|
||||
*/
|
||||
protected function extendAssetMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['assetMethods'] = Asset::$methods = [
|
||||
...Asset::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional authentication challenges
|
||||
*/
|
||||
protected function extendAuthChallenges(array $challenges): array
|
||||
{
|
||||
return $this->extensions['authChallenges'] = Auth::$challenges = [
|
||||
...Auth::$challenges,
|
||||
...$challenges
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional block methods
|
||||
*/
|
||||
protected function extendBlockMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['blockMethods'] = Block::$methods = [
|
||||
...Block::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional block models
|
||||
*/
|
||||
protected function extendBlockModels(array $models): array
|
||||
{
|
||||
return $this->extensions['blockModels'] = Block::extendModels($models);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional blocks methods
|
||||
*/
|
||||
protected function extendBlocksMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['blockMethods'] = Blocks::$methods = [
|
||||
...Blocks::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional blueprints
|
||||
*/
|
||||
protected function extendBlueprints(array $blueprints): array
|
||||
{
|
||||
return $this->extensions['blueprints'] = [
|
||||
...$this->extensions['blueprints'],
|
||||
...$blueprints
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional cache types
|
||||
*/
|
||||
protected function extendCacheTypes(array $cacheTypes): array
|
||||
{
|
||||
return $this->extensions['cacheTypes'] = [
|
||||
...$this->extensions['cacheTypes'],
|
||||
...$cacheTypes
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional CLI commands
|
||||
*/
|
||||
protected function extendCommands(array $commands): array
|
||||
{
|
||||
return $this->extensions['commands'] = [
|
||||
...$this->extensions['commands'],
|
||||
...$commands
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional collection filters
|
||||
*/
|
||||
protected function extendCollectionFilters(array $filters): array
|
||||
{
|
||||
return $this->extensions['collectionFilters'] = ToolkitCollection::$filters = [
|
||||
...ToolkitCollection::$filters,
|
||||
...$filters
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional collection methods
|
||||
*/
|
||||
protected function extendCollectionMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['collectionMethods'] = Collection::$methods = [
|
||||
...Collection::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional collections
|
||||
*/
|
||||
protected function extendCollections(array $collections): array
|
||||
{
|
||||
return $this->extensions['collections'] = [
|
||||
...$this->extensions['collections'],
|
||||
...$collections
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers core components
|
||||
*/
|
||||
protected function extendComponents(array $components): array
|
||||
{
|
||||
return $this->extensions['components'] = [
|
||||
...$this->extensions['components'],
|
||||
...$components
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional controllers
|
||||
*/
|
||||
protected function extendControllers(array $controllers): array
|
||||
{
|
||||
return $this->extensions['controllers'] = [
|
||||
...$this->extensions['controllers'],
|
||||
...$controllers
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional file methods
|
||||
*/
|
||||
protected function extendFileMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['fileMethods'] = File::$methods = [
|
||||
...File::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional file preview handlers
|
||||
* @since 5.0.0
|
||||
*/
|
||||
protected function extendFilePreviews(array $previews): array
|
||||
{
|
||||
return $this->extensions['filePreviews'] = [
|
||||
...$previews,
|
||||
// make sure new previews go first, so that custom
|
||||
// handler can override core default previews
|
||||
...$this->extensions['filePreviews'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional custom file types and mimes
|
||||
*/
|
||||
protected function extendFileTypes(array $fileTypes): array
|
||||
{
|
||||
// normalize array
|
||||
foreach ($fileTypes as $ext => $file) {
|
||||
$extension = $file['extension'] ?? $ext;
|
||||
$type = $file['type'] ?? null;
|
||||
$mime = $file['mime'] ?? null;
|
||||
$resizable = $file['resizable'] ?? false;
|
||||
$viewable = $file['viewable'] ?? false;
|
||||
|
||||
if (is_string($type) === true) {
|
||||
if (isset(F::$types[$type]) === false) {
|
||||
F::$types[$type] = [];
|
||||
}
|
||||
|
||||
if (in_array($extension, F::$types[$type], true) === false) {
|
||||
F::$types[$type][] = $extension;
|
||||
}
|
||||
}
|
||||
|
||||
if ($mime !== null) {
|
||||
// if `Mime::$types[$extension]` is not already an array,
|
||||
// make it one and append the new MIME type
|
||||
// unless it's already in the list
|
||||
if (array_key_exists($extension, Mime::$types) === true) {
|
||||
Mime::$types[$extension] = array_unique([
|
||||
...(array)Mime::$types[$extension],
|
||||
...(array)$mime
|
||||
]);
|
||||
} else {
|
||||
Mime::$types[$extension] = $mime;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
$resizable === true &&
|
||||
in_array($extension, Image::$resizableTypes, true) === false
|
||||
) {
|
||||
Image::$resizableTypes[] = $extension;
|
||||
}
|
||||
|
||||
if (
|
||||
$viewable === true &&
|
||||
in_array($extension, Image::$viewableTypes, true) === false
|
||||
) {
|
||||
Image::$viewableTypes[] = $extension;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->extensions['fileTypes'] = [
|
||||
'type' => F::$types,
|
||||
'mime' => Mime::$types,
|
||||
'resizable' => Image::$resizableTypes,
|
||||
'viewable' => Image::$viewableTypes
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional files methods
|
||||
*/
|
||||
protected function extendFilesMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['filesMethods'] = Files::$methods = [
|
||||
...Files::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional field methods
|
||||
*/
|
||||
protected function extendFieldMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['fieldMethods'] = Field::$methods = [
|
||||
...Field::$methods,
|
||||
...array_change_key_case($methods)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers Panel fields
|
||||
*/
|
||||
protected function extendFields(array $fields): array
|
||||
{
|
||||
return $this->extensions['fields'] = FormField::$types = [
|
||||
...FormField::$types,
|
||||
...$fields
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers hooks
|
||||
*/
|
||||
protected function extendHooks(array $hooks): array
|
||||
{
|
||||
foreach ($hooks as $name => $callbacks) {
|
||||
$this->extensions['hooks'][$name] ??= [];
|
||||
|
||||
if (is_array($callbacks) === false) {
|
||||
$callbacks = [$callbacks];
|
||||
}
|
||||
|
||||
foreach ($callbacks as $callback) {
|
||||
$this->extensions['hooks'][$name][] = $callback;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->extensions['hooks'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers markdown component
|
||||
*/
|
||||
protected function extendMarkdown(Closure $markdown): Closure
|
||||
{
|
||||
return $this->extensions['markdown'] = $markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional layout methods
|
||||
*/
|
||||
protected function extendLayoutMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['layoutMethods'] = Layout::$methods = [
|
||||
...Layout::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional layout column methods
|
||||
*/
|
||||
protected function extendLayoutColumnMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['layoutColumnMethods'] = LayoutColumn::$methods = [
|
||||
...LayoutColumn::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional layouts methods
|
||||
*/
|
||||
protected function extendLayoutsMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['layoutsMethods'] = Layouts::$methods = [
|
||||
...Layouts::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional options
|
||||
*/
|
||||
protected function extendOptions(
|
||||
array $options,
|
||||
Plugin|null $plugin = null
|
||||
): array {
|
||||
if ($plugin !== null) {
|
||||
$options = [$plugin->prefix() => $options];
|
||||
}
|
||||
|
||||
return $this->extensions['options'] = $this->options = A::merge(
|
||||
$options,
|
||||
$this->options,
|
||||
A::MERGE_REPLACE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional page methods
|
||||
*/
|
||||
protected function extendPageMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['pageMethods'] = Page::$methods = [
|
||||
...Page::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional pages methods
|
||||
*/
|
||||
protected function extendPagesMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['pagesMethods'] = Pages::$methods = [
|
||||
...Pages::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional page models
|
||||
*/
|
||||
protected function extendPageModels(array $models): array
|
||||
{
|
||||
return $this->extensions['pageModels'] = Page::extendModels($models);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers pages
|
||||
*/
|
||||
protected function extendPages(array $pages): array
|
||||
{
|
||||
return $this->extensions['pages'] = [
|
||||
...$this->extensions['pages'],
|
||||
...$pages
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional permissions
|
||||
*/
|
||||
protected function extendPermissions(
|
||||
array $permissions,
|
||||
Plugin|null $plugin = null
|
||||
): array {
|
||||
if ($plugin !== null) {
|
||||
$permissions = [$plugin->prefix() => $permissions];
|
||||
}
|
||||
|
||||
return $this->extensions['permissions'] = Permissions::$extendedActions = [
|
||||
...Permissions::$extendedActions,
|
||||
...$permissions
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional routes
|
||||
*/
|
||||
protected function extendRoutes(array|Closure $routes): array
|
||||
{
|
||||
if ($routes instanceof Closure) {
|
||||
$routes = $routes($this);
|
||||
}
|
||||
|
||||
return $this->extensions['routes'] = [
|
||||
...$this->extensions['routes'],
|
||||
...$routes
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers Panel sections
|
||||
*/
|
||||
protected function extendSections(array $sections): array
|
||||
{
|
||||
return $this->extensions['sections'] = Section::$types = [
|
||||
...Section::$types,
|
||||
...$sections
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional site methods
|
||||
*/
|
||||
protected function extendSiteMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['siteMethods'] = Site::$methods = [
|
||||
...Site::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers SmartyPants component
|
||||
*/
|
||||
protected function extendSmartypants(Closure $smartypants): Closure
|
||||
{
|
||||
return $this->extensions['smartypants'] = $smartypants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional snippets
|
||||
*/
|
||||
protected function extendSnippets(array $snippets): array
|
||||
{
|
||||
return $this->extensions['snippets'] = [
|
||||
...$this->extensions['snippets'],
|
||||
...$snippets
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional structure methods
|
||||
*/
|
||||
protected function extendStructureMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['structureMethods'] = Structure::$methods = [
|
||||
...Structure::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional structure object methods
|
||||
*/
|
||||
protected function extendStructureObjectMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['structureObjectMethods'] = StructureObject::$methods = [
|
||||
...StructureObject::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional KirbyTags
|
||||
*/
|
||||
protected function extendTags(array $tags): array
|
||||
{
|
||||
return $this->extensions['tags'] = KirbyTag::$types = [
|
||||
...KirbyTag::$types,
|
||||
...array_change_key_case($tags)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional templates
|
||||
*/
|
||||
protected function extendTemplates(array $templates): array
|
||||
{
|
||||
return $this->extensions['templates'] = [
|
||||
...$this->extensions['templates'],
|
||||
...$templates
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers translations
|
||||
*/
|
||||
protected function extendTranslations(array $translations): array
|
||||
{
|
||||
return $this->extensions['translations'] = array_replace_recursive(
|
||||
$this->extensions['translations'],
|
||||
$translations
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add third party extensions to the registry
|
||||
* so they can be used as plugins for plugins
|
||||
* for example.
|
||||
*/
|
||||
protected function extendThirdParty(array $extensions): array
|
||||
{
|
||||
return $this->extensions['thirdParty'] = array_replace_recursive(
|
||||
$this->extensions['thirdParty'],
|
||||
$extensions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional user methods
|
||||
*/
|
||||
protected function extendUserMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['userMethods'] = User::$methods = [
|
||||
...User::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional user models
|
||||
*/
|
||||
protected function extendUserModels(array $models): array
|
||||
{
|
||||
return $this->extensions['userModels'] = User::extendModels($models);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional users methods
|
||||
*/
|
||||
protected function extendUsersMethods(array $methods): array
|
||||
{
|
||||
return $this->extensions['usersMethods'] = Users::$methods = [
|
||||
...Users::$methods,
|
||||
...$methods
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers additional custom validators
|
||||
*/
|
||||
protected function extendValidators(array $validators): array
|
||||
{
|
||||
return $this->extensions['validators'] = V::$validators = [
|
||||
...V::$validators,
|
||||
...$validators
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a given extension by type and name
|
||||
*
|
||||
* @param string $type i.e. `'hooks'`
|
||||
* @param string $name i.e. `'page.delete:before'`
|
||||
*/
|
||||
public function extension(
|
||||
string $type,
|
||||
string $name,
|
||||
mixed $fallback = null
|
||||
): mixed {
|
||||
return $this->extensions($type)[$name] ?? $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the extensions registry
|
||||
*/
|
||||
public function extensions(string|null $type = null): array
|
||||
{
|
||||
if ($type === null) {
|
||||
return $this->extensions;
|
||||
}
|
||||
|
||||
return $this->extensions[$type] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load extensions from site folders.
|
||||
* This is only used for models for now, but
|
||||
* could be extended later
|
||||
*/
|
||||
protected function extensionsFromFolders(): void
|
||||
{
|
||||
$models = [];
|
||||
|
||||
foreach (glob($this->root('models') . '/*.php') as $model) {
|
||||
$name = F::name($model);
|
||||
$class = str_replace(['.', '-', '_'], '', $name) . 'Page';
|
||||
|
||||
// load the model class
|
||||
F::loadOnce($model, allowOutput: false);
|
||||
|
||||
if (class_exists($class) === true) {
|
||||
$models[$name] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
$this->extendPageModels($models);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register extensions that could be located in
|
||||
* the options array. I.e. hooks and routes can be
|
||||
* setup from the config.
|
||||
*/
|
||||
protected function extensionsFromOptions(): void
|
||||
{
|
||||
// register routes and hooks from options
|
||||
$this->extend([
|
||||
'api' => $this->options['api'] ?? [],
|
||||
'routes' => $this->options['routes'] ?? [],
|
||||
'hooks' => $this->options['hooks'] ?? []
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all plugin extensions
|
||||
*/
|
||||
protected function extensionsFromPlugins(): void
|
||||
{
|
||||
// register all their extensions
|
||||
foreach ($this->plugins() as $plugin) {
|
||||
$extends = $plugin->extends();
|
||||
|
||||
if (empty($extends) === false) {
|
||||
$this->extend($extends, $plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all passed extensions
|
||||
*/
|
||||
protected function extensionsFromProps(array $props): void
|
||||
{
|
||||
$this->extend($props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all default extensions
|
||||
*/
|
||||
protected function extensionsFromSystem(): void
|
||||
{
|
||||
// Always start with fresh fields and sections
|
||||
// from the core and add plugins on top of that
|
||||
FormField::$types = [];
|
||||
Section::$types = [];
|
||||
|
||||
// mixins
|
||||
FormField::$mixins = $this->core->fieldMixins();
|
||||
Section::$mixins = $this->core->sectionMixins();
|
||||
|
||||
// aliases
|
||||
KirbyTag::$aliases = $this->core->kirbyTagAliases();
|
||||
Field::$aliases = $this->core->fieldMethodAliases();
|
||||
|
||||
// blueprint presets
|
||||
PageBlueprint::$presets = $this->core->blueprintPresets();
|
||||
|
||||
$this->extendAuthChallenges($this->core->authChallenges());
|
||||
$this->extendCacheTypes($this->core->cacheTypes());
|
||||
$this->extendComponents($this->core->components());
|
||||
$this->extendBlueprints($this->core->blueprints());
|
||||
$this->extendFieldMethods($this->core->fieldMethods());
|
||||
$this->extendFields($this->core->fields());
|
||||
$this->extendFilePreviews($this->core->filePreviews());
|
||||
$this->extendSections($this->core->sections());
|
||||
$this->extendSnippets($this->core->snippets());
|
||||
$this->extendTags($this->core->kirbyTags());
|
||||
$this->extendTemplates($this->core->templates());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a native component was extended
|
||||
* @since 3.7.0
|
||||
*/
|
||||
public function isNativeComponent(string $component): bool
|
||||
{
|
||||
return $this->component($component) === $this->nativeComponent($component);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the native implementation
|
||||
* of a core component
|
||||
*/
|
||||
public function nativeComponent(string $component): Closure|false
|
||||
{
|
||||
return $this->core->components()[$component] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kirby plugin factory and getter
|
||||
*
|
||||
* @param array|null $extends If null is passed it will be used as getter. Otherwise as factory.
|
||||
* @throws \Kirby\Exception\DuplicateException
|
||||
*/
|
||||
public static function plugin(
|
||||
string $name,
|
||||
array|null $extends = null,
|
||||
array $info = [],
|
||||
string|null $root = null,
|
||||
string|null $version = null,
|
||||
Closure|string|array|null $license = null,
|
||||
): Plugin|null {
|
||||
if ($extends === null) {
|
||||
return static::$plugins[$name] ?? null;
|
||||
}
|
||||
|
||||
$plugin = new Plugin(
|
||||
name: $name,
|
||||
extends: $extends,
|
||||
info: $info,
|
||||
license: $license,
|
||||
// TODO: Remove fallback to $extends in v7
|
||||
root: $root ?? $extends['root'] ?? dirname(debug_backtrace()[0]['file']),
|
||||
version: $version
|
||||
);
|
||||
|
||||
$name = $plugin->name();
|
||||
|
||||
if (isset(static::$plugins[$name]) === true) {
|
||||
throw new DuplicateException(
|
||||
message: 'The plugin "' . $name . '" has already been registered'
|
||||
);
|
||||
}
|
||||
|
||||
return static::$plugins[$name] = $plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and returns all plugins in the site/plugins directory
|
||||
* Loading only happens on the first call.
|
||||
*
|
||||
* @param array|null $plugins Can be used to overwrite the plugins registry
|
||||
*/
|
||||
public function plugins(array|null $plugins = null): array
|
||||
{
|
||||
// overwrite the existing plugins registry
|
||||
if ($plugins !== null) {
|
||||
$this->pluginsAreLoaded = true;
|
||||
return static::$plugins = $plugins;
|
||||
}
|
||||
|
||||
// don't load plugins twice
|
||||
if ($this->pluginsAreLoaded === true) {
|
||||
return static::$plugins;
|
||||
}
|
||||
|
||||
// load all plugins from site/plugins
|
||||
$this->pluginsLoader();
|
||||
|
||||
// mark plugins as loaded to stop doing it twice
|
||||
$this->pluginsAreLoaded = true;
|
||||
return static::$plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all plugins from site/plugins
|
||||
*
|
||||
* @return array Array of loaded directories
|
||||
*/
|
||||
protected function pluginsLoader(): array
|
||||
{
|
||||
$root = $this->root('plugins');
|
||||
$loaded = [];
|
||||
|
||||
foreach (Dir::read($root) as $dirname) {
|
||||
if (
|
||||
str_starts_with($dirname, '.') ||
|
||||
str_starts_with($dirname, '_')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dir = $root . '/' . $dirname;
|
||||
|
||||
if (is_dir($dir) !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = $dir . '/index.php';
|
||||
$script = $dir . '/index.js';
|
||||
$styles = $dir . '/index.css';
|
||||
|
||||
if (is_file($entry) === true) {
|
||||
F::loadOnce($entry, allowOutput: false);
|
||||
} elseif (is_file($script) === true || is_file($styles) === true) {
|
||||
// if no PHP file is present but an index.js or index.css,
|
||||
// register as anonymous plugin (without actual extensions)
|
||||
// to be picked up by the Panel\Document class when
|
||||
// rendering the Panel view
|
||||
static::plugin(
|
||||
name: 'plugins/' . $dirname,
|
||||
extends: [],
|
||||
root: $dir
|
||||
);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
$loaded[] = $dir;
|
||||
}
|
||||
|
||||
return $loaded;
|
||||
}
|
||||
}
|
||||
183
kirby/src/Cms/AppTranslations.php
Normal file
183
kirby/src/Cms/AppTranslations.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* AppTranslations
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait AppTranslations
|
||||
{
|
||||
protected Translations|null $translations = null;
|
||||
|
||||
/**
|
||||
* Setup internationalization
|
||||
*/
|
||||
protected function i18n(): void
|
||||
{
|
||||
I18n::$load = function ($locale): array {
|
||||
$data = $this->translation($locale)?->data() ?? [];
|
||||
|
||||
// inject translations from the current language
|
||||
if (
|
||||
$this->multilang() === true &&
|
||||
$language = $this->languages()->find($locale)
|
||||
) {
|
||||
$data = [...$data, ...$language->translations()];
|
||||
}
|
||||
|
||||
|
||||
return $data;
|
||||
};
|
||||
|
||||
// the actual locale is set using $app->setCurrentTranslation()
|
||||
I18n::$locale = function (): string {
|
||||
if ($this->multilang() === true) {
|
||||
return $this->defaultLanguage()->code();
|
||||
}
|
||||
|
||||
return 'en';
|
||||
};
|
||||
|
||||
I18n::$fallback = function (): array {
|
||||
if ($this->multilang() === true) {
|
||||
// first try to fall back to the configured default language
|
||||
$defaultCode = $this->defaultLanguage()->code();
|
||||
$fallback = [$defaultCode];
|
||||
|
||||
// if the default language is specified with a country code
|
||||
// (e.g. `en-us`), also try with just the language code
|
||||
if (preg_match('/^([a-z]{2})-[a-z]+$/i', $defaultCode, $matches) === 1) {
|
||||
$fallback[] = $matches[1];
|
||||
}
|
||||
|
||||
// fall back to the complete English translation
|
||||
// as a last resort
|
||||
$fallback[] = 'en';
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return ['en'];
|
||||
};
|
||||
|
||||
I18n::$translations = [];
|
||||
|
||||
// add slug rules based on config option
|
||||
if ($slugs = $this->option('slugs')) {
|
||||
// two ways that the option can be defined:
|
||||
// "slugs" => "de" or "slugs" => ["language" => "de"]
|
||||
Str::$language = Language::loadRules($slugs['language'] ?? $slugs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the language code that will be used
|
||||
* for the Panel if no user is logged in or if
|
||||
* no language is configured for the user
|
||||
*/
|
||||
public function panelLanguage(): string
|
||||
{
|
||||
$translation = $this->request()->get('translation');
|
||||
|
||||
if ($translation !== null && $this->translations()->find($translation)) {
|
||||
return $translation;
|
||||
}
|
||||
|
||||
if ($this->multilang() === true) {
|
||||
$defaultCode = $this->defaultLanguage()->code();
|
||||
|
||||
// extract the language code from a language that
|
||||
// contains the country code (e.g. `en-us`)
|
||||
if (preg_match('/^([a-z]{2})-[a-z]+$/i', $defaultCode, $matches) === 1) {
|
||||
$defaultCode = $matches[1];
|
||||
}
|
||||
} else {
|
||||
$defaultCode = 'en';
|
||||
}
|
||||
|
||||
return $this->option('panel.language', $defaultCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current translation
|
||||
*/
|
||||
public function setCurrentTranslation(string|null $translationCode = null): void
|
||||
{
|
||||
I18n::$locale = $translationCode ?? 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific translation by locale
|
||||
*
|
||||
* @param string|null $locale Locale name or `null` for the current locale
|
||||
*/
|
||||
public function translation(string|null $locale = null): Translation
|
||||
{
|
||||
$locale ??= I18n::locale();
|
||||
$locale = basename($locale);
|
||||
|
||||
// prefer loading them from the translations collection
|
||||
if ($this->translations instanceof Translations) {
|
||||
if ($translation = $this->translations()->find($locale)) {
|
||||
return $translation;
|
||||
}
|
||||
}
|
||||
|
||||
// get injected translation data from plugins etc.
|
||||
$inject = $this->extensions['translations'][$locale] ?? [];
|
||||
|
||||
// inject current language translations
|
||||
if ($language = $this->language($locale)) {
|
||||
$inject = [...$inject, ...$language->translations()];
|
||||
}
|
||||
|
||||
// load from disk instead
|
||||
return Translation::load(
|
||||
$locale,
|
||||
$this->root('i18n:translations') . '/' . $locale . '.json',
|
||||
$inject
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all available translations
|
||||
*/
|
||||
public function translations(): Translations
|
||||
{
|
||||
if ($this->translations instanceof Translations) {
|
||||
return $this->translations;
|
||||
}
|
||||
|
||||
$translations = $this->extensions['translations'] ?? [];
|
||||
|
||||
// injects languages translations
|
||||
if ($languages = $this->languages()) {
|
||||
foreach ($languages as $language) {
|
||||
$languageCode = $language->code();
|
||||
$languageTranslations = $language->translations();
|
||||
|
||||
// merges language translations with extensions translations
|
||||
if (empty($languageTranslations) === false) {
|
||||
$translations[$languageCode] = [
|
||||
...$translations[$languageCode] ?? [],
|
||||
...$languageTranslations
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->translations = Translations::load(
|
||||
$this->root('i18n:translations'),
|
||||
$translations
|
||||
);
|
||||
}
|
||||
}
|
||||
157
kirby/src/Cms/AppUsers.php
Normal file
157
kirby/src/Cms/AppUsers.php
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* AppUsers
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait AppUsers
|
||||
{
|
||||
protected Auth|null $auth = null;
|
||||
protected User|string|null $user = null;
|
||||
protected Users|null $users = null;
|
||||
|
||||
/**
|
||||
* Returns the Authentication layer class
|
||||
*/
|
||||
public function auth(): Auth
|
||||
{
|
||||
return $this->auth ??= new Auth($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Become any existing user or disable the current user
|
||||
*
|
||||
* @param string|null $who User ID or email address,
|
||||
* `null` to use the actual user again,
|
||||
* `'kirby'` for a virtual admin user or
|
||||
* `'nobody'` to disable the actual user
|
||||
* @param Closure|null $callback Optional action function that will be run with
|
||||
* the permissions of the impersonated user; the
|
||||
* impersonation will be reset afterwards
|
||||
* @return mixed If called without callback: User that was impersonated;
|
||||
* if called with callback: Return value from the callback
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function impersonate(
|
||||
string|null $who = null,
|
||||
Closure|null $callback = null
|
||||
): mixed {
|
||||
$auth = $this->auth();
|
||||
|
||||
$userBefore = $auth->currentUserFromImpersonation();
|
||||
$userAfter = $auth->impersonate($who);
|
||||
|
||||
if ($callback === null) {
|
||||
return $userAfter;
|
||||
}
|
||||
|
||||
try {
|
||||
return $callback($userAfter);
|
||||
} catch (Throwable $e) {
|
||||
throw $e;
|
||||
} finally {
|
||||
// ensure that the impersonation is *always* reset
|
||||
// to the original value, even if an error occurred
|
||||
$auth->impersonate($userBefore?->id());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all user roles
|
||||
*/
|
||||
public function roles(): Roles
|
||||
{
|
||||
return $this->roles ??= Roles::load($this->root('roles'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a specific user role by id
|
||||
* or the role of the current user if no id is given
|
||||
*
|
||||
* @param bool $allowImpersonation If set to false, only the role of the
|
||||
* actually logged in user will be returned
|
||||
* (when `$id` is passed as `null`)
|
||||
*/
|
||||
public function role(
|
||||
string|null $id = null,
|
||||
bool $allowImpersonation = true
|
||||
): Role|null {
|
||||
if ($id !== null) {
|
||||
return $this->roles()->find($id);
|
||||
}
|
||||
|
||||
return $this->user(null, $allowImpersonation)?->role();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the currently active user id
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setUser(User|string|null $user = null): static
|
||||
{
|
||||
$this->user = $user;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create your own set of app users
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setUsers(array|null $users = null): static
|
||||
{
|
||||
if ($users !== null) {
|
||||
$this->users = Users::factory($users);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a specific user by id
|
||||
* or the current user if no id is given
|
||||
*
|
||||
* @param bool $allowImpersonation If set to false, only the actually
|
||||
* logged in user will be returned
|
||||
* (when `$id` is passed as `null`)
|
||||
*/
|
||||
public function user(
|
||||
string|null $id = null,
|
||||
bool $allowImpersonation = true
|
||||
): User|null {
|
||||
if ($id !== null) {
|
||||
return $this->users()->find($id);
|
||||
}
|
||||
|
||||
if ($allowImpersonation === true && is_string($this->user) === true) {
|
||||
return $this->auth()->impersonate($this->user);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->auth()->user(null, $allowImpersonation);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all users
|
||||
*/
|
||||
public function users(): Users
|
||||
{
|
||||
return $this->users ??= Users::load(
|
||||
$this->root('accounts'),
|
||||
);
|
||||
}
|
||||
}
|
||||
954
kirby/src/Cms/Auth.php
Normal file
954
kirby/src/Cms/Auth.php
Normal file
|
|
@ -0,0 +1,954 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Cms\Auth\Challenge;
|
||||
use Kirby\Cms\Auth\Status;
|
||||
use Kirby\Data\Data;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Exception\PermissionException;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Http\Idn;
|
||||
use Kirby\Http\Request\Auth\BasicAuth;
|
||||
use Kirby\Session\Session;
|
||||
use Kirby\Toolkit\A;
|
||||
use SensitiveParameter;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Authentication layer
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Auth
|
||||
{
|
||||
/**
|
||||
* Available auth challenge classes
|
||||
* from the core and plugins
|
||||
*/
|
||||
public static array $challenges = [];
|
||||
|
||||
/**
|
||||
* Currently impersonated user
|
||||
*/
|
||||
protected User|null $impersonate = null;
|
||||
|
||||
/**
|
||||
* Cache of the auth status object
|
||||
*/
|
||||
protected Status|null $status = null;
|
||||
|
||||
/**
|
||||
* Instance of the currently logged in user or
|
||||
* `false` if the user was not yet determined
|
||||
*/
|
||||
protected User|false|null $user = false;
|
||||
|
||||
/**
|
||||
* Exception that was thrown while
|
||||
* determining the current user
|
||||
*/
|
||||
protected Throwable|null $userException = null;
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function __construct(
|
||||
protected App $kirby
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an authentication challenge
|
||||
* (one-time auth code)
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @param bool $long If `true`, a long session will be created
|
||||
* @param 'login'|'password-reset'|'2fa' $mode Purpose of the code
|
||||
*
|
||||
* @throws \Kirby\Exception\LogicException If there is no suitable authentication challenge (only in debug mode)
|
||||
* @throws \Kirby\Exception\NotFoundException If the user does not exist (only in debug mode)
|
||||
* @throws \Kirby\Exception\PermissionException If the rate limit is exceeded
|
||||
*/
|
||||
public function createChallenge(
|
||||
string $email,
|
||||
bool $long = false,
|
||||
string $mode = 'login'
|
||||
): Status {
|
||||
$email = Idn::decodeEmail($email);
|
||||
|
||||
$session = $this->kirby->session([
|
||||
'createMode' => 'cookie',
|
||||
'long' => $long === true
|
||||
]);
|
||||
|
||||
$timeout = $this->kirby->option('auth.challenge.timeout', 10 * 60);
|
||||
|
||||
// catch every exception to hide them from attackers
|
||||
// unless auth debugging is enabled
|
||||
try {
|
||||
$this->checkRateLimit($email);
|
||||
|
||||
// rate-limit the number of challenges for DoS/DDoS protection
|
||||
$this->track($email, false);
|
||||
|
||||
// try to find the provided user
|
||||
$user = $this->kirby->users()->find($email);
|
||||
if ($user === null) {
|
||||
$this->kirby->trigger('user.login:failed', compact('email'));
|
||||
|
||||
throw new NotFoundException(
|
||||
key: 'user.notFound',
|
||||
data: ['name' => $email]
|
||||
);
|
||||
}
|
||||
|
||||
// try to find an enabled challenge that is available for that user
|
||||
$challenge = null;
|
||||
foreach ($this->enabledChallenges() as $name) {
|
||||
$class = static::$challenges[$name] ?? null;
|
||||
if (
|
||||
$class &&
|
||||
class_exists($class) === true &&
|
||||
is_subclass_of($class, Challenge::class) === true &&
|
||||
$class::isAvailable($user, $mode) === true
|
||||
) {
|
||||
$challenge = $name;
|
||||
$code = $class::create($user, compact('mode', 'timeout'));
|
||||
|
||||
$session->set('kirby.challenge.type', $challenge);
|
||||
|
||||
if ($code !== null) {
|
||||
$session->set(
|
||||
'kirby.challenge.code',
|
||||
password_hash($code, PASSWORD_DEFAULT)
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// if no suitable challenge was found, `$challenge === null` at this point
|
||||
if ($challenge === null) {
|
||||
throw new LogicException(
|
||||
'Could not find a suitable authentication challenge'
|
||||
);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// only throw the exception in auth debug mode
|
||||
$this->fail($e);
|
||||
}
|
||||
|
||||
// always set the email, mode and timeout, even if the challenge
|
||||
// won't be created; this avoids leaking whether the user exists
|
||||
$session->set('kirby.challenge.email', $email);
|
||||
$session->set('kirby.challenge.mode', $mode);
|
||||
$session->set('kirby.challenge.timeout', time() + $timeout);
|
||||
|
||||
// sleep for a random amount of milliseconds
|
||||
// to make automated attacks harder and to
|
||||
// avoid leaking whether the user exists
|
||||
usleep(random_int(50000, 300000));
|
||||
|
||||
// clear the status cache
|
||||
$this->status = null;
|
||||
|
||||
return $this->status($session, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the csrf token if it exists and if it is valid
|
||||
*/
|
||||
public function csrf(): string|false
|
||||
{
|
||||
// get the csrf from the header
|
||||
$fromHeader = $this->kirby->request()->csrf();
|
||||
|
||||
// check for a predefined csrf or use the one from session
|
||||
$fromSession = $this->csrfFromSession();
|
||||
|
||||
// compare both tokens
|
||||
if (hash_equals((string)$fromSession, (string)$fromHeader) !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $fromSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns either predefined csrf or the one from session
|
||||
* @since 3.6.0
|
||||
*/
|
||||
public function csrfFromSession(): string
|
||||
{
|
||||
$isDev = $this->kirby->option('panel.dev', false) !== false;
|
||||
$fallback = $isDev ? 'dev' : $this->kirby->csrf();
|
||||
return $this->kirby->option('api.csrf', $fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the logged in user by checking
|
||||
* for a basic authentication header with
|
||||
* valid credentials
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException if the authorization header is invalid
|
||||
* @throws \Kirby\Exception\PermissionException if basic authentication is not allowed
|
||||
*/
|
||||
public function currentUserFromBasicAuth(BasicAuth|null $auth = null): User|null
|
||||
{
|
||||
if ($this->kirby->option('api.basicAuth', false) !== true) {
|
||||
throw new PermissionException(
|
||||
'Basic authentication is not activated'
|
||||
);
|
||||
}
|
||||
|
||||
// if logging in with password is disabled, basic auth cannot be possible either
|
||||
$loginMethods = $this->kirby->system()->loginMethods();
|
||||
if (isset($loginMethods['password']) !== true) {
|
||||
throw new PermissionException(
|
||||
'Login with password is not enabled'
|
||||
);
|
||||
}
|
||||
|
||||
// if any login method requires 2FA, basic auth without 2FA would be a weakness
|
||||
foreach ($loginMethods as $method) {
|
||||
if (isset($method['2fa']) === true && $method['2fa'] === true) {
|
||||
throw new PermissionException(
|
||||
'Basic authentication cannot be used with 2FA'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$request = $this->kirby->request();
|
||||
$auth ??= $request->auth();
|
||||
|
||||
if (!$auth || $auth->type() !== 'basic') {
|
||||
throw new InvalidArgumentException(
|
||||
'Invalid authorization header'
|
||||
);
|
||||
}
|
||||
|
||||
// only allow basic auth when https is enabled or
|
||||
// insecure requests permitted
|
||||
if (
|
||||
$request->ssl() === false &&
|
||||
$this->kirby->option('api.allowInsecure', false) !== true
|
||||
) {
|
||||
throw new PermissionException(
|
||||
'Basic authentication is only allowed over HTTPS'
|
||||
);
|
||||
}
|
||||
|
||||
return $this->validatePassword($auth->username(), $auth->password());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently impersonated user
|
||||
*/
|
||||
public function currentUserFromImpersonation(): User|null
|
||||
{
|
||||
return $this->impersonate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the logged in user by checking
|
||||
* the current session and finding a valid
|
||||
* valid user id in there
|
||||
*/
|
||||
public function currentUserFromSession(
|
||||
Session|array|null $session = null
|
||||
): User|null {
|
||||
$session = $this->session($session);
|
||||
|
||||
$id = $session->data()->get('kirby.userId');
|
||||
|
||||
// if no user is logged in, return immediately
|
||||
if (is_string($id) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// a user is logged in, ensure it exists
|
||||
$user = $this->kirby->users()->find($id);
|
||||
if ($user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($passwordTimestamp = $user->passwordTimestamp()) {
|
||||
$loginTimestamp = $session->data()->get('kirby.loginTimestamp');
|
||||
|
||||
if (is_int($loginTimestamp) !== true) {
|
||||
// session that was created before Kirby
|
||||
// 3.5.8.3, 3.6.6.3, 3.7.5.2, 3.8.4.1 or 3.9.6
|
||||
// or when the user didn't have a password set
|
||||
$user->logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
// invalidate the session if the password
|
||||
// changed since the login
|
||||
if ($loginTimestamp < $passwordTimestamp) {
|
||||
$user->logout();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// in case the session needs to be updated, do it now
|
||||
// for better performance
|
||||
$session->commit();
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of enabled challenges in the
|
||||
* configured order
|
||||
* @since 3.5.1
|
||||
*/
|
||||
public function enabledChallenges(): array
|
||||
{
|
||||
return A::wrap(
|
||||
$this->kirby->option('auth.challenges', ['totp', 'email'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Become any existing user or disable the current user
|
||||
*
|
||||
* @param string|null $who User ID or email address,
|
||||
* `null` to use the actual user again,
|
||||
* `'kirby'` for a virtual admin user or
|
||||
* `'nobody'` to disable the actual user
|
||||
* @throws \Kirby\Exception\NotFoundException if the given user cannot be found
|
||||
*/
|
||||
public function impersonate(string|null $who = null): User|null
|
||||
{
|
||||
// clear the status cache
|
||||
$this->status = null;
|
||||
|
||||
return $this->impersonate = match ($who) {
|
||||
null => null,
|
||||
'kirby' => new User([
|
||||
'email' => 'kirby@getkirby.com',
|
||||
'id' => 'kirby',
|
||||
'role' => 'admin',
|
||||
]),
|
||||
'nobody' => new User([
|
||||
'email' => 'nobody@getkirby.com',
|
||||
'id' => 'nobody',
|
||||
'role' => 'nobody',
|
||||
]),
|
||||
default => $this->kirby->users()->find($who) ?? throw new NotFoundException(message: 'The user "' . $who . '" cannot be found'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hashed ip of the visitor
|
||||
* which is used to track invalid logins
|
||||
*/
|
||||
public function ipHash(): string
|
||||
{
|
||||
$hash = hash('sha256', $this->kirby->visitor()->ip());
|
||||
|
||||
// only use the first 50 chars to ensure privacy
|
||||
return substr($hash, 0, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if logins are blocked for the current ip or email
|
||||
*/
|
||||
public function isBlocked(string $email): bool
|
||||
{
|
||||
$ip = $this->ipHash();
|
||||
$log = $this->log();
|
||||
$trials = $this->kirby->option('auth.trials', 10);
|
||||
|
||||
if ($entry = ($log['by-ip'][$ip] ?? null)) {
|
||||
if ($entry['trials'] >= $trials) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->kirby->users()->find($email)) {
|
||||
if ($entry = ($log['by-email'][$email] ?? null)) {
|
||||
if ($entry['trials'] >= $trials) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login a user by email and password
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off
|
||||
* @throws \Kirby\Exception\NotFoundException If the email was invalid
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`)
|
||||
*/
|
||||
public function login(
|
||||
string $email,
|
||||
#[SensitiveParameter]
|
||||
string $password,
|
||||
bool $long = false
|
||||
): User {
|
||||
// session options
|
||||
$options = [
|
||||
'createMode' => 'cookie',
|
||||
'long' => $long === true
|
||||
];
|
||||
|
||||
// validate the user and log in to the session
|
||||
$user = $this->validatePassword($email, $password);
|
||||
$user->loginPasswordless($options);
|
||||
|
||||
// clear the status cache
|
||||
$this->status = null;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login a user by email, password and auth challenge
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off
|
||||
* @throws \Kirby\Exception\NotFoundException If the email was invalid
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`)
|
||||
*/
|
||||
public function login2fa(
|
||||
string $email,
|
||||
#[SensitiveParameter]
|
||||
string $password,
|
||||
bool $long = false
|
||||
): Status {
|
||||
$this->validatePassword($email, $password);
|
||||
return $this->createChallenge($email, $long, '2fa');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a user object as the current user in the cache
|
||||
* @internal
|
||||
*/
|
||||
public function setUser(User $user): void
|
||||
{
|
||||
// stop impersonating
|
||||
$this->impersonate = null;
|
||||
$this->user = $user;
|
||||
|
||||
// clear the status cache
|
||||
$this->status = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authentication status object
|
||||
* @since 3.5.1
|
||||
*
|
||||
* @param bool $allowImpersonation If set to false, only the actually
|
||||
* logged in user will be returned
|
||||
*/
|
||||
public function status(
|
||||
Session|array|null $session = null,
|
||||
bool $allowImpersonation = true
|
||||
): Status {
|
||||
// try to return from cache
|
||||
if (
|
||||
$this->status &&
|
||||
$session === null &&
|
||||
$allowImpersonation === true
|
||||
) {
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
$sessionObj = $this->session($session);
|
||||
|
||||
$props = ['kirby' => $this->kirby];
|
||||
if ($user = $this->user($sessionObj, $allowImpersonation)) {
|
||||
// a user is currently logged in
|
||||
$props['email'] = $user->email();
|
||||
$props['status'] = match (true) {
|
||||
$allowImpersonation === true &&
|
||||
$this->impersonate !== null => 'impersonated',
|
||||
default => 'active'
|
||||
};
|
||||
|
||||
} elseif ($email = $sessionObj->get('kirby.challenge.email')) {
|
||||
// a challenge is currently pending
|
||||
$props['status'] = 'pending';
|
||||
$props['email'] = $email;
|
||||
$props['mode'] = $sessionObj->get('kirby.challenge.mode');
|
||||
$props['challenge'] = $sessionObj->get('kirby.challenge.type');
|
||||
$props['challengeFallback'] = A::last($this->enabledChallenges());
|
||||
} else {
|
||||
// no active authentication
|
||||
$props['status'] = 'inactive';
|
||||
}
|
||||
|
||||
$status = new Status($props);
|
||||
|
||||
// only cache the default object
|
||||
if ($session === null && $allowImpersonation === true) {
|
||||
$this->status = $status;
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the rate limit was not exceeded
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded
|
||||
*/
|
||||
protected function checkRateLimit(string $email): void
|
||||
{
|
||||
// check for blocked ips
|
||||
if ($this->isBlocked($email) === true) {
|
||||
$this->kirby->trigger('user.login:failed', compact('email'));
|
||||
|
||||
throw new PermissionException(
|
||||
details: ['reason' => 'rate-limited'],
|
||||
fallback: 'Rate limit exceeded'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the user credentials and returns the user object on success;
|
||||
* otherwise logs the failed attempt
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off
|
||||
* @throws \Kirby\Exception\NotFoundException If the email was invalid
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`)
|
||||
*/
|
||||
public function validatePassword(
|
||||
string $email,
|
||||
#[SensitiveParameter]
|
||||
string $password
|
||||
): User {
|
||||
$email = Idn::decodeEmail($email);
|
||||
|
||||
try {
|
||||
$this->checkRateLimit($email);
|
||||
|
||||
// validate the user and its password
|
||||
if ($user = $this->kirby->users()->find($email)) {
|
||||
if ($user->validatePassword($password) === true) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
key: 'user.notFound',
|
||||
data: ['name' => $email]
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$details = $e instanceof Exception ? $e->getDetails() : [];
|
||||
|
||||
// log invalid login trial unless the rate limit is already active
|
||||
if (($details['reason'] ?? null) !== 'rate-limited') {
|
||||
try {
|
||||
$this->track($email);
|
||||
} catch (Throwable) {
|
||||
// $e is overwritten with the exception
|
||||
// from the track method if there's one
|
||||
}
|
||||
}
|
||||
|
||||
// sleep for a random amount of milliseconds
|
||||
// to make automated attacks harder
|
||||
usleep(random_int(10000, 2000000));
|
||||
|
||||
// keep throwing the original error in debug mode,
|
||||
// otherwise hide it to avoid leaking security-relevant information
|
||||
$this->fail($e, new PermissionException(key: 'access.login'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the logins log
|
||||
*/
|
||||
public function logfile(): string
|
||||
{
|
||||
return $this->kirby->root('accounts') . '/.logins';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all tracked logins
|
||||
*/
|
||||
public function log(): array
|
||||
{
|
||||
try {
|
||||
$log = Data::read($this->logfile(), 'json');
|
||||
$read = true;
|
||||
} catch (Throwable) {
|
||||
$log = [];
|
||||
$read = false;
|
||||
}
|
||||
|
||||
// ensure that the category arrays are defined
|
||||
$log['by-ip'] ??= [];
|
||||
$log['by-email'] ??= [];
|
||||
|
||||
// remove all elements on the top level with different keys (old structure)
|
||||
$log = array_intersect_key($log, array_flip(['by-ip', 'by-email']));
|
||||
|
||||
// remove entries that are no longer needed
|
||||
$originalLog = $log;
|
||||
$time = time() - $this->kirby->option('auth.timeout', 3600);
|
||||
|
||||
foreach ($log as $category => $entries) {
|
||||
$log[$category] = array_filter(
|
||||
$entries,
|
||||
fn ($entry) => $entry['time'] > $time
|
||||
);
|
||||
}
|
||||
|
||||
// write new log to the file system if it changed
|
||||
if ($read === false || $log !== $originalLog) {
|
||||
if (count($log['by-ip']) === 0 && count($log['by-email']) === 0) {
|
||||
F::remove($this->logfile());
|
||||
} else {
|
||||
Data::write($this->logfile(), $log, 'json');
|
||||
}
|
||||
}
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout the current user
|
||||
*/
|
||||
public function logout(): void
|
||||
{
|
||||
// stop impersonating;
|
||||
// ensures that we log out the actually logged in user
|
||||
$this->impersonate = null;
|
||||
|
||||
// logout the current user if it exists
|
||||
$this->user()?->logout();
|
||||
|
||||
// clear the pending challenge
|
||||
$session = $this->kirby->session();
|
||||
$session->remove('kirby.challenge.code');
|
||||
$session->remove('kirby.challenge.email');
|
||||
$session->remove('kirby.challenge.mode');
|
||||
$session->remove('kirby.challenge.timeout');
|
||||
$session->remove('kirby.challenge.type');
|
||||
|
||||
// clear the status cache
|
||||
$this->status = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached user data after logout
|
||||
*/
|
||||
public function flush(): void
|
||||
{
|
||||
$this->impersonate = null;
|
||||
$this->status = null;
|
||||
$this->user = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a login
|
||||
*
|
||||
* @param bool $triggerHook If `false`, no user.login:failed hook is triggered
|
||||
*/
|
||||
public function track(
|
||||
string|null $email,
|
||||
bool $triggerHook = true
|
||||
): bool {
|
||||
if ($triggerHook === true) {
|
||||
$this->kirby->trigger('user.login:failed', compact('email'));
|
||||
}
|
||||
|
||||
$ip = $this->ipHash();
|
||||
$log = $this->log();
|
||||
$time = time();
|
||||
|
||||
if (isset($log['by-ip'][$ip]) === true) {
|
||||
$log['by-ip'][$ip] = [
|
||||
'time' => $time,
|
||||
'trials' => ($log['by-ip'][$ip]['trials'] ?? 0) + 1
|
||||
];
|
||||
} else {
|
||||
$log['by-ip'][$ip] = [
|
||||
'time' => $time,
|
||||
'trials' => 1
|
||||
];
|
||||
}
|
||||
|
||||
if ($email !== null && $this->kirby->users()->find($email)) {
|
||||
if (isset($log['by-email'][$email]) === true) {
|
||||
$log['by-email'][$email] = [
|
||||
'time' => $time,
|
||||
'trials' => ($log['by-email'][$email]['trials'] ?? 0) + 1
|
||||
];
|
||||
} else {
|
||||
$log['by-email'][$email] = [
|
||||
'time' => $time,
|
||||
'trials' => 1
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return Data::write($this->logfile(), $log, 'json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current authentication type
|
||||
*
|
||||
* @param bool $allowImpersonation If set to false, 'impersonate' won't
|
||||
* be returned as authentication type
|
||||
* even if an impersonation is active
|
||||
*/
|
||||
public function type(bool $allowImpersonation = true): string
|
||||
{
|
||||
$basicAuth = $this->kirby->option('api.basicAuth', false);
|
||||
$request = $this->kirby->request();
|
||||
|
||||
if (
|
||||
$basicAuth === true &&
|
||||
|
||||
// only get the auth object if the option is enabled
|
||||
// to avoid triggering `$responder->usesAuth()` if
|
||||
// the option is disabled
|
||||
$request->auth() &&
|
||||
$request->auth()->type() === 'basic'
|
||||
) {
|
||||
return 'basic';
|
||||
}
|
||||
|
||||
if ($allowImpersonation === true && $this->impersonate !== null) {
|
||||
return 'impersonate';
|
||||
}
|
||||
|
||||
return 'session';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the currently logged in user
|
||||
*
|
||||
* @param bool $allowImpersonation If set to false, only the actually
|
||||
* logged in user will be returned
|
||||
*
|
||||
* @throws \Throwable If an authentication error occurred
|
||||
*/
|
||||
public function user(
|
||||
Session|array|null $session = null,
|
||||
bool $allowImpersonation = true
|
||||
): User|null {
|
||||
if ($allowImpersonation === true && $this->impersonate !== null) {
|
||||
return $this->impersonate;
|
||||
}
|
||||
|
||||
// return from cache
|
||||
if ($this->user === null) {
|
||||
// throw the same Exception again if one was captured before
|
||||
if ($this->userException !== null) {
|
||||
throw $this->userException;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->user !== false) {
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->type() === 'basic') {
|
||||
return $this->user = $this->currentUserFromBasicAuth();
|
||||
}
|
||||
|
||||
return $this->user = $this->currentUserFromSession($session);
|
||||
} catch (Throwable $e) {
|
||||
$this->user = null;
|
||||
|
||||
// capture the Exception for future calls
|
||||
$this->userException = $e;
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an authentication code that was
|
||||
* requested with the `createChallenge()` method;
|
||||
* if successful, the user is automatically logged in
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @param string $code User-provided auth code to verify
|
||||
* @return \Kirby\Cms\User User object of the logged-in user
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded, the challenge timed out, the code
|
||||
* is incorrect or if any other error occurred with debug mode off
|
||||
* @throws \Kirby\Exception\NotFoundException If the user from the challenge doesn't exist
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If no authentication challenge is active
|
||||
* @throws \Kirby\Exception\LogicException If the authentication challenge is invalid
|
||||
*/
|
||||
public function verifyChallenge(
|
||||
#[SensitiveParameter]
|
||||
string $code
|
||||
): User {
|
||||
try {
|
||||
$session = $this->kirby->session();
|
||||
|
||||
// time-limiting; check this early so that we can
|
||||
// destroy the session no matter if the user exists
|
||||
// (avoids leaking user information to attackers)
|
||||
$timeout = $session->get('kirby.challenge.timeout');
|
||||
|
||||
if ($timeout !== null && time() > $timeout) {
|
||||
// this challenge can never be completed,
|
||||
// so delete it immediately
|
||||
$this->logout();
|
||||
|
||||
throw new PermissionException(
|
||||
details: ['challengeDestroyed' => true],
|
||||
fallback: 'Authentication challenge timeout'
|
||||
);
|
||||
}
|
||||
|
||||
// check if we have an active challenge
|
||||
$email = $session->get('kirby.challenge.email');
|
||||
$challenge = $session->get('kirby.challenge.type');
|
||||
if (is_string($email) !== true || is_string($challenge) !== true) {
|
||||
// if the challenge timed out on the previous request, the
|
||||
// challenge data was already deleted from the session, so we can
|
||||
// set `challengeDestroyed` to `true` in this response as well;
|
||||
// however we must only base this on the email, not the type
|
||||
// (otherwise "faked" challenges would be leaked)
|
||||
$challengeDestroyed = is_string($email) !== true;
|
||||
|
||||
throw new InvalidArgumentException(
|
||||
details: compact('challengeDestroyed'),
|
||||
fallback: 'No authentication challenge is active'
|
||||
);
|
||||
}
|
||||
|
||||
$user = $this->kirby->users()->find($email);
|
||||
if ($user === null) {
|
||||
throw new NotFoundException(
|
||||
key: 'user.notFound',
|
||||
data: ['name' => $email]
|
||||
);
|
||||
}
|
||||
|
||||
// rate-limiting
|
||||
$this->checkRateLimit($email);
|
||||
|
||||
if (
|
||||
isset(static::$challenges[$challenge]) === true &&
|
||||
class_exists(static::$challenges[$challenge]) === true &&
|
||||
is_subclass_of(static::$challenges[$challenge], Challenge::class) === true
|
||||
) {
|
||||
$class = static::$challenges[$challenge];
|
||||
if ($class::verify($user, $code) === true) {
|
||||
$mode = $session->get('kirby.challenge.mode');
|
||||
|
||||
$this->logout();
|
||||
$user->loginPasswordless();
|
||||
|
||||
// allow the user to set a new password without knowing the previous one
|
||||
if ($mode === 'password-reset') {
|
||||
$session->set('kirby.resetPassword', true);
|
||||
}
|
||||
|
||||
// clear the status cache
|
||||
$this->status = null;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
throw new PermissionException(key: 'access.code');
|
||||
}
|
||||
|
||||
throw new LogicException(
|
||||
'Invalid authentication challenge: ' . $challenge
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$details = $e instanceof Exception ? $e->getDetails() : [];
|
||||
|
||||
if (
|
||||
empty($email) === false &&
|
||||
($details['reason'] ?? null) !== 'rate-limited'
|
||||
) {
|
||||
$this->track($email);
|
||||
}
|
||||
|
||||
// sleep for a random amount of milliseconds
|
||||
// to make automated attacks harder and to
|
||||
// avoid leaking whether the user exists
|
||||
usleep(random_int(10000, 2000000));
|
||||
|
||||
// specifically copy over the marker for a destroyed challenge
|
||||
// even in production (used by the Panel to reset to the login form)
|
||||
$challengeDestroyed = $details['challengeDestroyed'] ?? false;
|
||||
|
||||
$fallback = new PermissionException(
|
||||
details: compact('challengeDestroyed'),
|
||||
key: 'access.code'
|
||||
);
|
||||
|
||||
// keep throwing the original error in debug mode,
|
||||
// otherwise hide it to avoid leaking security-relevant information
|
||||
$this->fail($e, $fallback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an exception only in debug mode, otherwise falls back
|
||||
* to a public error without sensitive information
|
||||
*
|
||||
* @throws \Throwable Either the passed `$exception` or the `$fallback`
|
||||
* (no exception if debugging is disabled and no fallback was passed)
|
||||
*/
|
||||
protected function fail(
|
||||
Throwable $exception,
|
||||
Throwable|null $fallback = null
|
||||
): void {
|
||||
$debug = $this->kirby->option('auth.debug', 'log');
|
||||
|
||||
// throw the original exception only in debug mode
|
||||
if ($debug === true) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
// otherwise hide the real error and only print it to the error log
|
||||
// unless disabled by setting `auth.debug` to `false`
|
||||
if ($debug === 'log') {
|
||||
error_log($exception); // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
// only throw an error in production if requested by the calling method
|
||||
if ($fallback !== null) {
|
||||
throw $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a session object from the passed options
|
||||
*/
|
||||
protected function session(Session|array|null $session = null): Session
|
||||
{
|
||||
// use passed session options or session object if set
|
||||
if (is_array($session) === true) {
|
||||
return $this->kirby->session($session);
|
||||
}
|
||||
|
||||
// try session in header or cookie
|
||||
if ($session instanceof Session === false) {
|
||||
return $this->kirby->session(['detect' => true]);
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
65
kirby/src/Cms/Auth/Challenge.php
Normal file
65
kirby/src/Cms/Auth/Challenge.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms\Auth;
|
||||
|
||||
use Kirby\Cms\User;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* Template class for authentication challenges
|
||||
* that create and verify one-time auth codes
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Lukas Bestle <lukas@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
abstract class Challenge
|
||||
{
|
||||
/**
|
||||
* Checks whether the challenge is available
|
||||
* for the passed user and purpose
|
||||
*
|
||||
* @param \Kirby\Cms\User $user User the code will be generated for
|
||||
* @param 'login'|'password-reset'|'2fa' $mode Purpose of the code
|
||||
*/
|
||||
abstract public static function isAvailable(User $user, string $mode): bool;
|
||||
|
||||
/**
|
||||
* Generates a random one-time auth code and returns that code
|
||||
* for later verification
|
||||
*
|
||||
* @param \Kirby\Cms\User $user User to generate the code for
|
||||
* @param array $options Details of the challenge request:
|
||||
* - 'mode': Purpose of the code ('login', 'password-reset' or '2fa')
|
||||
* - 'timeout': Number of seconds the code will be valid for
|
||||
* @return string|null The generated and sent code or `null` in case
|
||||
* there was no code to generate by this algorithm
|
||||
*/
|
||||
abstract public static function create(User $user, array $options): string|null;
|
||||
|
||||
/**
|
||||
* Verifies the provided code against the created one;
|
||||
* default implementation that checks the code that was
|
||||
* returned from the `create()` method
|
||||
*
|
||||
* @param \Kirby\Cms\User $user User to check the code for
|
||||
* @param string $code Code to verify
|
||||
*/
|
||||
public static function verify(
|
||||
User $user,
|
||||
#[SensitiveParameter]
|
||||
string $code
|
||||
): bool {
|
||||
$hash = $user->kirby()->session()->get('kirby.challenge.code');
|
||||
if (is_string($hash) !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// normalize the formatting in the user-provided code
|
||||
$code = str_replace(' ', '', $code);
|
||||
|
||||
return password_verify($code, $hash);
|
||||
}
|
||||
}
|
||||
86
kirby/src/Cms/Auth/EmailChallenge.php
Normal file
86
kirby/src/Cms/Auth/EmailChallenge.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms\Auth;
|
||||
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Creates and verifies one-time auth codes
|
||||
* that are sent via email
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Lukas Bestle <lukas@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class EmailChallenge extends Challenge
|
||||
{
|
||||
/**
|
||||
* Checks whether the challenge is available
|
||||
* for the passed user and purpose
|
||||
*
|
||||
* @param \Kirby\Cms\User $user User the code will be generated for
|
||||
* @param 'login'|'password-reset'|'2fa' $mode Purpose of the code
|
||||
*/
|
||||
public static function isAvailable(User $user, string $mode): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random one-time auth code and returns that code
|
||||
* for later verification
|
||||
*
|
||||
* @param \Kirby\Cms\User $user User to generate the code for
|
||||
* @param array $options Details of the challenge request:
|
||||
* - 'mode': Purpose of the code ('login', 'password-reset' or '2fa')
|
||||
* - 'timeout': Number of seconds the code will be valid for
|
||||
* @return string The generated and sent code
|
||||
*/
|
||||
public static function create(User $user, array $options): string
|
||||
{
|
||||
$code = Str::random(6, 'num');
|
||||
|
||||
// insert a space in the middle for easier readability
|
||||
$formatted = substr($code, 0, 3) . ' ' . substr($code, 3, 3);
|
||||
|
||||
// use the login templates for 2FA
|
||||
$mode = match($options['mode']) {
|
||||
'2fa' => 'login',
|
||||
default => $options['mode']
|
||||
};
|
||||
|
||||
$kirby = $user->kirby();
|
||||
$from = $kirby->option(
|
||||
'auth.challenge.email.from',
|
||||
'noreply@' . $kirby->url('index', true)->host()
|
||||
);
|
||||
$name = $kirby->option(
|
||||
'auth.challenge.email.fromName',
|
||||
$kirby->site()->title()
|
||||
);
|
||||
$subject = $kirby->option(
|
||||
'auth.challenge.email.subject',
|
||||
I18n::translate('login.email.' . $mode . '.subject', null, $user->language())
|
||||
);
|
||||
|
||||
$kirby->email([
|
||||
'from' => $from,
|
||||
'fromName' => $name,
|
||||
'to' => $user,
|
||||
'subject' => $subject,
|
||||
'template' => 'auth/' . $mode,
|
||||
'data' => [
|
||||
'user' => $user,
|
||||
'site' => $kirby->system()->title(),
|
||||
'code' => $formatted,
|
||||
'timeout' => round($options['timeout'] / 60)
|
||||
]
|
||||
]);
|
||||
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
176
kirby/src/Cms/Auth/Status.php
Normal file
176
kirby/src/Cms/Auth/Status.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms\Auth;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* Information container for the
|
||||
* authentication status
|
||||
* @since 3.5.1
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Lukas Bestle <lukas@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Status implements Stringable
|
||||
{
|
||||
/**
|
||||
* Type of the active challenge
|
||||
*/
|
||||
protected string|null $challenge = null;
|
||||
|
||||
/**
|
||||
* Challenge type to use as a fallback
|
||||
* when $challenge is `null`
|
||||
*/
|
||||
protected string|null $challengeFallback = null;
|
||||
|
||||
/**
|
||||
* Email address of the current/pending user
|
||||
*/
|
||||
protected string|null $email;
|
||||
|
||||
/**
|
||||
* Kirby instance for user lookup
|
||||
*/
|
||||
protected App $kirby;
|
||||
|
||||
/**
|
||||
* Purpose of the challenge:
|
||||
* `login|password-reset|2fa`
|
||||
*/
|
||||
protected string|null $mode;
|
||||
|
||||
/**
|
||||
* Authentication status:
|
||||
* `active|impersonated|pending|inactive`
|
||||
*/
|
||||
protected string $status;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
*/
|
||||
public function __construct(array $props)
|
||||
{
|
||||
if (in_array($props['status'], ['active', 'impersonated', 'pending', 'inactive'], true) !== true) {
|
||||
throw new InvalidArgumentException(
|
||||
data: [
|
||||
'argument' => '$props[\'status\']',
|
||||
'method' => 'Status::__construct'
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->kirby = $props['kirby'];
|
||||
$this->challenge = $props['challenge'] ?? null;
|
||||
$this->challengeFallback = $props['challengeFallback'] ?? null;
|
||||
$this->email = $props['email'] ?? null;
|
||||
$this->mode = $props['mode'] ?? null;
|
||||
$this->status = $props['status'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authentication status
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of the active challenge
|
||||
*
|
||||
* @param bool $automaticFallback If set to `false`, no faked challenge is returned;
|
||||
* WARNING: never send the resulting `null` value to the
|
||||
* user to avoid leaking whether the pending user exists
|
||||
*/
|
||||
public function challenge(bool $automaticFallback = true): string|null
|
||||
{
|
||||
// never return a challenge type if the status doesn't match
|
||||
if ($this->status() !== 'pending') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($automaticFallback === false) {
|
||||
return $this->challenge;
|
||||
}
|
||||
|
||||
return $this->challenge ?? $this->challengeFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance while
|
||||
* merging initial and new properties
|
||||
*/
|
||||
public function clone(array $props = []): static
|
||||
{
|
||||
return new static(array_replace_recursive([
|
||||
'kirby' => $this->kirby,
|
||||
'challenge' => $this->challenge,
|
||||
'challengeFallback' => $this->challengeFallback,
|
||||
'email' => $this->email,
|
||||
'status' => $this->status,
|
||||
], $props));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the email address of the current/pending user
|
||||
*/
|
||||
public function email(): string|null
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the purpose of the challenge
|
||||
*
|
||||
* @return string `login|password-reset|2fa`
|
||||
*/
|
||||
public function mode(): string|null
|
||||
{
|
||||
return $this->mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authentication status
|
||||
*
|
||||
* @return string `active|impersonated|pending|inactive`
|
||||
*/
|
||||
public function status(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with all public status data
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'challenge' => $this->challenge(),
|
||||
'email' => $this->email(),
|
||||
'mode' => $this->mode(),
|
||||
'status' => $this->status()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently logged in user
|
||||
*/
|
||||
public function user(): User|null
|
||||
{
|
||||
// for security, only return the user if they are
|
||||
// already logged in
|
||||
if (in_array($this->status(), ['active', 'impersonated'], true) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->kirby->user($this->email());
|
||||
}
|
||||
}
|
||||
65
kirby/src/Cms/Auth/TotpChallenge.php
Normal file
65
kirby/src/Cms/Auth/TotpChallenge.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms\Auth;
|
||||
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Toolkit\Totp;
|
||||
|
||||
/**
|
||||
* Verifies one-time time-based auth codes
|
||||
* that are generated with an authenticator app.
|
||||
* Users first have to set up time-based codes
|
||||
* (storing the TOTP secret in their user account).
|
||||
* @since 4.0.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class TotpChallenge extends Challenge
|
||||
{
|
||||
/**
|
||||
* Checks whether the challenge is available
|
||||
* for the passed user and purpose
|
||||
*
|
||||
* @param \Kirby\Cms\User $user User the code will be generated for
|
||||
* @param 'login'|'password-reset'|'2fa' $mode Purpose of the code
|
||||
*/
|
||||
public static function isAvailable(User $user, string $mode): bool
|
||||
{
|
||||
// user needs to have a TOTP secret set up
|
||||
return $user->secret('totp') !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random one-time auth code and returns that code
|
||||
* for later verification
|
||||
*
|
||||
* @param \Kirby\Cms\User $user User to generate the code for
|
||||
* @param array $options Details of the challenge request:
|
||||
* - 'mode': Purpose of the code ('login', 'password-reset' or '2fa')
|
||||
* - 'timeout': Number of seconds the code will be valid for
|
||||
* @todo set return type to `null` once support for PHP 8.1 is dropped
|
||||
*/
|
||||
public static function create(User $user, array $options): string|null
|
||||
{
|
||||
// the user's app will generate the code, we only verify it
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the provided code against the created one
|
||||
*
|
||||
* @param \Kirby\Cms\User $user User to check the code for
|
||||
* @param string $code Code to verify
|
||||
*/
|
||||
public static function verify(User $user, string $code): bool
|
||||
{
|
||||
// verify if code is current, previous or next TOTP code
|
||||
$secret = $user->secret('totp');
|
||||
$totp = new Totp($secret);
|
||||
return $totp->verify($code);
|
||||
}
|
||||
}
|
||||
223
kirby/src/Cms/Block.php
Normal file
223
kirby/src/Cms/Block.php
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Content\Content;
|
||||
use Kirby\Content\Field;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Stringable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Represents a single block
|
||||
* which can be inspected further or
|
||||
* converted to HTML
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Item<\Kirby\Cms\Blocks>
|
||||
*/
|
||||
class Block extends Item implements Stringable
|
||||
{
|
||||
use HasMethods;
|
||||
use HasModels;
|
||||
|
||||
public const ITEMS_CLASS = Blocks::class;
|
||||
|
||||
protected Content $content;
|
||||
protected bool $isHidden;
|
||||
protected string $type;
|
||||
|
||||
/**
|
||||
* Proxy for content fields
|
||||
*/
|
||||
public function __call(string $method, array $args = []): mixed
|
||||
{
|
||||
// block methods
|
||||
if ($this->hasMethod($method)) {
|
||||
return $this->callMethod($method, $args);
|
||||
}
|
||||
|
||||
return $this->content()->get($method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new block object
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function __construct(array $params)
|
||||
{
|
||||
parent::__construct($params);
|
||||
|
||||
// @deprecated import old builder format
|
||||
// @todo block.converter remove eventually
|
||||
// @codeCoverageIgnoreStart
|
||||
$params = BlockConverter::builderBlock($params);
|
||||
$params = BlockConverter::editorBlock($params);
|
||||
// @codeCoverageIgnoreEnd
|
||||
|
||||
if (isset($params['type']) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'The block type is missing'
|
||||
);
|
||||
}
|
||||
|
||||
// make sure the content is always defined as array to keep
|
||||
// at least a bit of backward compatibility with older fields
|
||||
if (is_array($params['content'] ?? null) === false) {
|
||||
$params['content'] = [];
|
||||
}
|
||||
|
||||
$this->isHidden = $params['isHidden'] ?? false;
|
||||
$this->type = $params['type'];
|
||||
|
||||
// create the content object
|
||||
$this->content = new Content($params['content'], $this->parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the object to a string
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toHtml();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content object
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller for the block snippet
|
||||
*/
|
||||
public function controller(): array
|
||||
{
|
||||
return [
|
||||
'block' => $this,
|
||||
'content' => $this->content(),
|
||||
// deprecated block data
|
||||
'data' => $this,
|
||||
'id' => $this->id(),
|
||||
'prev' => $this->prev(),
|
||||
'next' => $this->next()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the block to HTML and then
|
||||
* uses the Str::excerpt method to create
|
||||
* a non-formatted, shortened excerpt from it
|
||||
*/
|
||||
public function excerpt(mixed ...$args): string
|
||||
{
|
||||
return Str::excerpt($this->toHtml(), ...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a block object with registering blocks models
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
public static function factory(array $params): static
|
||||
{
|
||||
return static::model($params['type'] ?? 'default', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the block is empty
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->content()->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the block is hidden
|
||||
* from being rendered in the frontend
|
||||
*/
|
||||
public function isHidden(): bool
|
||||
{
|
||||
return $this->isHidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the block is not empty
|
||||
*/
|
||||
public function isNotEmpty(): bool
|
||||
{
|
||||
return $this->isEmpty() === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sibling collection that filtered by block status
|
||||
*/
|
||||
protected function siblingsCollection(): Blocks
|
||||
{
|
||||
return $this->siblings->filter('isHidden', $this->isHidden());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the block type
|
||||
*/
|
||||
public function type(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result is being sent to the editor
|
||||
* via the API in the panel
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'content' => $this->content()->toArray(),
|
||||
'id' => $this->id(),
|
||||
'isHidden' => $this->isHidden(),
|
||||
'type' => $this->type(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the block to html first
|
||||
* and then places that inside a field
|
||||
* object. This can be used further
|
||||
* with all available field methods
|
||||
*/
|
||||
public function toField(): Field
|
||||
{
|
||||
return new Field($this->parent(), $this->id(), $this->toHtml());
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the block to HTML
|
||||
*/
|
||||
public function toHtml(): string
|
||||
{
|
||||
try {
|
||||
$kirby = $this->parent()->kirby();
|
||||
return (string)$kirby->snippet(
|
||||
'blocks/' . $this->type(),
|
||||
$this->controller(),
|
||||
true
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
if ($kirby->option('debug') === true) {
|
||||
return '<p>Block error: "' . $e->getMessage() . '" in block type: "' . $this->type() . '"</p>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
285
kirby/src/Cms/BlockConverter.php
Normal file
285
kirby/src/Cms/BlockConverter.php
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* Converts the data from the old builder and editor fields
|
||||
* to the format supported by the new block field.
|
||||
* @since 3.9.0
|
||||
* @deprecated
|
||||
*
|
||||
* @todo block.converter remove eventually
|
||||
* @codeCoverageIgnore
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier GmbH
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class BlockConverter
|
||||
{
|
||||
public static function builderBlock(array $params): array
|
||||
{
|
||||
if (isset($params['_key']) === false) {
|
||||
return $params;
|
||||
}
|
||||
|
||||
$ignore = array_flip(['field', 'options', 'parent', 'siblings', 'params', '_key', '_uid']);
|
||||
$params['content'] = array_diff_key($params, $ignore);
|
||||
$params['type'] = $params['_key'];
|
||||
|
||||
unset($params['_uid']);
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
public static function editorBlock(array $params): array
|
||||
{
|
||||
if (static::isEditorBlock($params) === false) {
|
||||
return $params;
|
||||
}
|
||||
|
||||
$method = 'editor' . $params['type'];
|
||||
|
||||
if (method_exists(static::class, $method) === true) {
|
||||
$params = static::$method($params);
|
||||
} else {
|
||||
$params = static::editorCustom($params);
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
public static function editorBlocks(array $blocks = []): array
|
||||
{
|
||||
if ($blocks === []) {
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
if (static::isEditorBlock($blocks[0]) === false) {
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
$list = [];
|
||||
$listStart = null;
|
||||
|
||||
foreach ($blocks as $index => $block) {
|
||||
if (in_array($block['type'], ['ul', 'ol'], true) === true) {
|
||||
$prev = $blocks[$index - 1] ?? null;
|
||||
$next = $blocks[$index + 1] ?? null;
|
||||
|
||||
// new list starts here
|
||||
if (!$prev || $prev['type'] !== $block['type']) {
|
||||
$listStart = $index;
|
||||
}
|
||||
|
||||
// add the block to the list
|
||||
$list[] = $block;
|
||||
|
||||
// list ends here
|
||||
if (!$next || $next['type'] !== $block['type']) {
|
||||
$blocks[$listStart] = [
|
||||
'content' => [
|
||||
'text' =>
|
||||
'<' . $block['type'] . '>' .
|
||||
implode(array_map(
|
||||
fn ($item) => '<li>' . $item['content'] . '</li>',
|
||||
$list
|
||||
)) .
|
||||
'</' . $block['type'] . '>',
|
||||
],
|
||||
'type' => 'list'
|
||||
];
|
||||
|
||||
$start = $listStart + 1;
|
||||
$end = $listStart + count($list);
|
||||
|
||||
for ($x = $start; $x <= $end; $x++) {
|
||||
$blocks[$x] = false;
|
||||
}
|
||||
|
||||
$listStart = null;
|
||||
$list = [];
|
||||
}
|
||||
} else {
|
||||
$blocks[$index] = static::editorBlock($block);
|
||||
}
|
||||
}
|
||||
|
||||
return array_filter($blocks);
|
||||
}
|
||||
|
||||
public static function editorBlockquote(array $params): array
|
||||
{
|
||||
return [
|
||||
'content' => [
|
||||
'text' => $params['content']
|
||||
],
|
||||
'type' => 'quote'
|
||||
];
|
||||
}
|
||||
|
||||
public static function editorCode(array $params): array
|
||||
{
|
||||
return [
|
||||
'content' => [
|
||||
'language' => $params['attrs']['language'] ?? null,
|
||||
'code' => $params['content']
|
||||
],
|
||||
'type' => 'code'
|
||||
];
|
||||
}
|
||||
|
||||
public static function editorCustom(array $params): array
|
||||
{
|
||||
return [
|
||||
'content' => [
|
||||
...$params['attrs'] ?? [],
|
||||
'body' => $params['content'] ?? null
|
||||
],
|
||||
'type' => $params['type'] ?? 'unknown'
|
||||
];
|
||||
}
|
||||
|
||||
public static function editorH1(array $params): array
|
||||
{
|
||||
return static::editorHeading($params, 'h1');
|
||||
}
|
||||
|
||||
public static function editorH2(array $params): array
|
||||
{
|
||||
return static::editorHeading($params, 'h2');
|
||||
}
|
||||
|
||||
public static function editorH3(array $params): array
|
||||
{
|
||||
return static::editorHeading($params, 'h3');
|
||||
}
|
||||
|
||||
public static function editorH4(array $params): array
|
||||
{
|
||||
return static::editorHeading($params, 'h4');
|
||||
}
|
||||
|
||||
public static function editorH5(array $params): array
|
||||
{
|
||||
return static::editorHeading($params, 'h5');
|
||||
}
|
||||
|
||||
public static function editorH6(array $params): array
|
||||
{
|
||||
return static::editorHeading($params, 'h6');
|
||||
}
|
||||
|
||||
public static function editorHr(array $params): array
|
||||
{
|
||||
return [
|
||||
'content' => [],
|
||||
'type' => 'line'
|
||||
];
|
||||
}
|
||||
|
||||
public static function editorHeading(array $params, string $level = 'h1'): array
|
||||
{
|
||||
return [
|
||||
'content' => [
|
||||
'level' => $level,
|
||||
'text' => $params['content']
|
||||
],
|
||||
'type' => 'heading'
|
||||
];
|
||||
}
|
||||
|
||||
public static function editorImage(array $params): array
|
||||
{
|
||||
// internal image
|
||||
if (isset($params['attrs']['id']) === true) {
|
||||
return [
|
||||
'content' => [
|
||||
'alt' => $params['attrs']['alt'] ?? null,
|
||||
'caption' => $params['attrs']['caption'] ?? null,
|
||||
'image' => $params['attrs']['id'] ?? $params['attrs']['src'] ?? null,
|
||||
'location' => 'kirby',
|
||||
'ratio' => $params['attrs']['ratio'] ?? null,
|
||||
],
|
||||
'type' => 'image'
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'alt' => $params['attrs']['alt'] ?? null,
|
||||
'caption' => $params['attrs']['caption'] ?? null,
|
||||
'src' => $params['attrs']['src'] ?? null,
|
||||
'location' => 'web',
|
||||
'ratio' => $params['attrs']['ratio'] ?? null,
|
||||
],
|
||||
'type' => 'image'
|
||||
];
|
||||
}
|
||||
|
||||
public static function editorKirbytext(array $params): array
|
||||
{
|
||||
return [
|
||||
'content' => [
|
||||
'text' => $params['content']
|
||||
],
|
||||
'type' => 'markdown'
|
||||
];
|
||||
}
|
||||
|
||||
public static function editorOl(array $params): array
|
||||
{
|
||||
return [
|
||||
'content' => [
|
||||
'text' => $params['content']
|
||||
],
|
||||
'type' => 'list'
|
||||
];
|
||||
}
|
||||
|
||||
public static function editorParagraph(array $params): array
|
||||
{
|
||||
return [
|
||||
'content' => [
|
||||
'text' => '<p>' . $params['content'] . '</p>'
|
||||
],
|
||||
'type' => 'text'
|
||||
];
|
||||
}
|
||||
|
||||
public static function editorUl(array $params): array
|
||||
{
|
||||
return [
|
||||
'content' => [
|
||||
'text' => $params['content']
|
||||
],
|
||||
'type' => 'list'
|
||||
];
|
||||
}
|
||||
|
||||
public static function editorVideo(array $params): array
|
||||
{
|
||||
return [
|
||||
'content' => [
|
||||
'caption' => $params['attrs']['caption'] ?? null,
|
||||
'url' => $params['attrs']['src'] ?? null
|
||||
],
|
||||
'type' => 'video'
|
||||
];
|
||||
}
|
||||
|
||||
public static function isEditorBlock(array $params): bool
|
||||
{
|
||||
if (isset($params['attrs']) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_string($params['content'] ?? null) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
172
kirby/src/Cms/Blocks.php
Normal file
172
kirby/src/Cms/Blocks.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Exception;
|
||||
use Kirby\Data\Json;
|
||||
use Kirby\Data\Yaml;
|
||||
use Kirby\Parsley\Parsley;
|
||||
use Kirby\Parsley\Schema\Blocks as BlockSchema;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* A collection of blocks
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Items<\Kirby\Cms\Block>
|
||||
*/
|
||||
class Blocks extends Items
|
||||
{
|
||||
public const ITEM_CLASS = Block::class;
|
||||
|
||||
/**
|
||||
* All registered blocks methods
|
||||
*/
|
||||
public static array $methods = [];
|
||||
|
||||
/**
|
||||
* Return HTML when the collection is
|
||||
* converted to a string
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toHtml();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the blocks to HTML and then
|
||||
* uses the Str::excerpt method to create
|
||||
* a non-formatted, shortened excerpt from it
|
||||
*/
|
||||
public function excerpt(mixed ...$args): string
|
||||
{
|
||||
return Str::excerpt($this->toHtml(), ...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around the factory to
|
||||
* catch blocks from layouts
|
||||
*/
|
||||
public static function factory(
|
||||
array|null $items = null,
|
||||
array $params = []
|
||||
): static {
|
||||
$items = static::extractFromLayouts($items);
|
||||
|
||||
// @deprecated old editor format
|
||||
// @todo block.converter remove eventually
|
||||
// @codeCoverageIgnoreStart
|
||||
$items = BlockConverter::editorBlocks($items);
|
||||
// @codeCoverageIgnoreEnd
|
||||
|
||||
return parent::factory($items, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull out blocks from layouts
|
||||
*/
|
||||
protected static function extractFromLayouts(array $input): array
|
||||
{
|
||||
if ($input === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
// no columns = no layout
|
||||
array_key_exists('columns', $input[0]) === false ||
|
||||
// @deprecated checks if this is a block for the builder plugin
|
||||
// @todo block.converter remove eventually
|
||||
array_key_exists('_key', $input[0]) === true
|
||||
) {
|
||||
return $input;
|
||||
}
|
||||
|
||||
$blocks = [];
|
||||
|
||||
foreach ($input as $layout) {
|
||||
foreach (($layout['columns'] ?? []) as $column) {
|
||||
foreach (($column['blocks'] ?? []) as $block) {
|
||||
$blocks[] = $block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given block type exists in the collection
|
||||
* @since 3.6.0
|
||||
*/
|
||||
public function hasType(string $type): bool
|
||||
{
|
||||
return $this->filterBy('type', $type)->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and sanitize various block formats
|
||||
*/
|
||||
public static function parse(array|string|null $input): array
|
||||
{
|
||||
if (
|
||||
empty($input) === false &&
|
||||
is_array($input) === false
|
||||
) {
|
||||
try {
|
||||
$input = Json::decode((string)$input);
|
||||
} catch (Throwable) {
|
||||
// @deprecated try to import the old YAML format
|
||||
// @todo block.converter remove eventually
|
||||
// @codeCoverageIgnoreStart
|
||||
try {
|
||||
$yaml = Yaml::decode((string)$input);
|
||||
$first = A::first($yaml);
|
||||
|
||||
// check for valid yaml
|
||||
if (
|
||||
$yaml === [] ||
|
||||
(
|
||||
isset($first['_key']) === false &&
|
||||
isset($first['type']) === false
|
||||
)
|
||||
) {
|
||||
throw new Exception(message: 'Invalid YAML');
|
||||
}
|
||||
|
||||
$input = $yaml;
|
||||
} catch (Throwable) {
|
||||
// the next 2 lines remain after removing block.converter
|
||||
// @codeCoverageIgnoreEnd
|
||||
$parser = new Parsley((string)$input, new BlockSchema());
|
||||
$input = $parser->blocks();
|
||||
// @codeCoverageIgnoreStart
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($input) === true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all blocks to HTML
|
||||
*/
|
||||
public function toHtml(): string
|
||||
{
|
||||
$html = A::map($this->data, fn ($block) => $block->toHtml());
|
||||
|
||||
return implode($html);
|
||||
}
|
||||
}
|
||||
902
kirby/src/Cms/Blueprint.php
Normal file
902
kirby/src/Cms/Blueprint.php
Normal file
|
|
@ -0,0 +1,902 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Exception;
|
||||
use Kirby\Data\Data;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Form\Field;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The Blueprint class normalizes an array from a
|
||||
* blueprint file and converts sections, columns, fields
|
||||
* etc. into a correct tab layout.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Blueprint
|
||||
{
|
||||
public static array $presets = [];
|
||||
public static array $loaded = [];
|
||||
|
||||
protected array $fields = [];
|
||||
protected ModelWithContent $model;
|
||||
protected array $props;
|
||||
protected array $sections = [];
|
||||
protected array $tabs = [];
|
||||
|
||||
protected array|null $fileTemplates = null;
|
||||
|
||||
/**
|
||||
* Creates a new blueprint object with the given props
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the blueprint model is missing
|
||||
*/
|
||||
public function __construct(array $props)
|
||||
{
|
||||
if (empty($props['model']) === true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'A blueprint model is required'
|
||||
);
|
||||
}
|
||||
|
||||
if ($props['model'] instanceof ModelWithContent === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Invalid blueprint model'
|
||||
);
|
||||
}
|
||||
|
||||
$this->model = $props['model'];
|
||||
|
||||
// the model should not be included in the props array
|
||||
unset($props['model']);
|
||||
|
||||
// extend the blueprint in general
|
||||
$props = static::extend($props);
|
||||
|
||||
// apply any blueprint preset
|
||||
$props = $this->preset($props);
|
||||
|
||||
// normalize the name
|
||||
$props['name'] ??= 'default';
|
||||
|
||||
// normalize and translate the title
|
||||
$props['title'] ??= Str::label($props['name']);
|
||||
$props['title'] = $this->i18n($props['title']);
|
||||
|
||||
// convert all shortcuts
|
||||
$props = $this->convertFieldsToSections('main', $props);
|
||||
$props = $this->convertSectionsToColumns('main', $props);
|
||||
$props = $this->convertColumnsToTabs('main', $props);
|
||||
|
||||
// normalize all tabs
|
||||
$props['tabs'] = $this->normalizeTabs($props['tabs'] ?? []);
|
||||
|
||||
$this->props = $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic getter/caller for any blueprint prop
|
||||
*/
|
||||
public function __call(string $key, array|null $arguments = null): mixed
|
||||
{
|
||||
return $this->props[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Improved `var_dump` output
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return $this->props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers what file templates are allowed in
|
||||
* this model based on the blueprint
|
||||
*/
|
||||
public function acceptedFileTemplates(string|null $inSection = null): array
|
||||
{
|
||||
// get cached results for the current file model
|
||||
// (except when collecting for a specific section)
|
||||
if ($inSection === null && $this->fileTemplates !== null) {
|
||||
return $this->fileTemplates; // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
$templates = [];
|
||||
|
||||
// collect all allowed file templates from blueprint…
|
||||
foreach ($this->sections() as $section) {
|
||||
// if collecting for a specific section, skip all others
|
||||
if ($inSection !== null && $section->name() !== $inSection) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templates = match ($section->type()) {
|
||||
'files' => [...$templates, $section->template() ?? 'default'],
|
||||
'fields' => [
|
||||
...$templates,
|
||||
...$this->acceptedFileTemplatesFromFields($section->fields())
|
||||
],
|
||||
default => $templates
|
||||
};
|
||||
}
|
||||
|
||||
// no caching for when collecting for specific section
|
||||
if ($inSection !== null) {
|
||||
return $templates; // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
return $this->fileTemplates = $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers the allowed file templates from model's fields
|
||||
*/
|
||||
protected function acceptedFileTemplatesFromFields(array $fields): array
|
||||
{
|
||||
$templates = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
// fields with uploads settings
|
||||
if (isset($field['uploads']) === true && is_array($field['uploads']) === true) {
|
||||
$templates = [
|
||||
...$templates,
|
||||
...$this->acceptedFileTemplatesFromFieldUploads($field['uploads'])
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// structure and object fields
|
||||
if (isset($field['fields']) === true && is_array($field['fields']) === true) {
|
||||
$templates = [
|
||||
...$templates,
|
||||
...$this->acceptedFileTemplatesFromFields($field['fields']),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// layout and blocks fields
|
||||
if (isset($field['fieldsets']) === true && is_array($field['fieldsets']) === true) {
|
||||
$templates = [
|
||||
...$templates,
|
||||
...$this->acceptedFileTemplatesFromFieldsets($field['fieldsets'])
|
||||
];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers the allowed file templates from fieldsets
|
||||
*/
|
||||
protected function acceptedFileTemplatesFromFieldsets(array $fieldsets): array
|
||||
{
|
||||
$templates = [];
|
||||
|
||||
foreach ($fieldsets as $fieldset) {
|
||||
foreach (($fieldset['tabs'] ?? []) as $tab) {
|
||||
$templates = [
|
||||
...$templates,
|
||||
...$this->acceptedFileTemplatesFromFields($tab['fields'] ?? [])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts templates from field uploads settings
|
||||
*/
|
||||
protected function acceptedFileTemplatesFromFieldUploads(array $uploads): array
|
||||
{
|
||||
// only if the `uploads` parent is this model
|
||||
if ($target = $uploads['parent'] ?? null) {
|
||||
if ($this->model->id() !== $target) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [($uploads['template'] ?? 'default')];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers custom config for Panel view buttons
|
||||
*/
|
||||
public function buttons(): array|false|null
|
||||
{
|
||||
return $this->props['buttons'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts all column definitions, that
|
||||
* are not wrapped in a tab, into a generic tab
|
||||
*/
|
||||
protected function convertColumnsToTabs(
|
||||
string $tabName,
|
||||
array $props
|
||||
): array {
|
||||
if (isset($props['columns']) === false) {
|
||||
return $props;
|
||||
}
|
||||
|
||||
// wrap everything in a main tab
|
||||
$props['tabs'] = [
|
||||
$tabName => [
|
||||
'columns' => $props['columns']
|
||||
]
|
||||
];
|
||||
|
||||
unset($props['columns']);
|
||||
|
||||
return $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts all field definitions, that are not
|
||||
* wrapped in a fields section into a generic
|
||||
* fields section.
|
||||
*/
|
||||
protected function convertFieldsToSections(
|
||||
string $tabName,
|
||||
array $props
|
||||
): array {
|
||||
if (isset($props['fields']) === false) {
|
||||
return $props;
|
||||
}
|
||||
|
||||
// wrap all fields in a section
|
||||
$props['sections'] = [
|
||||
$tabName . '-fields' => [
|
||||
'type' => 'fields',
|
||||
'fields' => $props['fields']
|
||||
]
|
||||
];
|
||||
|
||||
unset($props['fields']);
|
||||
|
||||
return $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts all sections that are not wrapped in
|
||||
* columns, into a single generic column.
|
||||
*/
|
||||
protected function convertSectionsToColumns(
|
||||
string $tabName,
|
||||
array $props
|
||||
): array {
|
||||
if (isset($props['sections']) === false) {
|
||||
return $props;
|
||||
}
|
||||
|
||||
// wrap everything in one big column
|
||||
$props['columns'] = [
|
||||
[
|
||||
'width' => '1/1',
|
||||
'sections' => $props['sections']
|
||||
]
|
||||
];
|
||||
|
||||
unset($props['sections']);
|
||||
|
||||
return $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the props with props from a given
|
||||
* mixin, when an extends key is set or the
|
||||
* props is just a string
|
||||
*
|
||||
* @param array|string $props
|
||||
*/
|
||||
public static function extend($props): array
|
||||
{
|
||||
if (is_string($props) === true) {
|
||||
$props = [
|
||||
'extends' => $props
|
||||
];
|
||||
}
|
||||
|
||||
if ($extends = $props['extends'] ?? null) {
|
||||
foreach (A::wrap($extends) as $extend) {
|
||||
try {
|
||||
$mixin = static::find($extend);
|
||||
$mixin = static::extend($mixin);
|
||||
$props = A::merge($mixin, $props, A::MERGE_REPLACE);
|
||||
} catch (Exception) {
|
||||
// keep the props unextended if the snippet wasn't found
|
||||
}
|
||||
}
|
||||
|
||||
// remove the extends flag
|
||||
unset($props['extends']);
|
||||
}
|
||||
|
||||
return $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new blueprint for a model
|
||||
*/
|
||||
public static function factory(
|
||||
string $name,
|
||||
string|null $fallback,
|
||||
ModelWithContent $model
|
||||
): static|null {
|
||||
try {
|
||||
$props = static::load($name);
|
||||
} catch (Exception) {
|
||||
$props = $fallback !== null ? static::load($fallback) : null;
|
||||
}
|
||||
|
||||
if ($props === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// inject the parent model
|
||||
$props['model'] = $model;
|
||||
|
||||
return new static($props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single field definition by name
|
||||
*/
|
||||
public function field(string $name): array|null
|
||||
{
|
||||
return $this->fields[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all field definitions
|
||||
*/
|
||||
public function fields(): array
|
||||
{
|
||||
return $this->fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a blueprint by name
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the blueprint cannot be found
|
||||
*/
|
||||
public static function find(string $name): array
|
||||
{
|
||||
if (isset(static::$loaded[$name]) === true) {
|
||||
return static::$loaded[$name];
|
||||
}
|
||||
|
||||
$kirby = App::instance();
|
||||
$root = $kirby->root('blueprints');
|
||||
$file = $root . '/' . $name . '.yml';
|
||||
|
||||
// first try to find the blueprint in the `site/blueprints` root,
|
||||
// then check in the plugin extensions which includes some default
|
||||
// core blueprints (e.g. page, file, site and block defaults)
|
||||
// as well as blueprints provided by plugins
|
||||
if (F::exists($file, $root) !== true) {
|
||||
$file = $kirby->extension('blueprints', $name);
|
||||
}
|
||||
|
||||
// callback option can be return array or blueprint file path
|
||||
if (is_callable($file) === true) {
|
||||
$file = $file($kirby);
|
||||
}
|
||||
|
||||
// now ensure that we always return the data array
|
||||
if (is_string($file) === true && F::exists($file) === true) {
|
||||
return static::$loaded[$name] = Data::read($file);
|
||||
}
|
||||
|
||||
if (is_array($file) === true) {
|
||||
return static::$loaded[$name] = $file;
|
||||
}
|
||||
|
||||
// neither a valid file nor array data
|
||||
throw new NotFoundException(
|
||||
key: 'blueprint.notFound',
|
||||
data: ['name' => $name]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to translate any label, heading, etc.
|
||||
*/
|
||||
protected function i18n(mixed $value, mixed $fallback = null): mixed
|
||||
{
|
||||
return I18n::translate($value, $fallback) ?? $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this is the default blueprint
|
||||
*/
|
||||
public function isDefault(): bool
|
||||
{
|
||||
return $this->name() === 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a blueprint from file or array
|
||||
*/
|
||||
public static function load(string $name): array
|
||||
{
|
||||
$props = static::find($name);
|
||||
|
||||
// inject the filename as name if no name is set
|
||||
$props['name'] ??= $name;
|
||||
|
||||
// normalize the title
|
||||
$title = $props['title'] ?? Str::label($props['name']);
|
||||
|
||||
// translate the title
|
||||
$props['title'] = I18n::translate($title) ?? $title;
|
||||
|
||||
return $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent model
|
||||
*/
|
||||
public function model(): ModelWithContent
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the blueprint name
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return $this->props['name'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes all required props in a column setup
|
||||
*/
|
||||
protected function normalizeColumns(string $tabName, array $columns): array
|
||||
{
|
||||
foreach ($columns as $columnKey => $columnProps) {
|
||||
// unset/remove column if its property is not array
|
||||
if (is_array($columnProps) === false) {
|
||||
unset($columns[$columnKey]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$columnProps = $this->convertFieldsToSections(
|
||||
$tabName . '-col-' . $columnKey,
|
||||
$columnProps
|
||||
);
|
||||
|
||||
// inject getting started info, if the sections are empty
|
||||
if (empty($columnProps['sections']) === true) {
|
||||
$columnProps['sections'] = [
|
||||
$tabName . '-info-' . $columnKey => [
|
||||
'label' => 'Column (' . ($columnProps['width'] ?? '1/1') . ')',
|
||||
'type' => 'info',
|
||||
'text' => 'No sections yet'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$columns[$columnKey] = [
|
||||
...$columnProps,
|
||||
'width' => $columnProps['width'] ?? '1/1',
|
||||
'sections' => $this->normalizeSections(
|
||||
$tabName,
|
||||
$columnProps['sections'] ?? []
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
public static function helpList(array $items): string
|
||||
{
|
||||
$md = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$md[] = '- *' . $item . '*';
|
||||
}
|
||||
|
||||
return PHP_EOL . implode(PHP_EOL, $md);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize field props for a single field
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the filed name is missing or the field type is invalid
|
||||
*/
|
||||
public static function fieldProps(array|string $props): array
|
||||
{
|
||||
$props = static::extend($props);
|
||||
|
||||
if (isset($props['name']) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'The field name is missing'
|
||||
);
|
||||
}
|
||||
|
||||
$name = $props['name'];
|
||||
$type = $props['type'] ?? $name;
|
||||
|
||||
if ($type !== 'group' && isset(Field::$types[$type]) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Invalid field type ("' . $type . '")'
|
||||
);
|
||||
}
|
||||
|
||||
// support for nested fields
|
||||
if (isset($props['fields']) === true) {
|
||||
$props['fields'] = static::fieldsProps($props['fields']);
|
||||
}
|
||||
|
||||
// groups don't need all the crap
|
||||
if ($type === 'group') {
|
||||
$fields = $props['fields'];
|
||||
|
||||
if (isset($props['when']) === true) {
|
||||
$fields = array_map(
|
||||
fn ($field) => array_replace_recursive(['when' => $props['when']], $field),
|
||||
$fields
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'fields' => $fields,
|
||||
'name' => $name,
|
||||
'type' => $type
|
||||
];
|
||||
}
|
||||
|
||||
// add some useful defaults
|
||||
return [
|
||||
...$props,
|
||||
'label' => $props['label'] ?? Str::label($name),
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'width' => $props['width'] ?? '1/1',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error field with the given error message
|
||||
*/
|
||||
public static function fieldError(string $name, string $message): array
|
||||
{
|
||||
return [
|
||||
'label' => 'Error',
|
||||
'name' => $name,
|
||||
'text' => strip_tags($message),
|
||||
'theme' => 'negative',
|
||||
'type' => 'info',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes all fields and adds automatic labels,
|
||||
* types and widths.
|
||||
*/
|
||||
public static function fieldsProps($fields): array
|
||||
{
|
||||
if (is_array($fields) === false) {
|
||||
$fields = [];
|
||||
}
|
||||
|
||||
foreach ($fields as $fieldName => $fieldProps) {
|
||||
// extend field from string
|
||||
if (is_string($fieldProps) === true) {
|
||||
$fieldProps = [
|
||||
'extends' => $fieldProps,
|
||||
'name' => $fieldName
|
||||
];
|
||||
}
|
||||
|
||||
// use the name as type definition
|
||||
if ($fieldProps === true) {
|
||||
$fieldProps = [];
|
||||
}
|
||||
|
||||
// unset / remove field if its property is false
|
||||
if ($fieldProps === false) {
|
||||
unset($fields[$fieldName]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// inject the name
|
||||
$fieldProps['name'] = $fieldName;
|
||||
|
||||
// create all props
|
||||
try {
|
||||
$fieldProps = static::fieldProps($fieldProps);
|
||||
} catch (Throwable $e) {
|
||||
$fieldProps = static::fieldError($fieldName, $e->getMessage());
|
||||
}
|
||||
|
||||
// resolve field groups
|
||||
if ($fieldProps['type'] === 'group') {
|
||||
if (
|
||||
empty($fieldProps['fields']) === false &&
|
||||
is_array($fieldProps['fields']) === true
|
||||
) {
|
||||
$index = array_search($fieldName, array_keys($fields));
|
||||
$fields = [
|
||||
...array_slice($fields, 0, $index),
|
||||
...$fieldProps['fields'] ?? [],
|
||||
...array_slice($fields, $index + 1)
|
||||
];
|
||||
} else {
|
||||
unset($fields[$fieldName]);
|
||||
}
|
||||
} else {
|
||||
$fields[$fieldName] = $fieldProps;
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes blueprint options. This must be used in the
|
||||
* constructor of an extended class, if you want to make use of it.
|
||||
*/
|
||||
protected function normalizeOptions(
|
||||
array|string|bool|null $options,
|
||||
array $defaults,
|
||||
array $aliases = []
|
||||
): array {
|
||||
// return defaults when options are not defined or set to true
|
||||
if ($options === true) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
// set all options to false
|
||||
if ($options === false) {
|
||||
return array_map(fn () => false, $defaults);
|
||||
}
|
||||
|
||||
// extend options if possible
|
||||
$options = static::extend($options);
|
||||
|
||||
foreach ($options as $key => $value) {
|
||||
$alias = $aliases[$key] ?? null;
|
||||
|
||||
if ($alias !== null) {
|
||||
$options[$alias] ??= $value;
|
||||
unset($options[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...$defaults, ...$options];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes all required keys in sections
|
||||
*/
|
||||
protected function normalizeSections(
|
||||
string $tabName,
|
||||
array $sections
|
||||
): array {
|
||||
foreach ($sections as $sectionName => $sectionProps) {
|
||||
// unset / remove section if its property is false
|
||||
if ($sectionProps === false) {
|
||||
unset($sections[$sectionName]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// fallback to default props when true is passed
|
||||
if ($sectionProps === true) {
|
||||
$sectionProps = [];
|
||||
}
|
||||
|
||||
// inject all section extensions
|
||||
$sectionProps = static::extend($sectionProps);
|
||||
|
||||
$sections[$sectionName] = $sectionProps = [
|
||||
...$sectionProps,
|
||||
'name' => $sectionName,
|
||||
'type' => $type = $sectionProps['type'] ?? $sectionName
|
||||
];
|
||||
|
||||
if (empty($type) === true || is_string($type) === false) {
|
||||
$sections[$sectionName] = [
|
||||
'name' => $sectionName,
|
||||
'label' => 'Invalid section type for section "' . $sectionName . '"',
|
||||
'type' => 'info',
|
||||
'text' => 'The following section types are available: ' . static::helpList(array_keys(Section::$types))
|
||||
];
|
||||
} elseif (isset(Section::$types[$type]) === false) {
|
||||
$sections[$sectionName] = [
|
||||
'name' => $sectionName,
|
||||
'label' => 'Invalid section type ("' . $type . '")',
|
||||
'type' => 'info',
|
||||
'text' => 'The following section types are available: ' . static::helpList(array_keys(Section::$types))
|
||||
];
|
||||
}
|
||||
|
||||
if ($sectionProps['type'] === 'fields') {
|
||||
$fields = Blueprint::fieldsProps($sectionProps['fields'] ?? []);
|
||||
|
||||
// inject guide fields guide
|
||||
if ($fields === []) {
|
||||
$fields = [
|
||||
$tabName . '-info' => [
|
||||
'label' => 'Fields',
|
||||
'text' => 'No fields yet',
|
||||
'type' => 'info'
|
||||
]
|
||||
];
|
||||
} else {
|
||||
foreach ($fields as $fieldName => $fieldProps) {
|
||||
if (isset($this->fields[$fieldName]) === true) {
|
||||
$this->fields[$fieldName] = $fields[$fieldName] = [
|
||||
'type' => 'info',
|
||||
'label' => $fieldProps['label'] ?? 'Error',
|
||||
'text' => 'The field name <strong>"' . $fieldName . '"</strong> already exists in your blueprint.',
|
||||
'theme' => 'negative'
|
||||
];
|
||||
} else {
|
||||
$this->fields[$fieldName] = $fieldProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$sections[$sectionName]['fields'] = $fields;
|
||||
}
|
||||
}
|
||||
|
||||
// store all normalized sections
|
||||
$this->sections = [...$this->sections, ...$sections];
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes all required keys in tabs
|
||||
*/
|
||||
protected function normalizeTabs($tabs): array
|
||||
{
|
||||
if (is_array($tabs) === false) {
|
||||
$tabs = [];
|
||||
}
|
||||
|
||||
foreach ($tabs as $tabName => $tabProps) {
|
||||
// unset / remove tab if its property is false
|
||||
if ($tabProps === false) {
|
||||
unset($tabs[$tabName]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// inject all tab extensions
|
||||
$tabProps = static::extend($tabProps);
|
||||
|
||||
// inject a preset if available
|
||||
$tabProps = $this->preset($tabProps);
|
||||
|
||||
$tabProps = $this->convertFieldsToSections($tabName, $tabProps);
|
||||
$tabProps = $this->convertSectionsToColumns($tabName, $tabProps);
|
||||
|
||||
$tabs[$tabName] = [
|
||||
...$tabProps,
|
||||
'columns' => $this->normalizeColumns($tabName, $tabProps['columns'] ?? []),
|
||||
'icon' => $tabProps['icon'] ?? null,
|
||||
'label' => $this->i18n($tabProps['label'] ?? Str::label($tabName)),
|
||||
'link' => $this->model->panel()->url(true) . '/?tab=' . $tabName,
|
||||
'name' => $tabName,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->tabs = $tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a blueprint preset
|
||||
*/
|
||||
protected function preset(array $props): array
|
||||
{
|
||||
if (isset($props['preset']) === false) {
|
||||
return $props;
|
||||
}
|
||||
|
||||
if (isset(static::$presets[$props['preset']]) === false) {
|
||||
return $props;
|
||||
}
|
||||
|
||||
$preset = static::$presets[$props['preset']];
|
||||
|
||||
if (is_string($preset) === true) {
|
||||
$preset = F::load($preset, allowOutput: false);
|
||||
}
|
||||
|
||||
return $preset($props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single section by name
|
||||
*/
|
||||
public function section(string $name): Section|null
|
||||
{
|
||||
if (empty($this->sections[$name]) === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->sections[$name] instanceof Section) {
|
||||
return $this->sections[$name]; //@codeCoverageIgnore
|
||||
}
|
||||
|
||||
// get all props
|
||||
$props = $this->sections[$name];
|
||||
|
||||
// inject the blueprint model
|
||||
$props['model'] = $this->model();
|
||||
|
||||
// create a new section object
|
||||
return $this->sections[$name] = new Section($props['type'], $props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all sections
|
||||
*/
|
||||
public function sections(): array
|
||||
{
|
||||
return A::map(
|
||||
$this->sections,
|
||||
fn ($section) => match (true) {
|
||||
$section instanceof Section => $section,
|
||||
default => $this->section($section['name'])
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single tab by name
|
||||
*/
|
||||
public function tab(string|null $name = null): array|null
|
||||
{
|
||||
if ($name === null) {
|
||||
return A::first($this->tabs);
|
||||
}
|
||||
|
||||
return $this->tabs[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all tabs
|
||||
*/
|
||||
public function tabs(): array
|
||||
{
|
||||
return array_values($this->tabs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the blueprint title
|
||||
*/
|
||||
public function title(): string
|
||||
{
|
||||
return $this->props['title'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the blueprint object to a plain array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->props;
|
||||
}
|
||||
}
|
||||
403
kirby/src/Cms/Collection.php
Normal file
403
kirby/src/Cms/Collection.php
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Toolkit\Collection as BaseCollection;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Kirby\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* The Collection class serves as foundation
|
||||
* for the Pages, Files, Users and Structure
|
||||
* classes. It handles object validation and sets
|
||||
* the parent collection property for each object.
|
||||
* The `getAttribute` method is also adjusted to
|
||||
* handle values from Field objects correctly, so
|
||||
* those can be used in filters as well.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @template TValue
|
||||
* @extends \Kirby\Toolkit\Collection<TValue>
|
||||
*/
|
||||
class Collection extends BaseCollection
|
||||
{
|
||||
use HasMethods;
|
||||
|
||||
/**
|
||||
* @var \Kirby\Cms\Pagination|null
|
||||
*/
|
||||
protected $pagination;
|
||||
|
||||
/**
|
||||
* Creates a new Collection with the given objects
|
||||
*
|
||||
* @param iterable<TValue> $objects
|
||||
* @param object|null $parent Stores the parent object,
|
||||
* which is needed in some collections
|
||||
* to get the finder methods right
|
||||
*/
|
||||
public function __construct(
|
||||
iterable $objects = [],
|
||||
protected object|null $parent = null
|
||||
) {
|
||||
foreach ($objects as $object) {
|
||||
$this->add($object);
|
||||
}
|
||||
}
|
||||
|
||||
public function __call(string $key, $arguments)
|
||||
{
|
||||
// collection methods
|
||||
if ($this->hasMethod($key) === true) {
|
||||
return $this->callMethod($key, $arguments);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal setter for each object in the Collection;
|
||||
* override from the Toolkit Collection is needed to
|
||||
* make the CMS collections case-sensitive;
|
||||
* child classes can override it again to add validation
|
||||
* and custom behavior depending on the object type
|
||||
*
|
||||
* @param TValue $object
|
||||
*/
|
||||
public function __set(string $id, $object): void
|
||||
{
|
||||
$this->data[$id] = $object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal remover for each object in the Collection;
|
||||
* override from the Toolkit Collection is needed to
|
||||
* make the CMS collections case-sensitive
|
||||
*/
|
||||
public function __unset(string $id)
|
||||
{
|
||||
unset($this->data[$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single object or
|
||||
* an entire second collection to the
|
||||
* current collection
|
||||
*
|
||||
* @param \Kirby\Cms\Collection<TValue>|array<TValue>|TValue $object
|
||||
* @return $this
|
||||
*/
|
||||
public function add($object): static
|
||||
{
|
||||
if ($object instanceof self) {
|
||||
$this->data = [...$this->data, ...$object->data];
|
||||
} elseif (
|
||||
is_object($object) === true &&
|
||||
method_exists($object, 'id') === true
|
||||
) {
|
||||
$this->__set($object->id(), $object);
|
||||
} else {
|
||||
$this->append($object);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an element to the data array
|
||||
*
|
||||
* ```php
|
||||
* $collection->append($object);
|
||||
* $collection->append('key', $object);
|
||||
* ```
|
||||
*
|
||||
* @param string|TValue ...$args
|
||||
* @return $this
|
||||
*/
|
||||
public function append(...$args): static
|
||||
{
|
||||
if (count($args) === 1) {
|
||||
// try to determine the key from the provided item
|
||||
if (
|
||||
is_object($args[0]) === true &&
|
||||
is_callable([$args[0], 'id']) === true
|
||||
) {
|
||||
return parent::append($args[0]->id(), $args[0]);
|
||||
}
|
||||
|
||||
return parent::append($args[0]);
|
||||
}
|
||||
|
||||
return parent::append(...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single element by an attribute and its value
|
||||
*
|
||||
* @return TValue|null
|
||||
*/
|
||||
public function findBy(string $attribute, $value)
|
||||
{
|
||||
// $value: cast UUID object to string to allow uses
|
||||
// like `$pages->findBy('related', $page->uuid())`
|
||||
if ($value instanceof Uuid) {
|
||||
$value = $value->toString();
|
||||
}
|
||||
|
||||
return parent::findBy($attribute, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups the items by a given field or callback. Returns a collection
|
||||
* with an item for each group and a collection for each group.
|
||||
*
|
||||
* @param string|\Closure $field
|
||||
* @param bool $caseInsensitive Ignore upper/lowercase for group names
|
||||
* @throws \Kirby\Exception\Exception
|
||||
*/
|
||||
public function group(
|
||||
$field,
|
||||
bool $caseInsensitive = true
|
||||
): self {
|
||||
$groups = new self(parent: $this->parent());
|
||||
|
||||
if (is_string($field) === true) {
|
||||
foreach ($this->data as $key => $item) {
|
||||
$value = $this->getAttribute($item, $field);
|
||||
|
||||
// make sure that there's always a proper value to group by
|
||||
if (!$value) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Invalid grouping value for key: ' . $key
|
||||
);
|
||||
}
|
||||
|
||||
$value = (string)$value;
|
||||
|
||||
// ignore upper/lowercase for group names
|
||||
if ($caseInsensitive) {
|
||||
$value = Str::lower($value);
|
||||
}
|
||||
|
||||
if (isset($groups->data[$value]) === false) {
|
||||
// create a new entry for the group if it does not exist yet
|
||||
$groups->data[$value] = new static([$key => $item]);
|
||||
} else {
|
||||
// add the item to an existing group
|
||||
$groups->data[$value]->set($key, $item);
|
||||
}
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
// use the parent method but unwrap the Toolkit collection
|
||||
// and rewrap it as a Cms\Collection instance
|
||||
$groups->data = parent::group($field, $caseInsensitive)->data;
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given object or id
|
||||
* is in the collection
|
||||
*
|
||||
* @param string|TValue $key
|
||||
*/
|
||||
public function has($key): bool
|
||||
{
|
||||
if (is_object($key) === true) {
|
||||
$key = $key->id();
|
||||
}
|
||||
|
||||
return parent::has($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct position detection for objects.
|
||||
* The method will automatically detect objects
|
||||
* or ids and then search accordingly.
|
||||
*
|
||||
* @param string|TValue $needle
|
||||
*/
|
||||
public function indexOf($needle): int|false
|
||||
{
|
||||
if (is_string($needle) === true) {
|
||||
return array_search($needle, $this->keys());
|
||||
}
|
||||
|
||||
return array_search($needle->id(), $this->keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Collection without the given element(s)
|
||||
*
|
||||
* @param string|array|object ...$keys any number of keys,
|
||||
* passed as individual arguments
|
||||
*/
|
||||
public function not(string|array|object ...$keys): static
|
||||
{
|
||||
$collection = $this->clone();
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (is_array($key) === true) {
|
||||
return $this->not(...$key);
|
||||
}
|
||||
|
||||
if ($key instanceof BaseCollection) {
|
||||
$collection = $collection->not(...$key->keys());
|
||||
} elseif (is_object($key) === true) {
|
||||
$key = $key->id();
|
||||
}
|
||||
|
||||
unset($collection->{$key});
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add pagination and return a sliced set of data.
|
||||
*
|
||||
* @return $this|static
|
||||
*/
|
||||
public function paginate(...$arguments): static
|
||||
{
|
||||
$this->pagination = Pagination::for($this, ...$arguments);
|
||||
|
||||
// slice and clone the collection according to the pagination
|
||||
return $this->slice(
|
||||
$this->pagination->offset(),
|
||||
$this->pagination->limit()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previously added pagination object
|
||||
*/
|
||||
public function pagination(): Pagination|null
|
||||
{
|
||||
return $this->pagination;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent model
|
||||
*/
|
||||
public function parent(): object|null
|
||||
{
|
||||
return $this->parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepends an element to the data array
|
||||
*
|
||||
* ```php
|
||||
* $collection->prepend($object);
|
||||
* $collection->prepend('key', $object);
|
||||
* ```
|
||||
*
|
||||
* @param string|TValue ...$args
|
||||
* @return $this
|
||||
*/
|
||||
public function prepend(...$args): static
|
||||
{
|
||||
if (count($args) === 1) {
|
||||
// try to determine the key from the provided item
|
||||
if (
|
||||
is_object($args[0]) === true &&
|
||||
is_callable([$args[0], 'id']) === true
|
||||
) {
|
||||
return parent::prepend($args[0]->id(), $args[0]);
|
||||
}
|
||||
|
||||
return parent::prepend($args[0]);
|
||||
}
|
||||
|
||||
return parent::prepend(...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a combination of filter, sort, not,
|
||||
* offset, limit, search and paginate on the collection.
|
||||
* Any part of the query is optional.
|
||||
*/
|
||||
public function query(array $arguments = []): static
|
||||
{
|
||||
$paginate = $arguments['paginate'] ?? null;
|
||||
$search = $arguments['search'] ?? null;
|
||||
|
||||
unset($arguments['paginate']);
|
||||
|
||||
$result = parent::query($arguments);
|
||||
|
||||
if (empty($search) === false) {
|
||||
$result = match (true) {
|
||||
is_array($search) => $result->search(
|
||||
$search['query'] ?? null,
|
||||
$search['options'] ?? []
|
||||
),
|
||||
default => $result->search($search)
|
||||
};
|
||||
}
|
||||
|
||||
if (empty($paginate) === false) {
|
||||
$result = $result->paginate($paginate);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an object
|
||||
*
|
||||
* @param string|TValue $key the name of the key
|
||||
*/
|
||||
public function remove(string|object $key): static
|
||||
{
|
||||
if (is_object($key) === true) {
|
||||
$key = $key->id();
|
||||
}
|
||||
|
||||
return parent::remove($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches the collection
|
||||
*/
|
||||
public function search(
|
||||
string|null $query = null,
|
||||
string|array $params = []
|
||||
): static {
|
||||
return Search::collection($this, $query, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts all objects in the collection
|
||||
* to an array. This can also take a callback
|
||||
* function to further modify the array result.
|
||||
*/
|
||||
public function toArray(Closure|null $map = null): array
|
||||
{
|
||||
return parent::toArray(
|
||||
$map ?? fn ($object) => $object->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an object in the collection
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function update(string|object $key, $object = null): static
|
||||
{
|
||||
if (is_object($key) === true) {
|
||||
return $this->update($key->id(), $key);
|
||||
}
|
||||
|
||||
return $this->set($key, $object);
|
||||
}
|
||||
}
|
||||
131
kirby/src/Cms/Collections.php
Normal file
131
kirby/src/Cms/Collections.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\Controller;
|
||||
|
||||
/**
|
||||
* Manages and loads all collections
|
||||
* in site/collections, which can then
|
||||
* be reused in controllers, templates, etc
|
||||
*
|
||||
* This class is mainly used in the `$kirby->collection()`
|
||||
* method to provide easy access to registered collections
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Collections
|
||||
{
|
||||
/**
|
||||
* Each collection is cached once it
|
||||
* has been called, to avoid further
|
||||
* processing on sequential calls to
|
||||
* the same collection.
|
||||
*/
|
||||
protected array $cache = [];
|
||||
|
||||
/**
|
||||
* Store of all collections
|
||||
*/
|
||||
protected array $collections = [];
|
||||
|
||||
/**
|
||||
* Magic caller to enable something like
|
||||
* `$collections->myCollection()`
|
||||
*
|
||||
* @return \Kirby\Toolkit\Collection|null
|
||||
* @todo 6.0 Add return type declaration
|
||||
*/
|
||||
public function __call(string $name, array $arguments = [])
|
||||
{
|
||||
return $this->get($name, ...$arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a collection by name if registered
|
||||
*
|
||||
* @return \Kirby\Toolkit\Collection|null
|
||||
* @todo 6.0 Add deprecation warning when anything else than a Collection is returned
|
||||
* @todo 7.0 Add PHP return type declaration for `Toolkit\Collection`
|
||||
*/
|
||||
public function get(string $name, array $data = [])
|
||||
{
|
||||
// if not yet loaded
|
||||
$this->collections[$name] ??= $this->load($name);
|
||||
|
||||
// if not yet cached
|
||||
if (($this->cache[$name]['data'] ?? null) !== $data) {
|
||||
$controller = new Controller($this->collections[$name]);
|
||||
|
||||
$this->cache[$name] = [
|
||||
'result' => $controller->call(null, $data),
|
||||
'data' => $data
|
||||
];
|
||||
}
|
||||
|
||||
// return cloned object
|
||||
if (is_object($this->cache[$name]['result']) === true) {
|
||||
return clone $this->cache[$name]['result'];
|
||||
}
|
||||
|
||||
return $this->cache[$name]['result'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a collection exists
|
||||
*/
|
||||
public function has(string $name): bool
|
||||
{
|
||||
if (isset($this->collections[$name]) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->load($name);
|
||||
return true;
|
||||
} catch (NotFoundException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads collection from php file in a
|
||||
* given directory or from plugin extension.
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
*/
|
||||
public function load(string $name): mixed
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
// first check for collection file in the `collections` root
|
||||
$root = $kirby->root('collections');
|
||||
$file = $root . '/' . $name . '.php';
|
||||
|
||||
if (F::exists($file, $root) === true) {
|
||||
$collection = F::load($file, allowOutput: false);
|
||||
|
||||
if ($collection instanceof Closure) {
|
||||
return $collection;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to collections from plugins
|
||||
$collections = $kirby->extensions('collections');
|
||||
|
||||
if ($collection = $collections[$name] ?? null) {
|
||||
return $collection;
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
message: 'The collection cannot be found'
|
||||
);
|
||||
}
|
||||
}
|
||||
484
kirby/src/Cms/Core.php
Normal file
484
kirby/src/Cms/Core.php
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Cache\ApcuCache;
|
||||
use Kirby\Cache\FileCache;
|
||||
use Kirby\Cache\MemCached;
|
||||
use Kirby\Cache\MemoryCache;
|
||||
use Kirby\Cache\RedisCache;
|
||||
use Kirby\Cms\Auth\EmailChallenge;
|
||||
use Kirby\Cms\Auth\TotpChallenge;
|
||||
use Kirby\Form\Field\BlocksField;
|
||||
use Kirby\Form\Field\EntriesField;
|
||||
use Kirby\Form\Field\LayoutField;
|
||||
use Kirby\Form\Field\StatsField;
|
||||
use Kirby\Panel\Ui\FilePreviews\AudioFilePreview;
|
||||
use Kirby\Panel\Ui\FilePreviews\ImageFilePreview;
|
||||
use Kirby\Panel\Ui\FilePreviews\PdfFilePreview;
|
||||
use Kirby\Panel\Ui\FilePreviews\VideoFilePreview;
|
||||
|
||||
/**
|
||||
* The Core class lists all parts of Kirby
|
||||
* that need to be loaded or initalized in order
|
||||
* to make the system work. Most core parts can
|
||||
* be overwritten by plugins.
|
||||
*
|
||||
* You can get such lists as kirbytags, components,
|
||||
* areas, etc. by accessing them through `$kirby->core()`
|
||||
*
|
||||
* I.e. `$kirby->core()->areas()`
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Core
|
||||
{
|
||||
/**
|
||||
* Optional override for the auto-detected index root
|
||||
*/
|
||||
public static string|null $indexRoot = null;
|
||||
|
||||
protected array $cache = [];
|
||||
protected string $root;
|
||||
|
||||
public function __construct(protected App $kirby)
|
||||
{
|
||||
$this->root = dirname(__DIR__, 2) . '/config';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the definition array of a particular area.
|
||||
*
|
||||
* This is a shortcut for `$kirby->core()->load()->area()`
|
||||
* to give faster access to original area code in plugins.
|
||||
*/
|
||||
public function area(string $name): array|null
|
||||
{
|
||||
return $this->load()->area($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all paths to area definition files
|
||||
*
|
||||
* They are located in `/kirby/config/areas`
|
||||
*/
|
||||
public function areas(): array
|
||||
{
|
||||
return [
|
||||
'account' => $this->root . '/areas/account.php',
|
||||
'installation' => $this->root . '/areas/installation.php',
|
||||
'lab' => $this->root . '/areas/lab.php',
|
||||
'languages' => $this->root . '/areas/languages.php',
|
||||
'login' => $this->root . '/areas/login.php',
|
||||
'logout' => $this->root . '/areas/logout.php',
|
||||
'search' => $this->root . '/areas/search.php',
|
||||
'site' => $this->root . '/areas/site.php',
|
||||
'system' => $this->root . '/areas/system.php',
|
||||
'users' => $this->root . '/areas/users.php',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all default auth challenge classes
|
||||
*/
|
||||
public function authChallenges(): array
|
||||
{
|
||||
return [
|
||||
'email' => EmailChallenge::class,
|
||||
'totp' => TotpChallenge::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all paths to blueprint presets
|
||||
*
|
||||
* They are located in `/kirby/config/presets`
|
||||
*/
|
||||
public function blueprintPresets(): array
|
||||
{
|
||||
return [
|
||||
'pages' => $this->root . '/presets/pages.php',
|
||||
'page' => $this->root . '/presets/page.php',
|
||||
'files' => $this->root . '/presets/files.php',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of paths to core blueprints or
|
||||
* the blueprint in array form
|
||||
*
|
||||
* Block blueprints are located in `/kirby/config/blocks`
|
||||
*/
|
||||
public function blueprints(): array
|
||||
{
|
||||
return [
|
||||
// blocks
|
||||
'blocks/code' => $this->root . '/blocks/code/code.yml',
|
||||
'blocks/gallery' => $this->root . '/blocks/gallery/gallery.yml',
|
||||
'blocks/heading' => $this->root . '/blocks/heading/heading.yml',
|
||||
'blocks/image' => $this->root . '/blocks/image/image.yml',
|
||||
'blocks/line' => $this->root . '/blocks/line/line.yml',
|
||||
'blocks/list' => $this->root . '/blocks/list/list.yml',
|
||||
'blocks/markdown' => $this->root . '/blocks/markdown/markdown.yml',
|
||||
'blocks/quote' => $this->root . '/blocks/quote/quote.yml',
|
||||
'blocks/table' => $this->root . '/blocks/table/table.yml',
|
||||
'blocks/text' => $this->root . '/blocks/text/text.yml',
|
||||
'blocks/video' => $this->root . '/blocks/video/video.yml',
|
||||
|
||||
// file blueprints
|
||||
'files/default' => ['title' => 'File'],
|
||||
|
||||
// page blueprints
|
||||
'pages/default' => ['title' => 'Page'],
|
||||
|
||||
// site blueprints
|
||||
'site' => [
|
||||
'title' => 'Site',
|
||||
'sections' => [
|
||||
'pages' => [
|
||||
'headline' => ['*' => 'pages'],
|
||||
'type' => 'pages'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all core caches
|
||||
*/
|
||||
public function caches(): array
|
||||
{
|
||||
return [
|
||||
'changes' => true,
|
||||
'updates' => true,
|
||||
'uuid' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all cache driver classes
|
||||
*/
|
||||
public function cacheTypes(): array
|
||||
{
|
||||
return [
|
||||
'apcu' => ApcuCache::class,
|
||||
'file' => FileCache::class,
|
||||
'memcached' => MemCached::class,
|
||||
'memory' => MemoryCache::class,
|
||||
'redis' => RedisCache::class
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all core component functions
|
||||
*
|
||||
* The component functions can be found in
|
||||
* `/kirby/config/components.php`
|
||||
*/
|
||||
public function components(): array
|
||||
{
|
||||
return $this->cache['components'] ??= include $this->root . '/components.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of all field method aliases
|
||||
*/
|
||||
public function fieldMethodAliases(): array
|
||||
{
|
||||
return [
|
||||
'bool' => 'toBool',
|
||||
'esc' => 'escape',
|
||||
'excerpt' => 'toExcerpt',
|
||||
'float' => 'toFloat',
|
||||
'h' => 'html',
|
||||
'int' => 'toInt',
|
||||
'kt' => 'kirbytext',
|
||||
'kti' => 'kirbytextinline',
|
||||
'link' => 'toLink',
|
||||
'md' => 'markdown',
|
||||
'sp' => 'smartypants',
|
||||
'v' => 'isValid',
|
||||
'x' => 'xml'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all field method functions
|
||||
*
|
||||
* Field methods are stored in `/kirby/config/methods.php`
|
||||
*/
|
||||
public function fieldMethods(): array
|
||||
{
|
||||
return $this->cache['fieldMethods'] ??= (include $this->root . '/methods.php')($this->kirby);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of paths for field mixins
|
||||
*
|
||||
* They are located in `/kirby/config/fields/mixins`
|
||||
*/
|
||||
public function fieldMixins(): array
|
||||
{
|
||||
return [
|
||||
'datetime' => $this->root . '/fields/mixins/datetime.php',
|
||||
'filepicker' => $this->root . '/fields/mixins/filepicker.php',
|
||||
'layout' => $this->root . '/fields/mixins/layout.php',
|
||||
'min' => $this->root . '/fields/mixins/min.php',
|
||||
'options' => $this->root . '/fields/mixins/options.php',
|
||||
'pagepicker' => $this->root . '/fields/mixins/pagepicker.php',
|
||||
'picker' => $this->root . '/fields/mixins/picker.php',
|
||||
'upload' => $this->root . '/fields/mixins/upload.php',
|
||||
'userpicker' => $this->root . '/fields/mixins/userpicker.php',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all paths and class names of panel fields
|
||||
*
|
||||
* Traditional panel fields are located in `/kirby/config/fields`
|
||||
*
|
||||
* The more complex field classes can be found in
|
||||
* `/kirby/src/Form/Fields`
|
||||
*/
|
||||
public function fields(): array
|
||||
{
|
||||
return [
|
||||
'blocks' => BlocksField::class,
|
||||
'checkboxes' => $this->root . '/fields/checkboxes.php',
|
||||
'color' => $this->root . '/fields/color.php',
|
||||
'date' => $this->root . '/fields/date.php',
|
||||
'email' => $this->root . '/fields/email.php',
|
||||
'entries' => EntriesField::class,
|
||||
'files' => $this->root . '/fields/files.php',
|
||||
'gap' => $this->root . '/fields/gap.php',
|
||||
'headline' => $this->root . '/fields/headline.php',
|
||||
'hidden' => $this->root . '/fields/hidden.php',
|
||||
'info' => $this->root . '/fields/info.php',
|
||||
'layout' => LayoutField::class,
|
||||
'line' => $this->root . '/fields/line.php',
|
||||
'link' => $this->root . '/fields/link.php',
|
||||
'list' => $this->root . '/fields/list.php',
|
||||
'multiselect' => $this->root . '/fields/multiselect.php',
|
||||
'number' => $this->root . '/fields/number.php',
|
||||
'object' => $this->root . '/fields/object.php',
|
||||
'pages' => $this->root . '/fields/pages.php',
|
||||
'radio' => $this->root . '/fields/radio.php',
|
||||
'range' => $this->root . '/fields/range.php',
|
||||
'select' => $this->root . '/fields/select.php',
|
||||
'slug' => $this->root . '/fields/slug.php',
|
||||
'stats' => StatsField::class,
|
||||
'structure' => $this->root . '/fields/structure.php',
|
||||
'tags' => $this->root . '/fields/tags.php',
|
||||
'tel' => $this->root . '/fields/tel.php',
|
||||
'text' => $this->root . '/fields/text.php',
|
||||
'textarea' => $this->root . '/fields/textarea.php',
|
||||
'time' => $this->root . '/fields/time.php',
|
||||
'toggle' => $this->root . '/fields/toggle.php',
|
||||
'toggles' => $this->root . '/fields/toggles.php',
|
||||
'url' => $this->root . '/fields/url.php',
|
||||
'users' => $this->root . '/fields/users.php',
|
||||
'writer' => $this->root . '/fields/writer.php'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of all default file preview handlers
|
||||
*/
|
||||
public function filePreviews(): array
|
||||
{
|
||||
return [
|
||||
AudioFilePreview::class,
|
||||
ImageFilePreview::class,
|
||||
PdfFilePreview::class,
|
||||
VideoFilePreview::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of all kirbytag aliases
|
||||
*/
|
||||
public function kirbyTagAliases(): array
|
||||
{
|
||||
return [
|
||||
'youtube' => 'video',
|
||||
'vimeo' => 'video'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all kirbytag definitions
|
||||
*
|
||||
* They are located in `/kirby/config/tags.php`
|
||||
*/
|
||||
public function kirbyTags(): array
|
||||
{
|
||||
return $this->cache['kirbytags'] ??= include $this->root . '/tags.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a core part of Kirby
|
||||
*
|
||||
* The loader is set to not include plugins.
|
||||
* This way, you can access original Kirby core code
|
||||
* through this load method.
|
||||
*/
|
||||
public function load(): Loader
|
||||
{
|
||||
return new Loader($this->kirby, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all absolute paths to important directories
|
||||
*
|
||||
* Roots are resolved and baked in `\Kirby\Cms\App::bakeRoots()`
|
||||
*/
|
||||
public function roots(): array
|
||||
{
|
||||
return $this->cache['roots'] ??= [
|
||||
'kirby' => fn (array $roots) => dirname(__DIR__, 2),
|
||||
'i18n' => fn (array $roots) => $roots['kirby'] . '/i18n',
|
||||
'i18n:translations' => fn (array $roots) => $roots['i18n'] . '/translations',
|
||||
'i18n:rules' => fn (array $roots) => $roots['i18n'] . '/rules',
|
||||
'index' => fn (array $roots) => static::$indexRoot ?? dirname(__DIR__, 3),
|
||||
'assets' => fn (array $roots) => $roots['index'] . '/assets',
|
||||
'content' => fn (array $roots) => $roots['index'] . '/content',
|
||||
'media' => fn (array $roots) => $roots['index'] . '/media',
|
||||
'panel' => fn (array $roots) => $roots['kirby'] . '/panel',
|
||||
'site' => fn (array $roots) => $roots['index'] . '/site',
|
||||
'accounts' => fn (array $roots) => $roots['site'] . '/accounts',
|
||||
'blueprints' => fn (array $roots) => $roots['site'] . '/blueprints',
|
||||
'cache' => fn (array $roots) => $roots['site'] . '/cache',
|
||||
'collections' => fn (array $roots) => $roots['site'] . '/collections',
|
||||
'commands' => fn (array $roots) => $roots['site'] . '/commands',
|
||||
'config' => fn (array $roots) => $roots['site'] . '/config',
|
||||
'controllers' => fn (array $roots) => $roots['site'] . '/controllers',
|
||||
'languages' => fn (array $roots) => $roots['site'] . '/languages',
|
||||
'licenses' => fn (array $roots) => $roots['site'] . '/licenses',
|
||||
'license' => fn (array $roots) => $roots['config'] . '/.license',
|
||||
'logs' => fn (array $roots) => $roots['site'] . '/logs',
|
||||
'models' => fn (array $roots) => $roots['site'] . '/models',
|
||||
'plugins' => fn (array $roots) => $roots['site'] . '/plugins',
|
||||
'sessions' => fn (array $roots) => $roots['site'] . '/sessions',
|
||||
'snippets' => fn (array $roots) => $roots['site'] . '/snippets',
|
||||
'templates' => fn (array $roots) => $roots['site'] . '/templates',
|
||||
'roles' => fn (array $roots) => $roots['blueprints'] . '/users',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all routes for Kirby’s router
|
||||
*
|
||||
* Routes are split into `before` and `after` routes.
|
||||
*
|
||||
* Plugin routes will be injected inbetween.
|
||||
*/
|
||||
public function routes(): array
|
||||
{
|
||||
return $this->cache['routes'] ??= (include $this->root . '/routes.php')($this->kirby);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all paths to core block snippets
|
||||
*
|
||||
* They are located in `/kirby/config/blocks`
|
||||
*/
|
||||
public function snippets(): array
|
||||
{
|
||||
return [
|
||||
'blocks/code' => $this->root . '/blocks/code/code.php',
|
||||
'blocks/gallery' => $this->root . '/blocks/gallery/gallery.php',
|
||||
'blocks/heading' => $this->root . '/blocks/heading/heading.php',
|
||||
'blocks/image' => $this->root . '/blocks/image/image.php',
|
||||
'blocks/line' => $this->root . '/blocks/line/line.php',
|
||||
'blocks/list' => $this->root . '/blocks/list/list.php',
|
||||
'blocks/markdown' => $this->root . '/blocks/markdown/markdown.php',
|
||||
'blocks/quote' => $this->root . '/blocks/quote/quote.php',
|
||||
'blocks/table' => $this->root . '/blocks/table/table.php',
|
||||
'blocks/text' => $this->root . '/blocks/text/text.php',
|
||||
'blocks/video' => $this->root . '/blocks/video/video.php',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of paths to section mixins
|
||||
*
|
||||
* They are located in `/kirby/config/sections/mixins`
|
||||
*/
|
||||
public function sectionMixins(): array
|
||||
{
|
||||
return [
|
||||
'batch' => $this->root . '/sections/mixins/batch.php',
|
||||
'details' => $this->root . '/sections/mixins/details.php',
|
||||
'empty' => $this->root . '/sections/mixins/empty.php',
|
||||
'headline' => $this->root . '/sections/mixins/headline.php',
|
||||
'help' => $this->root . '/sections/mixins/help.php',
|
||||
'layout' => $this->root . '/sections/mixins/layout.php',
|
||||
'max' => $this->root . '/sections/mixins/max.php',
|
||||
'min' => $this->root . '/sections/mixins/min.php',
|
||||
'pagination' => $this->root . '/sections/mixins/pagination.php',
|
||||
'parent' => $this->root . '/sections/mixins/parent.php',
|
||||
'search' => $this->root . '/sections/mixins/search.php',
|
||||
'sort' => $this->root . '/sections/mixins/sort.php',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all section definitions
|
||||
*
|
||||
* They are located in `/kirby/config/sections`
|
||||
*/
|
||||
public function sections(): array
|
||||
{
|
||||
return [
|
||||
'fields' => $this->root . '/sections/fields.php',
|
||||
'files' => $this->root . '/sections/files.php',
|
||||
'info' => $this->root . '/sections/info.php',
|
||||
'pages' => $this->root . '/sections/pages.php',
|
||||
'stats' => $this->root . '/sections/stats.php',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of paths to all system templates
|
||||
*
|
||||
* They are located in `/kirby/config/templates`
|
||||
*/
|
||||
public function templates(): array
|
||||
{
|
||||
return [
|
||||
'emails/auth/login' => $this->root . '/templates/emails/auth/login.php',
|
||||
'emails/auth/password-reset' => $this->root . '/templates/emails/auth/password-reset.php'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with all system URLs
|
||||
*
|
||||
* URLs are resolved and baked in `\Kirby\Cms\App::bakeUrls()`
|
||||
*/
|
||||
public function urls(): array
|
||||
{
|
||||
return $this->cache['urls'] ??= [
|
||||
'index' => fn () => $this->kirby->environment()->baseUrl(),
|
||||
'base' => fn (array $urls) => rtrim($urls['index'], '/'),
|
||||
'current' => function (array $urls) {
|
||||
$path = trim($this->kirby->path(), '/');
|
||||
|
||||
if (empty($path) === true) {
|
||||
return $urls['index'];
|
||||
}
|
||||
|
||||
return $urls['base'] . '/' . $path;
|
||||
},
|
||||
'assets' => fn (array $urls) => $urls['base'] . '/assets',
|
||||
'api' => fn (array $urls) => $urls['base'] . '/' . $this->kirby->option('api.slug', 'api'),
|
||||
'media' => fn (array $urls) => $urls['base'] . '/media',
|
||||
'panel' => fn (array $urls) => $urls['base'] . '/' . $this->kirby->option('panel.slug', 'panel')
|
||||
];
|
||||
}
|
||||
}
|
||||
249
kirby/src/Cms/Cors.php
Normal file
249
kirby/src/Cms/Cors.php
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Handles CORS (Cross-Origin Resource Sharing)
|
||||
* headers for HTTP responses
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.2.0
|
||||
*/
|
||||
class Cors
|
||||
{
|
||||
protected App $kirby;
|
||||
protected array $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->kirby = App::instance();
|
||||
|
||||
if ($this->kirby->isCorsEnabled() === false) {
|
||||
$this->config = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// get and resolve config
|
||||
$config = $this->kirby->option('cors', false);
|
||||
|
||||
// resolve closure
|
||||
if ($config instanceof \Closure) {
|
||||
$config = $config($this->kirby);
|
||||
}
|
||||
|
||||
// convert boolean to empty array (use defaults)
|
||||
if ($config === true) {
|
||||
$config = [];
|
||||
}
|
||||
|
||||
$this->config = is_array($config) ? $config : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CORS headers based on configuration
|
||||
*
|
||||
* @param bool $preflight Whether this is a preflight request
|
||||
*/
|
||||
public static function headers(bool $preflight = false): array
|
||||
{
|
||||
return (new static())->toArray($preflight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the CORS configuration to an array of headers
|
||||
*
|
||||
* @param bool $preflight Whether this is a preflight request
|
||||
*/
|
||||
public function toArray(bool $preflight = false): array
|
||||
{
|
||||
// empty array if CORS is disabled
|
||||
if ($this->kirby->isCorsEnabled() === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
|
||||
// determine allowed origin
|
||||
$allowOrigin = $this->allowOrigin();
|
||||
|
||||
// no origin match found
|
||||
if ($allowOrigin === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$headers['Access-Control-Allow-Origin'] = $allowOrigin;
|
||||
|
||||
$this->addVaryHeader($headers, $allowOrigin);
|
||||
$this->addCredentialsHeader($headers, $allowOrigin);
|
||||
$this->addExposeHeaders($headers);
|
||||
|
||||
// add preflight-specific headers
|
||||
if ($preflight === true) {
|
||||
$this->addPreflightHeaders($headers);
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the allowed origin based on config and request
|
||||
*/
|
||||
protected function allowOrigin(): string|null
|
||||
{
|
||||
$requestOrigin = $this->kirby->request()->header('Origin');
|
||||
$configOrigin = $this->config['allowOrigin'] ?? '*';
|
||||
|
||||
return match (true) {
|
||||
$configOrigin === '*' => '*',
|
||||
$requestOrigin === null => null,
|
||||
is_string($configOrigin) => strcasecmp($configOrigin, $requestOrigin) === 0 ? $requestOrigin : null,
|
||||
default => $this->matchOriginFromArray($configOrigin, $requestOrigin)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the request origin against an array of allowed origins
|
||||
*
|
||||
* @param array $configOrigin Array of allowed origins
|
||||
* @param string $requestOrigin Origin from the request header
|
||||
*/
|
||||
protected function matchOriginFromArray(array $configOrigin, string $requestOrigin): string|null
|
||||
{
|
||||
foreach ($configOrigin as $origin) {
|
||||
if (strcasecmp($origin, $requestOrigin) === 0) {
|
||||
return $requestOrigin;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the Vary header for cache control
|
||||
*
|
||||
* @param array $headers Headers array (passed by reference)
|
||||
* @param string $allowOrigin Allowed origin value
|
||||
*/
|
||||
protected function addVaryHeader(array &$headers, string $allowOrigin): void
|
||||
{
|
||||
// response varies by origin for non-wildcard origins
|
||||
if ($allowOrigin !== '*') {
|
||||
$headers['Vary'] = 'Origin';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the credentials header if configured
|
||||
*
|
||||
* @param array $headers Headers array (passed by reference)
|
||||
* @param string $allowOrigin Allowed origin value
|
||||
*/
|
||||
protected function addCredentialsHeader(array &$headers, string $allowOrigin): void
|
||||
{
|
||||
$allowCredentials = $this->config['allowCredentials'] ?? false;
|
||||
|
||||
if ($allowCredentials === true) {
|
||||
// wildcard origins cannot be used with credentials
|
||||
if ($allowOrigin === '*') {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Cannot allow credentials when using wildcard origin (*)'
|
||||
);
|
||||
}
|
||||
|
||||
$headers['Access-Control-Allow-Credentials'] = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds headers to expose custom headers to the client
|
||||
*
|
||||
* @param array $headers Headers array (passed by reference)
|
||||
*/
|
||||
protected function addExposeHeaders(array &$headers): void
|
||||
{
|
||||
$exposeHeaders = $this->config['exposeHeaders'] ?? [];
|
||||
|
||||
if ($this->hasConfigValue($exposeHeaders) === true) {
|
||||
$headers['Access-Control-Expose-Headers'] = is_array($exposeHeaders)
|
||||
? implode(', ', $exposeHeaders)
|
||||
: $exposeHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds preflight-specific headers
|
||||
*
|
||||
* @param array $headers Headers array (passed by reference)
|
||||
*/
|
||||
protected function addPreflightHeaders(array &$headers): void
|
||||
{
|
||||
// max age
|
||||
$maxAge = $this->config['maxAge'] ?? null;
|
||||
if ($maxAge !== null) {
|
||||
$headers['Access-Control-Max-Age'] = (string)$maxAge;
|
||||
}
|
||||
|
||||
// allowed methods
|
||||
$methods = $this->config['allowMethods'] ?? ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'];
|
||||
if ($this->hasConfigValue($methods) === true) {
|
||||
$headers['Access-Control-Allow-Methods'] = is_array($methods)
|
||||
? implode(', ', $methods)
|
||||
: $methods;
|
||||
}
|
||||
|
||||
// allowed headers
|
||||
$this->addAllowHeaders($headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds headers to allow custom request headers
|
||||
*
|
||||
* @param array $headers Headers array (passed by reference)
|
||||
*/
|
||||
protected function addAllowHeaders(array &$headers): void
|
||||
{
|
||||
$allowHeaders = $this->config['allowHeaders'] ?? null;
|
||||
|
||||
// reflect request headers only when explicitly enabled via `true`
|
||||
if ($allowHeaders === true) {
|
||||
$requestHeaders = $this->kirby->request()->header('Access-Control-Request-Headers');
|
||||
$allowHeaders = $requestHeaders !== null ? Str::split($requestHeaders, ',') : [];
|
||||
}
|
||||
|
||||
if ($this->hasConfigValue($allowHeaders) === true) {
|
||||
$headers['Access-Control-Allow-Headers'] = is_array($allowHeaders)
|
||||
? implode(', ', $allowHeaders)
|
||||
: $allowHeaders;
|
||||
|
||||
// preflight response varies by requested headers
|
||||
if (isset($headers['Vary']) === true) {
|
||||
$headers['Vary'] .= ', Access-Control-Request-Headers';
|
||||
} else {
|
||||
$headers['Vary'] = 'Access-Control-Request-Headers';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a config value contains meaningful data
|
||||
*/
|
||||
protected function hasConfigValue(mixed $input): bool
|
||||
{
|
||||
if (is_array($input) === true) {
|
||||
return $input !== [];
|
||||
}
|
||||
|
||||
if (is_string($input) === true) {
|
||||
return $input !== '';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
246
kirby/src/Cms/Email.php
Normal file
246
kirby/src/Cms/Email.php
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Template\Template;
|
||||
|
||||
/**
|
||||
* Wrapper around our Email package, which
|
||||
* handles all the magic connections between Kirby
|
||||
* and sending emails, like email templates, file
|
||||
* attachments, etc.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Email
|
||||
{
|
||||
/**
|
||||
* Options configured through the `email` CMS option
|
||||
*/
|
||||
protected array $options;
|
||||
|
||||
/**
|
||||
* Props for the email object; will be passed to the
|
||||
* \Kirby\Email\Email class
|
||||
*/
|
||||
protected array $props;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
*
|
||||
* @param string|array $preset Preset name from the config or a simple props array
|
||||
* @param array $props Props array to override the $preset
|
||||
*/
|
||||
public function __construct(string|array $preset = [], array $props = [])
|
||||
{
|
||||
$this->options = App::instance()->option('email', []);
|
||||
|
||||
// build a prop array based on preset and props
|
||||
$this->props = [...$this->preset($preset), ...$props];
|
||||
|
||||
// add transport settings
|
||||
$this->props['transport'] ??= $this->options['transport'] ?? [];
|
||||
|
||||
// add predefined beforeSend option
|
||||
$this->props['beforeSend'] ??= $this->options['beforeSend'] ?? null;
|
||||
|
||||
// transform model objects to values
|
||||
$this->transformUserSingle('from', 'fromName');
|
||||
$this->transformUserSingle('replyTo', 'replyToName');
|
||||
$this->transformUserMultiple('to');
|
||||
$this->transformUserMultiple('cc');
|
||||
$this->transformUserMultiple('bcc');
|
||||
$this->transformFile('attachments');
|
||||
|
||||
// load template for body text
|
||||
$this->template();
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs a preset from the options; supports fixed
|
||||
* prop arrays in case a preset is not needed
|
||||
*
|
||||
* @param string|array $preset Preset name or simple prop array
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
*/
|
||||
protected function preset(string|array $preset): array
|
||||
{
|
||||
// only passed props, not preset name
|
||||
if (is_array($preset) === true) {
|
||||
return $preset;
|
||||
}
|
||||
|
||||
// preset does not exist
|
||||
if (isset($this->options['presets'][$preset]) !== true) {
|
||||
throw new NotFoundException(
|
||||
key: 'email.preset.notFound',
|
||||
data: ['name' => $preset]
|
||||
);
|
||||
}
|
||||
|
||||
return $this->options['presets'][$preset];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the email template(s) and sets the body props
|
||||
* to the result
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
*/
|
||||
protected function template(): void
|
||||
{
|
||||
if (isset($this->props['template']) === true) {
|
||||
// prepare data to be passed to template
|
||||
$data = $this->props['data'] ?? [];
|
||||
|
||||
// check if html/text templates exist
|
||||
$html = $this->getTemplate($this->props['template'], 'html');
|
||||
$text = $this->getTemplate($this->props['template'], 'text');
|
||||
|
||||
if ($html->exists() === true) {
|
||||
$this->props['body'] = ['html' => $html->render($data)];
|
||||
|
||||
if ($text->exists() === true) {
|
||||
$this->props['body']['text'] = $text->render($data);
|
||||
}
|
||||
|
||||
// fallback to single email text template
|
||||
} elseif ($text->exists() === true) {
|
||||
$this->props['body'] = $text->render($data);
|
||||
} else {
|
||||
throw new NotFoundException(
|
||||
message: 'The email template "' . $this->props['template'] . '" cannot be found'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an email template by name and type
|
||||
*/
|
||||
protected function getTemplate(string $name, string|null $type = null): Template
|
||||
{
|
||||
return App::instance()->template('emails/' . $name, $type, 'text');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the prop array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms file object(s) to an array of file roots;
|
||||
* supports simple strings, file objects or collections/arrays of either
|
||||
*
|
||||
* @param string $prop Prop to transform
|
||||
*/
|
||||
protected function transformFile(string $prop): void
|
||||
{
|
||||
$this->props[$prop] = $this->transformModel($prop, File::class, 'root');
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms Kirby models to a simplified collection
|
||||
*
|
||||
* @param string $prop Prop to transform
|
||||
* @param string $class Fully qualified class name of the supported model
|
||||
* @param string $contentValue Model method that returns the array value
|
||||
* @param string|null $contentKey Optional model method that returns the array key;
|
||||
* returns a simple value-only array if not given
|
||||
* @return array Simple key-value or just value array with the transformed prop data
|
||||
*/
|
||||
protected function transformModel(
|
||||
string $prop,
|
||||
string $class,
|
||||
string $contentValue,
|
||||
string|null $contentKey = null
|
||||
): array {
|
||||
$value = $this->props[$prop] ?? [];
|
||||
|
||||
// ensure consistent input by making everything an iterable value
|
||||
if (is_iterable($value) !== true) {
|
||||
$value = [$value];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_string($item) === true) {
|
||||
// value is already a string
|
||||
if ($contentKey !== null && is_string($key) === true) {
|
||||
$result[$key] = $item;
|
||||
} else {
|
||||
$result[] = $item;
|
||||
}
|
||||
} elseif ($item instanceof $class) {
|
||||
// value is a model object, get value through content method(s)
|
||||
if ($contentKey !== null) {
|
||||
$result[(string)$item->$contentKey()] = (string)$item->$contentValue();
|
||||
} else {
|
||||
$result[] = (string)$item->$contentValue();
|
||||
}
|
||||
} else {
|
||||
// invalid input
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Invalid input for prop "' . $prop . '", expected string or "' . $class . '" object or collection'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms an user object to the email address and name;
|
||||
* supports simple strings, user objects or collections/arrays of either
|
||||
* (note: only the first item in a collection/array will be used)
|
||||
*
|
||||
* @param string $addressProp Prop with the email address
|
||||
* @param string $nameProp Prop with the name corresponding to the $addressProp
|
||||
*/
|
||||
protected function transformUserSingle(
|
||||
string $addressProp,
|
||||
string $nameProp
|
||||
): void {
|
||||
$result = $this->transformModel($addressProp, User::class, 'name', 'email');
|
||||
|
||||
$address = array_keys($result)[0] ?? null;
|
||||
$name = $result[$address ?? ''] ?? null;
|
||||
|
||||
// if the array is non-associative, the value is the address
|
||||
if (is_int($address) === true) {
|
||||
$address = $name;
|
||||
$name = null;
|
||||
}
|
||||
|
||||
// always use the address as we have transformed that prop above
|
||||
$this->props[$addressProp] = $address;
|
||||
|
||||
// only use the name from the user if no custom name was set
|
||||
$this->props[$nameProp] ??= $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms user object(s) to the email address(es) and name(s);
|
||||
* supports simple strings, user objects or collections/arrays of either
|
||||
*
|
||||
* @param string $prop Prop to transform
|
||||
*/
|
||||
protected function transformUserMultiple(string $prop): void
|
||||
{
|
||||
$this->props[$prop] = $this->transformModel(
|
||||
$prop,
|
||||
User::class,
|
||||
'name',
|
||||
'email'
|
||||
);
|
||||
}
|
||||
}
|
||||
278
kirby/src/Cms/Event.php
Normal file
278
kirby/src/Cms/Event.php
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Content\ImmutableMemoryStorage;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Toolkit\Controller;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* The Event object is created whenever the `$kirby->trigger()`
|
||||
* or `$kirby->apply()` methods are called. It collects all
|
||||
* event information and handles calling the individual hooks.
|
||||
* @since 3.4.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Lukas Bestle <lukas@getkirby.com>,
|
||||
* Ahmet Bora
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Event implements Stringable
|
||||
{
|
||||
/**
|
||||
* The event type
|
||||
* (e.g. `page` in `page.create:after`)
|
||||
*/
|
||||
protected string $type;
|
||||
|
||||
/**
|
||||
* The event action
|
||||
* (e.g. `create` in `page.create:after`)
|
||||
*/
|
||||
protected string|null $action;
|
||||
|
||||
/**
|
||||
* The event state
|
||||
* (e.g. `after` in `page.create:after`)
|
||||
*/
|
||||
protected string|null $state;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
*
|
||||
* @param string $name Full event name (e.g. `page.create:after`)
|
||||
* @param array $arguments Associative array of named event arguments
|
||||
*/
|
||||
public function __construct(
|
||||
protected string $name,
|
||||
protected array $arguments = []
|
||||
) {
|
||||
// split the event name into `$type.$action:$state`
|
||||
// $action and $state are optional;
|
||||
// if there is more than one dot, $type will be greedy
|
||||
$regex = '/^(?<type>.+?)(?:\.(?<action>[^.]*?))?(?:\:(?<state>.*))?$/';
|
||||
preg_match($regex, $name, $matches, PREG_UNMATCHED_AS_NULL);
|
||||
|
||||
$this->name = $name;
|
||||
$this->type = $matches['type'];
|
||||
$this->action = $matches['action'] ?? null;
|
||||
$this->state = $matches['state'] ?? null;
|
||||
$this->arguments = $arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic caller for event arguments
|
||||
*/
|
||||
public function __call(string $method, array $arguments = []): mixed
|
||||
{
|
||||
return $this->argument($method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Improved `var_dump` output
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes it possible to simply echo
|
||||
* or stringify the entire object
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the action of the event (e.g. `create`)
|
||||
* or `null` if the event name does not include an action
|
||||
*/
|
||||
public function action(): string|null
|
||||
{
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a specific event argument
|
||||
*/
|
||||
public function argument(string $name): mixed
|
||||
{
|
||||
return $this->arguments[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the arguments of the event
|
||||
*/
|
||||
public function arguments(): array
|
||||
{
|
||||
return $this->arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a hook with the event data and returns
|
||||
* the hook's return value
|
||||
*
|
||||
* @param object|null $bind Optional object to bind to the hook function
|
||||
*/
|
||||
public function call(object|null $bind, Closure $hook): mixed
|
||||
{
|
||||
// collect the list of possible event arguments
|
||||
$data = [
|
||||
...$this->arguments(),
|
||||
'event' => $this
|
||||
];
|
||||
|
||||
// magically call the hook with the arguments it requested
|
||||
$hook = new Controller($hook);
|
||||
return $hook->call($bind, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full name of the event
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full list of possible wildcard
|
||||
* event names based on the current event name
|
||||
*/
|
||||
public function nameWildcards(): array
|
||||
{
|
||||
// if the event is already a wildcard event,
|
||||
// no further variation is possible
|
||||
if (
|
||||
$this->type === '*' ||
|
||||
$this->action === '*' ||
|
||||
$this->state === '*'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->action !== null && $this->state !== null) {
|
||||
// full $type.$action:$state event
|
||||
|
||||
return [
|
||||
$this->type . '.*:' . $this->state,
|
||||
$this->type . '.' . $this->action . ':*',
|
||||
$this->type . '.*:*',
|
||||
'*.' . $this->action . ':' . $this->state,
|
||||
'*.' . $this->action . ':*',
|
||||
'*:' . $this->state,
|
||||
'*'
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->state !== null) {
|
||||
// event without action: $type:$state
|
||||
|
||||
return [
|
||||
$this->type . ':*',
|
||||
'*:' . $this->state,
|
||||
'*'
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->action !== null) {
|
||||
// event without state: $type.$action
|
||||
|
||||
return [
|
||||
$this->type . '.*',
|
||||
'*.' . $this->action,
|
||||
'*'
|
||||
];
|
||||
}
|
||||
|
||||
// event with a simple name
|
||||
return ['*'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the state of the event (e.g. `after`)
|
||||
*/
|
||||
public function state(): string|null
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the event data as array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'arguments' => $this->arguments
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the event name as string
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of the event (e.g. `page`)
|
||||
*/
|
||||
public function type(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a given argument with a new value
|
||||
*
|
||||
* @unstable
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function updateArgument(string $name, $value): void
|
||||
{
|
||||
if (array_key_exists($name, $this->arguments) !== true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'The argument ' . $name . ' does not exist'
|
||||
);
|
||||
}
|
||||
|
||||
// no new value has been supplied by the apply hook
|
||||
if ($value === null) {
|
||||
|
||||
// To support legacy model modification
|
||||
// in hooks without return values, we need to
|
||||
// check the state of the updated argument.
|
||||
// If the argument is an instance of ModelWithContent
|
||||
// and the storage is an instance of ImmutableMemoryStorage,
|
||||
// we can replace the argument with its clone to achieve
|
||||
// the same effect as if the hook returned the modified model.
|
||||
$state = $this->arguments[$name];
|
||||
|
||||
if ($state instanceof ModelWithContent) {
|
||||
$storage = $state->storage();
|
||||
|
||||
if (
|
||||
$storage instanceof ImmutableMemoryStorage &&
|
||||
$storage->nextModel() !== null
|
||||
) {
|
||||
$this->arguments[$name] = $storage->nextModel();
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, there's no need to update the argument
|
||||
// if no new value is provided
|
||||
return;
|
||||
}
|
||||
|
||||
$this->arguments[$name] = $value;
|
||||
}
|
||||
}
|
||||
130
kirby/src/Cms/Events.php
Normal file
130
kirby/src/Cms/Events.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
|
||||
/**
|
||||
* The `Events` class outsources the logic of
|
||||
* `App::apply()` and `App::trigger()` methods
|
||||
* and makes them easier and more predictable to test.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
*/
|
||||
class Events
|
||||
{
|
||||
protected int $level = 0;
|
||||
protected array $processed = [];
|
||||
|
||||
public function __construct(
|
||||
protected App $app
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the hook and applies the result to the argument
|
||||
* specified by the $modify parameter. By default, the
|
||||
* first argument is modified.
|
||||
*/
|
||||
public function apply(
|
||||
string $name,
|
||||
array $args = [],
|
||||
string|null $modify = null
|
||||
): mixed {
|
||||
// modify the first argument by default
|
||||
$modify ??= array_key_first($args);
|
||||
|
||||
return $this->process(
|
||||
$name,
|
||||
$args,
|
||||
// update $modify value after each hook callback
|
||||
fn ($event, $result) => $event->updateArgument($modify, $result),
|
||||
// return the modified value
|
||||
fn ($event) => $event->argument($modify)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all matching hook handlers for the given event
|
||||
*/
|
||||
public function hooks(Event $event): array
|
||||
{
|
||||
// get all hooks for the event name
|
||||
$name = $event->name();
|
||||
$hooks = $this->app->extensions('hooks') ?? [];
|
||||
$result = $hooks[$name] ?? [];
|
||||
|
||||
// get all hooks for the event name wildcards
|
||||
foreach ($event->nameWildcards() as $wildcard) {
|
||||
$result = [
|
||||
...$result,
|
||||
...$hooks[$wildcard] ?? []
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the hook
|
||||
*
|
||||
* @return ($return is null ? void : mixed)
|
||||
*/
|
||||
protected function process(
|
||||
string $name,
|
||||
array $args,
|
||||
Closure|null $afterEach = null,
|
||||
Closure|null $return = null
|
||||
) {
|
||||
// create the event object and get all hook callbacks for this event
|
||||
$event = new Event($name, $args);
|
||||
$hooks = $this->hooks($event);
|
||||
|
||||
$this->level++;
|
||||
|
||||
foreach ($hooks as $hook) {
|
||||
// skip hooks that have already been processed
|
||||
if (in_array($hook, $this->processed[$name] ?? []) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// mark the hook as processed, to avoid endless loops
|
||||
$this->processed[$name][] = $hook;
|
||||
|
||||
// bind the Kirby instance to the hook and run it
|
||||
$result = $event->call($this->app, $hook);
|
||||
|
||||
// run the afterEach callback
|
||||
if ($afterEach !== null) {
|
||||
$afterEach($event, $result);
|
||||
}
|
||||
}
|
||||
|
||||
$this->level--;
|
||||
|
||||
// reset the protection after the last nesting level has been closed
|
||||
if ($this->level === 0) {
|
||||
$this->processed = [];
|
||||
}
|
||||
|
||||
// run the return callback
|
||||
if ($return !== null) {
|
||||
return $return($event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the hook without modifying the arguments
|
||||
*/
|
||||
public function trigger(
|
||||
string $name,
|
||||
array $args = []
|
||||
): void {
|
||||
$this->process($name, $args);
|
||||
}
|
||||
}
|
||||
239
kirby/src/Cms/Fieldset.php
Normal file
239
kirby/src/Cms/Fieldset.php
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Form\Form;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Represents a single Fieldset
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Item<\Kirby\Cms\Fieldsets>
|
||||
*/
|
||||
class Fieldset extends Item
|
||||
{
|
||||
public const ITEMS_CLASS = Fieldsets::class;
|
||||
|
||||
protected bool $disabled;
|
||||
protected bool $editable;
|
||||
protected array $fields = [];
|
||||
protected string|null $icon;
|
||||
protected string|null $label;
|
||||
protected string|null $name;
|
||||
protected string|bool|null $preview;
|
||||
protected array $tabs;
|
||||
protected bool $translate;
|
||||
protected string $type;
|
||||
protected bool $unset;
|
||||
protected bool $wysiwyg;
|
||||
|
||||
/**
|
||||
* Creates a new Fieldset object
|
||||
*/
|
||||
public function __construct(array $params = [])
|
||||
{
|
||||
if (empty($params['type']) === true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'The fieldset type is missing'
|
||||
);
|
||||
}
|
||||
|
||||
$this->type = $params['id'] = $params['type'];
|
||||
|
||||
parent::__construct($params);
|
||||
|
||||
$this->disabled = $params['disabled'] ?? false;
|
||||
$this->editable = $params['editable'] ?? true;
|
||||
$this->icon = $params['icon'] ?? null;
|
||||
$params['title'] ??= $params['name'] ?? Str::label($this->type);
|
||||
$this->name = $this->createName($params['title']);
|
||||
$this->label = $this->createLabel($params['label'] ?? null);
|
||||
$this->preview = $params['preview'] ?? null;
|
||||
$this->tabs = $this->createTabs($params);
|
||||
$this->translate = $params['translate'] ?? true;
|
||||
$this->unset = $params['unset'] ?? false;
|
||||
$this->wysiwyg = $params['wysiwyg'] ?? false;
|
||||
|
||||
if (
|
||||
$this->translate === false &&
|
||||
$this->kirby()->multilang() === true &&
|
||||
$this->kirby()->language()->isDefault() === false
|
||||
) {
|
||||
// disable and unset the fieldset if it's not translatable
|
||||
$this->unset = true;
|
||||
$this->disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected function createFields(array $fields = []): array
|
||||
{
|
||||
$fields = Blueprint::fieldsProps($fields);
|
||||
$fields = $this->form($fields)->fields()->toProps();
|
||||
|
||||
// collect all fields
|
||||
$this->fields = [...$this->fields, ...$fields];
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
protected function createName(array|string $name): string|null
|
||||
{
|
||||
return I18n::translate($name, $name);
|
||||
}
|
||||
|
||||
protected function createLabel(array|string|null $label = null): string|null
|
||||
{
|
||||
return I18n::translate($label, $label);
|
||||
}
|
||||
|
||||
protected function createTabs(array $params = []): array
|
||||
{
|
||||
$tabs = $params['tabs'] ?? [];
|
||||
|
||||
// return a single tab if there are only fields
|
||||
if (empty($tabs) === true) {
|
||||
return [
|
||||
'content' => [
|
||||
'fields' => $this->createFields($params['fields'] ?? []),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// normalize tabs props
|
||||
foreach ($tabs as $name => $tab) {
|
||||
// unset/remove tab if its property is false
|
||||
if ($tab === false) {
|
||||
unset($tabs[$name]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$tab = Blueprint::extend($tab);
|
||||
|
||||
$tab['fields'] = $this->createFields($tab['fields'] ?? []);
|
||||
$tab['label'] ??= Str::label($name);
|
||||
$tab['label'] = $this->createLabel($tab['label']);
|
||||
$tab['name'] = $name;
|
||||
|
||||
$tabs[$name] = $tab;
|
||||
}
|
||||
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
public function disabled(): bool
|
||||
{
|
||||
return $this->disabled;
|
||||
}
|
||||
|
||||
public function editable(): bool
|
||||
{
|
||||
if ($this->editable === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->fields === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function fields(): array
|
||||
{
|
||||
return $this->fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a form for the given fields
|
||||
*/
|
||||
public function form(array $fields, array $input = []): Form
|
||||
{
|
||||
$form = new Form(
|
||||
fields: $fields,
|
||||
model: $this->parent,
|
||||
);
|
||||
|
||||
$form->fill(
|
||||
input: $input,
|
||||
passthrough: false
|
||||
);
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
public function icon(): string|null
|
||||
{
|
||||
return $this->icon;
|
||||
}
|
||||
|
||||
public function label(): string|null
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function model(): ModelWithContent
|
||||
{
|
||||
return $this->parent;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function preview(): string|bool|null
|
||||
{
|
||||
return $this->preview;
|
||||
}
|
||||
|
||||
public function tabs(): array
|
||||
{
|
||||
return $this->tabs;
|
||||
}
|
||||
|
||||
public function translate(): bool
|
||||
{
|
||||
return $this->translate;
|
||||
}
|
||||
|
||||
public function type(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'disabled' => $this->disabled(),
|
||||
'editable' => $this->editable(),
|
||||
'icon' => $this->icon(),
|
||||
'label' => $this->label(),
|
||||
'name' => $this->name(),
|
||||
'preview' => $this->preview(),
|
||||
'tabs' => $this->tabs(),
|
||||
'translate' => $this->translate(),
|
||||
'type' => $this->type(),
|
||||
'unset' => $this->unset(),
|
||||
'wysiwyg' => $this->wysiwyg(),
|
||||
];
|
||||
}
|
||||
|
||||
public function unset(): bool
|
||||
{
|
||||
return $this->unset;
|
||||
}
|
||||
|
||||
public function wysiwyg(): bool
|
||||
{
|
||||
return $this->wysiwyg;
|
||||
}
|
||||
}
|
||||
115
kirby/src/Cms/Fieldsets.php
Normal file
115
kirby/src/Cms/Fieldsets.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* A collection of fieldsets
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Items<\Kirby\Cms\Fieldset>
|
||||
*/
|
||||
class Fieldsets extends Items
|
||||
{
|
||||
public const ITEM_CLASS = Fieldset::class;
|
||||
|
||||
/**
|
||||
* All registered fieldsets methods
|
||||
*/
|
||||
public static array $methods = [];
|
||||
|
||||
protected static function createFieldsets(array $params): array
|
||||
{
|
||||
$fieldsets = [];
|
||||
$groups = [];
|
||||
|
||||
foreach ($params as $type => $fieldset) {
|
||||
if (is_int($type) === true && is_string($fieldset)) {
|
||||
$type = $fieldset;
|
||||
$fieldset = 'blocks/' . $type;
|
||||
}
|
||||
|
||||
if ($fieldset === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($fieldset === true) {
|
||||
$fieldset = 'blocks/' . $type;
|
||||
}
|
||||
|
||||
$fieldset = Blueprint::extend($fieldset);
|
||||
|
||||
// make sure the type is always set
|
||||
$fieldset['type'] ??= $type;
|
||||
|
||||
// extract groups
|
||||
if ($fieldset['type'] === 'group') {
|
||||
$result = static::createFieldsets($fieldset['fieldsets'] ?? []);
|
||||
$fieldsets = [...$fieldsets, ...$result['fieldsets']];
|
||||
$label = $fieldset['label'] ?? Str::label($type);
|
||||
|
||||
$groups[$type] = [
|
||||
'label' => I18n::translate($label, $label),
|
||||
'name' => $type,
|
||||
'open' => $fieldset['open'] ?? true,
|
||||
'sets' => array_column($result['fieldsets'], 'type'),
|
||||
];
|
||||
} else {
|
||||
$fieldsets[$fieldset['type']] = $fieldset;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'fieldsets' => $fieldsets,
|
||||
'groups' => $groups
|
||||
];
|
||||
}
|
||||
|
||||
public static function factory(
|
||||
array|null $items = null,
|
||||
array $params = []
|
||||
): static {
|
||||
$items ??= App::instance()->option('blocks.fieldsets', [
|
||||
'code' => 'blocks/code',
|
||||
'gallery' => 'blocks/gallery',
|
||||
'heading' => 'blocks/heading',
|
||||
'image' => 'blocks/image',
|
||||
'line' => 'blocks/line',
|
||||
'list' => 'blocks/list',
|
||||
'markdown' => 'blocks/markdown',
|
||||
'quote' => 'blocks/quote',
|
||||
'text' => 'blocks/text',
|
||||
'video' => 'blocks/video',
|
||||
]);
|
||||
|
||||
$result = static::createFieldsets($items);
|
||||
|
||||
return parent::factory(
|
||||
$result['fieldsets'],
|
||||
['groups' => $result['groups']] + $params
|
||||
);
|
||||
}
|
||||
|
||||
public function groups(): array
|
||||
{
|
||||
return $this->options['groups'] ?? [];
|
||||
}
|
||||
|
||||
public function toArray(Closure|null $map = null): array
|
||||
{
|
||||
return A::map(
|
||||
$this->data,
|
||||
$map ?? fn ($fieldset) => $fieldset->toArray()
|
||||
);
|
||||
}
|
||||
}
|
||||
668
kirby/src/Cms/File.php
Normal file
668
kirby/src/Cms/File.php
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Exception;
|
||||
use IntlDateFormatter;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Filesystem\IsFile;
|
||||
use Kirby\Panel\File as Panel;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The `$file` object provides a set
|
||||
* of methods that can be used when
|
||||
* dealing with a single image or
|
||||
* other media file, like getting the
|
||||
* URL or resizing an image. It also
|
||||
* handles file meta data.
|
||||
*
|
||||
* The File class proxies the `Kirby\Filesystem\File`
|
||||
* or `Kirby\Image\Image` class, which
|
||||
* is used to handle all asset file methods.
|
||||
* In addition the File class handles
|
||||
* meta data via `Kirby\Cms\Content`.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Files>
|
||||
* @method \Kirby\Uuid\FileUuid uuid()
|
||||
*/
|
||||
class File extends ModelWithContent
|
||||
{
|
||||
use FileActions;
|
||||
use FileModifications;
|
||||
use HasMethods;
|
||||
use HasSiblings;
|
||||
use IsFile;
|
||||
|
||||
public const CLASS_ALIAS = 'file';
|
||||
|
||||
/**
|
||||
* All registered file methods
|
||||
* @todo Remove when support for PHP 8.2 is dropped
|
||||
*/
|
||||
public static array $methods = [];
|
||||
|
||||
/**
|
||||
* Cache for the initialized blueprint object
|
||||
*/
|
||||
protected FileBlueprint|null $blueprint = null;
|
||||
|
||||
protected string $filename;
|
||||
|
||||
protected string $id;
|
||||
|
||||
/**
|
||||
* The parent object
|
||||
*/
|
||||
protected Page|Site|User|null $parent = null;
|
||||
|
||||
/**
|
||||
* The absolute path to the file
|
||||
*/
|
||||
protected string|null $root;
|
||||
|
||||
protected string|null $template;
|
||||
|
||||
/**
|
||||
* The public file Url
|
||||
*/
|
||||
protected string|null $url;
|
||||
|
||||
/**
|
||||
* Creates a new File object
|
||||
*/
|
||||
public function __construct(array $props)
|
||||
{
|
||||
if (isset($props['filename'], $props['parent']) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'The filename and parent are required'
|
||||
);
|
||||
}
|
||||
|
||||
$this->filename = $props['filename'];
|
||||
$this->parent = $props['parent'];
|
||||
$this->template = $props['template'] ?? null;
|
||||
// Always set the root to null, to invoke
|
||||
// auto root detection
|
||||
$this->root = null;
|
||||
$this->url = $props['url'] ?? null;
|
||||
|
||||
// Set blueprint before setting content
|
||||
// or translations in the parent constructor.
|
||||
// Otherwise, the blueprint definition cannot be
|
||||
// used when creating the right field values
|
||||
// for the content.
|
||||
$this->setBlueprint($props['blueprint'] ?? null);
|
||||
|
||||
parent::__construct($props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic caller for file methods
|
||||
* and content fields. (in this order)
|
||||
*/
|
||||
public function __call(string $method, array $arguments = []): mixed
|
||||
{
|
||||
// public property access
|
||||
if (isset($this->$method) === true) {
|
||||
return $this->$method;
|
||||
}
|
||||
|
||||
// asset method proxy
|
||||
if (method_exists($this->asset(), $method)) {
|
||||
return $this->asset()->$method(...$arguments);
|
||||
}
|
||||
|
||||
// file methods
|
||||
if ($this->hasMethod($method)) {
|
||||
return $this->callMethod($method, $arguments);
|
||||
}
|
||||
|
||||
// content fields
|
||||
return $this->content()->get($method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Improved `var_dump` output
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return [
|
||||
...$this->toArray(),
|
||||
'content' => $this->content(),
|
||||
'siblings' => $this->siblings(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the url to api endpoint
|
||||
* @internal
|
||||
*/
|
||||
public function apiUrl(bool $relative = false): string
|
||||
{
|
||||
return $this->parent()->apiUrl($relative) . '/files/' . $this->filename();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the FileBlueprint object for the file
|
||||
*/
|
||||
public function blueprint(): FileBlueprint
|
||||
{
|
||||
return $this->blueprint ??= FileBlueprint::factory(
|
||||
'files/' . $this->template(),
|
||||
'files/default',
|
||||
$this
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with all blueprints that are available for the file
|
||||
* by comparing files sections and files fields of the parent model
|
||||
*/
|
||||
public function blueprints(string|null $inSection = null): array
|
||||
{
|
||||
// get cached results for the current file model
|
||||
// (except when collecting for a specific section)
|
||||
if ($inSection === null && $this->blueprints !== null) {
|
||||
return $this->blueprints; // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
// always include the current template as option
|
||||
$templates = [
|
||||
$this->template() ?? 'default',
|
||||
...$this->parent()->blueprint()->acceptedFileTemplates($inSection)
|
||||
];
|
||||
|
||||
// make sure every template is only included once
|
||||
$templates = array_unique(array_filter($templates));
|
||||
|
||||
// load the blueprint details for each collected template name
|
||||
$blueprints = [];
|
||||
|
||||
foreach ($templates as $template) {
|
||||
// default template doesn't need to exist as file
|
||||
// to be included in the list
|
||||
if ($template === 'default') {
|
||||
$blueprints[$template] = [
|
||||
'name' => 'default',
|
||||
'title' => '– (default)',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($blueprint = FileBlueprint::factory('files/' . $template, null, $this)) {
|
||||
try {
|
||||
// ensure that file matches `accept` option,
|
||||
// if not remove template from available list
|
||||
$this->match($blueprint->accept());
|
||||
|
||||
$blueprints[$template] = [
|
||||
'name' => $name = Str::after($blueprint->name(), '/'),
|
||||
'title' => $blueprint->title() . ' (' . $name . ')',
|
||||
];
|
||||
} catch (Exception) {
|
||||
// skip when `accept` doesn't match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$blueprints = array_values($blueprints);
|
||||
|
||||
// sort blueprints alphabetically while
|
||||
// making sure the default blueprint is on top of list
|
||||
usort($blueprints, fn ($a, $b) => match (true) {
|
||||
$a['name'] === 'default' => -1,
|
||||
$b['name'] === 'default' => 1,
|
||||
default => strnatcmp($a['title'], $b['title'])
|
||||
});
|
||||
|
||||
// no caching for when collecting for specific section
|
||||
if ($inSection !== null) {
|
||||
return $blueprints; // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
return $this->blueprints = $blueprints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the template in addition to the
|
||||
* other content.
|
||||
* @unstable
|
||||
*/
|
||||
public function contentFileData(
|
||||
array $data,
|
||||
string|null $languageCode = null
|
||||
): array {
|
||||
$language = Language::ensure($languageCode);
|
||||
|
||||
// only add the template in, if the $data array
|
||||
// doesn't explicitly unset it and it was already
|
||||
// set in the content before
|
||||
if (array_key_exists('template', $data) === false && $template = $this->template()) {
|
||||
$data['template'] = $template;
|
||||
}
|
||||
|
||||
// don't store the template field for the default template
|
||||
if (($data['template'] ?? null) === 'default') {
|
||||
unset($data['template']);
|
||||
}
|
||||
|
||||
// only keep the template and sort fields in the
|
||||
// default language
|
||||
if ($language->isDefault() === false) {
|
||||
unset($data['template'], $data['sort']);
|
||||
return $data;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a File object
|
||||
*/
|
||||
public static function factory(array $props): static
|
||||
{
|
||||
return new static($props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filename with extension
|
||||
*/
|
||||
public function filename(): string
|
||||
{
|
||||
return $this->filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent Files collection
|
||||
*/
|
||||
public function files(): Files
|
||||
{
|
||||
return $this->siblingsCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the file to html
|
||||
*/
|
||||
public function html(array $attr = []): string
|
||||
{
|
||||
return $this->asset()->html([
|
||||
'alt' => $this->alt(),
|
||||
...$attr
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id
|
||||
*/
|
||||
public function id(): string
|
||||
{
|
||||
if (
|
||||
$this->parent() instanceof Page ||
|
||||
$this->parent() instanceof User
|
||||
) {
|
||||
return $this->id ??= $this->parent()->id() . '/' . $this->filename();
|
||||
}
|
||||
|
||||
return $this->id ??= $this->filename();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the current object with the given file object
|
||||
*/
|
||||
public function is(File $file): bool
|
||||
{
|
||||
return $this->id() === $file->id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the file is accessible to the current user
|
||||
* This permission depends on the `read` option until v6
|
||||
*/
|
||||
public function isAccessible(): bool
|
||||
{
|
||||
// TODO: remove this check when `read` option deprecated in v6
|
||||
if ($this->isReadable() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return FilePermissions::canFromCache($this, 'access');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the file can be listable by the current user
|
||||
* This permission depends on the `read` option until v6
|
||||
*/
|
||||
public function isListable(): bool
|
||||
{
|
||||
// TODO: remove this check when `read` option deprecated in v6
|
||||
if ($this->isReadable() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// not accessible also means not listable
|
||||
if ($this->isAccessible() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return FilePermissions::canFromCache($this, 'list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the file can be read by the current user
|
||||
*
|
||||
* @todo Deprecate `read` option in v6 and make the necessary changes for `access` and `list` options.
|
||||
*/
|
||||
public function isReadable(): bool
|
||||
{
|
||||
static $readable = [];
|
||||
$role = $this->kirby()->role()?->id() ?? '__none__';
|
||||
$template = $this->template() ?? '__none__';
|
||||
$readable[$role] ??= [];
|
||||
|
||||
return $readable[$role][$template] ??= $this->permissions()->can('read');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the media folder
|
||||
* for the file and its versions
|
||||
* @since 5.0.0
|
||||
*/
|
||||
public function mediaDir(): string
|
||||
{
|
||||
return $this->parent()->mediaDir() . '/' . $this->mediaHash();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique media hash
|
||||
*/
|
||||
public function mediaHash(): string
|
||||
{
|
||||
return $this->mediaToken() . '-' . $this->modifiedFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the file in the public media folder
|
||||
*
|
||||
* @param string|null $filename Optional override for the filename
|
||||
*/
|
||||
public function mediaRoot(string|null $filename = null): string
|
||||
{
|
||||
$filename ??= $this->filename();
|
||||
|
||||
return $this->mediaDir() . '/' . $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a non-guessable token string for this file
|
||||
*/
|
||||
public function mediaToken(): string
|
||||
{
|
||||
$token = $this->kirby()->contentToken($this, $this->id());
|
||||
return substr($token, 0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute Url to the file in the public media folder
|
||||
*
|
||||
* @param string|null $filename Optional override for the filename
|
||||
*/
|
||||
public function mediaUrl(string|null $filename = null): string
|
||||
{
|
||||
$url = $this->parent()->mediaUrl() . '/' . $this->mediaHash();
|
||||
$filename ??= $this->filename();
|
||||
|
||||
return $url . '/' . $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file's last modification time.
|
||||
*
|
||||
* @param string|null $handler date, intl or strftime
|
||||
*/
|
||||
public function modified(
|
||||
string|IntlDateFormatter|null $format = null,
|
||||
string|null $handler = null,
|
||||
string|null $languageCode = null
|
||||
): string|int|false {
|
||||
$file = $this->modifiedFile();
|
||||
$content = $this->modifiedContent($languageCode);
|
||||
$modified = max($file, $content);
|
||||
|
||||
return Str::date($modified, $format, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp of the last modification
|
||||
* of the content file
|
||||
*/
|
||||
protected function modifiedContent(string|null $languageCode = null): int
|
||||
{
|
||||
return $this->version('latest')->modified($languageCode ?? 'current') ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp of the last modification
|
||||
* of the source file
|
||||
*/
|
||||
protected function modifiedFile(): int
|
||||
{
|
||||
return F::modified($this->root());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent Page object
|
||||
*/
|
||||
public function page(): Page|null
|
||||
{
|
||||
if ($this->parent() instanceof Page) {
|
||||
return $this->parent();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the panel info object
|
||||
*/
|
||||
public function panel(): Panel
|
||||
{
|
||||
return new Panel($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent object
|
||||
*/
|
||||
public function parent(): Page|Site|User
|
||||
{
|
||||
return $this->parent ??= $this->kirby()->site();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent id if a parent exists
|
||||
*/
|
||||
public function parentId(): string
|
||||
{
|
||||
return $this->parent()->id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of all parent pages
|
||||
*/
|
||||
public function parents(): Pages
|
||||
{
|
||||
if ($this->parent() instanceof Page) {
|
||||
return $this->parent()->parents()->prepend(
|
||||
$this->parent()->id(),
|
||||
$this->parent()
|
||||
);
|
||||
}
|
||||
|
||||
return new Pages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the permanent URL to the file using its UUID
|
||||
* @since 3.8.0
|
||||
*/
|
||||
public function permalink(): string|null
|
||||
{
|
||||
return $this->uuid()?->toPermalink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the permissions object for this file
|
||||
*/
|
||||
public function permissions(): FilePermissions
|
||||
{
|
||||
return new FilePermissions($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute root to the file
|
||||
*/
|
||||
public function root(): string|null
|
||||
{
|
||||
return $this->root ??= $this->parent()->root() . '/' . $this->filename();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the FileRules class to
|
||||
* validate any important action.
|
||||
*/
|
||||
protected function rules(): FileRules
|
||||
{
|
||||
return new FileRules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Blueprint object
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setBlueprint(array|null $blueprint = null): static
|
||||
{
|
||||
if ($blueprint !== null) {
|
||||
$blueprint['model'] = $this;
|
||||
$this->blueprint = new FileBlueprint($blueprint);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent Files collection
|
||||
*/
|
||||
protected function siblingsCollection(): Files
|
||||
{
|
||||
return $this->parent()->files();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent Site object
|
||||
*/
|
||||
public function site(): Site
|
||||
{
|
||||
if ($this->parent() instanceof Site) {
|
||||
return $this->parent();
|
||||
}
|
||||
|
||||
return $this->kirby()->site();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the final template
|
||||
*/
|
||||
public function template(): string|null
|
||||
{
|
||||
return $this->template ??= $this->content('default')->get('template')->value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns siblings with the same template
|
||||
*/
|
||||
public function templateSiblings(bool $self = true): Files
|
||||
{
|
||||
return $this->siblings($self)->filter('template', $this->template());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended info for the array export
|
||||
* by injecting the information from
|
||||
* the asset.
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
...parent::toArray(),
|
||||
...$this->asset()->toArray(),
|
||||
'id' => $this->id(),
|
||||
'template' => $this->template(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Url
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
return $this->url ??= ($this->kirby()->component('file::url'))($this->kirby(), $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean file URL that uses the parent page URL
|
||||
* and the filename as a more stable alternative
|
||||
* for the media URLs if available. The `content.fileRedirects`
|
||||
* option is used to disable this behavior or enable it
|
||||
* on a per-file basis.
|
||||
*/
|
||||
public function previewUrl(): string|null
|
||||
{
|
||||
// check if the clean file URL is accessible,
|
||||
// otherwise we need to fall back to the media URL
|
||||
if ($this->kirby()->resolveFile($this) === null) {
|
||||
return $this->url();
|
||||
}
|
||||
|
||||
$parent = $this->parent();
|
||||
$url = Url::to($this->id());
|
||||
|
||||
switch ($parent::CLASS_ALIAS) {
|
||||
case 'page':
|
||||
$preview = $parent->blueprint()->preview();
|
||||
|
||||
// the page has a custom preview setting,
|
||||
// thus the file is only accessible through
|
||||
// the direct media URL
|
||||
if ($preview !== true) {
|
||||
return $this->url();
|
||||
}
|
||||
|
||||
// it's more stable to access files for drafts
|
||||
// through their direct URL to avoid conflicts
|
||||
// with draft token verification
|
||||
if ($parent->isDraft() === true) {
|
||||
return $this->url();
|
||||
}
|
||||
|
||||
// checks `file::url` component is extended
|
||||
if ($this->kirby()->isNativeComponent('file::url') === false) {
|
||||
return $this->url();
|
||||
}
|
||||
|
||||
return $url;
|
||||
case 'user':
|
||||
// there are no clean URL routes for user files
|
||||
return $this->url();
|
||||
default:
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
}
|
||||
467
kirby/src/Cms/FileActions.php
Normal file
467
kirby/src/Cms/FileActions.php
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Content\ImmutableMemoryStorage;
|
||||
use Kirby\Content\MemoryStorage;
|
||||
use Kirby\Content\VersionCache;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Uuid\Uuid;
|
||||
use Kirby\Uuid\Uuids;
|
||||
|
||||
/**
|
||||
* FileActions
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait FileActions
|
||||
{
|
||||
protected function changeExtension(
|
||||
File $file,
|
||||
string|null $extension = null
|
||||
): File {
|
||||
return $file->changeName($file->name(), false, $extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames the file (optionally also the extension).
|
||||
* The store is used to actually execute this.
|
||||
*
|
||||
* @throws \Kirby\Exception\LogicException
|
||||
*/
|
||||
public function changeName(
|
||||
string $name,
|
||||
bool $sanitize = true,
|
||||
string|null $extension = null
|
||||
): static {
|
||||
if ($sanitize === true) {
|
||||
// sanitize the basename part only
|
||||
// as the extension isn't included in $name
|
||||
$name = F::safeBasename($name, false);
|
||||
}
|
||||
|
||||
// if no extension is passed, make sure to maintain current one
|
||||
$extension ??= $this->extension();
|
||||
|
||||
// don't rename if not necessary
|
||||
if (
|
||||
$name === $this->name() &&
|
||||
$extension === $this->extension()
|
||||
) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return $this->commit('changeName', ['file' => $this, 'name' => $name, 'extension' => $extension], function ($oldFile, $name, $extension) {
|
||||
$newFile = $oldFile->clone([
|
||||
'filename' => $name . '.' . $extension,
|
||||
]);
|
||||
|
||||
// remove all public versions, lock and clear UUID cache
|
||||
$oldFile->unpublish();
|
||||
|
||||
if ($oldFile->exists() === false) {
|
||||
return $newFile;
|
||||
}
|
||||
|
||||
if ($newFile->exists() === true) {
|
||||
throw new LogicException(
|
||||
message: 'The new file exists and cannot be overwritten'
|
||||
);
|
||||
}
|
||||
|
||||
// rename the main file
|
||||
F::move($oldFile->root(), $newFile->root());
|
||||
|
||||
// hard reset for the version cache
|
||||
// to avoid broken/overlapping file references
|
||||
VersionCache::reset();
|
||||
|
||||
// move the content storage versions
|
||||
$oldFile->storage()->moveAll(to: $newFile->storage());
|
||||
|
||||
// update collections
|
||||
$newFile->parent()->files()->remove($oldFile->id());
|
||||
$newFile->parent()->files()->set($newFile->id(), $newFile);
|
||||
|
||||
return $newFile;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the file's sorting number in the meta file
|
||||
*/
|
||||
public function changeSort(int $sort): static
|
||||
{
|
||||
// skip if the sort number stays the same
|
||||
if ($this->sort()->value() === $sort) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$arguments = [
|
||||
'file' => $this,
|
||||
'position' => $sort
|
||||
];
|
||||
|
||||
return $this->commit(
|
||||
'changeSort',
|
||||
$arguments,
|
||||
function ($file, $sort) {
|
||||
// make sure to update the sort in the changes version as well
|
||||
// otherwise the new sort would be lost as soon as the changes are saved
|
||||
if ($file->version('changes')->exists() === true) {
|
||||
$file->version('changes')->update(['sort' => $sort]);
|
||||
}
|
||||
|
||||
return $file->save(['sort' => $sort]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this|static
|
||||
*/
|
||||
public function changeTemplate(string|null $template): static
|
||||
{
|
||||
if ($template === $this->template()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$arguments = [
|
||||
'file' => $this,
|
||||
'template' => $template ?? 'default'
|
||||
];
|
||||
|
||||
return $this->commit('changeTemplate', $arguments, function ($oldFile, $template) {
|
||||
// convert to new template/blueprint incl. content
|
||||
$file = $oldFile->convertTo($template);
|
||||
|
||||
// resize the file if configured by new blueprint
|
||||
$create = $file->blueprint()->create();
|
||||
$file = $file->manipulate($create);
|
||||
|
||||
return $file;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a file action, by following these steps
|
||||
*
|
||||
* 1. applies the `before` hook
|
||||
* 2. checks the action rules
|
||||
* 3. commits the store action
|
||||
* 4. applies the `after` hook
|
||||
* 5. returns the result
|
||||
*/
|
||||
protected function commit(
|
||||
string $action,
|
||||
array $arguments,
|
||||
Closure $callback
|
||||
): mixed {
|
||||
$commit = new ModelCommit(
|
||||
model: $this,
|
||||
action: $action
|
||||
);
|
||||
|
||||
return $commit->call($arguments, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the file to the given page
|
||||
*/
|
||||
public function copy(Page $page): static
|
||||
{
|
||||
F::copy($this->root(), $page->root() . '/' . $this->filename());
|
||||
|
||||
$copy = new static([
|
||||
'parent' => $page,
|
||||
'filename' => $this->filename(),
|
||||
]);
|
||||
|
||||
$this->storage()->copyAll(to: $copy->storage());
|
||||
|
||||
// overwrite with new UUID (remove old, add new)
|
||||
if (Uuids::enabled() === true) {
|
||||
$copy = $copy->save(['uuid' => Uuid::generate()]);
|
||||
}
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new file on disk and returns the
|
||||
* File object. The store is used to handle file
|
||||
* writing, so it can be replaced by any other
|
||||
* way of generating files.
|
||||
*
|
||||
* @param bool $move If set to `true`, the source will be deleted
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
* @throws \Kirby\Exception\LogicException
|
||||
*/
|
||||
public static function create(array $props, bool $move = false): static
|
||||
{
|
||||
$props = static::normalizeProps($props);
|
||||
|
||||
// create the basic file and a test upload object
|
||||
$file = File::factory([
|
||||
...$props,
|
||||
'content' => null,
|
||||
'translations' => null,
|
||||
]);
|
||||
|
||||
$upload = $file->assetFactory($props['source']);
|
||||
$existing = null;
|
||||
|
||||
// merge the content with the defaults
|
||||
$props['content'] = [
|
||||
...$file->createDefaultContent(),
|
||||
...$props['content'],
|
||||
];
|
||||
|
||||
// reuse the existing content if the uploaded file
|
||||
// is identical to an existing file
|
||||
if ($file->exists() === true) {
|
||||
$existing = $file->parent()->file($file->filename());
|
||||
|
||||
if (
|
||||
$file->sha1() === $upload->sha1() &&
|
||||
$file->template() === $existing->template()
|
||||
) {
|
||||
// read the content of the existing file and use it
|
||||
$props['content'] = $existing->content()->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
// make sure that a UUID gets generated
|
||||
// and added to content right away
|
||||
if (Uuids::enabled() === true) {
|
||||
$props['content']['uuid'] ??= Uuid::generate();
|
||||
}
|
||||
|
||||
// keep the initial storage class
|
||||
$storage = $file->storage()::class;
|
||||
|
||||
// make sure that the temporary page is stored in memory
|
||||
$file->changeStorage(
|
||||
toStorage: MemoryStorage::class,
|
||||
// when there’s already an existing file,
|
||||
// we need to make sure that the content is
|
||||
// copied to memory and the existing content
|
||||
// storage entry is not deleted by this step
|
||||
copy: $existing !== null
|
||||
);
|
||||
|
||||
// inject the content
|
||||
$file->setContent($props['content']);
|
||||
|
||||
// inject the translations
|
||||
$file->setTranslations($props['translations'] ?? null);
|
||||
|
||||
// if the format is different from the original,
|
||||
// we need to already rename it so that the correct file rules
|
||||
// are applied
|
||||
$create = $file->blueprint()->create();
|
||||
|
||||
// run the hook
|
||||
$arguments = compact('file', 'upload');
|
||||
return $file->commit('create', $arguments, function ($file, $upload) use ($create, $move, $storage) {
|
||||
// remove all public versions, lock and clear UUID cache
|
||||
$file->unpublish();
|
||||
|
||||
// only move the original source if intended
|
||||
$method = $move === true ? 'move' : 'copy';
|
||||
|
||||
// overwrite the original
|
||||
if (F::$method($upload->root(), $file->root(), true) !== true) {
|
||||
// @codeCoverageIgnoreStart
|
||||
throw new LogicException(
|
||||
message: 'The file could not be created'
|
||||
);
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
// resize the file on upload if configured
|
||||
$file = $file->manipulate($create);
|
||||
|
||||
// store the content if necessary
|
||||
$file->changeStorage($storage);
|
||||
|
||||
// return a fresh clone
|
||||
return $file->clone();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file. The store is used to
|
||||
* manipulate the filesystem or whatever you prefer.
|
||||
*/
|
||||
public function delete(): bool
|
||||
{
|
||||
return $this->commit('delete', ['file' => $this], function ($file) {
|
||||
$old = $file->clone();
|
||||
|
||||
// keep the content in iummtable memory storage
|
||||
// to still have access to it in after hooks
|
||||
$file->changeStorage(ImmutableMemoryStorage::class);
|
||||
|
||||
// clear UUID cache
|
||||
$file->uuid()?->clear();
|
||||
|
||||
// remove all public versions and clear the UUID cache
|
||||
$old->unpublish();
|
||||
|
||||
// delete all versions
|
||||
$old->versions()->delete();
|
||||
|
||||
// delete the file from disk
|
||||
F::remove($old->root());
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes/crops the original file with Kirby's thumb handler
|
||||
*/
|
||||
public function manipulate(array|null $options = []): static
|
||||
{
|
||||
// nothing to process
|
||||
if (empty($options) === true || $this->isResizable() === false) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
// generate image file and overwrite it in place
|
||||
$this->kirby()->thumb($this->root(), $this->root(), $options);
|
||||
|
||||
$file = $this->clone();
|
||||
|
||||
// change the file extension if format option configured
|
||||
if ($format = $options['format'] ?? null) {
|
||||
$file = $file->changeExtension($file, $format);
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
protected static function normalizeProps(array $props): array
|
||||
{
|
||||
if (isset($props['source'], $props['parent']) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Please provide the "source" and "parent" props for the File'
|
||||
);
|
||||
}
|
||||
|
||||
$content = $props['content'] ?? [];
|
||||
$template = $props['template'] ?? 'default';
|
||||
|
||||
// prefer the filename from the props
|
||||
$filename = $props['filename'] ?? null;
|
||||
$filename ??= basename($props['source']);
|
||||
$filename = F::safeName($props['filename']);
|
||||
|
||||
return [
|
||||
...$props,
|
||||
'content' => $content,
|
||||
'filename' => $filename,
|
||||
'model' => $props['model'] ?? $template,
|
||||
'template' => $template,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the file to the public media folder
|
||||
* if it's not already there.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function publish(): static
|
||||
{
|
||||
Media::publish($this, $this->mediaRoot());
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the file. The source must
|
||||
* be an absolute path to a file or a Url.
|
||||
* The store handles the replacement so it
|
||||
* finally decides what it will support as
|
||||
* source.
|
||||
*
|
||||
* @param bool $move If set to `true`, the source will be deleted
|
||||
* @throws \Kirby\Exception\LogicException
|
||||
*/
|
||||
public function replace(string $source, bool $move = false): static
|
||||
{
|
||||
$file = $this->clone();
|
||||
|
||||
$arguments = [
|
||||
'file' => $file,
|
||||
'upload' => $file->asset($source)
|
||||
];
|
||||
|
||||
return $this->commit('replace', $arguments, function ($file, $upload) use ($move) {
|
||||
// delete all public versions
|
||||
$file->unpublish(true);
|
||||
|
||||
// only move the original source if intended
|
||||
$method = $move === true ? 'move' : 'copy';
|
||||
|
||||
// overwrite the original
|
||||
if (F::$method($upload->root(), $file->root(), true) !== true) {
|
||||
throw new LogicException(
|
||||
message: 'The file could not be created'
|
||||
);
|
||||
}
|
||||
|
||||
// apply the resizing/crop options from the blueprint
|
||||
$create = $file->blueprint()->create();
|
||||
$file = $file->manipulate($create);
|
||||
|
||||
// return a fresh clone
|
||||
return $file->clone();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all public versions of this file
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function unpublish(bool $onlyMedia = false): static
|
||||
{
|
||||
// unpublish media files
|
||||
Media::unpublish($this->parent()->mediaRoot(), $this);
|
||||
|
||||
if ($onlyMedia !== true) {
|
||||
// clear UUID cache
|
||||
$this->uuid()?->clear();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the file's data and ensures that
|
||||
* media files get wiped if `focus` changed
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values
|
||||
*/
|
||||
public function update(
|
||||
array|null $input = null,
|
||||
string|null $languageCode = null,
|
||||
bool $validate = false
|
||||
): static {
|
||||
// delete all public media versions when focus field gets changed
|
||||
if (($input['focus'] ?? null) !== $this->focus()->value()) {
|
||||
$this->unpublish(true);
|
||||
}
|
||||
|
||||
return parent::update($input, $languageCode, $validate);
|
||||
}
|
||||
}
|
||||
254
kirby/src/Cms/FileBlueprint.php
Normal file
254
kirby/src/Cms/FileBlueprint.php
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Filesystem\Mime;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Extension of the basic blueprint class
|
||||
* to handle all blueprints for files.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class FileBlueprint extends Blueprint
|
||||
{
|
||||
/**
|
||||
* `true` if the default accepted
|
||||
* types are being used
|
||||
*/
|
||||
protected bool $defaultTypes = false;
|
||||
|
||||
public function __construct(array $props)
|
||||
{
|
||||
parent::__construct($props);
|
||||
|
||||
// normalize all available page options
|
||||
$this->props['options'] = $this->normalizeOptions(
|
||||
$this->props['options'] ?? true,
|
||||
// defaults
|
||||
[
|
||||
'access' => null,
|
||||
'changeName' => null,
|
||||
'changeTemplate' => null,
|
||||
'create' => null,
|
||||
'delete' => null,
|
||||
'list' => null,
|
||||
'read' => null,
|
||||
'replace' => null,
|
||||
'sort' => null,
|
||||
'update' => null,
|
||||
]
|
||||
);
|
||||
|
||||
// normalize the accept settings
|
||||
$this->props['accept'] = $this->normalizeAccept($this->props['accept'] ?? []);
|
||||
}
|
||||
|
||||
public function accept(): array
|
||||
{
|
||||
return $this->props['accept'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of all accepted MIME types for
|
||||
* file upload or `*` if all MIME types are allowed
|
||||
*
|
||||
* @deprecated 4.2.0 Use `acceptAttribute` instead
|
||||
* @todo 6.0.0 Remove method
|
||||
*/
|
||||
public function acceptMime(): string
|
||||
{
|
||||
// don't disclose the specific default types
|
||||
if ($this->defaultTypes === true) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
$accept = $this->accept();
|
||||
$restrictions = [];
|
||||
|
||||
if (is_array($accept['mime']) === true) {
|
||||
$restrictions[] = $accept['mime'];
|
||||
} else {
|
||||
// only fall back to the extension or type if
|
||||
// no explicit MIME types were defined
|
||||
// (allows to set custom MIME types for the frontend
|
||||
// check but still restrict the extension and/or type)
|
||||
|
||||
if (is_array($accept['extension']) === true) {
|
||||
// determine the main MIME type for each extension
|
||||
$restrictions[] = array_map(
|
||||
Mime::fromExtension(...),
|
||||
$accept['extension']
|
||||
);
|
||||
}
|
||||
|
||||
if (is_array($accept['type']) === true) {
|
||||
// determine the MIME types of each file type
|
||||
$mimes = [];
|
||||
foreach ($accept['type'] as $type) {
|
||||
if ($extensions = F::typeToExtensions($type)) {
|
||||
$mimes[] = array_map(
|
||||
Mime::fromExtension(...),
|
||||
$extensions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$restrictions[] = array_merge(...$mimes);
|
||||
}
|
||||
}
|
||||
|
||||
if ($restrictions !== []) {
|
||||
// only return the MIME types that are allowed by all restrictions
|
||||
$mimes = match (count($restrictions) > 1) {
|
||||
true => array_intersect(...$restrictions),
|
||||
false => $restrictions[0]
|
||||
};
|
||||
|
||||
// filter out empty MIME types and duplicates
|
||||
return implode(', ', array_filter(array_unique($mimes)));
|
||||
}
|
||||
|
||||
// no restrictions, accept everything
|
||||
return '*';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of all accepted file extensions
|
||||
* for file upload or `*` if all extensions are allowed
|
||||
*
|
||||
* If a MIME type is specified in the blueprint, the `extension` and `type` options are ignored for the browser.
|
||||
* Extensions and types, however, are still used to validate an uploaded file on the server.
|
||||
* This behavior might change in the future to better represent which file extensions are actually allowed.
|
||||
*
|
||||
* If no MIME type is specified, the intersection between manually defined extensions and the Kirby "file types" is returned.
|
||||
* If the intersection is empty, an empty string is returned.
|
||||
* This behavior might change in the future to instead return the union of `mime`, `extension` and `type`.
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function acceptAttribute(): string
|
||||
{
|
||||
// don't disclose the specific default types
|
||||
if ($this->defaultTypes === true) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
$accept = $this->accept();
|
||||
|
||||
// get extensions from "mime" option
|
||||
if (is_array($accept['mime']) === true) {
|
||||
// determine the extensions for each MIME type
|
||||
$extensions = array_map(
|
||||
fn ($pattern) => Mime::toExtensions($pattern, true),
|
||||
$accept['mime']
|
||||
);
|
||||
|
||||
$fromMime = array_unique(array_merge(...array_values($extensions)));
|
||||
|
||||
// return early to ignore the other options
|
||||
return implode(',', array_map(fn ($ext) => ".$ext", $fromMime));
|
||||
}
|
||||
|
||||
$restrictions = [];
|
||||
|
||||
// get extensions from "type" option
|
||||
if (is_array($accept['type']) === true) {
|
||||
$extensions = array_map(
|
||||
fn ($type) => F::typeToExtensions($type) ?? [],
|
||||
$accept['type']
|
||||
);
|
||||
|
||||
$fromType = array_merge(...array_values($extensions));
|
||||
$restrictions[] = $fromType;
|
||||
}
|
||||
|
||||
// get extensions from "extension" option
|
||||
if (is_array($accept['extension']) === true) {
|
||||
$restrictions[] = $accept['extension'];
|
||||
}
|
||||
|
||||
// intersect all restrictions
|
||||
$list = match (count($restrictions)) {
|
||||
0 => [],
|
||||
1 => $restrictions[0],
|
||||
default => array_intersect(...$restrictions)
|
||||
};
|
||||
|
||||
$list = array_unique($list);
|
||||
|
||||
// format the list to include a leading dot on each extension
|
||||
return implode(',', array_map(fn ($ext) => ".$ext", $list));
|
||||
}
|
||||
|
||||
protected function normalizeAccept(mixed $accept = null): array
|
||||
{
|
||||
$accept = match (true) {
|
||||
is_string($accept) => ['mime' => $accept],
|
||||
// explicitly no restrictions at all
|
||||
$accept === true => ['mime' => null],
|
||||
// no custom restrictions
|
||||
empty($accept) === true => [],
|
||||
// custom restrictions
|
||||
default => $accept
|
||||
};
|
||||
|
||||
$accept = array_change_key_case($accept);
|
||||
|
||||
$defaults = [
|
||||
'extension' => null,
|
||||
'mime' => null,
|
||||
'maxheight' => null,
|
||||
'maxsize' => null,
|
||||
'maxwidth' => null,
|
||||
'minheight' => null,
|
||||
'minsize' => null,
|
||||
'minwidth' => null,
|
||||
'orientation' => null,
|
||||
'type' => null
|
||||
];
|
||||
|
||||
// default type restriction if none are configured;
|
||||
// this ensures that no unexpected files are uploaded
|
||||
if (
|
||||
array_key_exists('mime', $accept) === false &&
|
||||
array_key_exists('extension', $accept) === false &&
|
||||
array_key_exists('type', $accept) === false
|
||||
) {
|
||||
$defaults['type'] = ['image', 'document', 'archive', 'audio', 'video'];
|
||||
$this->defaultTypes = true;
|
||||
}
|
||||
|
||||
$accept = [...$defaults, ...$accept];
|
||||
|
||||
// normalize the MIME, extension and type from strings into arrays
|
||||
if (is_string($accept['mime']) === true) {
|
||||
$accept['mime'] = array_map(
|
||||
fn ($mime) => $mime['value'],
|
||||
Str::accepted($accept['mime'])
|
||||
);
|
||||
}
|
||||
|
||||
if (is_string($accept['extension']) === true) {
|
||||
$accept['extension'] = array_map(
|
||||
'trim',
|
||||
explode(',', $accept['extension'])
|
||||
);
|
||||
}
|
||||
|
||||
if (is_string($accept['type']) === true) {
|
||||
$accept['type'] = array_map(
|
||||
'trim',
|
||||
explode(',', $accept['type'])
|
||||
);
|
||||
}
|
||||
|
||||
return $accept;
|
||||
}
|
||||
}
|
||||
217
kirby/src/Cms/FileModifications.php
Normal file
217
kirby/src/Cms/FileModifications.php
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Content\Field;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Filesystem\Asset;
|
||||
|
||||
/**
|
||||
* Trait for image resizing, blurring etc.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait FileModifications
|
||||
{
|
||||
/**
|
||||
* Blurs the image by the given amount of pixels
|
||||
*/
|
||||
public function blur(int|bool $pixels = true): FileVersion|File|Asset
|
||||
{
|
||||
return $this->thumb(['blur' => $pixels]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the image to black and white
|
||||
*/
|
||||
public function bw(): FileVersion|File|Asset
|
||||
{
|
||||
return $this->thumb(['grayscale' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crops the image by the given width and height
|
||||
*/
|
||||
public function crop(
|
||||
int $width,
|
||||
int|null $height = null,
|
||||
$options = null
|
||||
): FileVersion|File|Asset {
|
||||
$quality = null;
|
||||
$crop = true;
|
||||
|
||||
if (is_int($options) === true) {
|
||||
$quality = $options;
|
||||
} elseif (is_string($options)) {
|
||||
$crop = $options;
|
||||
} elseif ($options instanceof Field) {
|
||||
$crop = $options->value();
|
||||
} elseif (is_array($options)) {
|
||||
$quality = $options['quality'] ?? $quality;
|
||||
$crop = $options['crop'] ?? $crop;
|
||||
}
|
||||
|
||||
return $this->thumb([
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'quality' => $quality,
|
||||
'crop' => $crop
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for File::bw()
|
||||
*/
|
||||
public function grayscale(): FileVersion|File|Asset
|
||||
{
|
||||
return $this->thumb(['grayscale' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for File::bw()
|
||||
*/
|
||||
public function greyscale(): FileVersion|File|Asset
|
||||
{
|
||||
return $this->thumb(['grayscale' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the JPEG compression quality
|
||||
*/
|
||||
public function quality(int $quality): FileVersion|File|Asset
|
||||
{
|
||||
return $this->thumb(['quality' => $quality]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes the file with the given width and height
|
||||
* while keeping the aspect ratio.
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function resize(
|
||||
int|null $width = null,
|
||||
int|null $height = null,
|
||||
int|null $quality = null
|
||||
): FileVersion|File|Asset {
|
||||
return $this->thumb([
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'quality' => $quality
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sharpens the image
|
||||
*/
|
||||
public function sharpen(int $amount = 50): FileVersion|File|Asset
|
||||
{
|
||||
return $this->thumb(['sharpen' => $amount]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a srcset definition for the given sizes
|
||||
* Sizes can be defined as a simple array. They can
|
||||
* also be set up in the config with the thumbs.srcsets option.
|
||||
* @since 3.1.0
|
||||
*/
|
||||
public function srcset(array|string|null $sizes = null): string|null
|
||||
{
|
||||
if (empty($sizes) === true) {
|
||||
$sizes = $this->kirby()->option('thumbs.srcsets.default', []);
|
||||
}
|
||||
|
||||
if (is_string($sizes) === true) {
|
||||
$sizes = $this->kirby()->option('thumbs.srcsets.' . $sizes, []);
|
||||
}
|
||||
|
||||
if (is_array($sizes) === false || $sizes === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$set = [];
|
||||
|
||||
foreach ($sizes as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$options = $value;
|
||||
$condition = $key;
|
||||
} elseif (is_string($value) === true) {
|
||||
$options = [
|
||||
'width' => $key
|
||||
];
|
||||
$condition = $value;
|
||||
} else {
|
||||
$options = [
|
||||
'width' => $value
|
||||
];
|
||||
$condition = $value . 'w';
|
||||
}
|
||||
|
||||
$set[] = $this->thumb($options)->url() . ' ' . $condition;
|
||||
}
|
||||
|
||||
return implode(', ', $set);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a modified version of images
|
||||
* The media manager takes care of generating
|
||||
* those modified versions and putting them
|
||||
* in the right place. This is normally the
|
||||
* `/media` folder of your installation, but
|
||||
* could potentially also be a CDN or any other
|
||||
* place.
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function thumb(
|
||||
array|string|null $options = null
|
||||
): FileVersion|File|Asset {
|
||||
// thumb presets
|
||||
if (empty($options) === true) {
|
||||
$options = $this->kirby()->option('thumbs.presets.default');
|
||||
} elseif (is_string($options) === true) {
|
||||
$options = $this->kirby()->option('thumbs.presets.' . $options);
|
||||
}
|
||||
|
||||
if (empty($options) === true || is_array($options) === false) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
// fallback to content file options
|
||||
if (($options['crop'] ?? false) === true) {
|
||||
$options['crop'] = match (true) {
|
||||
$this instanceof ModelWithContent
|
||||
=> $this->focus()->value() ?? 'center',
|
||||
default
|
||||
=> 'center'
|
||||
};
|
||||
}
|
||||
|
||||
// fallback to global config options
|
||||
if (isset($options['format']) === false) {
|
||||
if ($format = $this->kirby()->option('thumbs.format')) {
|
||||
$options['format'] = $format;
|
||||
}
|
||||
}
|
||||
|
||||
$component = $this->kirby()->component('file::version');
|
||||
$result = $component($this->kirby(), $this, $options);
|
||||
|
||||
if (
|
||||
$result instanceof FileVersion === false &&
|
||||
$result instanceof File === false &&
|
||||
$result instanceof Asset === false
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'The file::version component must return a File, FileVersion or Asset object'
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
34
kirby/src/Cms/FilePermissions.php
Normal file
34
kirby/src/Cms/FilePermissions.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* FilePermissions
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class FilePermissions extends ModelPermissions
|
||||
{
|
||||
protected const CATEGORY = 'files';
|
||||
|
||||
/**
|
||||
* Used to cache once determined permissions in memory
|
||||
*/
|
||||
protected static function cacheKey(ModelWithContent|Language $model): string
|
||||
{
|
||||
return $model->template() ?? '__none__';
|
||||
}
|
||||
|
||||
protected function canChangeTemplate(): bool
|
||||
{
|
||||
if (count($this->model->blueprints()) <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
76
kirby/src/Cms/FilePicker.php
Normal file
76
kirby/src/Cms/FilePicker.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* The FilePicker class helps to
|
||||
* fetch the right files for the API calls
|
||||
* for the file picker component in the panel.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class FilePicker extends Picker
|
||||
{
|
||||
/**
|
||||
* Extends the basic defaults
|
||||
*/
|
||||
public function defaults(): array
|
||||
{
|
||||
return [
|
||||
...parent::defaults(),
|
||||
'text' => '{{ file.filename }}'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search all files for the picker
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function items(): Files|null
|
||||
{
|
||||
$model = $this->options['model'];
|
||||
|
||||
// find the right default query
|
||||
$query = match (true) {
|
||||
empty($this->options['query']) === false
|
||||
=> $this->options['query'],
|
||||
$model instanceof File
|
||||
=> 'file.siblings',
|
||||
default
|
||||
=> $model::CLASS_ALIAS . '.files'
|
||||
};
|
||||
|
||||
// fetch all files for the picker
|
||||
$files = $model->query($query);
|
||||
|
||||
// help mitigate some typical query usage issues
|
||||
// by converting site and page objects to proper
|
||||
// pages by returning their children
|
||||
$files = match (true) {
|
||||
$files instanceof Site,
|
||||
$files instanceof Page,
|
||||
$files instanceof User => $files->files(),
|
||||
$files instanceof Files => $files,
|
||||
|
||||
default => throw new InvalidArgumentException(
|
||||
message: 'Your query must return a set of files'
|
||||
)
|
||||
};
|
||||
|
||||
// filter protected and hidden pages
|
||||
$files = $files->filter('isListable', true);
|
||||
|
||||
// search
|
||||
$files = $this->search($files);
|
||||
|
||||
// paginate
|
||||
return $this->paginate($files);
|
||||
}
|
||||
}
|
||||
335
kirby/src/Cms/FileRules.php
Normal file
335
kirby/src/Cms/FileRules.php
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\DuplicateException;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Exception\PermissionException;
|
||||
use Kirby\Filesystem\File as BaseFile;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Kirby\Toolkit\V;
|
||||
|
||||
/**
|
||||
* Validators for all file actions
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class FileRules
|
||||
{
|
||||
/**
|
||||
* Validates if the filename can be changed
|
||||
*
|
||||
* @throws \Kirby\Exception\DuplicateException If a file with this name exists
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to rename the file
|
||||
*/
|
||||
public static function changeName(File $file, string $name): void
|
||||
{
|
||||
if ($file->permissions()->can('changeName') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'file.changeName.permission',
|
||||
data: ['filename' => $file->filename()]
|
||||
);
|
||||
}
|
||||
|
||||
if (Str::length($name) === 0) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.changeName.empty'
|
||||
);
|
||||
}
|
||||
|
||||
$parent = $file->parent();
|
||||
$duplicate = $parent->files()->not($file)->findBy('filename', $name . '.' . $file->extension());
|
||||
|
||||
if ($duplicate) {
|
||||
throw new DuplicateException(
|
||||
key: 'file.duplicate',
|
||||
data: ['filename' => $duplicate->filename()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the file can be sorted
|
||||
*/
|
||||
public static function changeSort(File $file, int $sort): void
|
||||
{
|
||||
if ($file->permissions()->can('sort') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'file.sort.permission',
|
||||
data: ['filename' => $file->filename()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the template of the file can be changed
|
||||
*
|
||||
* @throws \Kirby\Exception\LogicException If the template of the page cannot be changed at all
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the template
|
||||
*/
|
||||
public static function changeTemplate(File $file, string $template): void
|
||||
{
|
||||
if ($file->permissions()->can('changeTemplate') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'file.changeTemplate.permission',
|
||||
data: ['id' => $file->id()]
|
||||
);
|
||||
}
|
||||
|
||||
$blueprints = $file->blueprints();
|
||||
|
||||
// ensure that the $template is a valid blueprint
|
||||
// option for this file
|
||||
if (
|
||||
count($blueprints) <= 1 ||
|
||||
in_array($template, array_column($blueprints, 'name'), true) === false
|
||||
) {
|
||||
throw new LogicException(
|
||||
key: 'file.changeTemplate.invalid',
|
||||
data: [
|
||||
'id' => $file->id(),
|
||||
'template' => $template,
|
||||
'blueprints' => implode(', ', array_column($blueprints, 'name'))
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the file can be created
|
||||
*
|
||||
* @throws \Kirby\Exception\DuplicateException If a file with the same name exists
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to create the file
|
||||
*/
|
||||
public static function create(File $file, BaseFile $upload): void
|
||||
{
|
||||
// We want to ensure that we are not creating duplicate files.
|
||||
// If a file with the same name already exists
|
||||
if ($file->exists() === true) {
|
||||
// $file will be based on the props of the new file,
|
||||
// to compare templates, we need to get the props of
|
||||
// the already existing file from meta content file
|
||||
$existing = $file->parent()->file($file->filename());
|
||||
|
||||
// if the new upload is the exact same file
|
||||
// and uses the same template, we can continue
|
||||
if (
|
||||
$file->sha1() === $upload->sha1() &&
|
||||
$file->template() === $existing->template()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise throw an error for duplicate file
|
||||
throw new DuplicateException(
|
||||
key: 'file.duplicate',
|
||||
data: [
|
||||
'filename' => $file->filename()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if ($file->permissions()->can('create') !== true) {
|
||||
throw new PermissionException(
|
||||
message: 'The file cannot be created'
|
||||
);
|
||||
}
|
||||
|
||||
static::validFile($file, $upload->mime());
|
||||
|
||||
$upload->match($file->blueprint()->accept());
|
||||
$upload->validateContents(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the file can be deleted
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to delete the file
|
||||
*/
|
||||
public static function delete(File $file): void
|
||||
{
|
||||
if ($file->permissions()->can('delete') !== true) {
|
||||
throw new PermissionException(
|
||||
message: 'The file cannot be deleted'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the file can be replaced
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to replace the file
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the file type of the new file is different
|
||||
*/
|
||||
public static function replace(File $file, BaseFile $upload): void
|
||||
{
|
||||
if ($file->permissions()->can('replace') !== true) {
|
||||
throw new PermissionException(
|
||||
message: 'The file cannot be replaced'
|
||||
);
|
||||
}
|
||||
|
||||
static::validMime($file, $upload->mime());
|
||||
|
||||
if (
|
||||
(string)$upload->mime() !== (string)$file->mime() &&
|
||||
(string)$upload->extension() !== (string)$file->extension()
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.mime.differs',
|
||||
data: ['mime' => $file->mime()]
|
||||
);
|
||||
}
|
||||
|
||||
$upload->match($file->blueprint()->accept());
|
||||
$upload->validateContents(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the file can be updated
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to update the file
|
||||
*/
|
||||
public static function update(File $file, array $content = []): void
|
||||
{
|
||||
if ($file->permissions()->can('update') !== true) {
|
||||
throw new PermissionException(
|
||||
message: 'The file cannot be updated'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the file extension
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the extension is missing or forbidden
|
||||
*/
|
||||
public static function validExtension(File $file, string $extension): void
|
||||
{
|
||||
// make it easier to compare the extension
|
||||
$extension = strtolower($extension);
|
||||
|
||||
if (empty($extension) === true) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.extension.missing',
|
||||
data: ['filename' => $file->filename()]
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
Str::contains($extension, 'php') !== false ||
|
||||
Str::contains($extension, 'phar') !== false ||
|
||||
Str::contains($extension, 'pht') !== false
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.type.forbidden',
|
||||
data: ['type' => 'PHP']
|
||||
);
|
||||
}
|
||||
|
||||
if (Str::contains($extension, 'htm') !== false) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.type.forbidden',
|
||||
data: ['type' => 'HTML']
|
||||
);
|
||||
}
|
||||
|
||||
if (V::in($extension, ['exe', App::instance()->contentExtension()]) !== false) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.extension.forbidden',
|
||||
data: ['extension' => $extension]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the extension, MIME type and filename
|
||||
*
|
||||
* @param string|false|null $mime If not passed, the MIME type is detected from the file,
|
||||
* if `false`, the MIME type is not validated for performance reasons
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the extension, MIME type or filename is missing or forbidden
|
||||
*/
|
||||
public static function validFile(
|
||||
File $file,
|
||||
string|false|null $mime = null
|
||||
): void {
|
||||
// request to skip the MIME check for performance reasons
|
||||
if ($mime !== false) {
|
||||
static::validMime($file, $mime ?? $file->mime());
|
||||
}
|
||||
|
||||
static::validExtension($file, $file->extension());
|
||||
static::validFilename($file, $file->filename());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the filename
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the filename is missing or forbidden
|
||||
*/
|
||||
public static function validFilename(File $file, string $filename): void
|
||||
{
|
||||
// make it easier to compare the filename
|
||||
$filename = strtolower($filename);
|
||||
|
||||
// check for missing filenames
|
||||
if (empty($filename)) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.name.missing'
|
||||
);
|
||||
}
|
||||
|
||||
// Block htaccess files
|
||||
if (Str::startsWith($filename, '.ht')) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.type.forbidden',
|
||||
data: ['type' => 'Apache config']
|
||||
);
|
||||
}
|
||||
|
||||
// Block invisible files
|
||||
if (Str::startsWith($filename, '.')) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.type.forbidden',
|
||||
data: ['type' => 'invisible']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the MIME type
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the MIME type is missing or forbidden
|
||||
*/
|
||||
public static function validMime(File $file, string|null $mime = null): void
|
||||
{
|
||||
// make it easier to compare the mime
|
||||
$mime = strtolower($mime ?? '');
|
||||
|
||||
if (empty($mime)) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.mime.missing',
|
||||
data: ['filename' => $file->filename()]
|
||||
);
|
||||
}
|
||||
|
||||
if (Str::contains($mime, 'php')) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.type.forbidden',
|
||||
data: ['type' => 'PHP']
|
||||
);
|
||||
}
|
||||
|
||||
if (V::in($mime, ['text/html', 'application/x-msdownload'])) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'file.mime.forbidden',
|
||||
data:['mime' => $mime]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
kirby/src/Cms/FileVersion.php
Normal file
121
kirby/src/Cms/FileVersion.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Filesystem\Asset;
|
||||
use Kirby\Filesystem\IsFile;
|
||||
|
||||
/**
|
||||
* FileVersion
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class FileVersion
|
||||
{
|
||||
use IsFile;
|
||||
|
||||
protected array $modifications;
|
||||
protected File|Asset $original;
|
||||
|
||||
public function __construct(array $props)
|
||||
{
|
||||
$this->root = $props['root'] ?? null;
|
||||
$this->url = $props['url'] ?? null;
|
||||
$this->original = $props['original'];
|
||||
$this->modifications = $props['modifications'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy for public properties, asset methods
|
||||
* and content field getters
|
||||
*/
|
||||
public function __call(string $method, array $arguments = []): mixed
|
||||
{
|
||||
// public property access
|
||||
if (isset($this->$method) === true) {
|
||||
return $this->$method;
|
||||
}
|
||||
|
||||
// asset method proxy
|
||||
if (method_exists($this->asset(), $method)) {
|
||||
if ($this->exists() === false) {
|
||||
$this->save();
|
||||
}
|
||||
|
||||
return $this->asset()->$method(...$arguments);
|
||||
}
|
||||
|
||||
// content fields
|
||||
if ($this->original() instanceof File) {
|
||||
return $this->original()->content()->get($method);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unique ID
|
||||
*/
|
||||
public function id(): string
|
||||
{
|
||||
return dirname($this->original()->id()) . '/' . $this->filename();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent Kirby App instance
|
||||
*/
|
||||
public function kirby(): App
|
||||
{
|
||||
return $this->original()->kirby();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with all applied modifications
|
||||
*/
|
||||
public function modifications(): array
|
||||
{
|
||||
return $this->modifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the instance of the original File object
|
||||
*/
|
||||
public function original(): mixed
|
||||
{
|
||||
return $this->original;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the stored modifications and
|
||||
* saves the file on disk
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function save(): static
|
||||
{
|
||||
$this->kirby()->thumb(
|
||||
$this->original()->root(),
|
||||
$this->root(),
|
||||
$this->modifications()
|
||||
);
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts the object to an array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$array = [
|
||||
...$this->asset()->toArray(),
|
||||
'modifications' => $this->modifications()
|
||||
];
|
||||
|
||||
ksort($array);
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
226
kirby/src/Cms/Files.php
Normal file
226
kirby/src/Cms/Files.php
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Uuid\HasUuids;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The `$files` object extends the general
|
||||
* `Collection` class and refers to a
|
||||
* collection of files, i.e. images, documents
|
||||
* etc. Files can be filtered, searched,
|
||||
* converted, modified or evaluated with the
|
||||
* following methods:
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @template TFile of \Kirby\Cms\File
|
||||
* @extends \Kirby\Cms\Collection<TFile>
|
||||
*/
|
||||
class Files extends Collection
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
* All registered files methods
|
||||
*/
|
||||
public static array $methods = [];
|
||||
|
||||
/**
|
||||
* @var \Kirby\Cms\Page|\Kirby\Cms\Site|\Kirby\Cms\User
|
||||
*/
|
||||
protected object|null $parent = null;
|
||||
|
||||
/**
|
||||
* Adds a single file or
|
||||
* an entire second collection to the
|
||||
* current collection
|
||||
*
|
||||
* @param \Kirby\Cms\Files<TFile>|TFile|string $object
|
||||
* @return $this
|
||||
* @throws \Kirby\Exception\InvalidArgumentException When no `File` or `Files` object or an ID of an existing file is passed
|
||||
*/
|
||||
public function add($object): static
|
||||
{
|
||||
// add a files collection
|
||||
if ($object instanceof self) {
|
||||
$this->data = [...$this->data, ...$object->data];
|
||||
|
||||
// add a file by id
|
||||
} elseif (
|
||||
is_string($object) === true &&
|
||||
$file = App::instance()->file($object)
|
||||
) {
|
||||
$this->__set($file->id(), $file);
|
||||
|
||||
// add a file object
|
||||
} elseif ($object instanceof File) {
|
||||
$this->__set($object->id(), $object);
|
||||
|
||||
// give a useful error message on invalid input;
|
||||
// silently ignore "empty" values for compatibility with existing setups
|
||||
} elseif (in_array($object, [null, false, true], true) !== true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'You must pass a Files or File object or an ID of an existing file to the Files collection'
|
||||
);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort all given files by the
|
||||
* order in the array
|
||||
*
|
||||
* @param array $files List of file ids
|
||||
* @param int $offset Sorting offset
|
||||
* @return $this
|
||||
*/
|
||||
public function changeSort(array $files, int $offset = 0): static
|
||||
{
|
||||
foreach ($files as $filename) {
|
||||
if ($file = $this->get($filename)) {
|
||||
$offset++;
|
||||
$file->changeSort($offset);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the files with the given IDs
|
||||
* if they exist in the collection
|
||||
*
|
||||
* @throws \Kirby\Exception\Exception If not all files could be deleted
|
||||
*/
|
||||
public function delete(array $ids): void
|
||||
{
|
||||
$exceptions = [];
|
||||
|
||||
// delete all pages and collect errors
|
||||
foreach ($ids as $id) {
|
||||
try {
|
||||
$model = $this->get($id);
|
||||
|
||||
if ($model instanceof File === false) {
|
||||
throw new NotFoundException(
|
||||
key: 'file.undefined'
|
||||
);
|
||||
}
|
||||
|
||||
$model->delete();
|
||||
} catch (Throwable $e) {
|
||||
$exceptions[$id] = $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($exceptions !== []) {
|
||||
throw new Exception(
|
||||
key: 'file.delete.multiple',
|
||||
details: $exceptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a files collection from an array of props
|
||||
*/
|
||||
public static function factory(
|
||||
array $files,
|
||||
Page|Site|User $parent
|
||||
): static {
|
||||
$collection = new static([], $parent);
|
||||
|
||||
foreach ($files as $props) {
|
||||
$props['collection'] = $collection;
|
||||
$props['parent'] = $parent;
|
||||
|
||||
$file = File::factory($props);
|
||||
|
||||
$collection->data[$file->id()] = $file;
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a file by its filename
|
||||
* @internal Use `$files->find()` instead
|
||||
* @return TFile|null
|
||||
*/
|
||||
public function findByKey(string $key): File|null
|
||||
{
|
||||
if ($file = $this->findByUuid($key, 'file')) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
return $this->get(ltrim($this->parent?->id() . '/' . $key, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file size for all
|
||||
* files in the collection in a
|
||||
* human-readable format
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @param string|null|false $locale Locale for number formatting,
|
||||
* `null` for the current locale,
|
||||
* `false` to disable number formatting
|
||||
*/
|
||||
public function niceSize(string|false|null $locale = null): string
|
||||
{
|
||||
return F::niceSize($this->size(), $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw size for all
|
||||
* files in the collection
|
||||
* @since 3.6.0
|
||||
*/
|
||||
public function size(): int
|
||||
{
|
||||
return F::size($this->values(fn ($file) => $file->root()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collection sorted by
|
||||
* the sort number and the filename
|
||||
* @return \Kirby\Cms\Files<TFile>
|
||||
*/
|
||||
public function sorted(): static
|
||||
{
|
||||
return $this->sort('sort', 'asc', 'filename', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter all files by the given template
|
||||
*
|
||||
* @return $this|static
|
||||
*/
|
||||
public function template(string|array|null $template): static
|
||||
{
|
||||
if (empty($template) === true) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if ($template === 'default') {
|
||||
$template = ['default', ''];
|
||||
}
|
||||
|
||||
return $this->filter(
|
||||
'template',
|
||||
is_array($template) ? 'in' : '==',
|
||||
$template
|
||||
);
|
||||
}
|
||||
}
|
||||
172
kirby/src/Cms/Find.php
Normal file
172
kirby/src/Cms/Find.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The Find class is used in the API and
|
||||
* the Panel to find models and parents
|
||||
* based on request paths
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Find
|
||||
{
|
||||
/**
|
||||
* Returns the file object for the given
|
||||
* parent path and filename
|
||||
*
|
||||
* @param string $path Path to file's parent model
|
||||
* @throws \Kirby\Exception\NotFoundException if the file cannot be found
|
||||
*/
|
||||
public static function file(
|
||||
string $path,
|
||||
string $filename
|
||||
): File|null {
|
||||
$filename = urldecode($filename);
|
||||
$parent = empty($path) ? null : static::parent($path);
|
||||
$file = App::instance()->file($filename, $parent);
|
||||
|
||||
if ($file?->isAccessible() === true) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
key: 'file.notFound',
|
||||
data: ['filename' => $filename]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the language object for the given code
|
||||
*
|
||||
* @param string $code Language code
|
||||
* @throws \Kirby\Exception\NotFoundException if the language cannot be found
|
||||
*/
|
||||
public static function language(string $code): Language|null
|
||||
{
|
||||
if ($language = App::instance()->language($code)) {
|
||||
return $language;
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
key: 'language.notFound',
|
||||
data: ['code' => $code]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the page object for the given id
|
||||
*
|
||||
* @param string $id Page's id
|
||||
* @throws \Kirby\Exception\NotFoundException if the page cannot be found
|
||||
*/
|
||||
public static function page(string $id): Page|null
|
||||
{
|
||||
// decode API ID encoding
|
||||
$id = str_replace(['+', ' '], '/', $id);
|
||||
$kirby = App::instance();
|
||||
$page = $kirby->page($id, null, true);
|
||||
|
||||
if ($page?->isAccessible() === true) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
key: 'page.notFound',
|
||||
data: ['slug' => $id]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model's object for the given path
|
||||
*
|
||||
* @param string $path Path to parent model
|
||||
* @throws \Kirby\Exception\InvalidArgumentException if the model type is invalid
|
||||
* @throws \Kirby\Exception\NotFoundException if the model cannot be found
|
||||
*/
|
||||
public static function parent(string $path): ModelWithContent
|
||||
{
|
||||
$path = trim($path, '/');
|
||||
$modelType = match ($path) {
|
||||
'site', 'account' => $path,
|
||||
default => trim(dirname($path), '/')
|
||||
};
|
||||
$modelTypes = [
|
||||
'site' => 'site',
|
||||
'users' => 'user',
|
||||
'pages' => 'page',
|
||||
'account' => 'account'
|
||||
];
|
||||
|
||||
$modelName = $modelTypes[$modelType] ?? null;
|
||||
|
||||
if (Str::endsWith($modelType, '/files') === true) {
|
||||
$modelName = 'file';
|
||||
}
|
||||
|
||||
$kirby = App::instance();
|
||||
|
||||
$model = match ($modelName) {
|
||||
'site' => $kirby->site(),
|
||||
'account' => static::user(),
|
||||
'page' => static::page(basename($path)),
|
||||
// regular expression to split the path at the last
|
||||
// occurrence of /files/ which separates parent path
|
||||
// and filename
|
||||
'file' => static::file(...preg_split('$.*\K(/files/)$', $path)),
|
||||
'user' => $kirby->user(basename($path)),
|
||||
default => throw new InvalidArgumentException(
|
||||
message: 'Invalid model type: ' . $modelType
|
||||
)
|
||||
};
|
||||
|
||||
return $model ?? throw new NotFoundException(
|
||||
key: $modelName . '.undefined'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user object for the given id or
|
||||
* returns the current authenticated user if no
|
||||
* id is passed
|
||||
*
|
||||
* @param string|null $id User's id
|
||||
* @throws \Kirby\Exception\NotFoundException if the user for the given id cannot be found
|
||||
*/
|
||||
public static function user(string|null $id = null): User|null
|
||||
{
|
||||
// account is a reserved word to find the current
|
||||
// user. It's used in various API and area routes.
|
||||
if ($id === 'account') {
|
||||
$id = null;
|
||||
}
|
||||
|
||||
$kirby = App::instance();
|
||||
|
||||
// get the authenticated user
|
||||
if ($id === null) {
|
||||
$user = $kirby->user(
|
||||
null,
|
||||
$kirby->option('api.allowImpersonation', false)
|
||||
);
|
||||
|
||||
return $user ?? throw new NotFoundException(
|
||||
key: 'user.undefined'
|
||||
);
|
||||
}
|
||||
|
||||
// get a specific user by id
|
||||
return $kirby->user($id) ?? throw new NotFoundException(
|
||||
key: 'user.notFound',
|
||||
data: ['name' => $id]
|
||||
);
|
||||
}
|
||||
}
|
||||
201
kirby/src/Cms/HasChildren.php
Normal file
201
kirby/src/Cms/HasChildren.php
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* HasChildren
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait HasChildren
|
||||
{
|
||||
/**
|
||||
* The list of available published children
|
||||
*/
|
||||
public Pages|null $children = null;
|
||||
|
||||
/**
|
||||
* The list of available draft children
|
||||
*/
|
||||
public Pages|null $drafts = null;
|
||||
|
||||
/**
|
||||
* The combined list of available published
|
||||
* and draft children
|
||||
*/
|
||||
public Pages|null $childrenAndDrafts = null;
|
||||
|
||||
/**
|
||||
* Returns all published children
|
||||
*/
|
||||
public function children(): Pages
|
||||
{
|
||||
return $this->children ??= Pages::factory($this->inventory()['children'], $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all published and draft children at the same time
|
||||
*/
|
||||
public function childrenAndDrafts(): Pages
|
||||
{
|
||||
return $this->childrenAndDrafts ??= $this->children()->merge($this->drafts());
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for a draft child by ID
|
||||
*/
|
||||
public function draft(string $path): Page|null
|
||||
{
|
||||
$path = str_replace('_drafts/', '', $path);
|
||||
|
||||
if (Str::contains($path, '/') === false) {
|
||||
return $this->drafts()->find($path);
|
||||
}
|
||||
|
||||
$parts = explode('/', $path);
|
||||
$parent = $this;
|
||||
|
||||
foreach ($parts as $slug) {
|
||||
if ($page = $parent->find($slug)) {
|
||||
$parent = $page;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($draft = $parent->drafts()->find($slug)) {
|
||||
$parent = $draft;
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all draft children
|
||||
*/
|
||||
public function drafts(): Pages
|
||||
{
|
||||
if ($this->drafts instanceof Pages) {
|
||||
return $this->drafts;
|
||||
}
|
||||
|
||||
$kirby = $this->kirby();
|
||||
|
||||
// create the inventory for all drafts
|
||||
$inventory = Dir::inventory(
|
||||
$this->root() . '/_drafts',
|
||||
$kirby->contentExtension(),
|
||||
$kirby->contentIgnore(),
|
||||
$kirby->multilang()
|
||||
);
|
||||
|
||||
return $this->drafts = Pages::factory($inventory['children'], $this, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one or multiple published children by ID
|
||||
*/
|
||||
public function find(string|array ...$arguments): Page|Pages|null
|
||||
{
|
||||
return $this->children()->find(...$arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a single published or draft child
|
||||
*/
|
||||
public function findPageOrDraft(string $path): Page|null
|
||||
{
|
||||
return $this->children()->find($path) ?? $this->drafts()->find($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of all published children of published children
|
||||
*/
|
||||
public function grandChildren(): Pages
|
||||
{
|
||||
return $this->children()->children();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the model has any published children
|
||||
*/
|
||||
public function hasChildren(): bool
|
||||
{
|
||||
return $this->children()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the model has any draft children
|
||||
*/
|
||||
public function hasDrafts(): bool
|
||||
{
|
||||
return $this->drafts()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the page has any listed children
|
||||
*/
|
||||
public function hasListedChildren(): bool
|
||||
{
|
||||
return $this->children()->listed()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the page has any unlisted children
|
||||
*/
|
||||
public function hasUnlistedChildren(): bool
|
||||
{
|
||||
return $this->children()->unlisted()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a flat child index
|
||||
*
|
||||
* @param bool $drafts If set to `true`, draft children are included
|
||||
*/
|
||||
public function index(bool $drafts = false): Pages
|
||||
{
|
||||
if ($drafts === true) {
|
||||
return $this->childrenAndDrafts()->index($drafts);
|
||||
}
|
||||
|
||||
return $this->children()->index();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the published children collection
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setChildren(array|null $children = null): static
|
||||
{
|
||||
if ($children !== null) {
|
||||
$this->children = Pages::factory($children, $this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the draft children collection
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setDrafts(array|null $drafts = null): static
|
||||
{
|
||||
if ($drafts !== null) {
|
||||
$this->drafts = Pages::factory($drafts, $this, true);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
190
kirby/src/Cms/HasFiles.php
Normal file
190
kirby/src/Cms/HasFiles.php
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* HasFiles
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait HasFiles
|
||||
{
|
||||
/**
|
||||
* The Files collection
|
||||
*/
|
||||
protected Files|array|null $files = null;
|
||||
|
||||
/**
|
||||
* Filters the Files collection by type audio
|
||||
*/
|
||||
public function audio(): Files
|
||||
{
|
||||
return $this->files()->filter('type', '==', 'audio');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the Files collection by type code
|
||||
*/
|
||||
public function code(): Files
|
||||
{
|
||||
return $this->files()->filter('type', '==', 'code');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new file
|
||||
*
|
||||
* @param bool $move If set to `true`, the source will be deleted
|
||||
*/
|
||||
public function createFile(array $props, bool $move = false): File
|
||||
{
|
||||
$props = [
|
||||
...$props,
|
||||
'parent' => $this,
|
||||
'url' => null
|
||||
];
|
||||
|
||||
return File::create($props, $move);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the Files collection by type documents
|
||||
*/
|
||||
public function documents(): Files
|
||||
{
|
||||
return $this->files()->filter('type', '==', 'document');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a specific file by filename or the first one
|
||||
*/
|
||||
public function file(
|
||||
string|null $filename = null,
|
||||
string $in = 'files'
|
||||
): File|null {
|
||||
if ($filename === null) {
|
||||
return $this->$in()->first();
|
||||
}
|
||||
|
||||
// find by global UUID
|
||||
if (Uuid::is($filename, 'file') === true) {
|
||||
return Uuid::for($filename, $this->$in())->model();
|
||||
}
|
||||
|
||||
if (str_contains($filename, '/') === true) {
|
||||
$path = dirname($filename);
|
||||
$filename = basename($filename);
|
||||
|
||||
if ($page = $this->find($path)) {
|
||||
return $page->$in()->find($filename);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->$in()->find($filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Files collection
|
||||
*/
|
||||
public function files(): Files
|
||||
{
|
||||
if ($this->files instanceof Files) {
|
||||
return $this->files;
|
||||
}
|
||||
|
||||
return $this->files = Files::factory($this->inventory()['files'], $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Files collection has any audio files
|
||||
*/
|
||||
public function hasAudio(): bool
|
||||
{
|
||||
return $this->audio()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Files collection has any code files
|
||||
*/
|
||||
public function hasCode(): bool
|
||||
{
|
||||
return $this->code()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Files collection has any document files
|
||||
*/
|
||||
public function hasDocuments(): bool
|
||||
{
|
||||
return $this->documents()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Files collection has any files
|
||||
*/
|
||||
public function hasFiles(): bool
|
||||
{
|
||||
return $this->files()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Files collection has any images
|
||||
*/
|
||||
public function hasImages(): bool
|
||||
{
|
||||
return $this->images()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Files collection has any videos
|
||||
*/
|
||||
public function hasVideos(): bool
|
||||
{
|
||||
return $this->videos()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a specific image by filename or the first one
|
||||
*/
|
||||
public function image(string|null $filename = null): File|null
|
||||
{
|
||||
return $this->file($filename, 'images');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the Files collection by type image
|
||||
*/
|
||||
public function images(): Files
|
||||
{
|
||||
return $this->files()->filter('type', '==', 'image');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Files collection
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setFiles(array|null $files = null): static
|
||||
{
|
||||
if ($files !== null) {
|
||||
$this->files = Files::factory($files, $this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the Files collection by type videos
|
||||
*/
|
||||
public function videos(): Files
|
||||
{
|
||||
return $this->files()->filter('type', '==', 'video');
|
||||
}
|
||||
}
|
||||
70
kirby/src/Cms/HasMethods.php
Normal file
70
kirby/src/Cms/HasMethods.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Exception\BadMethodCallException;
|
||||
|
||||
/**
|
||||
* HasMethods
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait HasMethods
|
||||
{
|
||||
/**
|
||||
* All registered methods
|
||||
*/
|
||||
public static array $methods = [];
|
||||
|
||||
/**
|
||||
* Calls a registered method class with the
|
||||
* passed arguments
|
||||
*
|
||||
* @throws \Kirby\Exception\BadMethodCallException
|
||||
*/
|
||||
protected function callMethod(string $method, array $args = []): mixed
|
||||
{
|
||||
$closure = $this->getMethod($method);
|
||||
|
||||
if ($closure === null) {
|
||||
throw new BadMethodCallException(
|
||||
message: 'The method ' . $method . ' does not exist'
|
||||
);
|
||||
}
|
||||
|
||||
return $closure->call($this, ...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the object has a registered custom method
|
||||
*/
|
||||
public function hasMethod(string $method): bool
|
||||
{
|
||||
return $this->getMethod($method) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a registered method by name, either from
|
||||
* the current class or from a parent class ordered by
|
||||
* inheritance order (top to bottom)
|
||||
*/
|
||||
protected function getMethod(string $method): Closure|null
|
||||
{
|
||||
if (isset(static::$methods[$method]) === true) {
|
||||
return static::$methods[$method];
|
||||
}
|
||||
|
||||
foreach (class_parents($this) as $parent) {
|
||||
if (isset($parent::$methods[$method]) === true) {
|
||||
return $parent::$methods[$method];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
53
kirby/src/Cms/HasModels.php
Normal file
53
kirby/src/Cms/HasModels.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* HasModels
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
*/
|
||||
trait HasModels
|
||||
{
|
||||
/**
|
||||
* Registry with all custom models
|
||||
*/
|
||||
public static array $models = [];
|
||||
|
||||
/**
|
||||
* Adds new models to the registry
|
||||
* @internal
|
||||
*/
|
||||
public static function extendModels(array $models): array
|
||||
{
|
||||
return static::$models = [
|
||||
...static::$models,
|
||||
...array_change_key_case($models, CASE_LOWER)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an object from model if it has been registered
|
||||
*/
|
||||
public static function model(string $name, array $props = []): static
|
||||
{
|
||||
$name = strtolower($name);
|
||||
$class = static::$models[$name] ?? null;
|
||||
$class ??= static::$models['default'] ?? null;
|
||||
|
||||
if ($class !== null) {
|
||||
$object = new $class($props);
|
||||
|
||||
if ($object instanceof self) {
|
||||
return $object;
|
||||
}
|
||||
}
|
||||
|
||||
return new static($props);
|
||||
}
|
||||
}
|
||||
159
kirby/src/Cms/HasSiblings.php
Normal file
159
kirby/src/Cms/HasSiblings.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Toolkit\Collection;
|
||||
|
||||
/**
|
||||
* This trait is used by pages, files and users
|
||||
* to handle navigation through parent collections
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @template TCollection of \Kirby\Toolkit\Collection
|
||||
*/
|
||||
trait HasSiblings
|
||||
{
|
||||
/**
|
||||
* Checks if there's a next item in the collection
|
||||
*
|
||||
* @param TCollection|null $collection
|
||||
*/
|
||||
public function hasNext(Collection|null $collection = null): bool
|
||||
{
|
||||
return $this->next($collection) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there's a previous item in the collection
|
||||
*
|
||||
* @param TCollection|null $collection
|
||||
*/
|
||||
public function hasPrev(Collection|null $collection = null): bool
|
||||
{
|
||||
return $this->prev($collection) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position / index in the collection
|
||||
*
|
||||
* @param TCollection|null $collection
|
||||
*/
|
||||
public function indexOf(Collection|null $collection = null): int|false
|
||||
{
|
||||
$collection ??= $this->siblingsCollection();
|
||||
return $collection->indexOf($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the item is the first in the collection
|
||||
*
|
||||
* @param TCollection|null $collection
|
||||
*/
|
||||
public function isFirst(Collection|null $collection = null): bool
|
||||
{
|
||||
$collection ??= $this->siblingsCollection();
|
||||
return $collection->first()->is($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the item is the last in the collection
|
||||
*
|
||||
* @param TCollection|null $collection
|
||||
*/
|
||||
public function isLast(Collection|null $collection = null): bool
|
||||
{
|
||||
$collection ??= $this->siblingsCollection();
|
||||
return $collection->last()->is($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the item is at a certain position
|
||||
*
|
||||
* @param TCollection|null $collection
|
||||
*/
|
||||
public function isNth(int $n, Collection|null $collection = null): bool
|
||||
{
|
||||
return $this->indexOf($collection) === $n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next item in the collection if available
|
||||
* @todo `static` return type hint is not 100% accurate because of
|
||||
* quirks in the `Form` classes; would break if enforced
|
||||
* (https://github.com/getkirby/kirby/pull/5175)
|
||||
*
|
||||
* @param TCollection|null $collection
|
||||
* @return static|null
|
||||
*/
|
||||
public function next($collection = null)
|
||||
{
|
||||
$collection ??= $this->siblingsCollection();
|
||||
return $collection->nth($this->indexOf($collection) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the end of the collection starting after the current item
|
||||
*
|
||||
* @param TCollection|null $collection
|
||||
* @return TCollection
|
||||
*/
|
||||
public function nextAll(Collection|null $collection = null): Collection
|
||||
{
|
||||
$collection ??= $this->siblingsCollection();
|
||||
return $collection->slice($this->indexOf($collection) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous item in the collection if available
|
||||
* @todo `static` return type hint is not 100% accurate because of
|
||||
* quirks in the `Form` classes; would break if enforced
|
||||
* (https://github.com/getkirby/kirby/pull/5175)
|
||||
*
|
||||
* @param TCollection|null $collection
|
||||
* @return static|null
|
||||
*/
|
||||
public function prev(Collection|null $collection = null)
|
||||
{
|
||||
$collection ??= $this->siblingsCollection();
|
||||
return $collection->nth($this->indexOf($collection) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the beginning of the collection before the current item
|
||||
*
|
||||
* @param TCollection|null $collection
|
||||
* @return TCollection
|
||||
*/
|
||||
public function prevAll(Collection|null $collection = null): Collection
|
||||
{
|
||||
$collection ??= $this->siblingsCollection();
|
||||
return $collection->slice(0, $this->indexOf($collection));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all sibling elements
|
||||
*
|
||||
* @return TCollection
|
||||
*/
|
||||
public function siblings(bool $self = true): Collection
|
||||
{
|
||||
$siblings = $this->siblingsCollection();
|
||||
|
||||
if ($self === false) {
|
||||
return $siblings->not($this);
|
||||
}
|
||||
|
||||
return $siblings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collection of siblings
|
||||
* @return TCollection
|
||||
*/
|
||||
abstract protected function siblingsCollection(): Collection;
|
||||
}
|
||||
214
kirby/src/Cms/Helpers.php
Normal file
214
kirby/src/Cms/Helpers.php
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The `Helpers` class hosts a few handy helper methods
|
||||
* @since 3.7.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Helpers
|
||||
{
|
||||
/**
|
||||
* Allows to disable specific deprecation warnings
|
||||
* by setting them to `false`.
|
||||
* You can do this by putting the following code in
|
||||
* `site/config/config.php`:
|
||||
*
|
||||
* ```php
|
||||
* Helpers::$deprecations['<deprecation-key>'] = false;
|
||||
* ```
|
||||
*/
|
||||
public static array $deprecations = [
|
||||
// The internal `$model->contentFile*()` methods have been deprecated
|
||||
'model-content-file' => true,
|
||||
|
||||
// Passing an `info` array inside the `extends` array
|
||||
// has been deprecated. Pass the individual entries (e.g. root, version)
|
||||
// directly as named arguments.
|
||||
// TODO: switch to true in v6
|
||||
'plugin-extends-root' => false,
|
||||
|
||||
// The `Content\Translation` class keeps a set of methods from the old
|
||||
// `ContentTranslation` class for compatibility that should no longer be used.
|
||||
// Some of them can be replaced by using `Version` class methods instead
|
||||
// (see method comments). `Content\Translation::contentFile` should be avoided
|
||||
// entirely and has no recommended replacement.
|
||||
'translation-methods' => true
|
||||
];
|
||||
|
||||
/**
|
||||
* Triggers a deprecation warning if debug mode is active
|
||||
* and warning has not been surpressed via `Helpers::$deprecations`
|
||||
*
|
||||
* @param string|null $key If given, the key will be checked against the static array
|
||||
* @return bool Whether the warning was triggered
|
||||
*/
|
||||
public static function deprecated(
|
||||
string $message,
|
||||
string|null $key = null
|
||||
): bool {
|
||||
// only trigger warning in debug mode or when running PHPUnit tests
|
||||
// @codeCoverageIgnoreStart
|
||||
if (
|
||||
App::instance()->option('debug') !== true &&
|
||||
(defined('KIRBY_TESTING') !== true || KIRBY_TESTING !== true)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
|
||||
// don't trigger the warning if disabled by default or by the dev
|
||||
if ($key !== null && (static::$deprecations[$key] ?? true) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return trigger_error($message, E_USER_DEPRECATED) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple object and variable dumper
|
||||
* to help with debugging.
|
||||
*/
|
||||
public static function dump(mixed $variable, bool $echo = true): string
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$output = print_r($variable, true);
|
||||
|
||||
if ($kirby->environment()->cli() === true) {
|
||||
$output .= PHP_EOL;
|
||||
} else {
|
||||
$output = Str::wrap($output, '<pre>', '</pre>');
|
||||
}
|
||||
|
||||
if ($echo === true) {
|
||||
echo $output;
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an action with custom handling
|
||||
* for all PHP errors and warnings
|
||||
* @since 3.7.4
|
||||
*
|
||||
* @param \Closure $action Any action that may cause an error or warning
|
||||
* @param \Closure $condition Closure that returns bool to determine if to
|
||||
* suppress an error, receives arguments for
|
||||
* `set_error_handler()`
|
||||
* @param mixed $fallback Value to return when error is suppressed
|
||||
* @return mixed Return value of the `$action` closure,
|
||||
* possibly overridden by `$fallback`
|
||||
*/
|
||||
public static function handleErrors(
|
||||
Closure $action,
|
||||
Closure $condition,
|
||||
$fallback = null
|
||||
) {
|
||||
$override = null;
|
||||
|
||||
// check if the LC_MESSAGES constant is defined
|
||||
// some environments do not support LC_MESSAGES especially on Windows
|
||||
// LC_MESSAGES constant is available if PHP was compiled with libintl
|
||||
if (defined('LC_MESSAGES') === true) {
|
||||
// backup current locale
|
||||
$locale = setlocale(LC_MESSAGES, 0);
|
||||
|
||||
// set locale to C so that errors and warning messages are
|
||||
// printed in English for robust comparisons in the handler
|
||||
setlocale(LC_MESSAGES, 'C');
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress UndefinedVariable
|
||||
*/
|
||||
$handler = set_error_handler(function () use (&$override, &$handler, $condition, $fallback) {
|
||||
// check if suppress condition is met
|
||||
$suppress = $condition(...func_get_args());
|
||||
|
||||
if ($suppress !== true) {
|
||||
// handle other warnings with Whoops if loaded
|
||||
if (is_callable($handler) === true) {
|
||||
return $handler(...func_get_args());
|
||||
}
|
||||
|
||||
// otherwise use the standard error handler
|
||||
return false; // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
// use fallback to override return for suppressed errors
|
||||
$override = $fallback;
|
||||
|
||||
if (is_callable($override) === true) {
|
||||
$override = $override();
|
||||
}
|
||||
|
||||
// no additional error handling
|
||||
return true;
|
||||
});
|
||||
|
||||
try {
|
||||
$result = $action();
|
||||
} finally {
|
||||
// always restore the error handler, even if the
|
||||
// action or the standard error handler threw an
|
||||
// exception; this avoids modifying global state
|
||||
restore_error_handler();
|
||||
|
||||
// check if the LC_MESSAGES constant is defined
|
||||
if (defined('LC_MESSAGES') === true) {
|
||||
// reset to original locale
|
||||
setlocale(LC_MESSAGES, $locale);
|
||||
}
|
||||
}
|
||||
|
||||
return $override ?? $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a helper was overridden by the user
|
||||
* by setting the `KIRBY_HELPER_*` constant
|
||||
*
|
||||
* @param string $name Name of the helper
|
||||
*/
|
||||
public static function hasOverride(string $name): bool
|
||||
{
|
||||
$name = 'KIRBY_HELPER_' . strtoupper($name);
|
||||
return defined($name) === true && constant($name) === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the size/length of numbers,
|
||||
* strings, arrays and countable objects
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
public static function size(mixed $value): int
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return Str::length(trim($value));
|
||||
}
|
||||
|
||||
if (is_countable($value)) {
|
||||
return count($value);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Could not determine the size of the given value'
|
||||
);
|
||||
}
|
||||
}
|
||||
164
kirby/src/Cms/Html.php
Normal file
164
kirby/src/Cms/Html.php
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Plugin\Assets;
|
||||
use Kirby\Plugin\Plugin;
|
||||
use Kirby\Toolkit\A;
|
||||
|
||||
/**
|
||||
* The `Html` class provides methods for building
|
||||
* common HTML tags and also contains some helper
|
||||
* methods.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Html extends \Kirby\Toolkit\Html
|
||||
{
|
||||
/**
|
||||
* Creates one or multiple CSS link tags
|
||||
* @since 3.7.0
|
||||
*
|
||||
* @param string|array $url Relative or absolute URLs, an array of URLs or `@auto` for automatic template css loading
|
||||
* @param string|array|null $options Pass an array of attributes for the link tag or a media attribute string
|
||||
*/
|
||||
public static function css(
|
||||
string|array|Plugin|Assets $url,
|
||||
string|array|null $options = null
|
||||
): string|null {
|
||||
if ($url instanceof Plugin) {
|
||||
$url = $url->assets();
|
||||
}
|
||||
|
||||
if ($url instanceof Assets) {
|
||||
$url = $url->css()->values(fn ($asset) => $asset->url());
|
||||
}
|
||||
|
||||
if (is_array($url) === true) {
|
||||
$links = A::map($url, fn ($url) => static::css($url, $options));
|
||||
return implode(PHP_EOL, $links);
|
||||
}
|
||||
|
||||
if (is_string($options) === true) {
|
||||
$options = ['media' => $options];
|
||||
}
|
||||
|
||||
$kirby = App::instance();
|
||||
|
||||
if ($url === '@auto') {
|
||||
if (!$url = Url::toTemplateAsset('css/templates', 'css')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// only valid value for 'rel' is 'alternate stylesheet',
|
||||
// if 'title' is given as well
|
||||
if (
|
||||
($options['rel'] ?? '') !== 'alternate stylesheet' ||
|
||||
($options['title'] ?? '') === ''
|
||||
) {
|
||||
$options['rel'] = 'stylesheet';
|
||||
}
|
||||
|
||||
$url = ($kirby->component('css'))($kirby, $url, $options);
|
||||
$url = Url::to($url);
|
||||
$attr = [...$options ?? [], 'href' => $url];
|
||||
|
||||
return '<link ' . static::attr($attr) . '>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an `a` tag with an absolute Url
|
||||
*
|
||||
* @param string|null $href Relative or absolute Url
|
||||
* @param string|array|null $text If `null`, the link will be used as link text. If an array is passed, each element will be added unencoded
|
||||
* @param array $attr Additional attributes for the a tag.
|
||||
*/
|
||||
public static function link(
|
||||
string|null $href = null,
|
||||
string|array|null $text = null,
|
||||
array $attr = []
|
||||
): string {
|
||||
return parent::link(Url::to($href), $text, $attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a script tag to load a javascript file
|
||||
* @since 3.7.0
|
||||
*/
|
||||
public static function js(
|
||||
string|array|Plugin|Assets $url,
|
||||
string|array|bool|null $options = null
|
||||
): string|null {
|
||||
if ($url instanceof Plugin) {
|
||||
$url = $url->assets();
|
||||
}
|
||||
|
||||
if ($url instanceof Assets) {
|
||||
$url = $url->js()->values(fn ($asset) => $asset->url());
|
||||
}
|
||||
|
||||
if (is_array($url) === true) {
|
||||
$scripts = A::map($url, fn ($url) => static::js($url, $options));
|
||||
return implode(PHP_EOL, $scripts);
|
||||
}
|
||||
|
||||
if (is_bool($options) === true) {
|
||||
$options = ['async' => $options];
|
||||
}
|
||||
|
||||
$kirby = App::instance();
|
||||
|
||||
if ($url === '@auto') {
|
||||
if (!$url = Url::toTemplateAsset('js/templates', 'js')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$url = ($kirby->component('js'))($kirby, $url, $options);
|
||||
$url = Url::to($url);
|
||||
$attr = [...$options ?? [], 'src' => $url];
|
||||
|
||||
return '<script ' . static::attr($attr) . '></script>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes an SVG file by absolute or
|
||||
* relative file path.
|
||||
* @since 3.7.0
|
||||
*/
|
||||
public static function svg(string|File $file): string|false
|
||||
{
|
||||
// support for Kirby's file objects
|
||||
if (
|
||||
$file instanceof File &&
|
||||
$file->extension() === 'svg'
|
||||
) {
|
||||
return $file->read();
|
||||
}
|
||||
|
||||
if (is_string($file) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$extension = F::extension($file);
|
||||
|
||||
// check for valid svg files
|
||||
if ($extension !== 'svg') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// try to convert relative paths to absolute
|
||||
if (file_exists($file) === false) {
|
||||
$root = App::instance()->root();
|
||||
$file = realpath($root . '/' . $file);
|
||||
}
|
||||
|
||||
return F::read($file);
|
||||
}
|
||||
}
|
||||
77
kirby/src/Cms/Ingredients.php
Normal file
77
kirby/src/Cms/Ingredients.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
|
||||
/**
|
||||
* The Ingredients class is the foundation for
|
||||
* `$kirby->urls()` and `$kirby->roots()` objects.
|
||||
* Those are configured in `kirby/config/urls.php`
|
||||
* and `kirby/config/roots.php`
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Ingredients
|
||||
{
|
||||
/**
|
||||
* Creates a new ingredient collection
|
||||
*/
|
||||
public function __construct(
|
||||
protected array $ingredients = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic getter for single ingredients
|
||||
*/
|
||||
public function __call(string $method, array|null $args = null): mixed
|
||||
{
|
||||
return $this->ingredients[$method] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Improved `var_dump` output
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return $this->ingredients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single ingredient by key
|
||||
*/
|
||||
public function __get(string $key)
|
||||
{
|
||||
return $this->ingredients[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves all ingredient callbacks
|
||||
* and creates a plain array
|
||||
* @internal
|
||||
*/
|
||||
public static function bake(array $ingredients): static
|
||||
{
|
||||
foreach ($ingredients as $name => $ingredient) {
|
||||
if ($ingredient instanceof Closure) {
|
||||
$ingredients[$name] = $ingredient($ingredients);
|
||||
}
|
||||
}
|
||||
|
||||
return new static($ingredients);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all ingredients as plain array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->ingredients;
|
||||
}
|
||||
}
|
||||
121
kirby/src/Cms/Item.php
Normal file
121
kirby/src/Cms/Item.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Content\Field;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The Item class is the foundation
|
||||
* for every object in context with
|
||||
* other objects. I.e.
|
||||
*
|
||||
* - a Block in a collection of Blocks
|
||||
* - a Layout in a collection of Layouts
|
||||
* - a Column in a collection of Columns
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @template TCollection of \Kirby\Cms\Items
|
||||
* @use \Kirby\Cms\HasSiblings<TCollection>
|
||||
*/
|
||||
class Item
|
||||
{
|
||||
use HasSiblings;
|
||||
|
||||
public const ITEMS_CLASS = Items::class;
|
||||
|
||||
protected Field|null $field;
|
||||
|
||||
protected string $id;
|
||||
protected array $params;
|
||||
protected ModelWithContent $parent;
|
||||
protected Items $siblings;
|
||||
|
||||
/**
|
||||
* Creates a new item
|
||||
*/
|
||||
public function __construct(array $params = [])
|
||||
{
|
||||
$class = static::ITEMS_CLASS;
|
||||
$this->id = $params['id'] ?? Str::uuid();
|
||||
$this->params = $params;
|
||||
$this->field = $params['field'] ?? null;
|
||||
$this->parent = $params['parent'] ?? App::instance()->site();
|
||||
$this->siblings = $params['siblings'] ?? new $class();
|
||||
}
|
||||
|
||||
/**
|
||||
* Static Item factory
|
||||
*/
|
||||
public static function factory(array $params): static
|
||||
{
|
||||
return new static($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent field if known
|
||||
*/
|
||||
public function field(): Field|null
|
||||
{
|
||||
return $this->field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unique item id (UUID v4)
|
||||
*/
|
||||
public function id(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the item to another one
|
||||
*/
|
||||
public function is(Item $item): bool
|
||||
{
|
||||
return $this->id() === $item->id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Kirby instance
|
||||
*/
|
||||
public function kirby(): App
|
||||
{
|
||||
return $this->parent()->kirby();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent model
|
||||
*/
|
||||
public function parent(): ModelWithContent
|
||||
{
|
||||
return $this->parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sibling collection
|
||||
* This is required by the HasSiblings trait
|
||||
*
|
||||
* @psalm-return self::ITEMS_CLASS
|
||||
*/
|
||||
protected function siblingsCollection(): Items
|
||||
{
|
||||
return $this->siblings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the item to an array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id(),
|
||||
];
|
||||
}
|
||||
}
|
||||
105
kirby/src/Cms/Items.php
Normal file
105
kirby/src/Cms/Items.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Content\Field;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* A collection of items
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @template TValue of \Kirby\Cms\Item
|
||||
* @extends \Kirby\Cms\Collection<TValue>
|
||||
*/
|
||||
class Items extends Collection
|
||||
{
|
||||
public const ITEM_CLASS = Item::class;
|
||||
|
||||
protected Field|null $field;
|
||||
|
||||
/**
|
||||
* All registered items methods
|
||||
*/
|
||||
public static array $methods = [];
|
||||
|
||||
protected array $options;
|
||||
|
||||
/**
|
||||
* @var \Kirby\Cms\ModelWithContent
|
||||
*/
|
||||
protected object|null $parent = null;
|
||||
|
||||
public function __construct($objects = [], array $options = [])
|
||||
{
|
||||
$this->options = $options;
|
||||
$this->parent = $options['parent'] ?? App::instance()->site();
|
||||
$this->field = $options['field'] ?? null;
|
||||
|
||||
parent::__construct($objects, $this->parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new item collection from a
|
||||
* an array of item props
|
||||
*/
|
||||
public static function factory(
|
||||
array|null $items = null,
|
||||
array $params = []
|
||||
): static {
|
||||
if (empty($items) === true || is_array($items) === false) {
|
||||
return new static();
|
||||
}
|
||||
|
||||
if (is_array($params) === false) {
|
||||
throw new InvalidArgumentException(message: 'Invalid item options');
|
||||
}
|
||||
|
||||
// create a new collection of blocks
|
||||
$collection = new static([], $params);
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (is_array($item) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Invalid data for ' . static::ITEM_CLASS
|
||||
);
|
||||
}
|
||||
|
||||
// inject properties from the parent
|
||||
$item['field'] = $collection->field();
|
||||
$item['options'] = $params['options'] ?? [];
|
||||
$item['parent'] = $collection->parent();
|
||||
$item['siblings'] = $collection;
|
||||
$item['params'] = $item;
|
||||
|
||||
$class = static::ITEM_CLASS;
|
||||
$item = $class::factory($item);
|
||||
$collection->append($item->id(), $item);
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent field if known
|
||||
*/
|
||||
public function field(): Field|null
|
||||
{
|
||||
return $this->field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the items to an array
|
||||
*/
|
||||
public function toArray(Closure|null $map = null): array
|
||||
{
|
||||
return array_values(parent::toArray($map));
|
||||
}
|
||||
}
|
||||
658
kirby/src/Cms/Language.php
Normal file
658
kirby/src/Cms/Language.php
Normal file
|
|
@ -0,0 +1,658 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Data\Data;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\Locale;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* The `$language` object represents
|
||||
* a single language in a multi-language
|
||||
* Kirby setup. You can, for example,
|
||||
* use the methods of this class to get
|
||||
* the name or locale of a language,
|
||||
* check for the default language,
|
||||
* get translation strings and many
|
||||
* more things.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Languages>
|
||||
*/
|
||||
class Language implements Stringable
|
||||
{
|
||||
use HasSiblings;
|
||||
|
||||
/**
|
||||
* Short human-readable version used in template queries
|
||||
*/
|
||||
public const CLASS_ALIAS = 'language';
|
||||
|
||||
/**
|
||||
* The parent Kirby instance
|
||||
*/
|
||||
public static App|null $kirby;
|
||||
|
||||
protected string $code;
|
||||
protected bool $default;
|
||||
protected string $direction;
|
||||
protected array $locale;
|
||||
protected string $name;
|
||||
protected bool $single;
|
||||
protected array $slugs;
|
||||
protected array $smartypants;
|
||||
protected array $translations;
|
||||
protected string|null $url;
|
||||
|
||||
/**
|
||||
* Creates a new language object
|
||||
*/
|
||||
public function __construct(array $props)
|
||||
{
|
||||
if (isset($props['code']) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'The property "code" is required'
|
||||
);
|
||||
}
|
||||
|
||||
static::$kirby = $props['kirby'] ?? null;
|
||||
$this->code = basename(trim($props['code'])); // prevent path traversal
|
||||
$this->default = ($props['default'] ?? false) === true;
|
||||
$this->direction = ($props['direction'] ?? null) === 'rtl' ? 'rtl' : 'ltr';
|
||||
$this->name = trim($props['name'] ?? $this->code);
|
||||
$this->single = $props['single'] ?? false;
|
||||
$this->slugs = $props['slugs'] ?? [];
|
||||
$this->smartypants = $props['smartypants'] ?? [];
|
||||
$this->translations = $props['translations'] ?? [];
|
||||
$this->url = $props['url'] ?? null;
|
||||
|
||||
if ($locale = $props['locale'] ?? null) {
|
||||
$this->locale = Locale::normalize($locale);
|
||||
} else {
|
||||
$this->locale = [LC_ALL => $this->code];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Improved `var_dump` output
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the language code
|
||||
* when the language is converted to a string
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->code();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base Url for the language
|
||||
* without the path or other cruft
|
||||
*/
|
||||
public function baseUrl(): string
|
||||
{
|
||||
$kirbyUrl = $this->kirby()->url();
|
||||
$languageUrl = $this->url();
|
||||
|
||||
if (empty($this->url)) {
|
||||
return $kirbyUrl;
|
||||
}
|
||||
|
||||
if (Str::startsWith($languageUrl, $kirbyUrl) === true) {
|
||||
return $kirbyUrl;
|
||||
}
|
||||
|
||||
return Url::base($languageUrl) ?? $kirbyUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance with the same
|
||||
* initial properties.
|
||||
*/
|
||||
public function clone(array $props = []): static
|
||||
{
|
||||
return new static(array_replace_recursive([
|
||||
'code' => $this->code,
|
||||
'default' => $this->default,
|
||||
'direction' => $this->direction,
|
||||
'locale' => $this->locale,
|
||||
'name' => $this->name,
|
||||
'slugs' => $this->slugs,
|
||||
'smartypants' => $this->smartypants,
|
||||
'translations' => $this->translations,
|
||||
'url' => $this->url,
|
||||
], $props));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the language code/id.
|
||||
* The language code is used in
|
||||
* text file names as appendix.
|
||||
*/
|
||||
public function code(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new language object
|
||||
*/
|
||||
public static function create(array $props): static
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$languages = $kirby->languages();
|
||||
$props['code'] = Str::slug($props['code'] ?? null);
|
||||
|
||||
// make the first language the default language
|
||||
if ($languages->count() === 0) {
|
||||
$props['default'] = true;
|
||||
}
|
||||
|
||||
$language = new static($props);
|
||||
|
||||
// validate the new language
|
||||
LanguageRules::create($language);
|
||||
|
||||
// apply before hook
|
||||
$language = $kirby->apply(
|
||||
'language.create:before',
|
||||
[
|
||||
'input' => $props,
|
||||
'language' => $language
|
||||
],
|
||||
'language'
|
||||
);
|
||||
|
||||
// re-validate the language after before hook was applied
|
||||
LanguageRules::create($language);
|
||||
|
||||
$language->save();
|
||||
|
||||
// convert content storage to multilang
|
||||
if ($languages->count() === 0) {
|
||||
foreach ($kirby->models() as $model) {
|
||||
$model->storage()->moveLanguage(
|
||||
Language::single(),
|
||||
$language
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// update the main languages collection in the app instance
|
||||
$kirby->languages(false)->append($language->code(), $language);
|
||||
|
||||
// apply after hook
|
||||
$language = $kirby->apply(
|
||||
'language.create:after',
|
||||
[
|
||||
'input' => $props,
|
||||
'language' => $language
|
||||
],
|
||||
'language'
|
||||
);
|
||||
|
||||
return $language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current language and
|
||||
* all its translation files
|
||||
*
|
||||
* @throws \Kirby\Exception\Exception
|
||||
*/
|
||||
public function delete(): bool
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$code = $this->code();
|
||||
|
||||
// validate the language rules
|
||||
LanguageRules::delete($this);
|
||||
|
||||
// apply before hook
|
||||
$language = $kirby->apply(
|
||||
'language.delete:before',
|
||||
['language' => $this]
|
||||
);
|
||||
|
||||
// re-validate the language rules after before hook was applied
|
||||
LanguageRules::delete($language);
|
||||
|
||||
if (F::remove($language->root()) !== true) {
|
||||
throw new Exception(message: 'The language could not be deleted');
|
||||
}
|
||||
|
||||
// if needed, convert content storage to single lang
|
||||
foreach ($kirby->models() as $model) {
|
||||
if ($language->isLast() === true) {
|
||||
$model->storage()->moveLanguage($this, Language::single());
|
||||
} else {
|
||||
$model->storage()->deleteLanguage($this);
|
||||
}
|
||||
}
|
||||
|
||||
// get the original language collection and remove the current language
|
||||
$kirby->languages(false)->remove($code);
|
||||
|
||||
// trigger after hook
|
||||
$kirby->trigger('language.delete:after', [
|
||||
'language' => $language
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading direction of this language
|
||||
*/
|
||||
public function direction(): string
|
||||
{
|
||||
return $this->direction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a "user-facing" language code to a `Language` object
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the language does not exist
|
||||
* @unstable
|
||||
*/
|
||||
public static function ensure(self|string|null $code = null): static
|
||||
{
|
||||
if ($code instanceof self) {
|
||||
return $code;
|
||||
}
|
||||
|
||||
$kirby = App::instance();
|
||||
|
||||
// single language
|
||||
if ($kirby->multilang() === false) {
|
||||
return static::single();
|
||||
}
|
||||
|
||||
// look up the actual language object if possible
|
||||
if ($language = $kirby->language($code)) {
|
||||
return $language;
|
||||
}
|
||||
|
||||
// validate the language code
|
||||
throw new NotFoundException(message: 'Invalid language: ' . $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the language file exists
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
return file_exists($this->root());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the language is the same
|
||||
* as the given language or language code
|
||||
* @since 5.0.0
|
||||
*/
|
||||
public function is(self|string $language): bool
|
||||
{
|
||||
return $this->code() === static::ensure($language)->code();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this is the default language
|
||||
* for the site.
|
||||
*/
|
||||
public function isDefault(): bool
|
||||
{
|
||||
return $this->default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the language can be deleted
|
||||
*/
|
||||
public function isDeletable(): bool
|
||||
{
|
||||
// a single-language object cannot be deleted
|
||||
if ($this->isSingle() === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// the default language can only be deleted if it's the last
|
||||
if ($this->isDefault() === true && $this->isLast() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this is the last language
|
||||
*/
|
||||
public function isLast(): bool
|
||||
{
|
||||
return App::instance()->languages()->count() === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this is the single language object
|
||||
*/
|
||||
public function isSingle(): bool
|
||||
{
|
||||
return $this->single;
|
||||
}
|
||||
|
||||
/**
|
||||
* The id is required for collections
|
||||
* to work properly. The code is used as id
|
||||
*/
|
||||
public function id(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent Kirby instance
|
||||
*/
|
||||
public function kirby(): App
|
||||
{
|
||||
return static::$kirby ??= App::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the language rules for provided locale code
|
||||
*/
|
||||
public static function loadRules(string $code): array
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$code = basename($code); // prevent path traversal
|
||||
$code = Str::contains($code, '.') ? Str::before($code, '.') : $code;
|
||||
$file = $kirby->root('i18n:rules') . '/' . $code . '.json';
|
||||
|
||||
if (F::exists($file) === false) {
|
||||
$file = $kirby->root('i18n:rules') . '/' . Str::before($code, '_') . '.json';
|
||||
}
|
||||
|
||||
return Data::read($file, fail: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the PHP locale setting array
|
||||
*
|
||||
* @param int $category If passed, returns the locale for the specified category (e.g. LC_ALL) as string
|
||||
*/
|
||||
public function locale(int|null $category = null): array|string|null
|
||||
{
|
||||
if ($category !== null) {
|
||||
return $this->locale[$category] ?? $this->locale[LC_ALL] ?? null;
|
||||
}
|
||||
|
||||
return $this->locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the human-readable name
|
||||
* of the language
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL path for the language
|
||||
*/
|
||||
public function path(): string
|
||||
{
|
||||
if ($this->url === null) {
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
return Url::path($this->url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the routing pattern for the language
|
||||
*/
|
||||
public function pattern(): string
|
||||
{
|
||||
$path = $this->path();
|
||||
|
||||
if (empty($path) === true) {
|
||||
return '(:all)';
|
||||
}
|
||||
|
||||
return $path . '/(:all?)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the permissions object for this language
|
||||
*/
|
||||
public function permissions(): LanguagePermissions
|
||||
{
|
||||
return new LanguagePermissions($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the language file
|
||||
*/
|
||||
public function root(): string
|
||||
{
|
||||
return App::instance()->root('languages') . '/' . $this->code() . '.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the LanguageRouter instance
|
||||
* which is used to handle language specific
|
||||
* routes.
|
||||
*/
|
||||
public function router(): LanguageRouter
|
||||
{
|
||||
return new LanguageRouter($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slug rules for language
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$code = $this->locale(LC_CTYPE);
|
||||
|
||||
return [
|
||||
...static::loadRules($code),
|
||||
...$this->slugs()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the language settings in the languages folder
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function save(): static
|
||||
{
|
||||
$existingData = Data::read($this->root(), fail: false);
|
||||
|
||||
$data = [
|
||||
...$existingData,
|
||||
'code' => $this->code(),
|
||||
'default' => $this->isDefault(),
|
||||
'direction' => $this->direction(),
|
||||
'locale' => Locale::export($this->locale()),
|
||||
'name' => $this->name(),
|
||||
'translations' => $this->translations(),
|
||||
'url' => $this->url,
|
||||
];
|
||||
|
||||
ksort($data);
|
||||
|
||||
Data::write($this->root(), $data);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private siblings collector
|
||||
*/
|
||||
protected function siblingsCollection(): Languages
|
||||
{
|
||||
return App::instance()->languages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a placeholder language object in a
|
||||
* single-language installation
|
||||
*/
|
||||
public static function single(): static
|
||||
{
|
||||
return new static([
|
||||
'code' => 'en',
|
||||
'default' => true,
|
||||
'locale' => App::instance()->option('locale', 'en_US.utf-8'),
|
||||
'single' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the custom slug rules for this language
|
||||
*/
|
||||
public function slugs(): array
|
||||
{
|
||||
return $this->slugs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the custom SmartyPants options for this language
|
||||
*/
|
||||
public function smartypants(): array
|
||||
{
|
||||
return $this->smartypants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most important properties as array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'code' => $this->code(),
|
||||
'default' => $this->isDefault(),
|
||||
'direction' => $this->direction(),
|
||||
'locale' => $this->locale(),
|
||||
'name' => $this->name(),
|
||||
'rules' => $this->rules(),
|
||||
'url' => $this->url()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translation strings for this language
|
||||
*/
|
||||
public function translations(): array
|
||||
{
|
||||
return $this->translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute Url for the language
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
$url = $this->url;
|
||||
$url ??= '/' . $this->code;
|
||||
return Url::makeAbsolute($url, $this->kirby()->url());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update language properties and save them
|
||||
*/
|
||||
public function update(array|null $props = null): static
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
// don't change the language code
|
||||
unset($props['code']);
|
||||
|
||||
// make sure the slug is nice and clean
|
||||
$props['slug'] = Str::slug($props['slug'] ?? null);
|
||||
|
||||
// trigger before hook
|
||||
$language = $kirby->apply(
|
||||
'language.update:before',
|
||||
[
|
||||
'language' => $this,
|
||||
'input' => $props
|
||||
]
|
||||
);
|
||||
|
||||
// updated language object
|
||||
$language = $language->clone($props);
|
||||
|
||||
if (isset($props['translations']) === true) {
|
||||
$language->translations = $props['translations'];
|
||||
}
|
||||
|
||||
// validate the language rules after before hook was applied
|
||||
LanguageRules::update($language, $this);
|
||||
|
||||
// if language just got promoted to be the new default language…
|
||||
if ($this->isDefault() === false && $language->isDefault() === true) {
|
||||
// convert the current default to a non-default language
|
||||
$previous = $kirby->defaultLanguage()?->clone(['default' => false])->save();
|
||||
$kirby->languages(false)->set($previous->code(), $previous);
|
||||
|
||||
foreach ($kirby->models() as $model) {
|
||||
$model->storage()->touchLanguage($this);
|
||||
}
|
||||
}
|
||||
|
||||
$language = $language->save();
|
||||
|
||||
// make sure the language is also updated in the languages collection
|
||||
$kirby->languages(false)->set($language->code(), $language);
|
||||
|
||||
// trigger after hook
|
||||
$language = $kirby->apply(
|
||||
'language.update:after',
|
||||
[
|
||||
'newLanguage' => $language,
|
||||
'oldLanguage' => $this,
|
||||
'input' => $props
|
||||
]
|
||||
);
|
||||
|
||||
return $language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a language variable object
|
||||
* for the key in the translations array
|
||||
*/
|
||||
public function variable(
|
||||
string $key,
|
||||
bool $decode = false
|
||||
): LanguageVariable {
|
||||
// allows decoding if base64-url encoded url is sent
|
||||
// for compatibility of different environments
|
||||
if ($decode === true) {
|
||||
$key = rawurldecode(base64_decode($key));
|
||||
}
|
||||
|
||||
return new LanguageVariable(
|
||||
language: $this,
|
||||
key: $key
|
||||
);
|
||||
}
|
||||
}
|
||||
22
kirby/src/Cms/LanguagePermissions.php
Normal file
22
kirby/src/Cms/LanguagePermissions.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* LanguagePermissions
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Ahmet Bora <ahmet@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class LanguagePermissions extends ModelPermissions
|
||||
{
|
||||
protected const CATEGORY = 'languages';
|
||||
|
||||
protected function canDelete(): bool
|
||||
{
|
||||
return $this->model->isDeletable() === true;
|
||||
}
|
||||
}
|
||||
149
kirby/src/Cms/LanguageRouter.php
Normal file
149
kirby/src/Cms/LanguageRouter.php
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Exception;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Http\Router;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Kirby\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* The language router is used internally
|
||||
* to handle language-specific (scoped) routes
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class LanguageRouter
|
||||
{
|
||||
protected Router $router;
|
||||
|
||||
/**
|
||||
* Creates a new language router instance
|
||||
* for the given language
|
||||
*/
|
||||
public function __construct(
|
||||
protected Language $language
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all scoped routes for the
|
||||
* current language from the Kirby instance
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException
|
||||
*/
|
||||
public function routes(): array
|
||||
{
|
||||
$language = $this->language;
|
||||
$kirby = $language->kirby();
|
||||
$routes = $kirby->routes();
|
||||
|
||||
// only keep the scoped language routes
|
||||
$routes = array_values(array_filter($routes, function ($route) use ($language) {
|
||||
// no language scope
|
||||
if (empty($route['language']) === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// wildcard
|
||||
if ($route['language'] === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// get all applicable languages
|
||||
$languages = Str::split(strtolower($route['language']), '|');
|
||||
|
||||
// validate the language
|
||||
return in_array($language->code(), $languages, true) === true;
|
||||
}));
|
||||
|
||||
// add the page-scope if necessary
|
||||
foreach ($routes as $index => $route) {
|
||||
if ($pageId = ($route['page'] ?? null)) {
|
||||
if ($page = $kirby->page($pageId)) {
|
||||
// convert string patterns to arrays
|
||||
$patterns = A::wrap($route['pattern']);
|
||||
|
||||
// prefix all patterns with the page slug
|
||||
$patterns = A::map(
|
||||
$patterns,
|
||||
fn ($pattern) => $page->uri($language) . '/' . $pattern
|
||||
);
|
||||
|
||||
// re-inject the pattern and the full page object
|
||||
$routes[$index]['pattern'] = $patterns;
|
||||
$routes[$index]['page'] = $page;
|
||||
} else {
|
||||
throw new NotFoundException(
|
||||
message: 'The page "' . $pageId . '" does not exist'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Language-specific UUID URLs
|
||||
$routes[] = [
|
||||
'pattern' => '@/(page|file)/(:all)',
|
||||
'method' => 'ALL',
|
||||
'env' => 'site',
|
||||
'action' => function (string $languageCode, string $type, string $id) use ($kirby, $language) {
|
||||
// try to resolve to model, but only from UUID cache;
|
||||
// this ensures that only existing UUIDs can be queried
|
||||
// and attackers can't force Kirby to go through the whole
|
||||
// site index with a non-existing UUID
|
||||
if ($model = Uuid::for($type . '://' . $id)?->model(true)) {
|
||||
return $kirby
|
||||
->response()
|
||||
->redirect($model->url($language->code()));
|
||||
}
|
||||
|
||||
// render the error page
|
||||
return false;
|
||||
}
|
||||
];
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around the Router::call method
|
||||
* that injects the Language instance and
|
||||
* if needed also the Page as arguments.
|
||||
*/
|
||||
public function call(string|null $path = null): mixed
|
||||
{
|
||||
$language = $this->language;
|
||||
$kirby = $language->kirby();
|
||||
$this->router ??= new Router($this->routes());
|
||||
|
||||
try {
|
||||
return $this->router->call($path, $kirby->request()->method(), function ($route) use ($kirby, $language) {
|
||||
$kirby->setCurrentTranslation($language);
|
||||
$kirby->setCurrentLanguage($language);
|
||||
|
||||
if ($page = $route->page()) {
|
||||
return $route->action()->call(
|
||||
$route,
|
||||
$language,
|
||||
$page,
|
||||
...$route->arguments()
|
||||
);
|
||||
}
|
||||
|
||||
return $route->action()->call(
|
||||
$route,
|
||||
$language,
|
||||
...$route->arguments()
|
||||
);
|
||||
});
|
||||
} catch (Exception) {
|
||||
return $kirby->resolve($path, $language->code());
|
||||
}
|
||||
}
|
||||
}
|
||||
155
kirby/src/Cms/LanguageRoutes.php
Normal file
155
kirby/src/Cms/LanguageRoutes.php
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Filesystem\F;
|
||||
|
||||
class LanguageRoutes
|
||||
{
|
||||
/**
|
||||
* Creates all multi-language routes
|
||||
*/
|
||||
public static function create(App $kirby): array
|
||||
{
|
||||
$routes = [];
|
||||
|
||||
// add the route for the home page
|
||||
$routes[] = static::home($kirby);
|
||||
|
||||
// Kirby's base url
|
||||
$baseurl = $kirby->url();
|
||||
|
||||
foreach ($kirby->languages() as $language) {
|
||||
// ignore languages with a different base url
|
||||
if ($language->baseurl() !== $baseurl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$routes[] = [
|
||||
'pattern' => $language->pattern(),
|
||||
'method' => 'ALL',
|
||||
'env' => 'site',
|
||||
'action' => function ($path = null) use ($language) {
|
||||
$result = $language->router()->call($path);
|
||||
|
||||
// explicitly test for null as $result can
|
||||
// contain falsy values that should still be returned
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// jump through to the fallback if nothing
|
||||
// can be found for this language
|
||||
/** @var \Kirby\Http\Route $this */
|
||||
$this->next();
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
$routes[] = static::fallback($kirby);
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create the fallback route
|
||||
* for unprefixed default language URLs.
|
||||
*/
|
||||
public static function fallback(App $kirby): array
|
||||
{
|
||||
return [
|
||||
'pattern' => '(:all)',
|
||||
'method' => 'ALL',
|
||||
'env' => 'site',
|
||||
'action' => function (string $path) use ($kirby) {
|
||||
// check for content representations or files
|
||||
$extension = F::extension($path);
|
||||
|
||||
// try to redirect prefixed pages
|
||||
if (
|
||||
empty($extension) === true &&
|
||||
$page = $kirby->page($path)
|
||||
) {
|
||||
$url = $kirby->request()->url([
|
||||
'query' => null,
|
||||
'params' => null,
|
||||
'fragment' => null
|
||||
]);
|
||||
|
||||
if ($url->toString() !== $page->url()) {
|
||||
// redirect to translated page directly if translation
|
||||
// is exists and languages detect is enabled
|
||||
$lang = $kirby->detectedLanguage()->code();
|
||||
|
||||
if (
|
||||
$kirby->option('languages.detect') === true &&
|
||||
$page->translation($lang)->exists() === true
|
||||
) {
|
||||
return $kirby
|
||||
->response()
|
||||
->redirect($page->url($lang));
|
||||
}
|
||||
|
||||
return $kirby
|
||||
->response()
|
||||
->redirect($page->url());
|
||||
}
|
||||
}
|
||||
|
||||
return $kirby->language()->router()->call($path);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the multi-language home page route
|
||||
*/
|
||||
public static function home(App $kirby): array
|
||||
{
|
||||
// Multi-language home
|
||||
return [
|
||||
'pattern' => '',
|
||||
'method' => 'ALL',
|
||||
'env' => 'site',
|
||||
'action' => function () use ($kirby) {
|
||||
// find all languages with the same base url as the current installation
|
||||
$languages = $kirby->languages()->filter(
|
||||
'baseurl',
|
||||
$kirby->url()
|
||||
);
|
||||
|
||||
// if there's no language with a matching base url,
|
||||
// redirect to the default language
|
||||
if ($languages->count() === 0) {
|
||||
return $kirby
|
||||
->response()
|
||||
->redirect($kirby->defaultLanguage()->url());
|
||||
}
|
||||
|
||||
// if there's just one language,
|
||||
// we take that to render the home page
|
||||
$currentLanguage = match ($languages->count()) {
|
||||
1 => $languages->first(),
|
||||
default => $kirby->defaultLanguage()
|
||||
};
|
||||
|
||||
// language detection on the home page with / as URL
|
||||
if ($kirby->url() !== $currentLanguage->url()) {
|
||||
if ($kirby->option('languages.detect') === true) {
|
||||
return $kirby
|
||||
->response()
|
||||
->redirect($kirby->detectedLanguage()->url());
|
||||
}
|
||||
|
||||
return $kirby
|
||||
->response()
|
||||
->redirect($currentLanguage->url());
|
||||
}
|
||||
|
||||
// render the home page of the current language
|
||||
return $currentLanguage->router()->call();
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
128
kirby/src/Cms/LanguageRules.php
Normal file
128
kirby/src/Cms/LanguageRules.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\DuplicateException;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Exception\PermissionException;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Validators for all language actions
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class LanguageRules
|
||||
{
|
||||
/**
|
||||
* Validates if the language can be created
|
||||
*
|
||||
* @throws \Kirby\Exception\DuplicateException If the language already exists
|
||||
* @throws \Kirby\Exception\PermissionException If current user has not sufficient permissions
|
||||
*/
|
||||
public static function create(Language $language): void
|
||||
{
|
||||
static::validLanguageCode($language);
|
||||
static::validLanguageName($language);
|
||||
|
||||
if ($language->exists() === true) {
|
||||
throw new DuplicateException(
|
||||
key: 'language.duplicate',
|
||||
data: ['code' => $language->code()]
|
||||
);
|
||||
}
|
||||
|
||||
if ($language->permissions()->can('create') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'language.create.permission'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the language can be deleted
|
||||
*
|
||||
* @throws \Kirby\Exception\LogicException If the language cannot be deleted
|
||||
* @throws \Kirby\Exception\PermissionException If current user has not sufficient permissions
|
||||
*/
|
||||
public static function delete(Language $language): void
|
||||
{
|
||||
if ($language->permissions()->can('delete') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'language.delete.permission'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the language can be updated
|
||||
*/
|
||||
public static function update(
|
||||
Language $newLanguage,
|
||||
Language|null $oldLanguage = null
|
||||
): void {
|
||||
static::validLanguageCode($newLanguage);
|
||||
static::validLanguageName($newLanguage);
|
||||
|
||||
$kirby = App::instance();
|
||||
|
||||
// if language was the default language and got demoted…
|
||||
if (
|
||||
$oldLanguage?->isDefault() === true &&
|
||||
$newLanguage->isDefault() === false &&
|
||||
$kirby->defaultLanguage()->code() === $oldLanguage?->code()
|
||||
) {
|
||||
// ensure another language has already been set as default
|
||||
throw new LogicException(
|
||||
message: 'Please select another language to be the primary language'
|
||||
);
|
||||
}
|
||||
|
||||
if ($newLanguage->permissions()->can('update') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'language.update.permission'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the language code is formatted correctly
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the language code is not valid
|
||||
*/
|
||||
public static function validLanguageCode(Language $language): void
|
||||
{
|
||||
if (Str::length($language->code()) < 2) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'language.code',
|
||||
data: [
|
||||
'code' => $language->code(),
|
||||
'name' => $language->name()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the language name is formatted correctly
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the language name is invalid
|
||||
*/
|
||||
public static function validLanguageName(Language $language): void
|
||||
{
|
||||
if (Str::length($language->name()) < 1) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'language.name',
|
||||
data: [
|
||||
'code' => $language->code(),
|
||||
'name' => $language->name()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
148
kirby/src/Cms/LanguageVariable.php
Normal file
148
kirby/src/Cms/LanguageVariable.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\DuplicateException;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* A language variable is a custom translation string
|
||||
* Those are stored in /site/languages/$code.php in the
|
||||
* translations array
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class LanguageVariable
|
||||
{
|
||||
protected App $kirby;
|
||||
|
||||
public function __construct(
|
||||
protected Language $language,
|
||||
protected string $key
|
||||
) {
|
||||
$this->kirby = App::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new language variable. This will
|
||||
* be added to the default language first and
|
||||
* can then be translated in other languages.
|
||||
*/
|
||||
public static function create(
|
||||
string $key,
|
||||
string|array|null $value = null
|
||||
): static {
|
||||
if (is_numeric($key) === true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'The variable key must not be numeric'
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($key) === true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'The variable needs a valid key'
|
||||
);
|
||||
}
|
||||
|
||||
$kirby = App::instance();
|
||||
$language = $kirby->defaultLanguage();
|
||||
$translations = $language->translations();
|
||||
|
||||
if ($kirby->translation()->get($key) !== null) {
|
||||
if (isset($translations[$key]) === true) {
|
||||
throw new DuplicateException(
|
||||
message: 'The variable already exists'
|
||||
);
|
||||
}
|
||||
|
||||
throw new DuplicateException(
|
||||
message: 'The variable is part of the core translation and cannot be overwritten'
|
||||
);
|
||||
}
|
||||
|
||||
$translations[$key] = $value ?? '';
|
||||
|
||||
$language = $language->update(['translations' => $translations]);
|
||||
|
||||
return $language->variable($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a language variable from the translations array.
|
||||
* This will go through all language files and delete the
|
||||
* key from all translation arrays to keep them clean.
|
||||
*/
|
||||
public function delete(): bool
|
||||
{
|
||||
// go through all languages and remove the variable
|
||||
foreach ($this->kirby->languages() as $language) {
|
||||
$variables = $language->translations();
|
||||
|
||||
unset($variables[$this->key]);
|
||||
|
||||
$language->update(['translations' => $variables]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a language variable exists in the default language
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
$language = $this->kirby->defaultLanguage();
|
||||
return isset($language->translations()[$this->key]) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value is an array
|
||||
* @since 5.0.0
|
||||
*/
|
||||
public function hasMultipleValues(): bool
|
||||
{
|
||||
return is_array($this->value()) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unique key for the variable
|
||||
*/
|
||||
public function key(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent language
|
||||
* @since 5.1.0
|
||||
*/
|
||||
public function language(): Language
|
||||
{
|
||||
return $this->language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new value for the language variable
|
||||
*/
|
||||
public function update(string|array|null $value = null): static
|
||||
{
|
||||
$translations = $this->language->translations();
|
||||
$translations[$this->key] = $value ?? '';
|
||||
|
||||
$language = $this->language->update(['translations' => $translations]);
|
||||
|
||||
return $language->variable($this->key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value if the variable has been translated.
|
||||
*/
|
||||
public function value(): string|array|null
|
||||
{
|
||||
return $this->language->translations()[$this->key] ?? null;
|
||||
}
|
||||
}
|
||||
115
kirby/src/Cms/Languages.php
Normal file
115
kirby/src/Cms/Languages.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\DuplicateException;
|
||||
use Kirby\Filesystem\F;
|
||||
|
||||
/**
|
||||
* A collection of all defined site languages
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Collection<\Kirby\Cms\Language>
|
||||
*/
|
||||
class Languages extends Collection
|
||||
{
|
||||
/**
|
||||
* All registered languages methods
|
||||
*/
|
||||
public static array $methods = [];
|
||||
|
||||
/**
|
||||
* Creates a new collection with the given language objects
|
||||
*
|
||||
* @param null $parent
|
||||
* @throws \Kirby\Exception\DuplicateException
|
||||
*/
|
||||
public function __construct(
|
||||
array $objects = [],
|
||||
$parent = null
|
||||
) {
|
||||
$defaults = array_filter(
|
||||
$objects,
|
||||
fn ($language) => $language->isDefault() === true
|
||||
);
|
||||
|
||||
if (count($defaults) > 1) {
|
||||
throw new DuplicateException(
|
||||
message: 'You cannot have multiple default languages. Please check your language config files.'
|
||||
);
|
||||
}
|
||||
|
||||
parent::__construct($objects, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all language codes as array
|
||||
*/
|
||||
public function codes(): array
|
||||
{
|
||||
return App::instance()->multilang() ? $this->keys() : ['default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new language with the given props
|
||||
*/
|
||||
public function create(array $props): Language
|
||||
{
|
||||
return Language::create($props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default language
|
||||
*/
|
||||
public function default(): Language|null
|
||||
{
|
||||
return $this->findBy('isDefault', true) ?? $this->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a collection of installed languages, even
|
||||
* if in single-language mode. In single-language mode
|
||||
* `Language::single()` is used to create the default language
|
||||
*
|
||||
* @unstable
|
||||
*/
|
||||
public static function ensure(): static
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
if ($kirby->multilang() === true) {
|
||||
return $kirby->languages();
|
||||
}
|
||||
|
||||
return new static([Language::single()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all languages from the languages directory
|
||||
* and convert them to a collection
|
||||
*/
|
||||
public static function load(): static
|
||||
{
|
||||
$languages = [];
|
||||
$files = glob(App::instance()->root('languages') . '/*.php');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$props = F::load($file, allowOutput: false);
|
||||
|
||||
if (is_array($props) === true) {
|
||||
// inject the language code from the filename
|
||||
// if it does not exist
|
||||
$props['code'] ??= F::name($file);
|
||||
|
||||
$languages[] = new Language($props);
|
||||
}
|
||||
}
|
||||
|
||||
return new static($languages);
|
||||
}
|
||||
}
|
||||
107
kirby/src/Cms/Layout.php
Normal file
107
kirby/src/Cms/Layout.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Content\Content;
|
||||
|
||||
/**
|
||||
* Represents a single Layout with
|
||||
* multiple columns
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Item<\Kirby\Cms\Layouts>
|
||||
*/
|
||||
class Layout extends Item
|
||||
{
|
||||
use HasMethods;
|
||||
|
||||
public const ITEMS_CLASS = Layouts::class;
|
||||
|
||||
protected Content $attrs;
|
||||
protected LayoutColumns $columns;
|
||||
|
||||
/**
|
||||
* Proxy for attrs
|
||||
*/
|
||||
public function __call(string $method, array $args = []): mixed
|
||||
{
|
||||
// layout methods
|
||||
if ($this->hasMethod($method) === true) {
|
||||
return $this->callMethod($method, $args);
|
||||
}
|
||||
|
||||
return $this->attrs()->get($method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Layout object
|
||||
*/
|
||||
public function __construct(array $params = [])
|
||||
{
|
||||
parent::__construct($params);
|
||||
|
||||
$this->columns = LayoutColumns::factory($params['columns'] ?? [], [
|
||||
'field' => $this->field,
|
||||
'parent' => $this->parent
|
||||
]);
|
||||
|
||||
// create the attrs object
|
||||
$this->attrs = new Content($params['attrs'] ?? [], $this->parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the attrs object
|
||||
*/
|
||||
public function attrs(): Content
|
||||
{
|
||||
return $this->attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the columns in this layout
|
||||
*/
|
||||
public function columns(): LayoutColumns
|
||||
{
|
||||
return $this->columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the layout is empty
|
||||
* @since 3.5.2
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this
|
||||
->columns()
|
||||
->filter('isEmpty', false)
|
||||
->count() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the layout is not empty
|
||||
* @since 3.5.2
|
||||
*/
|
||||
public function isNotEmpty(): bool
|
||||
{
|
||||
return $this->isEmpty() === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result is being sent to the editor
|
||||
* via the API in the panel
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'attrs' => $this->attrs()->toArray(),
|
||||
'columns' => $this->columns()->toArray(),
|
||||
'id' => $this->id(),
|
||||
];
|
||||
}
|
||||
}
|
||||
122
kirby/src/Cms/LayoutColumn.php
Normal file
122
kirby/src/Cms/LayoutColumn.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Represents a single layout column with
|
||||
* multiple blocks
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Item<\Kirby\Cms\LayoutColumns>
|
||||
*/
|
||||
class LayoutColumn extends Item
|
||||
{
|
||||
use HasMethods;
|
||||
|
||||
public const ITEMS_CLASS = LayoutColumns::class;
|
||||
|
||||
protected Blocks $blocks;
|
||||
protected string $width;
|
||||
|
||||
/**
|
||||
* Creates a new LayoutColumn object
|
||||
*/
|
||||
public function __construct(array $params = [])
|
||||
{
|
||||
parent::__construct($params);
|
||||
|
||||
$this->blocks = Blocks::factory($params['blocks'] ?? [], [
|
||||
'field' => $this->field,
|
||||
'parent' => $this->parent
|
||||
]);
|
||||
|
||||
$this->width = $params['width'] ?? '1/1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic getter function
|
||||
*/
|
||||
public function __call(string $method, mixed $args): mixed
|
||||
{
|
||||
// layout column methods
|
||||
if ($this->hasMethod($method) === true) {
|
||||
return $this->callMethod($method, $args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the blocks collection
|
||||
*
|
||||
* @param bool $includeHidden Sets whether to include hidden blocks
|
||||
*/
|
||||
public function blocks(bool $includeHidden = false): Blocks
|
||||
{
|
||||
if ($includeHidden === false) {
|
||||
return $this->blocks->filter('isHidden', false);
|
||||
}
|
||||
|
||||
return $this->blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the column is empty
|
||||
* @since 3.5.2
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this
|
||||
->blocks()
|
||||
->filter('isHidden', false)
|
||||
->count() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the column is not empty
|
||||
* @since 3.5.2
|
||||
*/
|
||||
public function isNotEmpty(): bool
|
||||
{
|
||||
return $this->isEmpty() === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of columns this column spans
|
||||
*/
|
||||
public function span(int $columns = 12): int
|
||||
{
|
||||
$fraction = Str::split($this->width, '/');
|
||||
$a = $fraction[0] ?? 1;
|
||||
$b = $fraction[1] ?? 1;
|
||||
|
||||
return $columns * $a / $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result is being sent to the editor
|
||||
* via the API in the panel
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'blocks' => $this->blocks(true)->toArray(),
|
||||
'id' => $this->id(),
|
||||
'width' => $this->width(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the width of the column
|
||||
*/
|
||||
public function width(): string
|
||||
{
|
||||
return $this->width;
|
||||
}
|
||||
}
|
||||
25
kirby/src/Cms/LayoutColumns.php
Normal file
25
kirby/src/Cms/LayoutColumns.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* A collection of layout columns
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Items<\Kirby\Cms\LayoutColumn>
|
||||
*/
|
||||
class LayoutColumns extends Items
|
||||
{
|
||||
public const ITEM_CLASS = LayoutColumn::class;
|
||||
|
||||
/**
|
||||
* All registered layout columns methods
|
||||
*/
|
||||
public static array $methods = [];
|
||||
}
|
||||
122
kirby/src/Cms/Layouts.php
Normal file
122
kirby/src/Cms/Layouts.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Data\Json;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* A collection of layouts
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Items<\Kirby\Cms\Layout>
|
||||
*/
|
||||
class Layouts extends Items
|
||||
{
|
||||
public const ITEM_CLASS = Layout::class;
|
||||
|
||||
/**
|
||||
* All registered layouts methods
|
||||
*/
|
||||
public static array $methods = [];
|
||||
|
||||
public static function factory(
|
||||
array|null $items = null,
|
||||
array $params = []
|
||||
): static {
|
||||
// convert single layout to layouts array
|
||||
if (
|
||||
isset($items['columns']) === true ||
|
||||
isset($items['id']) === true
|
||||
) {
|
||||
$items = [$items];
|
||||
}
|
||||
|
||||
$first = $items[0] ?? [];
|
||||
|
||||
// if there are no wrapping layouts for blocks yet …
|
||||
if (
|
||||
isset($first['content']) === true ||
|
||||
isset($first['type']) === true
|
||||
) {
|
||||
$items = [
|
||||
[
|
||||
'id' => Str::uuid(),
|
||||
'columns' => [
|
||||
[
|
||||
'width' => '1/1',
|
||||
'blocks' => $items
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return parent::factory($items, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given block type exists in the layouts collection
|
||||
* @since 3.6.0
|
||||
*/
|
||||
public function hasBlockType(string $type): bool
|
||||
{
|
||||
return $this->toBlocks()->hasType($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse layouts data
|
||||
*/
|
||||
public static function parse(array|string|null $input): array
|
||||
{
|
||||
if (
|
||||
empty($input) === false &&
|
||||
is_array($input) === false
|
||||
) {
|
||||
try {
|
||||
$input = Json::decode((string)$input);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($input) === true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts layouts to blocks
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @param bool $includeHidden Sets whether to include hidden blocks
|
||||
*/
|
||||
public function toBlocks(bool $includeHidden = false): Blocks
|
||||
{
|
||||
$blocks = [];
|
||||
|
||||
if ($this->isNotEmpty() === true) {
|
||||
foreach ($this->data() as $layout) {
|
||||
foreach ($layout->columns() as $column) {
|
||||
foreach ($column->blocks($includeHidden) as $block) {
|
||||
$blocks[] = $block->toArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Blocks::factory($blocks, [
|
||||
'field' => $this->field,
|
||||
'parent' => $this->parent
|
||||
]);
|
||||
}
|
||||
}
|
||||
570
kirby/src/Cms/License.php
Normal file
570
kirby/src/Cms/License.php
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use IntlDateFormatter;
|
||||
use Kirby\Data\Json;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Http\Remote;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Kirby\Toolkit\V;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class License
|
||||
{
|
||||
public const HISTORY = [
|
||||
'3' => '2019-02-05',
|
||||
'4' => '2023-11-28',
|
||||
'5' => '2025-06-24'
|
||||
];
|
||||
|
||||
protected const SALT = 'kwAHMLyLPBnHEskzH9pPbJsBxQhKXZnX';
|
||||
|
||||
protected App $kirby;
|
||||
|
||||
// cache
|
||||
protected LicenseStatus $status;
|
||||
protected LicenseType $type;
|
||||
|
||||
public function __construct(
|
||||
protected string|null $activation = null,
|
||||
protected string|null $code = null,
|
||||
protected string|null $domain = null,
|
||||
protected string|null $email = null,
|
||||
protected string|null $order = null,
|
||||
protected string|null $date = null,
|
||||
protected string|null $signature = null,
|
||||
) {
|
||||
if ($code !== null) {
|
||||
$this->code = trim($code);
|
||||
}
|
||||
|
||||
if ($email !== null) {
|
||||
$this->email = $this->normalizeEmail($email);
|
||||
}
|
||||
|
||||
$this->kirby = App::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the activation date if available
|
||||
*/
|
||||
public function activation(
|
||||
string|IntlDateFormatter|null $format = null,
|
||||
string|null $handler = null
|
||||
): int|string|null {
|
||||
return $this->activation !== null ? Str::date(strtotime($this->activation), $format, $handler) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the license code if available
|
||||
*/
|
||||
public function code(bool $obfuscated = false): string|null
|
||||
{
|
||||
if ($this->code !== null && $obfuscated === true) {
|
||||
return Str::substr($this->code, 0, 10) . str_repeat('X', 22);
|
||||
}
|
||||
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content for the license file
|
||||
*/
|
||||
public function content(): array
|
||||
{
|
||||
return [
|
||||
'activation' => $this->activation,
|
||||
'code' => $this->code,
|
||||
'date' => $this->date,
|
||||
'domain' => $this->domain,
|
||||
'email' => $this->email,
|
||||
'order' => $this->order,
|
||||
'signature' => $this->signature,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the purchase date if available
|
||||
*/
|
||||
public function date(
|
||||
string|IntlDateFormatter|null $format = null,
|
||||
string|null $handler = null
|
||||
): int|string|null {
|
||||
return $this->date !== null ? Str::date(strtotime($this->date), $format, $handler) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the license file if it exists
|
||||
* @since 5.1.0
|
||||
*/
|
||||
public function delete(): bool
|
||||
{
|
||||
return F::remove($this->root());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the activation domain if available
|
||||
*/
|
||||
public function domain(): string|null
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the activation email if available
|
||||
*/
|
||||
public function email(): string|null
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the email address of the license
|
||||
*/
|
||||
public function hasValidEmailAddress(): bool
|
||||
{
|
||||
return V::email($this->email) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hub address
|
||||
*/
|
||||
public static function hub(): string
|
||||
{
|
||||
return App::instance()->option('hub', 'https://hub.getkirby.com');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for all required components of a valid license
|
||||
*/
|
||||
public function isComplete(): bool
|
||||
{
|
||||
if (
|
||||
$this->code !== null &&
|
||||
$this->date !== null &&
|
||||
$this->domain !== null &&
|
||||
$this->email !== null &&
|
||||
$this->order !== null &&
|
||||
$this->signature !== null &&
|
||||
$this->hasValidEmailAddress() === true &&
|
||||
$this->type() !== LicenseType::Invalid
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The license is still valid for the currently
|
||||
* installed version, but it passed the 3 year period.
|
||||
*/
|
||||
public function isInactive(): bool
|
||||
{
|
||||
return $this->renewal() < time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for licenses beyond their 3 year period
|
||||
*/
|
||||
public function isLegacy(): bool
|
||||
{
|
||||
if ($this->type() === LicenseType::Legacy) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// without an activation date, the license
|
||||
// renewal cannot be evaluated and the license
|
||||
// has to be marked as expired
|
||||
if ($this->activation === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// get release date of current major version
|
||||
$major = Str::before($this->kirby->version(), '.');
|
||||
$release = strtotime(static::HISTORY[$major] ?? '');
|
||||
|
||||
// if there's no matching version in the history
|
||||
// rather throw an exception to avoid further issues
|
||||
// @codeCoverageIgnoreStart
|
||||
if ($release === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'The version for your license could not be found'
|
||||
);
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
|
||||
// If the renewal date is older than the version launch
|
||||
// date, the license is expired
|
||||
return $this->renewal() < $release;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs multiple checks to find out if the license is
|
||||
* installed and verifiable
|
||||
*/
|
||||
public function isMissing(): bool
|
||||
{
|
||||
return
|
||||
$this->isComplete() === false ||
|
||||
$this->isOnCorrectDomain() === false ||
|
||||
$this->isSigned() === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the license is on the correct domain
|
||||
*/
|
||||
public function isOnCorrectDomain(): bool
|
||||
{
|
||||
if ($this->domain === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// compare domains
|
||||
if ($this->normalizeDomain($this->kirby->system()->indexUrl()) !== $this->normalizeDomain($this->domain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the signature with all ingredients
|
||||
*/
|
||||
public function isSigned(): bool
|
||||
{
|
||||
if ($this->signature === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// get the public key
|
||||
$pubKey = F::read($this->kirby->root('kirby') . '/kirby.pub');
|
||||
|
||||
// verify the license signature
|
||||
$data = json_encode($this->signatureData());
|
||||
$signature = hex2bin($this->signature);
|
||||
|
||||
return openssl_verify($data, $signature, $pubKey, 'RSA-SHA256') === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a reliable label for the license type
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
if ($this->status() === LicenseStatus::Missing) {
|
||||
return LicenseType::Invalid->label();
|
||||
}
|
||||
|
||||
return $this->type()->label();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the email address to be make sure it
|
||||
* does not have trailing spaces and is lowercase.
|
||||
*/
|
||||
protected function normalizeEmail(string $email): string
|
||||
{
|
||||
return Str::lower(trim($email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the domain to be comparable
|
||||
*/
|
||||
protected function normalizeDomain(string $domain): string
|
||||
{
|
||||
// remove common "testing" subdomains as well as www.
|
||||
// to ensure that installations of the same site have
|
||||
// the same license URL; only for installations at /,
|
||||
// subdirectory installations are difficult to normalize
|
||||
if (Str::contains($domain, '/') === false) {
|
||||
if (Str::startsWith($domain, 'www.')) {
|
||||
return substr($domain, 4);
|
||||
}
|
||||
|
||||
if (Str::startsWith($domain, 'dev.')) {
|
||||
return substr($domain, 4);
|
||||
}
|
||||
|
||||
if (Str::startsWith($domain, 'test.')) {
|
||||
return substr($domain, 5);
|
||||
}
|
||||
|
||||
if (Str::startsWith($domain, 'staging.')) {
|
||||
return substr($domain, 8);
|
||||
}
|
||||
}
|
||||
|
||||
return $domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the order id if available
|
||||
*/
|
||||
public function order(): string|null
|
||||
{
|
||||
return $this->order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Support the old license file dataset
|
||||
* from older licenses
|
||||
*/
|
||||
public static function polyfill(array $license): array
|
||||
{
|
||||
return [
|
||||
'activation' => $license['activation'] ?? null,
|
||||
'code' => $license['code'] ?? $license['license'] ?? null,
|
||||
'date' => $license['date'] ?? null,
|
||||
'domain' => $license['domain'] ?? null,
|
||||
'email' => $license['email'] ?? null,
|
||||
'order' => $license['order'] ?? null,
|
||||
'signature' => $license['signature'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the license file in the config folder
|
||||
* and creates a new license instance for it.
|
||||
*/
|
||||
public static function read(): static
|
||||
{
|
||||
try {
|
||||
$license = Json::read(static::root());
|
||||
} catch (Throwable) {
|
||||
return new static();
|
||||
}
|
||||
|
||||
return new static(...static::polyfill($license));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to the hub to register the license
|
||||
*/
|
||||
public function register(): static
|
||||
{
|
||||
if ($this->type() === LicenseType::Invalid) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'license.format'
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasValidEmailAddress() === false) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'license.email'
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->domain === null) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'license.domain'
|
||||
);
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
$response = $this->request('register', [
|
||||
'license' => $this->code,
|
||||
'email' => $this->email,
|
||||
'domain' => $this->domain
|
||||
]);
|
||||
|
||||
return $this->update($response);
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the renewal date
|
||||
*/
|
||||
public function renewal(
|
||||
string|IntlDateFormatter|null $format = null,
|
||||
string|null $handler = null
|
||||
): int|string|null {
|
||||
if ($this->activation === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$time = strtotime('+3 years', $this->activation());
|
||||
return Str::date($time, $format, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a hub request
|
||||
*/
|
||||
public function request(string $path, array $data): array
|
||||
{
|
||||
// @codeCoverageIgnoreStart
|
||||
$response = Remote::get(static::hub() . '/' . $path, [
|
||||
'data' => $data
|
||||
]);
|
||||
|
||||
// handle request errors
|
||||
if ($response->code() !== 200) {
|
||||
$message = $response->json()['message'] ?? 'The request failed';
|
||||
|
||||
throw new LogicException(
|
||||
key: $response->code(),
|
||||
message: $message,
|
||||
);
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the root path to the license file
|
||||
* @since 5.1.0
|
||||
*/
|
||||
public static function root(): string
|
||||
{
|
||||
return App::instance()->root('license');
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the license in the config folder
|
||||
*/
|
||||
public function save(): bool
|
||||
{
|
||||
if ($this->status()->activatable() !== true) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'license.verification'
|
||||
);
|
||||
}
|
||||
|
||||
// save the license information
|
||||
return Json::write(
|
||||
file: $this->root(),
|
||||
data: $this->content()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the signature if available
|
||||
*/
|
||||
public function signature(): string|null
|
||||
{
|
||||
return $this->signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the signature data array to compare
|
||||
* with the signature in ::isSigned
|
||||
*/
|
||||
public function signatureData(): array
|
||||
{
|
||||
if ($this->type() === LicenseType::Legacy) {
|
||||
return [
|
||||
'license' => $this->code,
|
||||
'order' => $this->order,
|
||||
'email' => hash('sha256', $this->email . static::SALT),
|
||||
'domain' => $this->domain,
|
||||
'date' => $this->date,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'activation' => $this->activation,
|
||||
'code' => $this->code,
|
||||
'date' => $this->date,
|
||||
'domain' => $this->domain,
|
||||
'email' => hash('sha256', $this->email . static::SALT),
|
||||
'order' => $this->order,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the license status as string
|
||||
* This is used to build the proper UI elements
|
||||
* for the license activation
|
||||
*/
|
||||
public function status(): LicenseStatus
|
||||
{
|
||||
return $this->status ??= match (true) {
|
||||
$this->isMissing() => LicenseStatus::Missing,
|
||||
$this->isLegacy() => LicenseStatus::Legacy,
|
||||
$this->isInactive() => LicenseStatus::Inactive,
|
||||
default => LicenseStatus::Active
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the license type if the license key is available
|
||||
*/
|
||||
public function type(): LicenseType
|
||||
{
|
||||
return $this->type ??= LicenseType::detect($this->code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the license file
|
||||
*/
|
||||
public function update(array $data): static
|
||||
{
|
||||
// decode the response
|
||||
$data = static::polyfill($data);
|
||||
|
||||
$this->activation = $data['activation'];
|
||||
$this->code = $data['code'];
|
||||
$this->date = $data['date'];
|
||||
$this->order = $data['order'];
|
||||
$this->signature = $data['signature'];
|
||||
|
||||
// clear the caches
|
||||
unset($this->status, $this->type);
|
||||
|
||||
// save the new state of the license
|
||||
$this->save();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an upgrade request to the hub in order
|
||||
* to either redirect to the upgrade form or
|
||||
* sync the new license state
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function upgrade(): array
|
||||
{
|
||||
$response = $this->request('upgrade', [
|
||||
'domain' => $this->domain,
|
||||
'email' => $this->email,
|
||||
'license' => $this->code,
|
||||
]);
|
||||
|
||||
// the license still needs an upgrade
|
||||
if (empty($response['url']) === false) {
|
||||
// validate the redirect URL
|
||||
if (Str::startsWith($response['url'], static::hub()) === false) {
|
||||
throw new Exception(
|
||||
message: 'We couldn’t redirect you to the Hub'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'upgrade',
|
||||
'url' => $response['url']
|
||||
];
|
||||
}
|
||||
|
||||
// the license has already been upgraded
|
||||
// and can now be replaced
|
||||
$this->update($response);
|
||||
|
||||
return [
|
||||
'status' => 'complete',
|
||||
];
|
||||
}
|
||||
}
|
||||
151
kirby/src/Cms/LicenseStatus.php
Normal file
151
kirby/src/Cms/LicenseStatus.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* @package Kirby Cms
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
enum LicenseStatus: string
|
||||
{
|
||||
/**
|
||||
* The license is valid and active
|
||||
*/
|
||||
case Active = 'active';
|
||||
|
||||
/**
|
||||
* Only used for the demo instance
|
||||
*/
|
||||
case Demo = 'demo';
|
||||
|
||||
/**
|
||||
* The included updates period of
|
||||
* the license is over.
|
||||
*/
|
||||
case Inactive = 'inactive';
|
||||
|
||||
/**
|
||||
* The installation has an old
|
||||
* license (v1, v2, v3)
|
||||
*/
|
||||
case Legacy = 'legacy';
|
||||
|
||||
/**
|
||||
* The installation has no license or
|
||||
* the license cannot be validated
|
||||
*/
|
||||
case Missing = 'missing';
|
||||
|
||||
/**
|
||||
* The license status is unknown
|
||||
*/
|
||||
case Unknown = 'unknown';
|
||||
|
||||
/**
|
||||
* Checks if the license can be saved when it
|
||||
* was entered in the activation dialog;
|
||||
* renewable licenses are accepted as well
|
||||
* to allow renewal from the Panel
|
||||
*/
|
||||
public function activatable(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
static::Active,
|
||||
static::Inactive,
|
||||
static::Legacy => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dialog according to the status
|
||||
*/
|
||||
public function dialog(): string|null
|
||||
{
|
||||
return match ($this) {
|
||||
static::Demo => null,
|
||||
static::Missing => 'registration',
|
||||
default => 'license'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the icon according to the status.
|
||||
* The icon is used for the system view and
|
||||
* in the license dialog.
|
||||
*/
|
||||
public function icon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
static::Active => 'check',
|
||||
static::Demo => 'preview',
|
||||
static::Inactive => 'clock',
|
||||
static::Legacy => 'alert',
|
||||
static::Missing => 'key',
|
||||
static::Unknown => 'question',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The info text is shown in the license dialog
|
||||
* in the status row.
|
||||
*/
|
||||
public function info(string|null $end = null): string
|
||||
{
|
||||
return I18n::template('license.status.' . $this->value . '.info', ['date' => $end]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for the system view
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return I18n::translate('license.status.' . $this->value . '.label');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the license can be renewed
|
||||
* The license dialog will show the renew
|
||||
* button in this case and redirect to the hub
|
||||
*/
|
||||
public function renewable(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
static::Active,
|
||||
static::Demo => false,
|
||||
default => true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the theme according to the status
|
||||
* The theme is used for the label in the system
|
||||
* view and the status icon in the license dialog.
|
||||
*/
|
||||
public function theme(): string
|
||||
{
|
||||
return match ($this) {
|
||||
static::Active => 'positive',
|
||||
static::Demo => 'notice',
|
||||
static::Inactive => 'notice',
|
||||
static::Legacy => 'negative',
|
||||
static::Missing => 'love',
|
||||
static::Unknown => 'passive',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the status as string value
|
||||
*/
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
111
kirby/src/Cms/LicenseType.php
Normal file
111
kirby/src/Cms/LicenseType.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
enum LicenseType: string
|
||||
{
|
||||
/**
|
||||
* New basic licenses
|
||||
*/
|
||||
case Basic = 'basic';
|
||||
|
||||
/**
|
||||
* New enterprise licenses
|
||||
*/
|
||||
case Enterprise = 'enterprise';
|
||||
|
||||
/**
|
||||
* Invalid license codes
|
||||
*/
|
||||
case Invalid = 'invalid';
|
||||
|
||||
/**
|
||||
* Old Kirby 3 licenses
|
||||
*/
|
||||
case Legacy = 'legacy';
|
||||
|
||||
/**
|
||||
* Detects the correct LicenseType based on the code
|
||||
*/
|
||||
public static function detect(string|null $code): static
|
||||
{
|
||||
return match (true) {
|
||||
static::Basic->isValidCode($code) => static::Basic,
|
||||
static::Enterprise->isValidCode($code) => static::Enterprise,
|
||||
static::Legacy->isValidCode($code) => static::Legacy,
|
||||
default => static::Invalid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for a valid license code
|
||||
* by prefix and length. This is just a
|
||||
* rough validation.
|
||||
*/
|
||||
public function isValidCode(string|null $code): bool
|
||||
{
|
||||
return
|
||||
$code !== null &&
|
||||
Str::length($code) === $this->length() &&
|
||||
Str::startsWith($code, $this->prefix()) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The expected lengths of the license code
|
||||
*/
|
||||
public function length(): int
|
||||
{
|
||||
return match ($this) {
|
||||
static::Basic => 38,
|
||||
static::Enterprise => 38,
|
||||
static::Legacy => 39,
|
||||
static::Invalid => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A human-readable license type label
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
static::Basic => 'Kirby Basic',
|
||||
static::Enterprise => 'Kirby Enterprise',
|
||||
static::Legacy => 'Kirby 3',
|
||||
static::Invalid => I18n::translate('license.unregistered.label'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The expected prefix for the license code
|
||||
*/
|
||||
public function prefix(): string|null
|
||||
{
|
||||
return match ($this) {
|
||||
static::Basic => 'K-BAS-',
|
||||
static::Enterprise => 'K-ENT-',
|
||||
static::Legacy => 'K3-PRO-',
|
||||
static::Invalid => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the enum value
|
||||
*/
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
208
kirby/src/Cms/Loader.php
Normal file
208
kirby/src/Cms/Loader.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Data\Data;
|
||||
use Kirby\Filesystem\F;
|
||||
|
||||
/**
|
||||
* The Loader class is an internal loader for
|
||||
* core parts, like areas, components, sections, etc.
|
||||
*
|
||||
* It's exposed in the `$kirby->load()` and the
|
||||
* `$kirby->core()->load()` methods.
|
||||
*
|
||||
* With `$kirby->load()` you get access to core parts
|
||||
* that might be overwritten by plugins.
|
||||
*
|
||||
* With `$kirby->core()->load()` you get access to
|
||||
* untouched core parts. This is useful if you want to
|
||||
* reuse or fall back to core features in your plugins.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Loader
|
||||
{
|
||||
public function __construct(
|
||||
protected App $kirby,
|
||||
protected bool $withPlugins = true
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the area definition
|
||||
*/
|
||||
public function area(string $name): array|null
|
||||
{
|
||||
return $this->areas()[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all areas and makes sure that plugins
|
||||
* are injected properly
|
||||
*/
|
||||
public function areas(): array
|
||||
{
|
||||
$areas = [];
|
||||
$extensions = match ($this->withPlugins) {
|
||||
true => $this->kirby->extensions('areas'),
|
||||
false => []
|
||||
};
|
||||
|
||||
// load core areas and extend them with elements
|
||||
// from plugins if they exist
|
||||
foreach ($this->kirby->core()->areas() as $id => $area) {
|
||||
$area = $this->resolveArea($area);
|
||||
|
||||
if (isset($extensions[$id]) === true) {
|
||||
foreach ($extensions[$id] as $areaExtension) {
|
||||
$extension = $this->resolveArea($areaExtension);
|
||||
$area = array_replace_recursive($area, $extension);
|
||||
}
|
||||
|
||||
unset($extensions[$id]);
|
||||
}
|
||||
|
||||
$areas[$id] = $area;
|
||||
}
|
||||
|
||||
// add additional areas from plugins
|
||||
foreach ($extensions as $id => $areaExtensions) {
|
||||
foreach ($areaExtensions as $areaExtension) {
|
||||
$areas[$id] = $this->resolve($areaExtension);
|
||||
}
|
||||
}
|
||||
|
||||
return $areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a core component closure
|
||||
*/
|
||||
public function component(string $name): Closure|null
|
||||
{
|
||||
return $this->extension('components', $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all core component closures
|
||||
*/
|
||||
public function components(): array
|
||||
{
|
||||
return $this->extensions('components');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a particular extension
|
||||
*/
|
||||
public function extension(string $type, string $name): mixed
|
||||
{
|
||||
return $this->extensions($type)[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all defined extensions
|
||||
*/
|
||||
public function extensions(string $type): array
|
||||
{
|
||||
return match ($this->withPlugins) {
|
||||
true => $this->kirby->extensions($type),
|
||||
false => $this->kirby->core()->$type()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolver takes a string, array or closure.
|
||||
*
|
||||
* 1.) a string is supposed to be a path to an existing file.
|
||||
* The file will either be included when it's a PHP file and
|
||||
* the array contents will be read. Or it will be parsed with
|
||||
* the Data class to read yml or json data into an array
|
||||
*
|
||||
* 2.) arrays are untouched and returned
|
||||
*
|
||||
* 3.) closures will be called and the Kirby instance will be
|
||||
* passed as first argument
|
||||
*/
|
||||
public function resolve(mixed $item): mixed
|
||||
{
|
||||
if (is_string($item) === true) {
|
||||
$item = match (F::extension($item)) {
|
||||
'php' => F::load($item, allowOutput: false),
|
||||
default => Data::read($item)
|
||||
};
|
||||
}
|
||||
|
||||
if (is_callable($item) === true) {
|
||||
$item = $item($this->kirby);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `static::resolve()` on all items
|
||||
* in the given array
|
||||
*/
|
||||
public function resolveAll(array $items): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($items as $key => $value) {
|
||||
$result[$key] = $this->resolve($value);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Areas need a bit of special treatment
|
||||
* when they are being loaded
|
||||
*/
|
||||
public function resolveArea(string|array|Closure $area): array
|
||||
{
|
||||
$area = $this->resolve($area);
|
||||
|
||||
// convert closure dropdowns to an array definition
|
||||
// otherwise they cannot be merged properly later
|
||||
foreach ($area['dropdowns'] ?? [] as $key => $dropdown) {
|
||||
if ($dropdown instanceof Closure) {
|
||||
$area['dropdowns'][$key] = [
|
||||
'options' => $dropdown
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $area;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a particular section definition
|
||||
*/
|
||||
public function section(string $name): array|null
|
||||
{
|
||||
return $this->resolve($this->extension('sections', $name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all section defintions
|
||||
*/
|
||||
public function sections(): array
|
||||
{
|
||||
return $this->resolveAll($this->extensions('sections'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the status flag, which shows
|
||||
* if plugins are loaded as well.
|
||||
*/
|
||||
public function withPlugins(): bool
|
||||
{
|
||||
return $this->withPlugins;
|
||||
}
|
||||
}
|
||||
194
kirby/src/Cms/Media.php
Normal file
194
kirby/src/Cms/Media.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Data\Data;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Handles all tasks to get the Media API
|
||||
* up and running and link files correctly
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Media
|
||||
{
|
||||
/**
|
||||
* Tries to find a file by model and filename
|
||||
* and to copy it to the media folder.
|
||||
*/
|
||||
public static function link(
|
||||
Page|Site|User|null $model,
|
||||
string $hash,
|
||||
string $filename
|
||||
): Response|false {
|
||||
if ($model === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// fix issues with spaces in filenames
|
||||
$filename = urldecode($filename);
|
||||
|
||||
// try to find a file by model and filename
|
||||
// this should work for all original files
|
||||
if ($file = $model->file($filename)) {
|
||||
// check if the request contained an outdated media hash
|
||||
if ($file->mediaHash() !== $hash) {
|
||||
// if at least the token was correct, redirect
|
||||
if (Str::startsWith($hash, $file->mediaToken() . '-') === true) {
|
||||
return Response::redirect($file->mediaUrl(), 307);
|
||||
}
|
||||
|
||||
// don't leak the correct token, render the error page
|
||||
return false;
|
||||
}
|
||||
|
||||
// send the file to the browser
|
||||
return Response::file($file->publish()->mediaRoot());
|
||||
}
|
||||
|
||||
// try to generate a thumb for the file
|
||||
try {
|
||||
return static::thumb($model, $hash, $filename);
|
||||
} catch (NotFoundException) {
|
||||
// render the error page if there is no job for this filename
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the file to the final media folder location
|
||||
*/
|
||||
public static function publish(File $file, string $dest): bool
|
||||
{
|
||||
// never publish risky files (e.g. HTML, PHP or Apache config files)
|
||||
FileRules::validFile($file, false);
|
||||
|
||||
$src = $file->root();
|
||||
$version = dirname($dest);
|
||||
$directory = dirname($version);
|
||||
|
||||
// unpublish all files except stuff in the version folder
|
||||
Media::unpublish($directory, $file, $version);
|
||||
|
||||
// copy/overwrite the file to the dest folder
|
||||
return F::copy($src, $dest, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find a job file for the
|
||||
* given filename and then calls the thumb
|
||||
* component to create a thumbnail accordingly
|
||||
*/
|
||||
public static function thumb(
|
||||
File|Page|Site|User|string $model,
|
||||
string $hash,
|
||||
string $filename
|
||||
): Response|false {
|
||||
$kirby = App::instance();
|
||||
$index = $kirby->root('index');
|
||||
$media = $kirby->root('media');
|
||||
|
||||
$root = match (true) {
|
||||
// assets
|
||||
is_string($model)
|
||||
=> $media . '/assets/' . $model . '/' . $hash,
|
||||
// parent files for file model that already included hash
|
||||
$model instanceof File
|
||||
=> $model->mediaDir(),
|
||||
// model files
|
||||
default
|
||||
=> $model->mediaRoot() . '/' . $hash
|
||||
};
|
||||
|
||||
try {
|
||||
// prevent path traversal
|
||||
$root = Dir::realpath($root, $media);
|
||||
|
||||
$thumb = $root . '/' . $filename;
|
||||
$job = $root . '/.jobs/' . $filename . '.json';
|
||||
|
||||
$options = Data::read($job);
|
||||
} catch (Throwable) {
|
||||
// send a customized error message to make clearer what happened here
|
||||
throw new NotFoundException(
|
||||
message: 'The thumbnail configuration could not be found'
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($options['filename']) === true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Incomplete thumbnail configuration'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// find the correct source file depending on the model
|
||||
// this adds support for custom assets
|
||||
$source = match (true) {
|
||||
is_string($model) === true
|
||||
=> F::realpath(
|
||||
$index . '/' . $model . '/' . $options['filename'],
|
||||
$index
|
||||
),
|
||||
$model instanceof File
|
||||
=> $model->root(),
|
||||
default
|
||||
=> $model->file($options['filename'])->root()
|
||||
};
|
||||
|
||||
// generate the thumbnail and save it in the media folder
|
||||
$kirby->thumb($source, $thumb, $options);
|
||||
|
||||
// remove the job file once the thumbnail has been created
|
||||
F::remove($job);
|
||||
|
||||
// read the file and send it to the browser
|
||||
return Response::file($thumb);
|
||||
} catch (Throwable $e) {
|
||||
// remove potentially broken thumbnails
|
||||
F::remove($thumb);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all versions of the given file
|
||||
* within the parent directory
|
||||
*/
|
||||
public static function unpublish(
|
||||
string $directory,
|
||||
File $file,
|
||||
string|null $ignore = null
|
||||
): bool {
|
||||
if (is_dir($directory) === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// get both old and new versions (pre and post Kirby 3.4.0)
|
||||
$versions = [
|
||||
...glob($directory . '/' . crc32($file->filename()) . '-*', GLOB_ONLYDIR),
|
||||
...glob($directory . '/' . $file->mediaToken() . '-*', GLOB_ONLYDIR)
|
||||
];
|
||||
|
||||
// delete all versions of the file
|
||||
foreach ($versions as $version) {
|
||||
if ($version === $ignore) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Dir::remove($version);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
243
kirby/src/Cms/ModelCommit.php
Normal file
243
kirby/src/Cms/ModelCommit.php
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Exception\Exception;
|
||||
|
||||
/**
|
||||
* The ModelCommit class is used to commit a given model action
|
||||
* in the model action classes. It takes care of running
|
||||
* the `before` and `after` hooks and updating the state
|
||||
* of the given model.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class ModelCommit
|
||||
{
|
||||
protected App $kirby;
|
||||
protected string $prefix;
|
||||
|
||||
public function __construct(
|
||||
protected ModelWithContent $model,
|
||||
protected string $action
|
||||
) {
|
||||
$this->kirby = $this->model->kirby();
|
||||
$this->prefix = $this->model::CLASS_ALIAS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the `after` hook and returns the result.
|
||||
*/
|
||||
public function after(mixed $state): mixed
|
||||
{
|
||||
// run the `after` hook
|
||||
$arguments = $this->afterHookArguments($state);
|
||||
$hook = $this->hook('after', $arguments);
|
||||
|
||||
// flush the page cache after any model action
|
||||
$this->kirby->cache('pages')->flush();
|
||||
|
||||
return $hook['result'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate arguments for the `after` hook
|
||||
* for the given model action. It's a wrapper around the
|
||||
* more specific `afterHookArgumentsFor*Actions` methods.
|
||||
*/
|
||||
public function afterHookArguments(mixed $state): array
|
||||
{
|
||||
return match (true) {
|
||||
$this->model instanceof File =>
|
||||
$this->afterHookArgumentsForFileActions($this->model, $this->action, $state),
|
||||
$this->model instanceof Page =>
|
||||
$this->afterHookArgumentsForPageActions($this->model, $this->action, $state),
|
||||
$this->model instanceof Site =>
|
||||
$this->afterHookArgumentsForSiteActions($this->model, $state),
|
||||
$this->model instanceof User =>
|
||||
$this->afterHookArgumentsForUserActions($this->model, $this->action, $state),
|
||||
default =>
|
||||
throw new Exception('Invalid model class')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate arguments for the `after` hook
|
||||
* for the given page action.
|
||||
*/
|
||||
protected function afterHookArgumentsForPageActions(
|
||||
Page $model,
|
||||
string $action,
|
||||
mixed $state
|
||||
): array {
|
||||
return match ($action) {
|
||||
'create' => [
|
||||
'page' => $state
|
||||
],
|
||||
'duplicate' => [
|
||||
'duplicatePage' => $state,
|
||||
'originalPage' => $model
|
||||
],
|
||||
'delete' => [
|
||||
'status' => $state,
|
||||
'page' => $model
|
||||
],
|
||||
default => [
|
||||
'newPage' => $state,
|
||||
'oldPage' => $model
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate arguments for the `after` hook
|
||||
* for the given file action.
|
||||
*/
|
||||
protected function afterHookArgumentsForFileActions(
|
||||
File $model,
|
||||
string $action,
|
||||
mixed $state
|
||||
): array {
|
||||
return match ($action) {
|
||||
'create' => [
|
||||
'file' => $state
|
||||
],
|
||||
'delete' => [
|
||||
'status' => $state,
|
||||
'file' => $model
|
||||
],
|
||||
default => [
|
||||
'newFile' => $state,
|
||||
'oldFile' => $model
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate arguments for the `after` hook
|
||||
* for the given site action.
|
||||
*/
|
||||
protected function afterHookArgumentsForSiteActions(
|
||||
Site $model,
|
||||
mixed $state
|
||||
): array {
|
||||
return [
|
||||
'newSite' => $state,
|
||||
'oldSite' => $model
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate arguments for the `after` hook
|
||||
* for the given user action.
|
||||
*/
|
||||
protected function afterHookArgumentsForUserActions(
|
||||
User $model,
|
||||
string $action,
|
||||
mixed $state
|
||||
): array {
|
||||
return match ($action) {
|
||||
'create' => [
|
||||
'user' => $state
|
||||
],
|
||||
'delete' => [
|
||||
'status' => $state,
|
||||
'user' => $model
|
||||
],
|
||||
default => [
|
||||
'newUser' => $state,
|
||||
'oldUser' => $model
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the `before` hook and modifies the arguments
|
||||
*/
|
||||
public function before(array $arguments): array
|
||||
{
|
||||
// check model rules
|
||||
$this->validate($arguments);
|
||||
|
||||
// run the `before` hook
|
||||
$hook = $this->hook('before', $arguments);
|
||||
|
||||
// check model rules again, after the hook got applied
|
||||
$this->validate($hook['arguments']);
|
||||
|
||||
return $hook['arguments'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the full call of the given action,
|
||||
* runs the `before` and `after` hooks and updates
|
||||
* the state of the given model.
|
||||
*/
|
||||
public function call(array $arguments, Closure $callback): mixed
|
||||
{
|
||||
// run the before hook
|
||||
$arguments = $this->before($arguments);
|
||||
|
||||
// run the commit action
|
||||
$state = $callback(...array_values($arguments));
|
||||
|
||||
// update the state for the after hook
|
||||
ModelState::update(
|
||||
method: $this->action,
|
||||
current: $this->model,
|
||||
next: $state
|
||||
);
|
||||
|
||||
// run the after hook and return the result
|
||||
return $this->after($state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the given hook and modifies the first argument
|
||||
* of the given arguments array. It returns an array with
|
||||
* `arguments` and `result` keys.
|
||||
*/
|
||||
public function hook(string $hook, array $arguments): array
|
||||
{
|
||||
// the very first argument (which should be the model)
|
||||
// is modified by the return value from the hook (if any returned)
|
||||
$appliedTo = array_key_first($arguments);
|
||||
|
||||
// run the hook and modify the first argument
|
||||
$arguments[$appliedTo] = $this->kirby->apply(
|
||||
// e.g. page.create:before
|
||||
$this->prefix . '.' . $this->action . ':' . $hook,
|
||||
$arguments,
|
||||
$appliedTo
|
||||
);
|
||||
|
||||
return [
|
||||
'arguments' => $arguments,
|
||||
'result' => $arguments[$appliedTo],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the model rules for the given action
|
||||
* if there's a matching rule method.
|
||||
*/
|
||||
public function validate(array $arguments): void
|
||||
{
|
||||
$rules = match (true) {
|
||||
$this->model instanceof File => FileRules::class,
|
||||
$this->model instanceof Page => PageRules::class,
|
||||
$this->model instanceof Site => SiteRules::class,
|
||||
$this->model instanceof User => UserRules::class,
|
||||
default => throw new Exception('Invalid model class') // @codeCoverageIgnore
|
||||
};
|
||||
|
||||
if (method_exists($rules, $this->action) === true) {
|
||||
$rules::{$this->action}(...array_values($arguments));
|
||||
}
|
||||
}
|
||||
}
|
||||
186
kirby/src/Cms/ModelPermissions.php
Normal file
186
kirby/src/Cms/ModelPermissions.php
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Toolkit\A;
|
||||
|
||||
/**
|
||||
* ModelPermissions
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
abstract class ModelPermissions
|
||||
{
|
||||
protected const CATEGORY = 'model';
|
||||
protected array $options;
|
||||
|
||||
protected static array $cache = [];
|
||||
|
||||
public function __construct(protected ModelWithContent|Language $model)
|
||||
{
|
||||
$this->options = match (true) {
|
||||
$model instanceof ModelWithContent => $model->blueprint()->options(),
|
||||
default => []
|
||||
};
|
||||
}
|
||||
|
||||
public function __call(string $method, array $arguments = []): bool
|
||||
{
|
||||
return $this->can($method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Improved `var_dump` output
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be overridden by specific child classes
|
||||
* to return a model-specific value used to
|
||||
* cache a once determined permission in memory
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
protected static function cacheKey(ModelWithContent|Language $model): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the current user is allowed to do
|
||||
* a certain action on the model
|
||||
*
|
||||
* @param bool $default Will be returned if $action does not exist
|
||||
*/
|
||||
public function can(
|
||||
string $action,
|
||||
bool $default = false
|
||||
): bool {
|
||||
$user = static::user();
|
||||
$userId = $user->id();
|
||||
$role = $user->role()->id();
|
||||
|
||||
// users with the `nobody` role can do nothing
|
||||
// that needs a permission check
|
||||
if ($role === 'nobody') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check for a custom `can` method
|
||||
// which would take priority over any other
|
||||
// role-based permission rules
|
||||
if (
|
||||
method_exists($this, 'can' . $action) === true &&
|
||||
$this->{'can' . $action}() === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// the almighty `kirby` user can do anything
|
||||
if ($userId === 'kirby' && $role === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// evaluate the blueprint options block
|
||||
if (isset($this->options[$action]) === true) {
|
||||
$options = $this->options[$action];
|
||||
|
||||
if ($options === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($options === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
is_array($options) === true &&
|
||||
A::isAssociative($options) === true
|
||||
) {
|
||||
if (isset($options[$role]) === true) {
|
||||
return $options[$role];
|
||||
}
|
||||
|
||||
if (isset($options['*']) === true) {
|
||||
return $options['*'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$permissions = $user->role()->permissions();
|
||||
return $permissions->for(static::category($this->model), $action, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quickly determines a permission for the current user role
|
||||
* and model blueprint unless dynamic checking is required
|
||||
*/
|
||||
public static function canFromCache(
|
||||
ModelWithContent|Language $model,
|
||||
string $action,
|
||||
bool $default = false
|
||||
): bool {
|
||||
$role = $model->kirby()->role()?->id() ?? '__none__';
|
||||
$category = static::category($model);
|
||||
$cacheKey = $category . '.' . $action . '/' . static::cacheKey($model) . '/' . $role;
|
||||
|
||||
if (isset(static::$cache[$cacheKey]) === true) {
|
||||
return static::$cache[$cacheKey];
|
||||
}
|
||||
|
||||
if (method_exists(static::class, 'can' . $action) === true) {
|
||||
throw new LogicException('Cannot use permission cache for dynamically-determined permission');
|
||||
}
|
||||
|
||||
return static::$cache[$cacheKey] = $model->permissions()->can($action, $role, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the current user is not allowed to do
|
||||
* a certain action on the model
|
||||
*
|
||||
* @param bool $default Will be returned if $action does not exist
|
||||
*/
|
||||
public function cannot(
|
||||
string $action,
|
||||
bool $default = true
|
||||
): bool {
|
||||
return $this->can($action, !$default) === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be overridden by specific child classes
|
||||
* if the permission category needs to be dynamic
|
||||
*/
|
||||
protected static function category(ModelWithContent|Language $model): string
|
||||
{
|
||||
return static::CATEGORY;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
$array = [];
|
||||
|
||||
foreach ($this->options as $key => $value) {
|
||||
$array[$key] = $this->can($key);
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently logged in user
|
||||
*/
|
||||
protected static function user(): User
|
||||
{
|
||||
return App::instance()->user() ?? User::nobody();
|
||||
}
|
||||
}
|
||||
107
kirby/src/Cms/ModelState.php
Normal file
107
kirby/src/Cms/ModelState.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* The ModelState class is used to update app-wide model states.
|
||||
* It's mainly used in the `ModelCommit` class to update the
|
||||
* state of the given model after the action has been
|
||||
* executed.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class ModelState
|
||||
{
|
||||
/**
|
||||
* Updates the state of the given model.
|
||||
*/
|
||||
public static function update(
|
||||
string $method,
|
||||
ModelWithContent $current,
|
||||
ModelWithContent|bool|null $next = null,
|
||||
ModelWithContent|Site|null $parent = null
|
||||
): void {
|
||||
// normalize the method
|
||||
$method = match ($method) {
|
||||
'append', 'create' => 'append',
|
||||
'remove', 'delete' => 'remove',
|
||||
'duplicate' => false, // The models need to take care of this
|
||||
default => 'update'
|
||||
};
|
||||
|
||||
if ($method === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
match (true) {
|
||||
$current instanceof File => static::updateFile($method, $current, $next),
|
||||
$current instanceof Page => static::updatePage($method, $current, $next, $parent),
|
||||
$current instanceof Site => static::updateSite($current, $next),
|
||||
$current instanceof User => static::updateUser($method, $current, $next),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the state of the given file.
|
||||
*/
|
||||
protected static function updateFile(
|
||||
string $method,
|
||||
File $current,
|
||||
File|bool|null $next = null
|
||||
): void {
|
||||
$next = $next instanceof File ? $next : $current;
|
||||
|
||||
// update the files collection
|
||||
$next->parent()->files()->$method($next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the state of the given page.
|
||||
*/
|
||||
protected static function updatePage(
|
||||
string $method,
|
||||
Page $current,
|
||||
Page|bool|null $next = null,
|
||||
Page|Site|null $parent = null
|
||||
): void {
|
||||
$next = $next instanceof Page ? $next : $current;
|
||||
$parent ??= $next->parentModel();
|
||||
|
||||
if ($next->isDraft() === true) {
|
||||
$parent->drafts()->$method($next);
|
||||
} else {
|
||||
$parent->children()->$method($next);
|
||||
}
|
||||
|
||||
// update the childrenAndDrafts() cache
|
||||
$parent->childrenAndDrafts()->$method($next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the state of the given site.
|
||||
*/
|
||||
protected static function updateSite(
|
||||
Site $current,
|
||||
Site|null $next = null
|
||||
): void {
|
||||
App::instance()->setSite($next ?? $current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the state of the given user.
|
||||
*/
|
||||
protected static function updateUser(
|
||||
string $method,
|
||||
User $current,
|
||||
User|bool|null $next = null
|
||||
): void {
|
||||
$next = $next instanceof User ? $next : $current;
|
||||
|
||||
// update the users collection
|
||||
App::instance()->users()->$method($next);
|
||||
}
|
||||
}
|
||||
732
kirby/src/Cms/ModelWithContent.php
Normal file
732
kirby/src/Cms/ModelWithContent.php
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Content\Content;
|
||||
use Kirby\Content\ImmutableMemoryStorage;
|
||||
use Kirby\Content\Lock;
|
||||
use Kirby\Content\MemoryStorage;
|
||||
use Kirby\Content\Storage;
|
||||
use Kirby\Content\Translation;
|
||||
use Kirby\Content\Translations;
|
||||
use Kirby\Content\Version;
|
||||
use Kirby\Content\VersionId;
|
||||
use Kirby\Content\Versions;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Form\Fields;
|
||||
use Kirby\Form\Form;
|
||||
use Kirby\Panel\Model;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Kirby\Uuid\Identifiable;
|
||||
use Kirby\Uuid\Uuid;
|
||||
use Stringable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* ModelWithContent
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
abstract class ModelWithContent implements Identifiable, Stringable
|
||||
{
|
||||
/**
|
||||
* Each model must define a CLASS_ALIAS
|
||||
* which will be used in template queries.
|
||||
* The CLASS_ALIAS is a short human-readable
|
||||
* version of the class name, i.e. page.
|
||||
*/
|
||||
public const CLASS_ALIAS = null;
|
||||
|
||||
/**
|
||||
* Cached array of valid blueprints
|
||||
* that could be used for the model
|
||||
*/
|
||||
public array|null $blueprints = null;
|
||||
|
||||
public static App $kirby;
|
||||
protected Site|null $site;
|
||||
protected Storage $storage;
|
||||
|
||||
/**
|
||||
* Store values used to initilaize object
|
||||
*/
|
||||
protected array $propertyData = [];
|
||||
|
||||
public function __construct(array $props = [])
|
||||
{
|
||||
$this->site = $props['site'] ?? null;
|
||||
|
||||
$this->setContent($props['content'] ?? null);
|
||||
$this->setTranslations($props['translations'] ?? null);
|
||||
|
||||
$this->propertyData = $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the blueprint of the model
|
||||
*/
|
||||
abstract public function blueprint(): Blueprint;
|
||||
|
||||
/**
|
||||
* Returns an array with all blueprints that are available
|
||||
*/
|
||||
public function blueprints(string|null $inSection = null): array
|
||||
{
|
||||
// helper function
|
||||
$toBlueprints = static function (array $sections): array {
|
||||
$blueprints = [];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
if ($section === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ((array)$section->blueprints() as $blueprint) {
|
||||
$blueprints[$blueprint['name']] = $blueprint;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($blueprints);
|
||||
};
|
||||
|
||||
$blueprint = $this->blueprint();
|
||||
|
||||
// no caching for when collecting for specific section
|
||||
if ($inSection !== null) {
|
||||
return $toBlueprints([$blueprint->section($inSection)]);
|
||||
}
|
||||
|
||||
return $this->blueprints ??= $toBlueprints($blueprint->sections());
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves or copies the model to a new storage instance/type
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
public function changeStorage(Storage|string $toStorage, bool $copy = false): static
|
||||
{
|
||||
if (is_string($toStorage) === true) {
|
||||
if (is_subclass_of($toStorage, Storage::class) === false) {
|
||||
throw new InvalidArgumentException('Invalid storage class');
|
||||
}
|
||||
|
||||
$toStorage = new $toStorage($this);
|
||||
}
|
||||
|
||||
$method = $copy ? 'copyAll' : 'moveAll';
|
||||
|
||||
$this->storage()->$method(to: $toStorage);
|
||||
$this->storage = $toStorage;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with the same
|
||||
* initial properties
|
||||
*
|
||||
* @todo eventually refactor without need of propertyData
|
||||
*/
|
||||
public function clone(array $props = []): static
|
||||
{
|
||||
$props = array_replace_recursive($this->propertyData, $props);
|
||||
$clone = new static($props);
|
||||
|
||||
// Move the clone to a new instance of the same storage class
|
||||
// The storage classes might need to rely on the model instance
|
||||
// and thus we need to make sure that the cloned object is
|
||||
// passed on to the new storage instance
|
||||
$storage = match (true) {
|
||||
isset($props['content']),
|
||||
isset($props['translations']) => $clone->storage()::class,
|
||||
default => $this->storage()::class
|
||||
};
|
||||
|
||||
$clone->changeStorage($storage);
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes any given model action
|
||||
*/
|
||||
abstract protected function commit(
|
||||
string $action,
|
||||
array $arguments,
|
||||
Closure $callback
|
||||
): mixed;
|
||||
|
||||
/**
|
||||
* Returns the content
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist
|
||||
*/
|
||||
public function content(string|null $languageCode = null): Content
|
||||
{
|
||||
// get the targeted language
|
||||
$language = Language::ensure($languageCode ?? 'current');
|
||||
$versionId = VersionId::$render ?? 'latest';
|
||||
$version = $this->version($versionId);
|
||||
|
||||
if ($version->exists($language) === true) {
|
||||
return $version->content($language);
|
||||
}
|
||||
|
||||
return $this->version()->content($language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the content that should be written
|
||||
* to the text file
|
||||
* @unstable
|
||||
*/
|
||||
public function contentFileData(
|
||||
array $data,
|
||||
string|null $languageCode = null
|
||||
): array {
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts model to new blueprint
|
||||
* incl. its content for all translations
|
||||
*/
|
||||
protected function convertTo(string $blueprint): static
|
||||
{
|
||||
// Keep a copy of the old model with the original storage handler.
|
||||
// This will be used to delete the old versions.
|
||||
$old = $this->clone();
|
||||
|
||||
// Clone the object with the new blueprint as template
|
||||
$new = $this->clone(['template' => $blueprint]);
|
||||
|
||||
// Make sure to use the same storage class as the original model.
|
||||
// This is needed if the model has been constructed with `content` or
|
||||
// `translations` props. In this case, the storage would be set to
|
||||
// `MemoryStorage` in the clone method again, even if it might have been
|
||||
// changed before.
|
||||
$new->changeStorage($this->storage()::class);
|
||||
|
||||
// Copy this instance into immutable storage.
|
||||
// Moving the content would prematurely delete the old content storage entries.
|
||||
// But we need to keep them until the new content is written.
|
||||
$this->changeStorage(
|
||||
toStorage: new ImmutableMemoryStorage(
|
||||
model: $this,
|
||||
nextModel: $new
|
||||
),
|
||||
copy: true
|
||||
);
|
||||
|
||||
// Get all languages to loop through
|
||||
$languages = Languages::ensure();
|
||||
|
||||
// Loop through all versions
|
||||
foreach ($old->versions() as $oldVersion) {
|
||||
// Loop through all languages
|
||||
foreach ($languages as $language) {
|
||||
// Skip non-existing versions
|
||||
if ($oldVersion->exists($language) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert the content to the new blueprint
|
||||
$content = $oldVersion->content($language)->convertTo($blueprint);
|
||||
|
||||
// Delete the old versions. This will also remove the
|
||||
// content files from the storage if this is a plain text
|
||||
// storage instance.
|
||||
$oldVersion->delete($language);
|
||||
|
||||
// Save to re-create the new version
|
||||
// with the converted/updated content
|
||||
$new->version($oldVersion->id())->save($content, $language);
|
||||
}
|
||||
}
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates default content for the model, by using our
|
||||
* Form class to generate the defaults, based on the
|
||||
* model's blueprint setup.
|
||||
*
|
||||
* @since 5.0.0
|
||||
*/
|
||||
public function createDefaultContent(): array
|
||||
{
|
||||
$fields = Fields::for($this, 'default');
|
||||
return $fields->fill($fields->defaults())->toStoredValues();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement a given field value
|
||||
*/
|
||||
public function decrement(
|
||||
string $field,
|
||||
int $by = 1,
|
||||
int $min = 0
|
||||
): static {
|
||||
$value = (int)$this->content()->get($field)->value() - $by;
|
||||
|
||||
if ($value < $min) {
|
||||
$value = $min;
|
||||
}
|
||||
|
||||
return $this->update([$field => $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all content validation errors
|
||||
*/
|
||||
public function errors(): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($this->blueprint()->sections() as $section) {
|
||||
$errors = [...$errors, ...$section->errors()];
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a clone and fetches all
|
||||
* lazy-loaded getters to get a full copy
|
||||
*/
|
||||
public function hardcopy(): static
|
||||
{
|
||||
$clone = $this->clone();
|
||||
|
||||
foreach (get_object_vars($clone) as $name => $default) {
|
||||
if (method_exists($clone, $name) === true) {
|
||||
$clone->$name();
|
||||
}
|
||||
}
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each model must return a unique id
|
||||
*/
|
||||
public function id(): string|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment a given field value
|
||||
*/
|
||||
public function increment(
|
||||
string $field,
|
||||
int $by = 1,
|
||||
int|null $max = null
|
||||
): static {
|
||||
$value = (int)$this->content()->get($field)->value() + $by;
|
||||
|
||||
if ($max && $value > $max) {
|
||||
$value = $max;
|
||||
}
|
||||
|
||||
return $this->update([$field => $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the model is locked for the current user
|
||||
* @deprecated 5.0.0 Use `->lock()->isLocked()` instead
|
||||
*/
|
||||
public function isLocked(): bool
|
||||
{
|
||||
return $this->lock()->isLocked() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the data has any errors
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->version('latest')->isValid('current');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent Kirby instance
|
||||
*/
|
||||
public function kirby(): App
|
||||
{
|
||||
return static::$kirby ??= App::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns lock for the model
|
||||
*/
|
||||
public function lock(): Lock
|
||||
{
|
||||
return $this->version('changes')->lock('*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the panel info of the model
|
||||
* @since 3.6.0
|
||||
*/
|
||||
abstract public function panel(): Model;
|
||||
|
||||
/**
|
||||
* Must return the permissions object for the model
|
||||
*/
|
||||
abstract public function permissions(): ModelPermissions;
|
||||
|
||||
/**
|
||||
* Clean internal caches
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function purge(): static
|
||||
{
|
||||
$this->blueprints = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a string query, starting from the model
|
||||
*/
|
||||
public function query(
|
||||
string|null $query = null,
|
||||
string|null $expect = null
|
||||
): mixed {
|
||||
if ($query === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Str::query($query, [
|
||||
'kirby' => $this->kirby(),
|
||||
'site' => $this instanceof Site ? $this : $this->site(),
|
||||
'model' => $this,
|
||||
static::CLASS_ALIAS => $this
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($expect !== null && $result instanceof $expect === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the content from the content file
|
||||
* @internal
|
||||
* @deprecated 5.0.0 Use `->version()->read()` instead
|
||||
*/
|
||||
public function readContent(string|null $languageCode = null): array
|
||||
{
|
||||
Helpers::deprecated('$model->readContent() is deprecated. Use $model->version()->read() instead.'); // @codeCoverageIgnore
|
||||
return $this->version()->read($languageCode ?? 'default') ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the model
|
||||
*/
|
||||
abstract public function root(): string|null;
|
||||
|
||||
/**
|
||||
* Low-level method to save the model with the given data.
|
||||
* Consider using `::update()` instead.
|
||||
*/
|
||||
public function save(
|
||||
array|null $data = null,
|
||||
string|null $languageCode = null,
|
||||
bool $overwrite = false
|
||||
): static {
|
||||
// create a clone to avoid modifying the original
|
||||
$clone = $this->clone();
|
||||
|
||||
// move the old model into memory
|
||||
$this->changeStorage(
|
||||
toStorage: new ImmutableMemoryStorage(
|
||||
model: $this,
|
||||
nextModel: $clone
|
||||
),
|
||||
copy: true
|
||||
);
|
||||
|
||||
// update the clone
|
||||
$clone->version()->save(
|
||||
$data ?? [],
|
||||
$languageCode ?? 'current',
|
||||
$overwrite
|
||||
);
|
||||
|
||||
ModelState::update(
|
||||
method: 'set',
|
||||
current: $this,
|
||||
next: $clone
|
||||
);
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 5.0.0 Use $model->save() instead
|
||||
*/
|
||||
protected function saveContent(
|
||||
array|null $data = null,
|
||||
bool $overwrite = false
|
||||
): static {
|
||||
Helpers::deprecated('$model->saveContent() is deprecated. Use $model->save() instead.');
|
||||
return $this->save($data, 'default', $overwrite);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 5.0.0 Use $model->save() instead
|
||||
*/
|
||||
protected function saveTranslation(
|
||||
array|null $data = null,
|
||||
string|null $languageCode = null,
|
||||
bool $overwrite = false
|
||||
): static {
|
||||
Helpers::deprecated('$model->saveTranslation() is deprecated. Use $model->save() instead.');
|
||||
return $this->save($data, $languageCode ?? 'default', $overwrite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Content object
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setContent(array|null $content = null): static
|
||||
{
|
||||
if ($content === null) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->changeStorage(MemoryStorage::class, copy: true);
|
||||
$this->version()->save($content, 'default');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the translations collection from an array
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setTranslations(array|null $translations = null): static
|
||||
{
|
||||
if ($translations === null) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->changeStorage(MemoryStorage::class, copy: true);
|
||||
|
||||
Translations::create(
|
||||
model: $this,
|
||||
version: $this->version(),
|
||||
translations: $translations
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent Site instance
|
||||
*/
|
||||
public function site(): Site
|
||||
{
|
||||
return $this->site ??= $this->kirby()->site();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content storage handler
|
||||
*/
|
||||
public function storage(): Storage
|
||||
{
|
||||
return $this->storage ??= $this->kirby()->storage($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the model to a simple array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'content' => $this->content()->toArray(),
|
||||
'translations' => $this->translations()->toArray()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* String template builder with automatic HTML escaping
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @param string|null $template Template string or `null` to use the model ID
|
||||
* @param string|null $fallback Fallback for tokens in the template that cannot be replaced
|
||||
* (`null` to keep the original token)
|
||||
*/
|
||||
public function toSafeString(
|
||||
string|null $template = null,
|
||||
array $data = [],
|
||||
string|null $fallback = ''
|
||||
): string {
|
||||
return $this->toString($template, $data, $fallback, 'safeTemplate');
|
||||
}
|
||||
|
||||
/**
|
||||
* String template builder
|
||||
*
|
||||
* @param string|null $template Template string or `null` to use the model ID
|
||||
* @param string|null $fallback Fallback for tokens in the template that cannot be replaced
|
||||
* (`null` to keep the original token)
|
||||
* @param string $handler For internal use
|
||||
*/
|
||||
public function toString(
|
||||
string|null $template = null,
|
||||
array $data = [],
|
||||
string|null $fallback = '',
|
||||
string $handler = 'template'
|
||||
): string {
|
||||
if ($template === null) {
|
||||
return $this->id() ?? '';
|
||||
}
|
||||
|
||||
if ($handler !== 'template' && $handler !== 'safeTemplate') {
|
||||
throw new InvalidArgumentException(message: 'Invalid toString handler'); // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
$result = Str::$handler($template, array_replace([
|
||||
'kirby' => $this->kirby(),
|
||||
'site' => $this instanceof Site ? $this : $this->site(),
|
||||
'model' => $this,
|
||||
static::CLASS_ALIAS => $this,
|
||||
], $data), ['fallback' => $fallback]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes it possible to convert the entire model
|
||||
* to a string. Mostly useful for debugging
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return (string)$this->id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single translation by language code
|
||||
* If no code is specified the current translation is returned
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the language does not exist
|
||||
*/
|
||||
public function translation(
|
||||
string|null $languageCode = null
|
||||
): Translation {
|
||||
$language = Language::ensure($languageCode ?? 'current');
|
||||
|
||||
return new Translation(
|
||||
model: $this,
|
||||
version: $this->version(),
|
||||
language: $language
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translations collection
|
||||
*/
|
||||
public function translations(): Translations
|
||||
{
|
||||
return Translations::load(
|
||||
model: $this,
|
||||
version: $this->version()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the model data
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values
|
||||
*/
|
||||
public function update(
|
||||
array|null $input = null,
|
||||
string|null $languageCode = null,
|
||||
bool $validate = false
|
||||
): static {
|
||||
$form = Form::for(
|
||||
model: $this,
|
||||
language: $languageCode,
|
||||
);
|
||||
|
||||
$form->submit(
|
||||
input: $input ?? [],
|
||||
force: $validate === false
|
||||
);
|
||||
|
||||
if ($validate === true) {
|
||||
$form->validate();
|
||||
}
|
||||
|
||||
return $this->commit(
|
||||
'update',
|
||||
[
|
||||
static::CLASS_ALIAS => $this,
|
||||
'values' => $form->toFormValues(),
|
||||
'strings' => $form->toStoredValues(),
|
||||
'languageCode' => $languageCode
|
||||
],
|
||||
fn ($model, $values, $strings, $languageCode) =>
|
||||
$model->save($strings, $languageCode, true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model's UUID
|
||||
* @since 3.8.0
|
||||
*/
|
||||
public function uuid(): Uuid|null
|
||||
{
|
||||
return Uuid::for($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a content version instance
|
||||
* @since 5.0.0
|
||||
*/
|
||||
public function version(VersionId|string|null $versionId = null): Version
|
||||
{
|
||||
return new Version(
|
||||
model: $this,
|
||||
id: VersionId::from($versionId ?? 'latest')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a versions collection
|
||||
* @since 5.0.0
|
||||
*/
|
||||
public function versions(): Versions
|
||||
{
|
||||
return Versions::load($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low level data writer method
|
||||
* to store the given data on disk or anywhere else
|
||||
* @internal
|
||||
* @deprecated 5.0.0 Use `->version()->save()` instead
|
||||
*/
|
||||
public function writeContent(array $data, string|null $languageCode = null): bool
|
||||
{
|
||||
Helpers::deprecated('$model->writeContent() is deprecated. Use $model->version()->save() instead.'); // @codeCoverageIgnore
|
||||
$this->version()->save($data, $languageCode ?? 'default', true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
49
kirby/src/Cms/Nest.php
Normal file
49
kirby/src/Cms/Nest.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Content\Field;
|
||||
|
||||
/**
|
||||
* The Nest class converts any array type
|
||||
* into a Kirby style collection/object. This
|
||||
* can be used make any type of array compatible
|
||||
* with Kirby queries.
|
||||
*
|
||||
* REFACTOR: move this to the toolkit
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Nest
|
||||
{
|
||||
public static function create(
|
||||
$data,
|
||||
object|null $parent = null
|
||||
): NestCollection|NestObject|Field {
|
||||
if (is_scalar($data) === true) {
|
||||
return new Field($parent, $data, $data);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value) === true) {
|
||||
$result[$key] = static::create($value, $parent);
|
||||
} elseif (is_scalar($value) === true) {
|
||||
$result[$key] = new Field($parent, $key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
$key = key($data);
|
||||
|
||||
if ($key === null || is_int($key) === true) {
|
||||
return new NestCollection($result);
|
||||
}
|
||||
|
||||
return new NestObject($result);
|
||||
}
|
||||
}
|
||||
32
kirby/src/Cms/NestCollection.php
Normal file
32
kirby/src/Cms/NestCollection.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Toolkit\Collection as BaseCollection;
|
||||
|
||||
/**
|
||||
* NestCollection
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Toolkit\Collection<\Kirby\Cms\NestObject|\Kirby\Content\Field>
|
||||
*/
|
||||
class NestCollection extends BaseCollection
|
||||
{
|
||||
/**
|
||||
* Converts all objects in the collection
|
||||
* to an array. This can also take a callback
|
||||
* function to further modify the array result.
|
||||
*/
|
||||
public function toArray(Closure|null $map = null): array
|
||||
{
|
||||
return parent::toArray(
|
||||
$map ?? fn ($object) => $object->toArray()
|
||||
);
|
||||
}
|
||||
}
|
||||
45
kirby/src/Cms/NestObject.php
Normal file
45
kirby/src/Cms/NestObject.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Content\Field;
|
||||
use Kirby\Toolkit\Obj;
|
||||
|
||||
/**
|
||||
* NestObject
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class NestObject extends Obj
|
||||
{
|
||||
/**
|
||||
* Converts the object to an array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ((array)$this as $key => $value) {
|
||||
if ($value instanceof Field) {
|
||||
$result[$key] = $value->value();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
is_object($value) === true &&
|
||||
method_exists($value, 'toArray')
|
||||
) {
|
||||
$result[$key] = $value->toArray();
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$key] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
1306
kirby/src/Cms/Page.php
Normal file
1306
kirby/src/Cms/Page.php
Normal file
File diff suppressed because it is too large
Load diff
959
kirby/src/Cms/PageActions.php
Normal file
959
kirby/src/Cms/PageActions.php
Normal file
|
|
@ -0,0 +1,959 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Content\ImmutableMemoryStorage;
|
||||
use Kirby\Content\MemoryStorage;
|
||||
use Kirby\Content\VersionCache;
|
||||
use Kirby\Content\VersionId;
|
||||
use Kirby\Exception\DuplicateException;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Kirby\Uuid\Uuid;
|
||||
use Kirby\Uuid\Uuids;
|
||||
|
||||
/**
|
||||
* PageActions
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait PageActions
|
||||
{
|
||||
/**
|
||||
* Changes the sorting number.
|
||||
* The sorting number must already be correct
|
||||
* when the method is called.
|
||||
* This only affects this page,
|
||||
* siblings will not be resorted.
|
||||
*
|
||||
* @return $this|static
|
||||
* @throws \Kirby\Exception\LogicException If a draft is being sorted or the directory cannot be moved
|
||||
*/
|
||||
public function changeNum(int|null $num = null): static
|
||||
{
|
||||
if ($this->isDraft() === true) {
|
||||
throw new LogicException(
|
||||
message: 'Drafts cannot change their sorting number'
|
||||
);
|
||||
}
|
||||
|
||||
// don't run the action if everything stayed the same
|
||||
if ($this->num() === $num) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return $this->commit('changeNum', ['page' => $this, 'num' => $num], function ($oldPage, $num) {
|
||||
$newPage = $oldPage->clone([
|
||||
'num' => $num,
|
||||
'dirname' => null,
|
||||
'root' => null,
|
||||
'template' => $oldPage->intendedTemplate()->name(),
|
||||
]);
|
||||
|
||||
// actually move the page on disk
|
||||
if ($oldPage->exists() === true) {
|
||||
if (Dir::move($oldPage->root(), $newPage->root()) === true) {
|
||||
// Updates the root path of the old page with the root path
|
||||
// of the moved new page to use fly actions on old page in loop
|
||||
$oldPage->root = $newPage->root();
|
||||
} else {
|
||||
throw new LogicException(
|
||||
message: 'The page directory cannot be moved'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $newPage;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the slug/uid of the page
|
||||
*
|
||||
* @return $this|static
|
||||
* @throws \Kirby\Exception\LogicException If the directory cannot be moved
|
||||
*/
|
||||
public function changeSlug(
|
||||
string $slug,
|
||||
string|null $languageCode = null
|
||||
): static {
|
||||
// always sanitize the slug
|
||||
$slug = Url::slug($slug);
|
||||
$language = Language::ensure($languageCode ?? 'current');
|
||||
|
||||
// in multi-language installations the slug for the non-default
|
||||
// languages is stored in the text file. The changeSlugForLanguage
|
||||
// method takes care of that.
|
||||
if ($language->isDefault() === false) {
|
||||
return $this->changeSlugForLanguage($slug, $language->code());
|
||||
}
|
||||
|
||||
// if the slug stays exactly the same,
|
||||
// nothing needs to be done.
|
||||
if ($slug === $this->slug()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$arguments = [
|
||||
'page' => $this,
|
||||
'slug' => $slug,
|
||||
'languageCode' => null,
|
||||
'language' => $language
|
||||
];
|
||||
|
||||
return $this->commit('changeSlug', $arguments, function ($oldPage, $slug, $languageCode, $language) {
|
||||
$newPage = $oldPage->clone([
|
||||
'slug' => $slug,
|
||||
'dirname' => null,
|
||||
'root' => null,
|
||||
'template' => $oldPage->intendedTemplate()->name(),
|
||||
]);
|
||||
|
||||
// clear UUID cache recursively (for children and files as well)
|
||||
$oldPage->uuid()?->clear(true);
|
||||
|
||||
if ($oldPage->exists() === true) {
|
||||
// actually move stuff on disk
|
||||
if (Dir::move($oldPage->root(), $newPage->root()) !== true) {
|
||||
throw new LogicException(
|
||||
message: 'The page directory cannot be moved'
|
||||
);
|
||||
}
|
||||
|
||||
// hard reset for the version cache
|
||||
// to avoid broken/overlapping page references
|
||||
VersionCache::reset();
|
||||
|
||||
// remove from the siblings
|
||||
ModelState::update(
|
||||
method: 'remove',
|
||||
current: $oldPage,
|
||||
);
|
||||
|
||||
Dir::remove($oldPage->mediaRoot());
|
||||
}
|
||||
|
||||
return $newPage;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the slug for a specific language
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the language for the given language code cannot be found
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the slug for the default language is being changed
|
||||
*/
|
||||
protected function changeSlugForLanguage(
|
||||
string $slug,
|
||||
string|null $languageCode = null
|
||||
): static {
|
||||
$language = Language::ensure($languageCode ?? 'current');
|
||||
|
||||
if ($language->isDefault() === true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Use the changeSlug method to change the slug for the default language'
|
||||
);
|
||||
}
|
||||
|
||||
$arguments = [
|
||||
'page' => $this,
|
||||
'slug' => $slug,
|
||||
'languageCode' => $language->code(),
|
||||
'language' => $language
|
||||
];
|
||||
|
||||
return $this->commit('changeSlug', $arguments, function ($page, $slug, $languageCode, $language) {
|
||||
// remove the slug if it's the same as the folder name
|
||||
if ($slug === $page->uid()) {
|
||||
$slug = null;
|
||||
}
|
||||
|
||||
// make sure to update the slug in the changes version as well
|
||||
// otherwise the new slug would be lost as soon as the changes are saved
|
||||
if ($page->version('changes')->exists($language) === true) {
|
||||
$page->version('changes')->update(['slug' => $slug], $language);
|
||||
}
|
||||
|
||||
return $page->save(['slug' => $slug], $languageCode);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the status of the current page
|
||||
* to either draft, listed or unlisted.
|
||||
* If changing to `listed`, you can pass a position for the
|
||||
* page in the siblings collection. Siblings will be resorted.
|
||||
*
|
||||
* @param string $status "draft", "listed" or "unlisted"
|
||||
* @param int|null $position Optional sorting number
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If an invalid status is being passed
|
||||
*/
|
||||
public function changeStatus(
|
||||
string $status,
|
||||
int|null $position = null
|
||||
): static {
|
||||
return match ($status) {
|
||||
'draft' => $this->changeStatusToDraft(),
|
||||
'listed' => $this->changeStatusToListed($position),
|
||||
'unlisted' => $this->changeStatusToUnlisted(),
|
||||
default => throw new InvalidArgumentException(
|
||||
message: 'Invalid status: ' . $status
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
protected function changeStatusToDraft(): static
|
||||
{
|
||||
$arguments = ['page' => $this, 'status' => 'draft', 'position' => null];
|
||||
$page = $this->commit(
|
||||
'changeStatus',
|
||||
$arguments,
|
||||
fn ($page) => $page->unpublish()
|
||||
);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this|static
|
||||
*/
|
||||
protected function changeStatusToListed(int|null $position = null): static
|
||||
{
|
||||
// create a sorting number for the page
|
||||
$num = $this->createNum($position);
|
||||
|
||||
// don't sort if not necessary
|
||||
if ($this->status() === 'listed' && $num === $this->num()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$page = $this->commit(
|
||||
'changeStatus',
|
||||
[
|
||||
'page' => $this,
|
||||
'status' => 'listed',
|
||||
'position' => $num
|
||||
],
|
||||
fn ($page, $status, $position) =>
|
||||
$page->publish()->changeNum($position)
|
||||
);
|
||||
|
||||
if ($this->blueprint()->num() === 'default') {
|
||||
$page->resortSiblingsAfterListing($num);
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this|static
|
||||
*/
|
||||
protected function changeStatusToUnlisted(): static
|
||||
{
|
||||
if ($this->status() === 'unlisted') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$page = $this->commit(
|
||||
'changeStatus',
|
||||
[
|
||||
'page' => $this,
|
||||
'status' => 'unlisted',
|
||||
'position' => null
|
||||
],
|
||||
fn ($page) => $page->publish()->changeNum(null)
|
||||
);
|
||||
|
||||
$this->resortSiblingsAfterUnlisting();
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the position of the page in its siblings
|
||||
* collection. Siblings will be resorted. If the page
|
||||
* status isn't yet `listed`, it will be changed to it.
|
||||
*
|
||||
* @return $this|static
|
||||
*/
|
||||
public function changeSort(int|null $position = null): static
|
||||
{
|
||||
return $this->changeStatus('listed', $position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the page template
|
||||
*
|
||||
* @return $this|static
|
||||
* @throws \Kirby\Exception\LogicException If the textfile cannot be renamed/moved
|
||||
*/
|
||||
public function changeTemplate(string $template): static
|
||||
{
|
||||
if ($template === $this->intendedTemplate()->name()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return $this->commit('changeTemplate', ['page' => $this, 'template' => $template], function ($oldPage, $template) {
|
||||
// convert for new template/blueprint
|
||||
return $oldPage->convertTo($template);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the page title
|
||||
*/
|
||||
public function changeTitle(
|
||||
string $title,
|
||||
string|null $languageCode = null
|
||||
): static {
|
||||
$language = Language::ensure($languageCode ?? 'current');
|
||||
|
||||
$arguments = [
|
||||
'page' => $this,
|
||||
'title' => $title,
|
||||
'languageCode' => $languageCode,
|
||||
'language' => $language
|
||||
];
|
||||
|
||||
return $this->commit('changeTitle', $arguments, function ($page, $title, $languageCode, $language) {
|
||||
|
||||
// make sure to update the title in the changes version as well
|
||||
// otherwise the new title would be lost as soon as the changes are saved
|
||||
if ($page->version('changes')->exists($language) === true) {
|
||||
$page->version('changes')->update(['title' => $title], $language);
|
||||
}
|
||||
|
||||
return $page->save(['title' => $title], $language->code());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a page action, by following these steps
|
||||
*
|
||||
* 1. applies the `before` hook
|
||||
* 2. checks the action rules
|
||||
* 3. commits the store action
|
||||
* 4. applies the `after` hook
|
||||
* 5. returns the result
|
||||
*/
|
||||
protected function commit(
|
||||
string $action,
|
||||
array $arguments,
|
||||
Closure $callback
|
||||
): mixed {
|
||||
$commit = new ModelCommit(
|
||||
model: $this,
|
||||
action: $action
|
||||
);
|
||||
|
||||
return $commit->call($arguments, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the page to a new parent
|
||||
*
|
||||
* @throws \Kirby\Exception\DuplicateException If the page already exists
|
||||
*/
|
||||
public function copy(array $options = []): static
|
||||
{
|
||||
$slug = $options['slug'] ?? $this->slug();
|
||||
$isDraft = $options['isDraft'] ?? $this->isDraft();
|
||||
$parent = $options['parent'] ?? null;
|
||||
$parentModel = $options['parent'] ?? $this->site();
|
||||
$num = $options['num'] ?? null;
|
||||
$children = $options['children'] ?? false;
|
||||
$files = $options['files'] ?? false;
|
||||
|
||||
// clean up the slug
|
||||
$slug = Url::slug($slug);
|
||||
|
||||
if ($parentModel->findPageOrDraft($slug)) {
|
||||
throw new DuplicateException(
|
||||
key: 'page.duplicate',
|
||||
data: ['slug' => $slug]
|
||||
);
|
||||
}
|
||||
|
||||
$tmp = new static([
|
||||
'isDraft' => $isDraft,
|
||||
'num' => $num,
|
||||
'parent' => $parent,
|
||||
'slug' => $slug,
|
||||
]);
|
||||
|
||||
$ignore = [];
|
||||
|
||||
// don't copy files
|
||||
if ($files === false) {
|
||||
foreach ($this->files() as $file) {
|
||||
$ignore[] = $file->root();
|
||||
|
||||
// append all content files
|
||||
array_push($ignore, ...$file->storage()->contentFiles(VersionId::latest()));
|
||||
array_push($ignore, ...$file->storage()->contentFiles(VersionId::changes()));
|
||||
}
|
||||
}
|
||||
|
||||
Dir::copy($this->root(), $tmp->root(), $children, $ignore);
|
||||
|
||||
$copy = $parentModel->clone()->findPageOrDraft($slug);
|
||||
|
||||
// normalize copy object
|
||||
$copy = PageCopy::process(
|
||||
copy: $copy,
|
||||
original: $this,
|
||||
withFiles: $files,
|
||||
withChildren: $children
|
||||
);
|
||||
|
||||
// add copy to siblings
|
||||
ModelState::update(
|
||||
method: 'append',
|
||||
current: $copy,
|
||||
parent: $parentModel
|
||||
);
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and stores a new page
|
||||
*/
|
||||
public static function create(array $props): Page
|
||||
{
|
||||
$props = self::normalizeProps($props);
|
||||
|
||||
// create the instance without content or translations
|
||||
// to avoid that the page is created in memory storage
|
||||
$page = Page::factory([
|
||||
...$props,
|
||||
'content' => null,
|
||||
'translations' => null
|
||||
]);
|
||||
|
||||
// merge the content with the defaults
|
||||
$props['content'] = [
|
||||
...$page->createDefaultContent(),
|
||||
...$props['content'],
|
||||
];
|
||||
|
||||
// make sure that a UUID gets generated
|
||||
// and added to content right away
|
||||
if (Uuids::enabled() === true) {
|
||||
$props['content']['uuid'] ??= Uuid::generate();
|
||||
}
|
||||
|
||||
// keep the initial storage class
|
||||
$storage = $page->storage()::class;
|
||||
|
||||
// Make sure that the page does not already exist at this point.
|
||||
// Otherwise, moving the storage to memory storage, might delete
|
||||
// an existing page before we can even run the checks.
|
||||
PageRules::create($page);
|
||||
|
||||
// make sure that the temporary page is stored in memory
|
||||
$page->changeStorage(MemoryStorage::class);
|
||||
|
||||
// inject the content
|
||||
$page->setContent($props['content']);
|
||||
|
||||
// inject the translations
|
||||
$page->setTranslations($props['translations'] ?? null);
|
||||
|
||||
// run the hooks and creation action
|
||||
$page = $page->commit(
|
||||
'create',
|
||||
[
|
||||
'page' => $page,
|
||||
'input' => $props
|
||||
],
|
||||
function ($page) use ($storage) {
|
||||
// move to final storage
|
||||
return $page->changeStorage($storage);
|
||||
}
|
||||
);
|
||||
|
||||
// publish the new page if a number is given
|
||||
if (isset($props['num']) === true) {
|
||||
$page = $page->changeStatus('listed', $props['num']);
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a child of the current page
|
||||
*/
|
||||
public function createChild(array $props): Page
|
||||
{
|
||||
$props = [
|
||||
...$props,
|
||||
'url' => null,
|
||||
'num' => null,
|
||||
'parent' => $this,
|
||||
'site' => $this->site(),
|
||||
];
|
||||
|
||||
if (
|
||||
($template = $props['template'] ?? null) &&
|
||||
($model = static::$models[$template] ?? null)
|
||||
) {
|
||||
return $model::create($props);
|
||||
}
|
||||
|
||||
return static::create($props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the sorting number for the page
|
||||
* depending on the blueprint settings
|
||||
*/
|
||||
public function createNum(int|null $num = null): int
|
||||
{
|
||||
$mode = $this->blueprint()->num();
|
||||
|
||||
switch ($mode) {
|
||||
case 'zero':
|
||||
return 0;
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
// the $format needs to produce only digits,
|
||||
// so it can be converted to integer below
|
||||
$format = $mode === 'date' ? 'Ymd' : 'YmdHi';
|
||||
$field = $this->content('default')->get('date');
|
||||
$date = $field->isEmpty() ? 'now' : $field;
|
||||
return (int)date($format, strtotime($date));
|
||||
case 'default':
|
||||
|
||||
$max = $this
|
||||
->parentModel()
|
||||
->children()
|
||||
->listed()
|
||||
->merge($this)
|
||||
->count();
|
||||
|
||||
// default positioning at the end
|
||||
$num ??= $max;
|
||||
|
||||
// avoid zeros or negative numbers
|
||||
if ($num < 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// avoid higher numbers than possible
|
||||
if ($num > $max) {
|
||||
return $max;
|
||||
}
|
||||
|
||||
return $num;
|
||||
default:
|
||||
// get instance with default language
|
||||
$app = $this->kirby()->clone([], false);
|
||||
$app->setCurrentLanguage();
|
||||
|
||||
$template = Str::template($mode, [
|
||||
'kirby' => $app,
|
||||
'page' => $this,
|
||||
'site' => $app->site(),
|
||||
], ['fallback' => '']);
|
||||
|
||||
return (int)$template;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the page
|
||||
*/
|
||||
public function delete(bool $force = false): bool
|
||||
{
|
||||
return $this->commit('delete', ['page' => $this, 'force' => $force], function ($page, $force) {
|
||||
$old = $page->clone();
|
||||
|
||||
// keep the content in iummtable memory storage
|
||||
// to still have access to it in after hooks
|
||||
$page->changeStorage(ImmutableMemoryStorage::class);
|
||||
|
||||
// clear UUID cache
|
||||
$page->uuid()?->clear();
|
||||
|
||||
// Explanation: The two while loops below are only
|
||||
// necessary because our property caches result in
|
||||
// outdated collections when deleting nested pages.
|
||||
// When we use a foreach loop to go through those collections,
|
||||
// we encounter outdated objects. Using a while loop
|
||||
// fixes this issue.
|
||||
//
|
||||
// TODO: We can remove this part as soon
|
||||
// as we move away from our immutable object architecture.
|
||||
|
||||
// delete all files individually
|
||||
while ($file = $page->files()->first()) {
|
||||
$file->delete();
|
||||
}
|
||||
|
||||
// delete all children individually
|
||||
while ($child = $page->childrenAndDrafts()->first()) {
|
||||
$child->delete(true);
|
||||
}
|
||||
|
||||
// delete all versions,
|
||||
// the plain text storage handler will then clean
|
||||
// up the directory if it's empty
|
||||
$old->versions()->delete();
|
||||
|
||||
if (
|
||||
$page->isListed() === true &&
|
||||
$page->blueprint()->num() === 'default'
|
||||
) {
|
||||
$page->resortSiblingsAfterUnlisting();
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates the page with the given
|
||||
* slug and optionally copies all files
|
||||
*/
|
||||
public function duplicate(string|null $slug = null, array $options = []): static
|
||||
{
|
||||
// create the slug for the duplicate
|
||||
$slug = Url::slug($slug ?? $this->slug() . '-' . Url::slug(I18n::translate('page.duplicate.appendix')));
|
||||
|
||||
$arguments = [
|
||||
'originalPage' => $this,
|
||||
'input' => $slug,
|
||||
'options' => $options
|
||||
];
|
||||
|
||||
return $this->commit('duplicate', $arguments, function ($page, $slug, $options) {
|
||||
$page = $this->copy([
|
||||
'parent' => $this->parent(),
|
||||
'slug' => $slug,
|
||||
'isDraft' => true,
|
||||
'files' => $options['files'] ?? false,
|
||||
'children' => $options['children'] ?? false,
|
||||
]);
|
||||
|
||||
if (isset($options['title']) === true) {
|
||||
$page = $page->changeTitle($options['title']);
|
||||
}
|
||||
|
||||
return $page;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the page to a new parent if the
|
||||
* new parent accepts the page type
|
||||
*/
|
||||
public function move(Site|Page $parent): Page
|
||||
{
|
||||
// nothing to move
|
||||
if ($this->parentModel()->is($parent) === true) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$arguments = [
|
||||
'page' => $this,
|
||||
'parent' => $parent
|
||||
];
|
||||
|
||||
return $this->commit('move', $arguments, function ($page, $parent) {
|
||||
// remove the uuid cache for this page
|
||||
$page->uuid()?->clear(true);
|
||||
|
||||
// move drafts into the drafts folder of the parent
|
||||
$newRoot = match ($page->isDraft()) {
|
||||
true => $parent->root() . '/_drafts/' . $page->dirname(),
|
||||
false => $parent->root() . '/' . $page->dirname()
|
||||
};
|
||||
|
||||
// try to move the page directory on disk
|
||||
if (Dir::move($page->root(), $newRoot) !== true) {
|
||||
throw new LogicException(
|
||||
key: 'page.move.directory'
|
||||
);
|
||||
}
|
||||
|
||||
// flush all collection caches to be sure that
|
||||
// the new child is included afterwards
|
||||
$parent->purge();
|
||||
|
||||
// double-check if the new child can actually be found
|
||||
if (!$newPage = $parent->childrenAndDrafts()->find($page->slug())) {
|
||||
throw new LogicException(
|
||||
key: 'page.move.notFound'
|
||||
);
|
||||
}
|
||||
|
||||
return $newPage;
|
||||
});
|
||||
}
|
||||
|
||||
protected static function normalizeProps(array $props): array
|
||||
{
|
||||
$content = $props['content'] ?? [];
|
||||
$template = $props['template'] ?? 'default';
|
||||
|
||||
return [
|
||||
...$props,
|
||||
'content' => $content,
|
||||
'isDraft' => $props['isDraft'] ?? $props['draft'] ?? true,
|
||||
'model' => $props['model'] ?? $template,
|
||||
'slug' => Url::slug($props['slug'] ?? $content['title'] ?? null),
|
||||
'template' => $template,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this|static
|
||||
* @throws \Kirby\Exception\LogicException If the folder cannot be moved
|
||||
*/
|
||||
public function publish(): static
|
||||
{
|
||||
if ($this->isDraft() === false) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$page = $this->clone([
|
||||
'isDraft' => false,
|
||||
'root' => null,
|
||||
'template' => $this->intendedTemplate()->name(),
|
||||
]);
|
||||
|
||||
// actually do it on disk
|
||||
if ($this->exists() === true) {
|
||||
if (Dir::move($this->root(), $page->root()) !== true) {
|
||||
throw new LogicException(
|
||||
message: 'The draft folder cannot be moved'
|
||||
);
|
||||
}
|
||||
|
||||
// Get the draft folder and check if there are any other drafts
|
||||
// left. Otherwise delete it.
|
||||
$draftDir = dirname($this->root());
|
||||
|
||||
if (Dir::isEmpty($draftDir) === true) {
|
||||
Dir::remove($draftDir);
|
||||
}
|
||||
}
|
||||
|
||||
// remove the page from the parent drafts and add it to children
|
||||
$parentModel = $page->parentModel();
|
||||
$parentModel->drafts()->remove($page);
|
||||
$parentModel->children()->append($page->id(), $page);
|
||||
|
||||
// update the childrenAndDrafts() cache if it is initialized
|
||||
if ($parentModel->childrenAndDrafts !== null) {
|
||||
$parentModel->childrenAndDrafts()->set($page->id(), $page);
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean internal caches
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function purge(): static
|
||||
{
|
||||
parent::purge();
|
||||
|
||||
$this->blueprint = null;
|
||||
$this->children = null;
|
||||
$this->childrenAndDrafts = null;
|
||||
$this->drafts = null;
|
||||
$this->files = null;
|
||||
$this->inventory = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\LogicException If the page is not included in the siblings collection
|
||||
*/
|
||||
protected function resortSiblingsAfterListing(int|null $position = null): bool
|
||||
{
|
||||
$parent = $this->parentModel();
|
||||
$siblings = $parent->children();
|
||||
|
||||
// Get all listed siblings including the current page
|
||||
$listed = $siblings
|
||||
->listed()
|
||||
->append($this)
|
||||
->filter(fn ($page) => $page->blueprint()->num() === 'default');
|
||||
|
||||
// Get a non-associative array of ids
|
||||
$keys = $listed->keys();
|
||||
$index = array_search($this->id(), $keys);
|
||||
|
||||
// If the page is not included in the siblings something went wrong
|
||||
if ($index === false) {
|
||||
throw new LogicException(
|
||||
message: 'The page is not included in the sorting index'
|
||||
);
|
||||
}
|
||||
|
||||
if ($position > count($keys)) {
|
||||
$position = count($keys);
|
||||
}
|
||||
|
||||
// Move the current page number in the array of keys.
|
||||
// Subtract 1 from the num and the position, because of the
|
||||
// zero-based array keys
|
||||
$sorted = A::move($keys, $index, $position - 1);
|
||||
|
||||
foreach ($sorted as $key => $id) {
|
||||
if ($id === $this->id()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply the new sorting number
|
||||
// and update the new object in the siblings collection
|
||||
$newSibling = $listed->get($id)?->changeNum($key + 1);
|
||||
$siblings->update($newSibling);
|
||||
}
|
||||
|
||||
// Update the parent's children collection with the new sorting
|
||||
$parent->children = $siblings->sort('isListed', 'desc', 'num', 'asc');
|
||||
$parent->childrenAndDrafts = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function resortSiblingsAfterUnlisting(): bool
|
||||
{
|
||||
$index = 0;
|
||||
$parent = $this->parentModel();
|
||||
$siblings = $parent->children();
|
||||
|
||||
// Get all listed siblings excluding the current page
|
||||
$listed = $siblings
|
||||
->listed()
|
||||
->not($this)
|
||||
->filter(fn ($page) => $page->blueprint()->num() === 'default');
|
||||
|
||||
if ($listed->count() > 0) {
|
||||
foreach ($listed as $sibling) {
|
||||
$index++;
|
||||
|
||||
// Apply the new sorting number
|
||||
// and update the new object in the siblings collection
|
||||
$newSibling = $sibling->changeNum($index);
|
||||
$siblings->update($newSibling);
|
||||
}
|
||||
|
||||
// Update the parent's children collection with the new sorting
|
||||
$parent->children = $siblings->sort('isListed', 'desc', 'num', 'asc');
|
||||
$parent->childrenAndDrafts = null;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a page from listed or unlisted to draft
|
||||
*
|
||||
* @return $this|static
|
||||
* @throws \Kirby\Exception\LogicException If the folder cannot be moved
|
||||
*/
|
||||
public function unpublish(): static
|
||||
{
|
||||
if ($this->isDraft() === true) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$page = $this->clone([
|
||||
'isDraft' => true,
|
||||
'num' => null,
|
||||
'dirname' => null,
|
||||
'root' => null,
|
||||
'template' => $this->intendedTemplate()->name(),
|
||||
]);
|
||||
|
||||
// remove the media directory
|
||||
Dir::remove($this->mediaRoot());
|
||||
|
||||
// actually do it on disk
|
||||
if ($this->exists() === true) {
|
||||
if (Dir::move($this->root(), $page->root()) !== true) {
|
||||
throw new LogicException(
|
||||
message: 'The page folder cannot be moved to drafts'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// remove the page from the parent children and add it to drafts
|
||||
$parentModel = $page->parentModel();
|
||||
$parentModel->children()->remove($page);
|
||||
$parentModel->drafts()->append($page->id(), $page);
|
||||
|
||||
// update the childrenAndDrafts() cache if it is initialized
|
||||
if ($parentModel->childrenAndDrafts !== null) {
|
||||
$parentModel->childrenAndDrafts()->set($page->id(), $page);
|
||||
}
|
||||
|
||||
$page->resortSiblingsAfterUnlisting();
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the page data
|
||||
*/
|
||||
public function update(
|
||||
array|null $input = null,
|
||||
string|null $languageCode = null,
|
||||
bool $validate = false
|
||||
): static {
|
||||
if ($this->isDraft() === true) {
|
||||
$validate = false;
|
||||
}
|
||||
|
||||
$page = parent::update($input, $languageCode, $validate);
|
||||
|
||||
// if num is created from page content, update num on content update
|
||||
if (
|
||||
$page->isListed() === true &&
|
||||
in_array($page->blueprint()->num(), ['zero', 'default'], true) === false
|
||||
) {
|
||||
$page = $page->changeNum($page->createNum());
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates parent collections with the new page object
|
||||
* after a page action
|
||||
*
|
||||
* @deprecated 5.0.0 Use ModelState::update instead
|
||||
*/
|
||||
protected static function updateParentCollections(
|
||||
Page $page,
|
||||
string|false $method,
|
||||
Page|Site|null $parentModel = null
|
||||
): void {
|
||||
ModelState::update(
|
||||
method: $method,
|
||||
current: $page,
|
||||
next: $page,
|
||||
parent: $parentModel
|
||||
);
|
||||
}
|
||||
}
|
||||
189
kirby/src/Cms/PageBlueprint.php
Normal file
189
kirby/src/Cms/PageBlueprint.php
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* PageBlueprint
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class PageBlueprint extends Blueprint
|
||||
{
|
||||
/**
|
||||
* Creates a new page blueprint object
|
||||
* with the given props
|
||||
*/
|
||||
public function __construct(array $props)
|
||||
{
|
||||
parent::__construct($props);
|
||||
|
||||
// normalize all available page options
|
||||
$this->props['options'] = $this->normalizeOptions(
|
||||
$this->props['options'] ?? true,
|
||||
// defaults
|
||||
[
|
||||
'access' => null,
|
||||
'changeSlug' => null,
|
||||
'changeStatus' => null,
|
||||
'changeTemplate' => null,
|
||||
'changeTitle' => null,
|
||||
'create' => null,
|
||||
'delete' => null,
|
||||
'duplicate' => null,
|
||||
'list' => null,
|
||||
'move' => null,
|
||||
'preview' => null,
|
||||
'read' => null,
|
||||
'sort' => null,
|
||||
'update' => null,
|
||||
],
|
||||
// aliases (from v2)
|
||||
[
|
||||
'status' => 'changeStatus',
|
||||
'template' => 'changeTemplate',
|
||||
'title' => 'changeTitle',
|
||||
'url' => 'changeSlug',
|
||||
]
|
||||
);
|
||||
|
||||
// normalize the ordering number
|
||||
$this->props['num'] = $this->normalizeNum($this->props['num'] ?? 'default');
|
||||
|
||||
// normalize the available status array
|
||||
$this->props['status'] = $this->normalizeStatus($this->props['status'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the page numbering mode
|
||||
*/
|
||||
public function num(): string
|
||||
{
|
||||
return $this->props['num'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the ordering number
|
||||
*/
|
||||
protected function normalizeNum($num): string
|
||||
{
|
||||
$aliases = [
|
||||
'0' => 'zero',
|
||||
'sort' => 'default',
|
||||
];
|
||||
|
||||
return $aliases[$num] ?? $num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the available status options for the page
|
||||
*/
|
||||
protected function normalizeStatus($status): array
|
||||
{
|
||||
$defaults = [
|
||||
'draft' => [
|
||||
'label' => $this->i18n('page.status.draft'),
|
||||
'text' => $this->i18n('page.status.draft.description'),
|
||||
],
|
||||
'unlisted' => [
|
||||
'label' => $this->i18n('page.status.unlisted'),
|
||||
'text' => $this->i18n('page.status.unlisted.description'),
|
||||
],
|
||||
'listed' => [
|
||||
'label' => $this->i18n('page.status.listed'),
|
||||
'text' => $this->i18n('page.status.listed.description'),
|
||||
]
|
||||
];
|
||||
|
||||
// use the defaults, when the status is not defined
|
||||
if (empty($status) === true) {
|
||||
$status = $defaults;
|
||||
}
|
||||
|
||||
// extend the status definition
|
||||
$status = $this->extend($status);
|
||||
|
||||
// clean up and translate each status
|
||||
foreach ($status as $key => $options) {
|
||||
// skip invalid status definitions
|
||||
if (in_array($key, ['draft', 'listed', 'unlisted'], true) === false || $options === false) {
|
||||
unset($status[$key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($options === true) {
|
||||
$status[$key] = $defaults[$key];
|
||||
continue;
|
||||
}
|
||||
|
||||
// convert everything to a simple array
|
||||
if (is_array($options) === false) {
|
||||
$status[$key] = [
|
||||
'label' => $options,
|
||||
'text' => null
|
||||
];
|
||||
}
|
||||
|
||||
// always make sure to have a proper label
|
||||
if (empty($status[$key]['label']) === true) {
|
||||
$status[$key]['label'] = $defaults[$key]['label'];
|
||||
}
|
||||
|
||||
// also make sure to have the text field set
|
||||
$status[$key]['text'] ??= null;
|
||||
|
||||
// translate text and label if necessary
|
||||
$status[$key]['label'] = $this->i18n($status[$key]['label'], $status[$key]['label']);
|
||||
$status[$key]['text'] = $this->i18n($status[$key]['text'], $status[$key]['text']);
|
||||
}
|
||||
|
||||
// the draft status is required
|
||||
if (isset($status['draft']) === false) {
|
||||
$status = ['draft' => $defaults['draft']] + $status;
|
||||
}
|
||||
|
||||
// remove the draft status for the home and error pages
|
||||
if ($this->model->isHomeOrErrorPage() === true) {
|
||||
unset($status['draft']);
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the options object
|
||||
* that handles page options and permissions
|
||||
*/
|
||||
public function options(): array
|
||||
{
|
||||
return $this->props['options'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preview settings
|
||||
* The preview setting controls the "Open"
|
||||
* button in the panel and redirects it to a
|
||||
* different URL if necessary.
|
||||
*/
|
||||
public function preview(): string|bool
|
||||
{
|
||||
$preview = $this->props['options']['preview'] ?? true;
|
||||
|
||||
if (is_string($preview) === true) {
|
||||
return $this->model->toString($preview);
|
||||
}
|
||||
|
||||
return $this->model->permissions()->can('preview', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the status array
|
||||
*/
|
||||
public function status(): array
|
||||
{
|
||||
return $this->props['status'];
|
||||
}
|
||||
}
|
||||
236
kirby/src/Cms/PageCopy.php
Normal file
236
kirby/src/Cms/PageCopy.php
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Uuid\Uuid;
|
||||
use Kirby\Uuid\Uuids;
|
||||
|
||||
/**
|
||||
* Normalizes a newly generated copy of a page,
|
||||
* adapting page slugs, UUIDs etc.
|
||||
* (for single as well as multilang setups)
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class PageCopy
|
||||
{
|
||||
public function __construct(
|
||||
public Page $copy,
|
||||
public Page|null $original = null,
|
||||
public bool $withFiles = false,
|
||||
public bool $withChildren = false,
|
||||
public array $uuids = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts UUIDs for copied pages,
|
||||
* replacing the old UUID with a newly generated one
|
||||
* for all newly generated pages and files
|
||||
*/
|
||||
public function convertUuids(Language|null $language): void
|
||||
{
|
||||
if (Uuids::enabled() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
$language instanceof Language &&
|
||||
$language->isDefault() === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// store old UUID
|
||||
$old = $this->copy->uuid()->toString();
|
||||
|
||||
// re-generate UUID for the page
|
||||
$this->copy = $this->copy->save(
|
||||
['uuid' => Uuid::generate()],
|
||||
$language?->code()
|
||||
);
|
||||
|
||||
// track UUID change
|
||||
$this->uuids[$old] = $this->copy->uuid()->toString();
|
||||
|
||||
$this->convertFileUuids($language);
|
||||
$this->convertChildrenUuids($language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-generate UUIDs for each child recursively
|
||||
* and merge with the tracked changed UUIDs
|
||||
*/
|
||||
protected function convertChildrenUuids(Language|null $language): void
|
||||
{
|
||||
// re-generate UUIDs and track changes
|
||||
if ($this->withChildren === true) {
|
||||
foreach ($this->copy->childrenAndDrafts() as $child) {
|
||||
// always adapt files of subpages as they are
|
||||
// currently always copied; adapt children recursively
|
||||
$child = new PageCopy(
|
||||
$child,
|
||||
withChildren: true,
|
||||
withFiles: true,
|
||||
uuids: $this->uuids
|
||||
);
|
||||
$child->convertUuids($language);
|
||||
$this->uuids = [...$this->uuids, ...$child->uuids];
|
||||
}
|
||||
}
|
||||
|
||||
// if children have not been copied over,
|
||||
// track all children UUIDs from original page to
|
||||
// remove/replace with empty string
|
||||
if ($this->withChildren === false) {
|
||||
foreach ($this->original?->index(drafts: true) ?? [] as $child) {
|
||||
$this->uuids[$child->uuid()->toString()] = '';
|
||||
|
||||
foreach ($child->files() as $file) {
|
||||
$this->uuids[$file->uuid()->toString()] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-generate UUID for each file and track the change
|
||||
*/
|
||||
protected function convertFileUuids(Language|null $language): void
|
||||
{
|
||||
// re-generate UUIDs and track changes
|
||||
if ($this->withFiles === true) {
|
||||
foreach ($this->copy->files() as $file) {
|
||||
// store old file UUID
|
||||
$old = $file->uuid()->toString();
|
||||
|
||||
// re-generate UUID for the file
|
||||
$file = $file->save(
|
||||
['uuid' => Uuid::generate()],
|
||||
$language?->code()
|
||||
);
|
||||
|
||||
// track UUID change
|
||||
$this->uuids[$old] = $file->uuid()->toString();
|
||||
}
|
||||
}
|
||||
|
||||
// if files have not been copied over,
|
||||
// track file UUIDs from original page to
|
||||
// remove/replace with empty string
|
||||
if ($this->withFiles === false) {
|
||||
foreach ($this->original?->files() ?? [] as $file) {
|
||||
$this->uuids[$file->uuid()->toString()] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all languages to adapt
|
||||
*
|
||||
* @todo Refactor once singe-lang mode also works with a language object
|
||||
*/
|
||||
public function languages(): Languages|iterable
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
if ($kirby->multilang() === true) {
|
||||
return $kirby->languages();
|
||||
}
|
||||
|
||||
return [null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the copy with all necessary adaptations.
|
||||
* Main method to use if not familiar with individual steps.
|
||||
*/
|
||||
public static function process(
|
||||
Page $copy,
|
||||
Page|null $original = null,
|
||||
bool $withFiles = false,
|
||||
bool $withChildren = false
|
||||
): Page {
|
||||
$converter = new static($copy, $original, $withFiles, $withChildren);
|
||||
|
||||
// loop through all languages to remove slug from non-default
|
||||
// languages and re-generate UUIDs (and track changes)
|
||||
foreach ($converter->languages() as $language) {
|
||||
$converter->removeSlug($language);
|
||||
$converter->convertUuids($language);
|
||||
}
|
||||
|
||||
// apply all tracked UUID changes at once
|
||||
$converter->replaceUuids();
|
||||
|
||||
return $converter->copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes translated slug for copied page.
|
||||
* This is needed to avoid translated slug
|
||||
* collisions with the original page.
|
||||
*/
|
||||
public function removeSlug(Language|null $language): void
|
||||
{
|
||||
// single lang setup
|
||||
if ($language === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// don't remove slug from default language
|
||||
if ($language->isDefault() === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->copy->translation($language)->exists() === true) {
|
||||
$this->copy = $this->copy->save(
|
||||
['slug' => null],
|
||||
$language->code()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace old UUIDs with new UUIDs in the content
|
||||
*/
|
||||
public function replaceUuids(): void
|
||||
{
|
||||
if (Uuids::enabled() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->copy->storage()->all() as $versionId => $language) {
|
||||
$this->copy->storage()->replaceStrings(
|
||||
$versionId,
|
||||
$language,
|
||||
$this->uuids
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->withFiles === true) {
|
||||
foreach ($this->copy->files() as $file) {
|
||||
foreach ($file->storage()->all() as $versionId => $language) {
|
||||
$file->storage()->replaceStrings(
|
||||
$versionId,
|
||||
$language,
|
||||
$this->uuids
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->withChildren === true) {
|
||||
foreach ($this->copy->childrenAndDrafts() as $child) {
|
||||
$child = new PageCopy($child, withFiles: true, withChildren: true, uuids: $this->uuids);
|
||||
$child->replaceUuids();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
kirby/src/Cms/PagePermissions.php
Normal file
75
kirby/src/Cms/PagePermissions.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* PagePermissions
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class PagePermissions extends ModelPermissions
|
||||
{
|
||||
protected const CATEGORY = 'pages';
|
||||
|
||||
/**
|
||||
* Used to cache once determined permissions in memory
|
||||
*/
|
||||
protected static function cacheKey(ModelWithContent|Language $model): string
|
||||
{
|
||||
return $model->intendedTemplate()->name();
|
||||
}
|
||||
|
||||
protected function canChangeSlug(): bool
|
||||
{
|
||||
return $this->model->isHomeOrErrorPage() !== true;
|
||||
}
|
||||
|
||||
protected function canChangeStatus(): bool
|
||||
{
|
||||
return $this->model->isErrorPage() !== true;
|
||||
}
|
||||
|
||||
protected function canChangeTemplate(): bool
|
||||
{
|
||||
if ($this->model->isErrorPage() === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (count($this->model->blueprints()) <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function canDelete(): bool
|
||||
{
|
||||
return $this->model->isHomeOrErrorPage() !== true;
|
||||
}
|
||||
|
||||
protected function canMove(): bool
|
||||
{
|
||||
return $this->model->isHomeOrErrorPage() !== true;
|
||||
}
|
||||
|
||||
protected function canSort(): bool
|
||||
{
|
||||
if ($this->model->isErrorPage() === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->model->isListed() !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->model->blueprint()->num() !== 'default') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
232
kirby/src/Cms/PagePicker.php
Normal file
232
kirby/src/Cms/PagePicker.php
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* The PagePicker class helps to
|
||||
* fetch the right pages and the parent
|
||||
* model for the API calls for the
|
||||
* page picker component in the panel.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class PagePicker extends Picker
|
||||
{
|
||||
// TODO: null only due to our Properties setters,
|
||||
// remove once our implementation is better
|
||||
protected Pages|null $items = null;
|
||||
protected Pages|null $itemsForQuery = null;
|
||||
protected Page|Site|null $parent = null;
|
||||
|
||||
/**
|
||||
* Extends the basic defaults
|
||||
*/
|
||||
public function defaults(): array
|
||||
{
|
||||
return [
|
||||
...parent::defaults(),
|
||||
// Page ID of the selected parent. Used to navigate
|
||||
'parent' => null,
|
||||
// enable/disable subpage navigation
|
||||
'subpages' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent model object that
|
||||
* is currently selected in the page picker.
|
||||
* It normally starts at the site, but can
|
||||
* also be any subpage. When a query is given
|
||||
* and subpage navigation is deactivated,
|
||||
* there will be no model available at all.
|
||||
*/
|
||||
public function model(): Page|Site|null
|
||||
{
|
||||
// no subpages navigation = no model
|
||||
if ($this->options['subpages'] === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// the model for queries is a bit more tricky to find
|
||||
if (empty($this->options['query']) === false) {
|
||||
return $this->modelForQuery();
|
||||
}
|
||||
|
||||
return $this->parent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a model object for the given
|
||||
* query, depending on the parent and subpages
|
||||
* options.
|
||||
*/
|
||||
public function modelForQuery(): Page|Site|null
|
||||
{
|
||||
if ($this->options['subpages'] === true && empty($this->options['parent']) === false) {
|
||||
return $this->parent();
|
||||
}
|
||||
|
||||
return $this->items()?->parent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns basic information about the
|
||||
* parent model that is currently selected
|
||||
* in the page picker.
|
||||
*/
|
||||
public function modelToArray(Page|Site|null $model = null): array|null
|
||||
{
|
||||
if ($model === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// the selected model is the site. there's nothing above
|
||||
if ($model instanceof Site) {
|
||||
return [
|
||||
'id' => null,
|
||||
'parent' => null,
|
||||
'title' => $model->title()->value()
|
||||
];
|
||||
}
|
||||
|
||||
// the top-most page has been reached
|
||||
// the missing id indicates that there's nothing above
|
||||
if ($model->id() === $this->start()->id()) {
|
||||
return [
|
||||
'id' => null,
|
||||
'parent' => null,
|
||||
'title' => $model->title()->value()
|
||||
];
|
||||
}
|
||||
|
||||
// the model is a regular page
|
||||
return [
|
||||
'id' => $model->id(),
|
||||
'parent' => $model->parentModel()->id(),
|
||||
'title' => $model->title()->value()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search all pages for the picker
|
||||
*/
|
||||
public function items(): Pages|null
|
||||
{
|
||||
// cache
|
||||
if ($this->items !== null) {
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
// no query? simple parent-based search for pages
|
||||
if (empty($this->options['query']) === true) {
|
||||
$items = $this->itemsForParent();
|
||||
|
||||
// when subpage navigation is enabled, a parent
|
||||
// might be passed in addition to the query.
|
||||
// The parent then takes priority.
|
||||
// Don't use the parent if it's the same as the start/top-most model.
|
||||
} elseif (
|
||||
$this->options['subpages'] === true &&
|
||||
empty($this->options['parent']) === false &&
|
||||
$this->model()->id() !== $this->start()->id()
|
||||
) {
|
||||
$items = $this->itemsForParent();
|
||||
|
||||
// search by query
|
||||
} else {
|
||||
$items = $this->itemsForQuery();
|
||||
}
|
||||
|
||||
// filter protected and hidden pages
|
||||
$items = $items->filter('isListable', true);
|
||||
|
||||
// search
|
||||
$items = $this->search($items);
|
||||
|
||||
// paginate the result
|
||||
return $this->items = $this->paginate($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for pages by parent
|
||||
*/
|
||||
public function itemsForParent(): Pages
|
||||
{
|
||||
return $this->parent()->children();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for pages by query string
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function itemsForQuery(): Pages
|
||||
{
|
||||
// cache
|
||||
if ($this->itemsForQuery !== null) {
|
||||
return $this->itemsForQuery;
|
||||
}
|
||||
|
||||
$model = $this->options['model'];
|
||||
$items = $model->query($this->options['query']);
|
||||
|
||||
// help mitigate some typical query usage issues
|
||||
// by converting site and page objects to proper
|
||||
// pages by returning their children
|
||||
$items = match (true) {
|
||||
$items instanceof Site,
|
||||
$items instanceof Page => $items->children(),
|
||||
$items instanceof Pages => $items,
|
||||
|
||||
default => throw new InvalidArgumentException(
|
||||
message: 'Your query must return a set of pages'
|
||||
)
|
||||
};
|
||||
|
||||
return $this->itemsForQuery = $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent model.
|
||||
* The model will be used to fetch
|
||||
* subpages unless there's a specific
|
||||
* query to find pages instead.
|
||||
*/
|
||||
public function parent(): Page|Site
|
||||
{
|
||||
return $this->parent ??= $this->kirby->page($this->options['parent']) ?? $this->site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the top-most model (page or site)
|
||||
* that can be accessed when navigating
|
||||
* through pages.
|
||||
*/
|
||||
public function start(): Page|Site
|
||||
{
|
||||
if (empty($this->options['query']) === false) {
|
||||
return $this->itemsForQuery()?->parent() ?? $this->site;
|
||||
}
|
||||
|
||||
return $this->site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an associative array
|
||||
* with all information for the picker.
|
||||
* This will be passed directly to the API.
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$array = parent::toArray();
|
||||
$array['model'] = $this->modelToArray($this->model());
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
473
kirby/src/Cms/PageRules.php
Normal file
473
kirby/src/Cms/PageRules.php
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\DuplicateException;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Exception\PermissionException;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Validators for all page actions
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class PageRules
|
||||
{
|
||||
/**
|
||||
* Validates if the sorting number of the page can be changed
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the given number is invalid
|
||||
*/
|
||||
public static function changeNum(Page $page, int|null $num = null): void
|
||||
{
|
||||
if ($num !== null && $num < 0) {
|
||||
throw new InvalidArgumentException(key: 'page.num.invalid');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the slug for the page can be changed
|
||||
*
|
||||
* @throws \Kirby\Exception\DuplicateException If a page with this slug already exists
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the slug
|
||||
*/
|
||||
public static function changeSlug(Page $page, string $slug): void
|
||||
{
|
||||
if ($page->permissions()->can('changeSlug') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.changeSlug.permission',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
|
||||
self::validateSlugLength($slug);
|
||||
self::validateSlugProtectedPaths($page, $slug);
|
||||
|
||||
$siblings = $page->parentModel()->children();
|
||||
$drafts = $page->parentModel()->drafts();
|
||||
|
||||
if ($siblings->find($slug)?->is($page) === false) {
|
||||
throw new DuplicateException(
|
||||
key: 'page.duplicate',
|
||||
data: ['slug' => $slug]
|
||||
);
|
||||
}
|
||||
|
||||
if ($drafts->find($slug)?->is($page) === false) {
|
||||
throw new DuplicateException(
|
||||
key: 'page.draft.duplicate',
|
||||
data: ['slug' => $slug]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the status for the page can be changed
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the given status is invalid
|
||||
*/
|
||||
public static function changeStatus(
|
||||
Page $page,
|
||||
string $status,
|
||||
int|null $position = null
|
||||
): void {
|
||||
if (isset($page->blueprint()->status()[$status]) === false) {
|
||||
throw new InvalidArgumentException(key: 'page.status.invalid');
|
||||
}
|
||||
|
||||
match ($status) {
|
||||
'draft' => static::changeStatusToDraft($page),
|
||||
'listed' => static::changeStatusToListed($page, $position),
|
||||
'unlisted' => static::changeStatusToUnlisted($page),
|
||||
default => throw new InvalidArgumentException(
|
||||
key: 'page.status.invalid'
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a page can be converted to a draft
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status or the page cannot be converted to a draft
|
||||
*/
|
||||
public static function changeStatusToDraft(Page $page): void
|
||||
{
|
||||
if ($page->permissions()->can('changeStatus') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.changeStatus.permission',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
|
||||
if ($page->isHomeOrErrorPage() === true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.changeStatus.toDraft.invalid',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the status of a page can be changed to listed
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the given position is invalid
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status or the status for the page cannot be changed by any user
|
||||
*/
|
||||
public static function changeStatusToListed(Page $page, int $position): void
|
||||
{
|
||||
// no need to check for status changing permissions,
|
||||
// instead we need to check for sorting permissions
|
||||
if ($page->isListed() === true) {
|
||||
if ($page->isSortable() !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.sort.permission',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
static::publish($page);
|
||||
|
||||
if ($position !== null && $position < 0) {
|
||||
throw new InvalidArgumentException(key: 'page.num.invalid');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the status of a page can be changed to unlisted
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status
|
||||
*/
|
||||
public static function changeStatusToUnlisted(Page $page)
|
||||
{
|
||||
static::publish($page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the template of the page can be changed
|
||||
*
|
||||
* @throws \Kirby\Exception\LogicException If the template of the page cannot be changed at all
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the template
|
||||
*/
|
||||
public static function changeTemplate(Page $page, string $template): void
|
||||
{
|
||||
if ($page->permissions()->can('changeTemplate') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.changeTemplate.permission',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
|
||||
$blueprints = $page->blueprints();
|
||||
|
||||
if (
|
||||
count($blueprints) <= 1 ||
|
||||
in_array($template, array_column($blueprints, 'name'), true) === false
|
||||
) {
|
||||
throw new LogicException(
|
||||
key: 'page.changeTemplate.invalid',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the title of the page can be changed
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the new title is empty
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the title
|
||||
*/
|
||||
public static function changeTitle(Page $page, string $title): void
|
||||
{
|
||||
if ($page->permissions()->can('changeTitle') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.changeTitle.permission',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
|
||||
static::validateTitleLength($title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the page can be created
|
||||
*
|
||||
* @throws \Kirby\Exception\DuplicateException If the same page or a draft already exists
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the slug is invalid
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to create this page
|
||||
*/
|
||||
public static function create(Page $page): void
|
||||
{
|
||||
if ($page->permissions()->can('create') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.create.permission',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
|
||||
self::validateSlugLength($page->slug());
|
||||
self::validateSlugProtectedPaths($page, $page->slug());
|
||||
|
||||
if ($page->exists() === true) {
|
||||
throw new DuplicateException(
|
||||
key: 'page.draft.duplicate',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
|
||||
$siblings = $page->parentModel()->children();
|
||||
$drafts = $page->parentModel()->drafts();
|
||||
$slug = $page->slug();
|
||||
|
||||
if ($siblings->find($slug)) {
|
||||
throw new DuplicateException(
|
||||
key: 'page.duplicate',
|
||||
data: ['slug' => $slug]
|
||||
);
|
||||
}
|
||||
|
||||
if ($drafts->find($slug)) {
|
||||
throw new DuplicateException(
|
||||
key: 'page.draft.duplicate',
|
||||
data: ['slug' => $slug]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the page can be deleted
|
||||
*
|
||||
* @throws \Kirby\Exception\LogicException If the page has children and should not be force-deleted
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to delete the page
|
||||
*/
|
||||
public static function delete(Page $page, bool $force = false): void
|
||||
{
|
||||
if ($page->permissions()->can('delete') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.delete.permission',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
|
||||
if (($page->hasChildren() === true || $page->hasDrafts() === true) && $force === false) {
|
||||
throw new LogicException(key: 'page.delete.hasChildren');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the page can be duplicated
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to duplicate the page
|
||||
*/
|
||||
public static function duplicate(
|
||||
Page $page,
|
||||
string $slug,
|
||||
array $options = []
|
||||
): void {
|
||||
if ($page->permissions()->can('duplicate') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.duplicate.permission',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
|
||||
self::validateSlugLength($slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the page can be moved
|
||||
* to the given parent
|
||||
*/
|
||||
public static function move(Page $page, Site|Page $parent): void
|
||||
{
|
||||
// if nothing changes, there's no need for checks
|
||||
if ($parent->is($page->parent()) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($page->permissions()->can('move') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.move.permission',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
|
||||
// the page cannot be moved into itself
|
||||
if (
|
||||
$parent instanceof Page &&
|
||||
(
|
||||
$page->is($parent) === true ||
|
||||
$page->isAncestorOf($parent) === true
|
||||
)
|
||||
) {
|
||||
throw new LogicException(key: 'page.move.ancestor');
|
||||
}
|
||||
|
||||
// check for duplicates
|
||||
if ($parent->childrenAndDrafts()->find($page->slug())) {
|
||||
throw new DuplicateException(
|
||||
key: 'page.move.duplicate',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
|
||||
$allowed = [];
|
||||
|
||||
// collect all allowed subpage templates
|
||||
// from all pages sections in the blueprint
|
||||
// (only consider page sections that list pages
|
||||
// of the targeted new parent page)
|
||||
$sections = array_filter(
|
||||
$parent->blueprint()->sections(),
|
||||
fn ($section) =>
|
||||
$section->type() === 'pages' &&
|
||||
$section->parent()->is($parent)
|
||||
);
|
||||
|
||||
// check if the parent has at least one pages section
|
||||
if ($sections === []) {
|
||||
throw new LogicException([
|
||||
'key' => 'page.move.noSections',
|
||||
'data' => [
|
||||
'parent' => $parent->id() ?? '/',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
// go through all allowed templates and
|
||||
// add the name to the allowlist
|
||||
foreach ($sections as $section) {
|
||||
foreach ($section->templates() as $template) {
|
||||
$allowed[] = $template;
|
||||
}
|
||||
}
|
||||
|
||||
// check if the template of this page is allowed as subpage type
|
||||
// for the potential new parent
|
||||
if (
|
||||
$allowed !== [] &&
|
||||
in_array($page->intendedTemplate()->name(), $allowed) === false
|
||||
) {
|
||||
throw new PermissionException(
|
||||
key: 'page.move.template',
|
||||
data: [
|
||||
'template' => $page->intendedTemplate()->name(),
|
||||
'parent' => $parent->id() ?? '/',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the page can be published
|
||||
* (status change from draft to listed or unlisted)
|
||||
*/
|
||||
public static function publish(Page $page): void
|
||||
{
|
||||
if ($page->permissions()->can('changeStatus') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.changeStatus.permission',
|
||||
data: [
|
||||
'slug' => $page->slug()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if ($page->isDraft() === true && empty($page->errors()) === false) {
|
||||
throw new PermissionException(
|
||||
key: 'page.changeStatus.incomplete',
|
||||
details: $page->errors()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the page can be updated
|
||||
*
|
||||
* @throws \Kirby\Exception\PermissionException If the user is not allowed to update the page
|
||||
*/
|
||||
public static function update(Page $page, array $content = []): void
|
||||
{
|
||||
if ($page->permissions()->can('update') !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'page.update.permission',
|
||||
data: ['slug' => $page->slug()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the slug is not empty and doesn't exceed the maximum length
|
||||
* to make sure that the directory name will be accepted by the filesystem
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the slug is empty or too long
|
||||
*/
|
||||
public static function validateSlugLength(string $slug): void
|
||||
{
|
||||
$slugLength = Str::length($slug);
|
||||
|
||||
if ($slugLength === 0) {
|
||||
throw new InvalidArgumentException(key: 'page.slug.invalid');
|
||||
}
|
||||
|
||||
if ($slugsMaxlength = App::instance()->option('slugs.maxlength', 255)) {
|
||||
$maxlength = (int)$slugsMaxlength;
|
||||
|
||||
if ($slugLength > $maxlength) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'page.slug.maxlength',
|
||||
data: ['length' => $maxlength]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Ensure that a top-level page path does not start with one of
|
||||
* the reserved URL paths, e.g. for API or the Panel
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the page ID starts as one of the disallowed paths
|
||||
*/
|
||||
protected static function validateSlugProtectedPaths(
|
||||
Page $page,
|
||||
string $slug
|
||||
): void {
|
||||
if ($page->parent() === null) {
|
||||
$paths = A::map(
|
||||
['api', 'assets', 'media', 'panel'],
|
||||
fn ($url) => $page->kirby()->url($url, true)->path()->toString()
|
||||
);
|
||||
|
||||
$index = array_search($slug, $paths);
|
||||
|
||||
if ($index !== false) {
|
||||
throw new InvalidArgumentException(
|
||||
key: 'page.changeSlug.reserved',
|
||||
data: ['path' => $paths[$index]]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the page title is not empty
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the title is empty
|
||||
*/
|
||||
public static function validateTitleLength(string $title): void
|
||||
{
|
||||
if (Str::length($title) === 0) {
|
||||
throw new InvalidArgumentException(key: 'page.changeTitle.empty');
|
||||
}
|
||||
}
|
||||
}
|
||||
106
kirby/src/Cms/PageSiblings.php
Normal file
106
kirby/src/Cms/PageSiblings.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* PageSiblings
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
trait PageSiblings
|
||||
{
|
||||
/**
|
||||
* Checks if there's a next listed
|
||||
* page in the siblings collection
|
||||
*/
|
||||
public function hasNextListed(Pages|null $collection = null): bool
|
||||
{
|
||||
return $this->nextListed($collection) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there's a next unlisted
|
||||
* page in the siblings collection
|
||||
*/
|
||||
public function hasNextUnlisted(Pages|null $collection = null): bool
|
||||
{
|
||||
return $this->nextUnlisted($collection) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there's a previous listed
|
||||
* page in the siblings collection
|
||||
*/
|
||||
public function hasPrevListed(Pages|null $collection = null): bool
|
||||
{
|
||||
return $this->prevListed($collection) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there's a previous unlisted
|
||||
* page in the siblings collection
|
||||
*/
|
||||
public function hasPrevUnlisted(Pages|null $collection = null): bool
|
||||
{
|
||||
return $this->prevUnlisted($collection) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next listed page if it exists
|
||||
*/
|
||||
public function nextListed(Pages|null $collection = null): Page|null
|
||||
{
|
||||
return $this->nextAll($collection)->listed()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next unlisted page if it exists
|
||||
*/
|
||||
public function nextUnlisted(Pages|null $collection = null): Page|null
|
||||
{
|
||||
return $this->nextAll($collection)->unlisted()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous listed page
|
||||
*/
|
||||
public function prevListed(Pages|null $collection = null): Page|null
|
||||
{
|
||||
return $this->prevAll($collection)->listed()->last();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous unlisted page
|
||||
*/
|
||||
public function prevUnlisted(Pages|null $collection = null): Page|null
|
||||
{
|
||||
return $this->prevAll($collection)->unlisted()->last();
|
||||
}
|
||||
|
||||
/**
|
||||
* Private siblings collector
|
||||
*/
|
||||
protected function siblingsCollection(): Pages
|
||||
{
|
||||
if ($this->isDraft() === true) {
|
||||
return $this->parentModel()->drafts();
|
||||
}
|
||||
|
||||
return $this->parentModel()->children();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns siblings with the same template
|
||||
*/
|
||||
public function templateSiblings(bool $self = true): Pages
|
||||
{
|
||||
return $this->siblings($self)->filter(
|
||||
'intendedTemplate',
|
||||
$this->intendedTemplate()->name()
|
||||
);
|
||||
}
|
||||
}
|
||||
547
kirby/src/Cms/Pages.php
Normal file
547
kirby/src/Cms/Pages.php
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Uuid\HasUuids;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The `$pages` object refers to a
|
||||
* collection of pages. The pages in this
|
||||
* collection can have the same or different
|
||||
* parents, they can actually exist as
|
||||
* subfolders in the content folder or be
|
||||
* virtual pages created from a database,
|
||||
* an Excel sheet, any API or any other
|
||||
* source.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @template TPage of \Kirby\Cms\Page
|
||||
* @extends \Kirby\Cms\Collection<TPage>
|
||||
*/
|
||||
class Pages extends Collection
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
* Cache for the index only listed and unlisted pages
|
||||
*/
|
||||
protected Pages|null $index = null;
|
||||
|
||||
/**
|
||||
* Cache for the index all statuses also including drafts
|
||||
*/
|
||||
protected Pages|null $indexWithDrafts = null;
|
||||
|
||||
/**
|
||||
* All registered pages methods
|
||||
*/
|
||||
public static array $methods = [];
|
||||
|
||||
/**
|
||||
* @var \Kirby\Cms\Page|\Kirby\Cms\Site|null
|
||||
*/
|
||||
protected object|null $parent = null;
|
||||
|
||||
/**
|
||||
* Adds a single page or
|
||||
* an entire second collection to the
|
||||
* current collection
|
||||
*
|
||||
* @param \Kirby\Cms\Pages<TPage>|TPage|string $object
|
||||
* @return $this
|
||||
* @throws \Kirby\Exception\InvalidArgumentException When no `Page` or `Pages` object or an ID of an existing page is passed
|
||||
*/
|
||||
public function add($object): static
|
||||
{
|
||||
$site = App::instance()->site();
|
||||
|
||||
// add a pages collection
|
||||
if ($object instanceof self) {
|
||||
$this->data = [...$this->data, ...$object->data];
|
||||
|
||||
// add a page by id
|
||||
} elseif (
|
||||
is_string($object) === true &&
|
||||
$page = $site->find($object)
|
||||
) {
|
||||
$this->__set($page->id(), $page);
|
||||
|
||||
// add a page object
|
||||
} elseif ($object instanceof Page) {
|
||||
$this->__set($object->id(), $object);
|
||||
|
||||
// give a useful error message on invalid input;
|
||||
// silently ignore "empty" values for compatibility with existing setups
|
||||
} elseif (in_array($object, [null, false, true], true) !== true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'You must pass a Pages or Page object or an ID of an existing page to the Pages collection'
|
||||
);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all audio files of all children
|
||||
*/
|
||||
public function audio(): Files
|
||||
{
|
||||
return $this->files()->filter('type', 'audio');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all children for each page in the array
|
||||
* @return \Kirby\Cms\Pages<TPage>
|
||||
*/
|
||||
public function children(): static
|
||||
{
|
||||
$children = new static([]);
|
||||
|
||||
foreach ($this->data as $page) {
|
||||
foreach ($page->children() as $childKey => $child) {
|
||||
$children->data[$childKey] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
return $children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all code files of all children
|
||||
*/
|
||||
public function code(): Files
|
||||
{
|
||||
return $this->files()->filter('type', 'code');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the pages with the given IDs
|
||||
* if they exist in the collection
|
||||
*
|
||||
* @throws \Kirby\Exception\Exception If not all pages could be deleted
|
||||
*/
|
||||
public function delete(array $ids): void
|
||||
{
|
||||
$exceptions = [];
|
||||
$kirby = App::instance();
|
||||
|
||||
// delete all pages and collect errors
|
||||
foreach ($ids as $id) {
|
||||
try {
|
||||
// Explanation: We get the page object from the global context
|
||||
// as the objects in the pages collection itself could have rendered
|
||||
// outdated from a sibling delete action in this loop (e.g. resorting
|
||||
// after deleting a sibling page and leaving the object in this collection
|
||||
// with an old root path).
|
||||
//
|
||||
// TODO: We can remove this part as soon
|
||||
// as we move away from our immutable object architecture.
|
||||
$page = $kirby->page($id);
|
||||
|
||||
if ($page === null || $this->get($id) instanceof Page === false) {
|
||||
throw new NotFoundException(
|
||||
key: 'page.undefined',
|
||||
);
|
||||
}
|
||||
|
||||
$page->delete();
|
||||
$this->remove($id);
|
||||
} catch (Throwable $e) {
|
||||
$exceptions[$id] = $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($exceptions !== []) {
|
||||
throw new Exception(
|
||||
key: 'page.delete.multiple',
|
||||
details: $exceptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all documents of all children
|
||||
*/
|
||||
public function documents(): Files
|
||||
{
|
||||
return $this->files()->filter('type', 'document');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all drafts for all pages in the collection
|
||||
* @return \Kirby\Cms\Pages<TPage>
|
||||
*/
|
||||
public function drafts(): static
|
||||
{
|
||||
$drafts = new static([]);
|
||||
|
||||
foreach ($this->data as $page) {
|
||||
foreach ($page->drafts() as $draftKey => $draft) {
|
||||
$drafts->data[$draftKey] = $draft;
|
||||
}
|
||||
}
|
||||
|
||||
return $drafts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pages collection from an array of props
|
||||
*/
|
||||
public static function factory(
|
||||
array $pages,
|
||||
Page|Site|null $model = null,
|
||||
bool|null $draft = null
|
||||
): static {
|
||||
$model ??= App::instance()->site();
|
||||
$children = new static([], $model);
|
||||
|
||||
if ($model instanceof Page) {
|
||||
$parent = $model;
|
||||
$site = $model->site();
|
||||
} else {
|
||||
$parent = null;
|
||||
$site = $model;
|
||||
}
|
||||
|
||||
foreach ($pages as $props) {
|
||||
$props['parent'] = $parent;
|
||||
$props['site'] = $site;
|
||||
$props['isDraft'] = $draft ?? $props['isDraft'] ?? $props['draft'] ?? false;
|
||||
|
||||
$page = Page::factory($props);
|
||||
|
||||
$children->data[$page->id()] = $page;
|
||||
}
|
||||
|
||||
return $children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all files of all children
|
||||
*/
|
||||
public function files(): Files
|
||||
{
|
||||
$files = new Files([], $this->parent);
|
||||
|
||||
foreach ($this->data as $page) {
|
||||
foreach ($page->files() as $fileKey => $file) {
|
||||
$files->data[$fileKey] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a page by its ID or URI
|
||||
* @internal Use `$pages->find()` instead
|
||||
* @return TPage|null
|
||||
*/
|
||||
public function findByKey(string|null $key = null): Page|null
|
||||
{
|
||||
if ($key === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($page = $this->findByUuid($key, 'page')) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
// remove trailing or leading slashes
|
||||
$key = trim($key, '/');
|
||||
|
||||
// strip extensions from the id
|
||||
if (str_contains($key, '.') === true) {
|
||||
$info = pathinfo($key);
|
||||
|
||||
if ($info['dirname'] !== '.') {
|
||||
$key = $info['dirname'] . '/' . $info['filename'];
|
||||
} else {
|
||||
$key = $info['filename'];
|
||||
}
|
||||
}
|
||||
|
||||
// try the obvious way
|
||||
if ($page = $this->get($key)) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
$kirby = App::instance();
|
||||
$multiLang = $kirby->multilang();
|
||||
|
||||
// try to find the page by its (translated) URI
|
||||
// by stepping through the page tree
|
||||
$start = $this->parent instanceof Page ? $this->parent->id() : '';
|
||||
if ($page = $this->findByKeyRecursive($key, $start, $multiLang)) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
// for secondary languages, try the full translated URI
|
||||
// (for collections without parent that won't have a result above)
|
||||
if (
|
||||
$multiLang === true &&
|
||||
$kirby->language()->isDefault() === false &&
|
||||
$page = $this->findBy('uri', $key)
|
||||
) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a child or child of a child recursively
|
||||
* @return TPage|null
|
||||
*/
|
||||
protected function findByKeyRecursive(
|
||||
string $id,
|
||||
string|null $startAt = null,
|
||||
bool $multiLang = false
|
||||
): Page|null {
|
||||
$path = explode('/', $id);
|
||||
$item = null;
|
||||
$query = $startAt;
|
||||
|
||||
foreach ($path as $key) {
|
||||
$collection = $item?->children() ?? $this;
|
||||
$query = ltrim($query . '/' . $key, '/');
|
||||
$item = $collection->get($query) ?? null;
|
||||
|
||||
if (
|
||||
$item === null &&
|
||||
$multiLang === true &&
|
||||
App::instance()->language()->isDefault() === false
|
||||
) {
|
||||
if (count($path) > 1 || $collection->parent()) {
|
||||
// either the desired path is definitely not a slug,
|
||||
// or collection is the children of another collection
|
||||
$item = $collection->findBy('slug', $key);
|
||||
} else {
|
||||
// desired path _could_ be a slug or a "top level" uri
|
||||
$item = $collection->findBy('uri', $key);
|
||||
}
|
||||
}
|
||||
|
||||
if ($item === null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the currently open page
|
||||
* @return TPage|null
|
||||
*/
|
||||
public function findOpen(): Page|null
|
||||
{
|
||||
return $this->findBy('isOpen', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom getter that is able to find
|
||||
* extension pages
|
||||
* @return TPage|null
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): Page|null
|
||||
{
|
||||
if ($key === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($item = parent::get($key)) {
|
||||
return $item;
|
||||
}
|
||||
|
||||
return App::instance()->extension('pages', $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all images of all children
|
||||
*/
|
||||
public function images(): Files
|
||||
{
|
||||
return $this->files()->filter('type', 'image');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a recursive flat index of all
|
||||
* pages and subpages, etc.
|
||||
*/
|
||||
public function index(bool $drafts = false): static
|
||||
{
|
||||
// get object property by cache mode
|
||||
$index = $drafts === true ? $this->indexWithDrafts : $this->index;
|
||||
|
||||
if ($index instanceof Pages) {
|
||||
return $index;
|
||||
}
|
||||
|
||||
$index = new static([]);
|
||||
|
||||
foreach ($this->data as $pageKey => $page) {
|
||||
$index->data[$pageKey] = $page;
|
||||
$pageIndex = $page->index($drafts);
|
||||
|
||||
if ($pageIndex) {
|
||||
foreach ($pageIndex as $childKey => $child) {
|
||||
$index->data[$childKey] = $child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($drafts === true) {
|
||||
return $this->indexWithDrafts = $index;
|
||||
}
|
||||
|
||||
return $this->index = $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all listed pages in the collection
|
||||
* @return \Kirby\Cms\Pages<TPage>
|
||||
*/
|
||||
public function listed(): static
|
||||
{
|
||||
return $this->filter('isListed', '==', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all unlisted pages in the collection
|
||||
* @return \Kirby\Cms\Pages<TPage>
|
||||
*/
|
||||
public function unlisted(): static
|
||||
{
|
||||
return $this->filter('isUnlisted', '==', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Include all given items in the collection
|
||||
*
|
||||
* @return $this|static
|
||||
*/
|
||||
public function merge(string|Pages|Page|array ...$args): static
|
||||
{
|
||||
// merge multiple arguments at once
|
||||
if (count($args) > 1) {
|
||||
$collection = clone $this;
|
||||
foreach ($args as $arg) {
|
||||
$collection = $collection->merge($arg);
|
||||
}
|
||||
return $collection;
|
||||
}
|
||||
|
||||
// merge all parent drafts
|
||||
if ($args[0] === 'drafts') {
|
||||
if ($parent = $this->parent()) {
|
||||
return $this->merge($parent->drafts());
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// merge an entire collection
|
||||
if ($args[0] instanceof Pages) {
|
||||
$collection = clone $this;
|
||||
$collection->data = [...$collection->data, ...$args[0]->data];
|
||||
return $collection;
|
||||
}
|
||||
|
||||
// append a single page
|
||||
if ($args[0] instanceof Page) {
|
||||
$collection = clone $this;
|
||||
return $collection->set($args[0]->id(), $args[0]);
|
||||
}
|
||||
|
||||
// merge an array
|
||||
if (is_array($args[0]) === true) {
|
||||
$collection = clone $this;
|
||||
foreach ($args[0] as $arg) {
|
||||
$collection = $collection->merge($arg);
|
||||
}
|
||||
return $collection;
|
||||
}
|
||||
|
||||
if (is_string($args[0]) === true) {
|
||||
return $this->merge(App::instance()->site()->find($args[0]));
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter all pages by excluding the given template
|
||||
* @since 3.3.0
|
||||
*
|
||||
* @return $this|static
|
||||
*/
|
||||
public function notTemplate(string|array|null $templates): static
|
||||
{
|
||||
if (empty($templates) === true) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (is_array($templates) === false) {
|
||||
$templates = [$templates];
|
||||
}
|
||||
|
||||
return $this->filter(
|
||||
fn ($page) => in_array($page->intendedTemplate()->name(), $templates, true) === false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with all page numbers
|
||||
*/
|
||||
public function nums(): array
|
||||
{
|
||||
return $this->pluck('num');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all listed and unlisted pages in the collection
|
||||
* @return \Kirby\Cms\Pages<TPage>
|
||||
*/
|
||||
public function published(): static
|
||||
{
|
||||
return $this->filter('isDraft', '==', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter all pages by the given template
|
||||
*
|
||||
* @return $this|static
|
||||
*/
|
||||
public function template(string|array|null $templates): static
|
||||
{
|
||||
if (empty($templates) === true) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (is_array($templates) === false) {
|
||||
$templates = [$templates];
|
||||
}
|
||||
|
||||
return $this->filter(
|
||||
fn ($page) => in_array($page->intendedTemplate()->name(), $templates, true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all video files of all children
|
||||
*/
|
||||
public function videos(): Files
|
||||
{
|
||||
return $this->files()->filter('type', 'video');
|
||||
}
|
||||
}
|
||||
162
kirby/src/Cms/Pagination.php
Normal file
162
kirby/src/Cms/Pagination.php
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Http\Uri;
|
||||
use Kirby\Toolkit\Pagination as BasePagination;
|
||||
|
||||
/**
|
||||
* The `$pagination` object divides
|
||||
* a collection of pages, files etc.
|
||||
* into discrete pages consisting of
|
||||
* the number of defined items. The
|
||||
* pagination object can then be used
|
||||
* to navigate between these pages,
|
||||
* create a navigation etc.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Pagination extends BasePagination
|
||||
{
|
||||
/**
|
||||
* Pagination method (param, query, none)
|
||||
*/
|
||||
protected string $method;
|
||||
|
||||
/**
|
||||
* The base URL
|
||||
*/
|
||||
protected Uri $url;
|
||||
|
||||
/**
|
||||
* Variable name for query strings
|
||||
*/
|
||||
protected string $variable;
|
||||
|
||||
/**
|
||||
* Creates the pagination object. As a new
|
||||
* property you can now pass the base Url.
|
||||
* That Url must be the Url of the first
|
||||
* page of the collection without additional
|
||||
* pagination information/query parameters in it.
|
||||
*
|
||||
* ```php
|
||||
* $pagination = new Pagination([
|
||||
* 'page' => 1,
|
||||
* 'limit' => 10,
|
||||
* 'total' => 120,
|
||||
* 'method' => 'query',
|
||||
* 'variable' => 'p',
|
||||
* 'url' => new Uri('https://getkirby.com/blog')
|
||||
* ]);
|
||||
* ```
|
||||
*/
|
||||
public function __construct(array $params = [])
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$config = $kirby->option('pagination', []);
|
||||
$request = $kirby->request();
|
||||
|
||||
$params['limit'] ??= $config['limit'] ?? 20;
|
||||
$params['method'] ??= $config['method'] ?? 'param';
|
||||
$params['variable'] ??= $config['variable'] ?? 'page';
|
||||
|
||||
if (empty($params['url']) === true) {
|
||||
$params['url'] = new Uri($kirby->url('current'), [
|
||||
'params' => $request->params(),
|
||||
'query' => $request->query()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
$params['page'] ??= match ($params['method']) {
|
||||
'query' => $params['url']->query()->get($params['variable']),
|
||||
'param' => $params['url']->params()->get($params['variable']),
|
||||
default => null
|
||||
};
|
||||
|
||||
parent::__construct($params);
|
||||
|
||||
$this->method = $params['method'];
|
||||
$this->url = $params['url'];
|
||||
$this->variable = $params['variable'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Url for the first page
|
||||
*/
|
||||
public function firstPageUrl(): string|null
|
||||
{
|
||||
return $this->pageUrl(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Url for the last page
|
||||
*/
|
||||
public function lastPageUrl(): string|null
|
||||
{
|
||||
return $this->pageUrl($this->lastPage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Url for the next page.
|
||||
* Returns null if there's no next page.
|
||||
*/
|
||||
public function nextPageUrl(): string|null
|
||||
{
|
||||
if ($page = $this->nextPage()) {
|
||||
return $this->pageUrl($page);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL of the current page.
|
||||
* If the `$page` variable is set, the URL
|
||||
* for that page will be returned.
|
||||
*/
|
||||
public function pageUrl(int|null $page = null): string|null
|
||||
{
|
||||
if ($page === null) {
|
||||
return $this->pageUrl($this->page());
|
||||
}
|
||||
|
||||
$url = clone $this->url;
|
||||
$variable = $this->variable;
|
||||
|
||||
if (
|
||||
$this->hasPage($page) === false ||
|
||||
in_array($this->method, ['query', 'param'], true) === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($page === 1) {
|
||||
$page = null;
|
||||
}
|
||||
|
||||
match ($this->method) {
|
||||
'query' => $url->query()->$variable = $page,
|
||||
'param' => $url->params()->$variable = $page
|
||||
};
|
||||
|
||||
return $url->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Url for the previous page.
|
||||
* Returns null if there's no previous page.
|
||||
*/
|
||||
public function prevPageUrl(): string|null
|
||||
{
|
||||
if ($page = $this->prevPage()) {
|
||||
return $this->pageUrl($page);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
221
kirby/src/Cms/Permissions.php
Normal file
221
kirby/src/Cms/Permissions.php
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Handles permission definition in each user
|
||||
* blueprint and wraps a couple useful methods
|
||||
* around it to check for available permissions.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Permissions
|
||||
{
|
||||
public static array $extendedActions = [];
|
||||
|
||||
protected array $actions = [
|
||||
'access' => [
|
||||
'account' => true,
|
||||
'languages' => true,
|
||||
'panel' => true,
|
||||
'site' => true,
|
||||
'system' => true,
|
||||
'users' => true,
|
||||
],
|
||||
'files' => [
|
||||
'access' => true,
|
||||
'changeName' => true,
|
||||
'changeTemplate' => true,
|
||||
'create' => true,
|
||||
'delete' => true,
|
||||
'list' => true,
|
||||
'read' => true,
|
||||
'replace' => true,
|
||||
'sort' => true,
|
||||
'update' => true
|
||||
],
|
||||
'languages' => [
|
||||
'create' => true,
|
||||
'delete' => true,
|
||||
'update' => true
|
||||
],
|
||||
'pages' => [
|
||||
'access' => true,
|
||||
'changeSlug' => true,
|
||||
'changeStatus' => true,
|
||||
'changeTemplate' => true,
|
||||
'changeTitle' => true,
|
||||
'create' => true,
|
||||
'delete' => true,
|
||||
'duplicate' => true,
|
||||
'list' => true,
|
||||
'move' => true,
|
||||
'preview' => true,
|
||||
'read' => true,
|
||||
'sort' => true,
|
||||
'update' => true
|
||||
],
|
||||
'site' => [
|
||||
'changeTitle' => true,
|
||||
'update' => true
|
||||
],
|
||||
'users' => [
|
||||
'changeEmail' => true,
|
||||
'changeLanguage' => true,
|
||||
'changeName' => true,
|
||||
'changePassword' => true,
|
||||
'changeRole' => true,
|
||||
'create' => true,
|
||||
'delete' => true,
|
||||
'update' => true
|
||||
],
|
||||
'user' => [
|
||||
'changeEmail' => true,
|
||||
'changeLanguage' => true,
|
||||
'changeName' => true,
|
||||
'changePassword' => true,
|
||||
'changeRole' => true,
|
||||
'delete' => true,
|
||||
'update' => true
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* Permissions constructor
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function __construct(array|bool|null $settings = [])
|
||||
{
|
||||
// dynamically register the extended actions
|
||||
foreach (static::$extendedActions as $key => $actions) {
|
||||
if (isset($this->actions[$key]) === true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'The action ' . $key . ' is already a core action'
|
||||
);
|
||||
}
|
||||
|
||||
$this->actions[$key] = $actions;
|
||||
}
|
||||
|
||||
if (is_array($settings) === true) {
|
||||
return $this->setCategories($settings);
|
||||
}
|
||||
|
||||
if (is_bool($settings) === true) {
|
||||
return $this->setAll($settings);
|
||||
}
|
||||
}
|
||||
|
||||
public function for(
|
||||
string|null $category = null,
|
||||
string|null $action = null,
|
||||
bool $default = false
|
||||
): bool {
|
||||
if ($action === null) {
|
||||
if ($this->hasCategory($category) === false) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $this->actions[$category];
|
||||
}
|
||||
|
||||
if ($this->hasAction($category, $action) === false) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $this->actions[$category][$action];
|
||||
}
|
||||
|
||||
protected function hasAction(string $category, string $action): bool
|
||||
{
|
||||
return
|
||||
$this->hasCategory($category) === true &&
|
||||
array_key_exists($action, $this->actions[$category]) === true;
|
||||
}
|
||||
|
||||
protected function hasCategory(string $category): bool
|
||||
{
|
||||
return array_key_exists($category, $this->actions) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
protected function setAction(
|
||||
string $category,
|
||||
string $action,
|
||||
$setting
|
||||
): static {
|
||||
// wildcard to overwrite the entire category
|
||||
if ($action === '*') {
|
||||
return $this->setCategory($category, $setting);
|
||||
}
|
||||
|
||||
$this->actions[$category][$action] = $setting;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
protected function setAll(bool $setting): static
|
||||
{
|
||||
foreach ($this->actions as $categoryName => $actions) {
|
||||
$this->setCategory($categoryName, $setting);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
protected function setCategories(array $settings): static
|
||||
{
|
||||
foreach ($settings as $name => $actions) {
|
||||
if (is_bool($actions) === true) {
|
||||
$this->setCategory($name, $actions);
|
||||
}
|
||||
|
||||
if (is_array($actions) === true) {
|
||||
foreach ($actions as $action => $setting) {
|
||||
$this->setAction($name, $action, $setting);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
protected function setCategory(string $category, bool $setting): static
|
||||
{
|
||||
if ($this->hasCategory($category) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Invalid permissions category'
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->actions[$category] as $action => $actionSetting) {
|
||||
$this->actions[$category][$action] = $setting;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->actions;
|
||||
}
|
||||
}
|
||||
148
kirby/src/Cms/Picker.php
Normal file
148
kirby/src/Cms/Picker.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* The Picker abstract is the foundation
|
||||
* for the UserPicker, PagePicker and FilePicker
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
abstract class Picker
|
||||
{
|
||||
protected App $kirby;
|
||||
protected array $options;
|
||||
protected Site $site;
|
||||
|
||||
/**
|
||||
* Creates a new Picker instance
|
||||
*/
|
||||
public function __construct(array $params = [])
|
||||
{
|
||||
$this->options = [...$this->defaults(), ...$params];
|
||||
$this->kirby = $this->options['model']->kirby();
|
||||
$this->site = $this->kirby->site();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the array of default values
|
||||
*/
|
||||
protected function defaults(): array
|
||||
{
|
||||
// default params
|
||||
return [
|
||||
// image settings (ratio, cover, etc.)
|
||||
'image' => [],
|
||||
// query template for the info field
|
||||
'info' => false,
|
||||
// listing style: list, cards, cardlets
|
||||
'layout' => 'list',
|
||||
// number of users displayed per pagination page
|
||||
'limit' => 20,
|
||||
// optional mapping function for the result array
|
||||
'map' => null,
|
||||
// the reference model
|
||||
'model' => App::instance()->site(),
|
||||
// current page when paginating
|
||||
'page' => 1,
|
||||
// a query string to fetch specific items
|
||||
'query' => null,
|
||||
// search query
|
||||
'search' => null,
|
||||
// query template for the text field
|
||||
'text' => null
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all items for the picker
|
||||
*/
|
||||
abstract public function items(): Collection|null;
|
||||
|
||||
/**
|
||||
* Converts all given items to an associative
|
||||
* array that is already optimized for the
|
||||
* panel picker component.
|
||||
*/
|
||||
public function itemsToArray(Collection|null $items = null): array
|
||||
{
|
||||
if ($items === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
if (empty($this->options['map']) === false) {
|
||||
$result[] = $this->options['map']($item);
|
||||
} else {
|
||||
$result[] = $item->panel()->pickerData([
|
||||
'image' => $this->options['image'],
|
||||
'info' => $this->options['info'],
|
||||
'layout' => $this->options['layout'],
|
||||
'model' => $this->options['model'],
|
||||
'text' => $this->options['text'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pagination to the collection
|
||||
* of items according to the options.
|
||||
*/
|
||||
public function paginate(Collection $items): Collection
|
||||
{
|
||||
return $items->paginate([
|
||||
'limit' => $this->options['limit'],
|
||||
'page' => $this->options['page']
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most relevant pagination
|
||||
* info as array
|
||||
*/
|
||||
public function paginationToArray(Pagination $pagination): array
|
||||
{
|
||||
return [
|
||||
'limit' => $pagination->limit(),
|
||||
'page' => $pagination->page(),
|
||||
'total' => $pagination->total()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search through the collection of items
|
||||
* if not deactivate in the options
|
||||
*/
|
||||
public function search(Collection $items): Collection
|
||||
{
|
||||
if (empty($this->options['search']) === false) {
|
||||
return $items->search($this->options['search']);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an associative array
|
||||
* with all information for the picker.
|
||||
* This will be passed directly to the API.
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$items = $this->items();
|
||||
|
||||
return [
|
||||
'data' => $this->itemsToArray($items),
|
||||
'pagination' => $this->paginationToArray($items->pagination()),
|
||||
];
|
||||
}
|
||||
}
|
||||
23
kirby/src/Cms/R.php
Normal file
23
kirby/src/Cms/R.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Http\Request;
|
||||
use Kirby\Toolkit\Facade;
|
||||
|
||||
/**
|
||||
* Shortcut to the request object
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class R extends Facade
|
||||
{
|
||||
public static function instance(): Request
|
||||
{
|
||||
return App::instance()->request();
|
||||
}
|
||||
}
|
||||
578
kirby/src/Cms/Responder.php
Normal file
578
kirby/src/Cms/Responder.php
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Filesystem\Mime;
|
||||
use Kirby\Http\Response as HttpResponse;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* Global response configuration
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Responder implements Stringable
|
||||
{
|
||||
/**
|
||||
* Timestamp when the response expires
|
||||
* in Kirby's cache
|
||||
*/
|
||||
protected int|null $expires = null;
|
||||
|
||||
/**
|
||||
* HTTP status code
|
||||
*/
|
||||
protected int|null $code = null;
|
||||
|
||||
/**
|
||||
* Response body
|
||||
*/
|
||||
protected string|null $body = null;
|
||||
|
||||
/**
|
||||
* Flag that defines whether the current
|
||||
* response can be cached by Kirby's cache
|
||||
*/
|
||||
protected bool $cache = true;
|
||||
|
||||
/**
|
||||
* HTTP headers
|
||||
*/
|
||||
protected array $headers = [];
|
||||
|
||||
/**
|
||||
* Content type
|
||||
*/
|
||||
protected string|null $type = null;
|
||||
|
||||
/**
|
||||
* Flag that defines whether the current
|
||||
* response uses the HTTP `Authorization`
|
||||
* request header
|
||||
*/
|
||||
protected bool $usesAuth = false;
|
||||
|
||||
/**
|
||||
* List of cookie names the response
|
||||
* relies on
|
||||
*/
|
||||
protected array $usesCookies = [];
|
||||
|
||||
/**
|
||||
* Tracks headers that depend on the request
|
||||
* and must not be persisted in the cache
|
||||
*/
|
||||
protected array $volatileHeaders = [];
|
||||
|
||||
/**
|
||||
* Creates and sends the response
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return (string)$this->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter and getter for the response body
|
||||
*
|
||||
* @return $this|string|null
|
||||
*/
|
||||
public function body(string|null $body = null): static|string|null
|
||||
{
|
||||
if ($body === null) {
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
$this->body = $body;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter and getter for the flag that defines
|
||||
* whether the current response can be cached
|
||||
* by Kirby's cache
|
||||
* @since 3.5.5
|
||||
*
|
||||
* @return bool|$this
|
||||
*/
|
||||
public function cache(bool|null $cache = null): bool|static
|
||||
{
|
||||
if ($cache === null) {
|
||||
// never ever cache private responses
|
||||
if (static::isPrivate($this->usesAuth(), $this->usesCookies()) === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
$this->cache = $cache;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter and getter for the flag that defines
|
||||
* whether the current response uses the HTTP
|
||||
* `Authorization` request header
|
||||
* @since 3.7.0
|
||||
*
|
||||
* @return bool|$this
|
||||
*/
|
||||
public function usesAuth(bool|null $usesAuth = null): bool|static
|
||||
{
|
||||
if ($usesAuth === null) {
|
||||
return $this->usesAuth;
|
||||
}
|
||||
|
||||
$this->usesAuth = $usesAuth;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for a cookie name that is
|
||||
* used by the response
|
||||
* @since 3.7.0
|
||||
*/
|
||||
public function usesCookie(string $name): void
|
||||
{
|
||||
// only add unique names
|
||||
if (in_array($name, $this->usesCookies, true) === false) {
|
||||
$this->usesCookies[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter and getter for the list of cookie
|
||||
* names the response relies on
|
||||
* @since 3.7.0
|
||||
*
|
||||
* @return array|$this
|
||||
*/
|
||||
public function usesCookies(array|null $usesCookies = null)
|
||||
{
|
||||
if ($usesCookies === null) {
|
||||
return $this->usesCookies;
|
||||
}
|
||||
|
||||
$this->usesCookies = $usesCookies;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter and getter for the cache expiry
|
||||
* timestamp for Kirby's cache
|
||||
* @since 3.5.5
|
||||
*
|
||||
* @param int|string|null $expires Timestamp, number of minutes or time string to parse
|
||||
* @param bool $override If `true`, the already defined timestamp will be overridden
|
||||
* @return int|null|$this
|
||||
*/
|
||||
public function expires($expires = null, bool $override = false)
|
||||
{
|
||||
// getter
|
||||
if ($expires === null && $override === false) {
|
||||
return $this->expires;
|
||||
}
|
||||
|
||||
// explicit un-setter
|
||||
if ($expires === null) {
|
||||
$this->expires = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
// normalize the value to an integer timestamp
|
||||
if (is_int($expires) === true && $expires < 1000000000) {
|
||||
// number of minutes
|
||||
$expires = time() + ($expires * 60);
|
||||
} elseif (is_int($expires) !== true) {
|
||||
// time string
|
||||
$parsedExpires = strtotime($expires);
|
||||
|
||||
if (is_int($parsedExpires) !== true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Invalid time string "' . $expires . '"'
|
||||
);
|
||||
}
|
||||
|
||||
$expires = $parsedExpires;
|
||||
}
|
||||
|
||||
// by default only ever *reduce* the cache expiry time
|
||||
if (
|
||||
$override === true ||
|
||||
$this->expires === null ||
|
||||
$expires < $this->expires
|
||||
) {
|
||||
$this->expires = $expires;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter and getter for the status code
|
||||
*
|
||||
* @return int|$this
|
||||
*/
|
||||
public function code(int|null $code = null)
|
||||
{
|
||||
if ($code === null) {
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
$this->code = $code;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct response from an array
|
||||
*/
|
||||
public function fromArray(array $response): void
|
||||
{
|
||||
$this->body($response['body'] ?? null);
|
||||
$this->cache($response['cache'] ?? null);
|
||||
$this->code($response['code'] ?? null);
|
||||
$this->expires($response['expires'] ?? null);
|
||||
$this->headers($response['headers'] ?? null);
|
||||
$this->type($response['type'] ?? null);
|
||||
$this->usesAuth($response['usesAuth'] ?? null);
|
||||
$this->usesCookies($response['usesCookies'] ?? null);
|
||||
$this->volatileHeaders = $response['volatileHeaders'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter and getter for a single header
|
||||
*
|
||||
* @param string|false|null $value
|
||||
* @param bool $lazy If `true`, an existing header value is not overridden
|
||||
* @return string|$this
|
||||
*/
|
||||
public function header(string $key, $value = null, bool $lazy = false)
|
||||
{
|
||||
if ($value === null) {
|
||||
return $this->headers()[$key] ?? null;
|
||||
}
|
||||
|
||||
if ($value === false) {
|
||||
unset($this->headers[$key]);
|
||||
return $this;
|
||||
}
|
||||
|
||||
if ($lazy === true && isset($this->headers[$key]) === true) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->headers[$key] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter and getter for all headers
|
||||
*
|
||||
* @return array|$this
|
||||
*/
|
||||
public function headers(array|null $headers = null)
|
||||
{
|
||||
if ($headers === null) {
|
||||
$injectedHeaders = [];
|
||||
$isPrivate = static::isPrivate($this->usesAuth(), $this->usesCookies());
|
||||
|
||||
if ($isPrivate === true) {
|
||||
// never ever cache private responses
|
||||
$injectedHeaders['Cache-Control'] = 'no-store, private';
|
||||
}
|
||||
|
||||
// inject CORS headers if enabled
|
||||
$corsHeaders = Cors::headers();
|
||||
if ($corsHeaders !== []) {
|
||||
$injectedHeaders = [...$injectedHeaders, ...$corsHeaders];
|
||||
}
|
||||
|
||||
if ($isPrivate === false) {
|
||||
// the response is public, but it may
|
||||
// vary based on request headers
|
||||
$vary = [];
|
||||
|
||||
if ($this->usesAuth() === true) {
|
||||
$vary[] = 'Authorization';
|
||||
}
|
||||
|
||||
if ($this->usesCookies() !== []) {
|
||||
$vary[] = 'Cookie';
|
||||
}
|
||||
|
||||
// merge Vary from CORS if present
|
||||
if (isset($injectedHeaders['Vary']) === true) {
|
||||
// split CORS Vary into individual values to avoid duplication
|
||||
$corsVaryValues = array_map('trim', explode(',', $injectedHeaders['Vary']));
|
||||
$vary = [...$vary, ...$corsVaryValues];
|
||||
}
|
||||
|
||||
if ($vary !== []) {
|
||||
$injectedHeaders['Vary'] = implode(', ', $vary);
|
||||
}
|
||||
}
|
||||
|
||||
// lazily inject (never override custom headers)
|
||||
return [...$injectedHeaders, ...$this->headers];
|
||||
}
|
||||
|
||||
$this->headers = $headers;
|
||||
$this->volatileHeaders = [];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut to configure a json response
|
||||
*
|
||||
* @return string|$this
|
||||
*/
|
||||
public function json(array|null $json = null)
|
||||
{
|
||||
if ($json !== null) {
|
||||
$this->body(json_encode($json));
|
||||
}
|
||||
|
||||
return $this->type('application/json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut to create a redirect response
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function redirect(
|
||||
string|null $location = null,
|
||||
int|null $code = null
|
||||
) {
|
||||
$location = Url::to($location ?? '/');
|
||||
$location = Url::unIdn($location);
|
||||
|
||||
return $this
|
||||
->header('Location', (string)$location)
|
||||
->code($code ?? 302);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns the response object from the config
|
||||
*/
|
||||
public function send(HttpResponse|string|null $body = null): HttpResponse
|
||||
{
|
||||
if ($body instanceof HttpResponse) {
|
||||
// inject headers from the responder into the response
|
||||
// (only if they are not already set);
|
||||
$body->setHeaderFallbacks($this->headers());
|
||||
return $body;
|
||||
}
|
||||
|
||||
if ($body !== null) {
|
||||
$this->body($body);
|
||||
}
|
||||
|
||||
return new Response($this->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the response configuration
|
||||
* to an array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
// the `cache`, `expires`, `usesAuth`, `usesCookies` and
|
||||
// `volatileHeaders` values are explicitly *not* serialized
|
||||
// as they are volatile and not to be exported
|
||||
return [
|
||||
'body' => $this->body(),
|
||||
'code' => $this->code(),
|
||||
'headers' => $this->headers(),
|
||||
'type' => $this->type(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the response configuration to an array
|
||||
* that can safely be cached
|
||||
*
|
||||
* @since 5.2.0
|
||||
*/
|
||||
public function toCacheArray(): array
|
||||
{
|
||||
$response = $this->toArray();
|
||||
$volatile = $this->collectVolatileHeaders();
|
||||
|
||||
if ($volatile === []) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response['headers'] = $this->stripVolatileHeaders($response['headers'], $volatile);
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter and getter for the content type
|
||||
*
|
||||
* @return string|$this
|
||||
*/
|
||||
public function type(string|null $type = null)
|
||||
{
|
||||
if ($type === null) {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
if (Str::contains($type, '/') === false) {
|
||||
$type = Mime::fromExtension($type);
|
||||
}
|
||||
|
||||
$this->type = $type;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the response needs to be exempted from
|
||||
* all caches due to using dynamic data based on auth
|
||||
* and/or cookies; the request data only matters if it
|
||||
* is actually used/relied on by the response
|
||||
*
|
||||
* @since 3.7.0
|
||||
* @unstable
|
||||
*/
|
||||
public static function isPrivate(bool $usesAuth, array $usesCookies): bool
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
if ($usesAuth === true && $kirby->request()->hasAuth() === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($usesCookies as $cookie) {
|
||||
if (isset($_COOKIE[$cookie]) === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks headers (or header parts) as request-dependent, so they
|
||||
* can be subtracted before caching a response snapshot
|
||||
*
|
||||
* @since 5.2.0
|
||||
*/
|
||||
public function markVolatileHeader(string $name, array|null $values = null): void
|
||||
{
|
||||
$this->appendVolatileHeader($this->volatileHeaders, $name, $values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects volatile headers from both manual configuration
|
||||
* and automatically injected CORS headers
|
||||
*/
|
||||
protected function collectVolatileHeaders(): array
|
||||
{
|
||||
$volatile = $this->volatileHeaders;
|
||||
$corsHeaders = Cors::headers();
|
||||
|
||||
if ($corsHeaders === []) {
|
||||
return $volatile;
|
||||
}
|
||||
|
||||
foreach ($corsHeaders as $name => $value) {
|
||||
if ($name === 'Vary') {
|
||||
$corsVaryValues = array_map('trim', explode(',', $value));
|
||||
$this->appendVolatileHeader($volatile, 'Vary', $corsVaryValues);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->appendVolatileHeader($volatile, $name);
|
||||
}
|
||||
|
||||
return $volatile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips request-dependent headers for safe caching
|
||||
*/
|
||||
protected function stripVolatileHeaders(array $headers, array $volatile): array
|
||||
{
|
||||
foreach ($volatile as $name => $values) {
|
||||
if ($name === 'Vary' && is_array($values) === true) {
|
||||
if (isset($headers['Vary']) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = $this->normalizeVaryValues($headers['Vary']);
|
||||
$remaining = $this->removeVaryValues($current, $values);
|
||||
|
||||
if ($remaining === []) {
|
||||
unset($headers['Vary']);
|
||||
} else {
|
||||
$headers['Vary'] = implode(', ', $remaining);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($headers[$name]);
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds (parts of) a header to the provided volatile header list
|
||||
*/
|
||||
protected function appendVolatileHeader(array &$target, string $name, array|null $values = null): void
|
||||
{
|
||||
if ($values === null) {
|
||||
$target[$name] = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (array_key_exists($name, $target) === true && $target[$name] === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$values = A::map($values, static fn ($value) => strtolower(trim($value)));
|
||||
$values = A::filter($values, static fn ($value) => $value !== '');
|
||||
|
||||
if ($values === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existingValues = $target[$name] ?? [];
|
||||
$target[$name] = array_values(array_unique([...$existingValues, ...$values]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a comma-separated list of Vary values
|
||||
* into a unique array without empty entries
|
||||
*/
|
||||
protected function normalizeVaryValues(string $value): array
|
||||
{
|
||||
$values = A::map(explode(',', $value), 'trim');
|
||||
$values = A::filter($values, static fn ($entry) => $entry !== '');
|
||||
|
||||
return array_values(array_unique($values));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Vary values with the provided entries removed
|
||||
*/
|
||||
protected function removeVaryValues(array $values, array $remove): array
|
||||
{
|
||||
$removeLower = A::map($remove, 'strtolower');
|
||||
|
||||
return array_values(A::filter(
|
||||
$values,
|
||||
static fn ($value) => in_array(strtolower($value), $removeLower, true) === false
|
||||
));
|
||||
}
|
||||
}
|
||||
28
kirby/src/Cms/Response.php
Normal file
28
kirby/src/Cms/Response.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* Custom response object with an optimized
|
||||
* redirect method to build correct Urls
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Response extends \Kirby\Http\Response
|
||||
{
|
||||
/**
|
||||
* Adjusted redirect creation which
|
||||
* parses locations with the Url::to method
|
||||
* first.
|
||||
*/
|
||||
public static function redirect(
|
||||
string $location = '/',
|
||||
int $code = 302
|
||||
): static {
|
||||
return parent::redirect(Url::to($location), $code);
|
||||
}
|
||||
}
|
||||
148
kirby/src/Cms/Role.php
Normal file
148
kirby/src/Cms/Role.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Data\Data;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* Represents a User role with attached
|
||||
* permissions. Roles are defined by user blueprints.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Role implements Stringable
|
||||
{
|
||||
protected string|null $description;
|
||||
protected string $name;
|
||||
protected Permissions $permissions;
|
||||
protected string|null $title;
|
||||
|
||||
public function __construct(array $props)
|
||||
{
|
||||
$this->name = $props['name'];
|
||||
$this->permissions = new Permissions($props['permissions'] ?? null);
|
||||
$title = $props['title'] ?? null;
|
||||
$this->title = I18n::translate($title) ?? $title;
|
||||
$description = $props['description'] ?? null;
|
||||
$this->description = I18n::translate($description) ?? $description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Improved `var_dump` output
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->name();
|
||||
}
|
||||
|
||||
public static function defaultAdmin(array $inject = []): static
|
||||
{
|
||||
return static::factory(static::defaults()['admin'], $inject);
|
||||
}
|
||||
|
||||
public static function defaultNobody(array $inject = []): static
|
||||
{
|
||||
return static::factory(static::defaults()['nobody'], $inject);
|
||||
}
|
||||
|
||||
protected static function defaults(): array
|
||||
{
|
||||
return [
|
||||
'admin' => [
|
||||
'name' => 'admin',
|
||||
'description' => I18n::translate('role.admin.description'),
|
||||
'title' => I18n::translate('role.admin.title'),
|
||||
'permissions' => true,
|
||||
],
|
||||
'nobody' => [
|
||||
'name' => 'nobody',
|
||||
'description' => I18n::translate('role.nobody.description'),
|
||||
'title' => I18n::translate('role.nobody.title'),
|
||||
'permissions' => false,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function description(): string|null
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public static function factory(array $props, array $inject = []): static
|
||||
{
|
||||
// ensure to properly extend the blueprint
|
||||
$props = $props + $inject;
|
||||
$props = Blueprint::extend($props);
|
||||
|
||||
return new static($props);
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return $this->name();
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->name() === 'admin';
|
||||
}
|
||||
|
||||
public function isNobody(): bool
|
||||
{
|
||||
return $this->name() === 'nobody';
|
||||
}
|
||||
|
||||
public static function load(string $file, array $inject = []): static
|
||||
{
|
||||
$data = [
|
||||
...Data::read($file),
|
||||
'name' => F::name($file)
|
||||
];
|
||||
|
||||
return static::factory($data, $inject);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function permissions(): Permissions
|
||||
{
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return $this->title ??= Str::label($this->name());
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the most important role
|
||||
* properties to an array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'description' => $this->description(),
|
||||
'id' => $this->id(),
|
||||
'name' => $this->name(),
|
||||
'permissions' => $this->permissions()->toArray(),
|
||||
'title' => $this->title(),
|
||||
];
|
||||
}
|
||||
}
|
||||
147
kirby/src/Cms/Roles.php
Normal file
147
kirby/src/Cms/Roles.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* Extension of the Collection class that
|
||||
* introduces `Roles::factory()` to convert an
|
||||
* array of role definitions into a proper
|
||||
* collection with Role objects. It also has
|
||||
* a `Roles::load()` method that handles loading
|
||||
* role definitions from disk.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Collection<\Kirby\Cms\Role>
|
||||
*/
|
||||
class Roles extends Collection
|
||||
{
|
||||
/**
|
||||
* All registered roles methods
|
||||
*/
|
||||
public static array $methods = [];
|
||||
|
||||
/**
|
||||
* Returns a filtered list of all
|
||||
* roles that can be changed by the
|
||||
* current user
|
||||
*
|
||||
* Use with `$kirby->roles()`. For retrieving
|
||||
* which roles are available for a specific user,
|
||||
* use `$user->roles()` without additional filters.
|
||||
*
|
||||
* @return $this|static
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function canBeChanged(): static
|
||||
{
|
||||
if (App::instance()->user()?->isAdmin() !== true) {
|
||||
return $this->filter(function ($role) {
|
||||
$newUser = new User([
|
||||
'email' => 'test@getkirby.com',
|
||||
'role' => $role->id()
|
||||
]);
|
||||
|
||||
return $newUser->permissions()->can('changeRole');
|
||||
});
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a filtered list of all
|
||||
* roles that can be created by the
|
||||
* current user.
|
||||
*
|
||||
* Use with `$kirby->roles()`.
|
||||
*
|
||||
* @return $this|static
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function canBeCreated(): static
|
||||
{
|
||||
if (App::instance()->user()?->isAdmin() !== true) {
|
||||
return $this->filter(function ($role) {
|
||||
$newUser = new User([
|
||||
'email' => 'test@getkirby.com',
|
||||
'role' => $role->id()
|
||||
]);
|
||||
|
||||
return $newUser->permissions()->can('create');
|
||||
});
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public static function factory(array $roles, array $inject = []): static
|
||||
{
|
||||
$collection = new static();
|
||||
|
||||
// read all user blueprints
|
||||
foreach ($roles as $props) {
|
||||
$role = Role::factory($props, $inject);
|
||||
$collection->set($role->id(), $role);
|
||||
}
|
||||
|
||||
// always include the admin role
|
||||
if ($collection->find('admin') === null) {
|
||||
$collection->set('admin', Role::defaultAdmin());
|
||||
}
|
||||
|
||||
// return the collection sorted by name
|
||||
return $collection->sort('name', 'asc');
|
||||
}
|
||||
|
||||
public static function load(string|null $root = null, array $inject = []): static
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$roles = new static();
|
||||
|
||||
// load roles from plugins
|
||||
foreach ($kirby->extensions('blueprints') as $name => $blueprint) {
|
||||
if (str_starts_with($name, 'users/') === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// callback option can be return array or blueprint file path
|
||||
if (is_callable($blueprint) === true) {
|
||||
$blueprint = $blueprint($kirby);
|
||||
}
|
||||
|
||||
$role = match (is_array($blueprint)) {
|
||||
true => Role::factory($blueprint, $inject),
|
||||
false => Role::load($blueprint, $inject)
|
||||
};
|
||||
|
||||
$roles->set($role->id(), $role);
|
||||
}
|
||||
|
||||
// load roles from directory
|
||||
if ($root !== null) {
|
||||
foreach (glob($root . '/*.yml') as $file) {
|
||||
$filename = basename($file);
|
||||
|
||||
if ($filename === 'default.yml') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$role = Role::load($file, $inject);
|
||||
$roles->set($role->id(), $role);
|
||||
}
|
||||
}
|
||||
|
||||
// always include the admin role
|
||||
if ($roles->find('admin') === null) {
|
||||
$roles->set('admin', Role::defaultAdmin($inject));
|
||||
}
|
||||
|
||||
// return the collection sorted by name
|
||||
return $roles->sort('name', 'asc');
|
||||
}
|
||||
}
|
||||
23
kirby/src/Cms/S.php
Normal file
23
kirby/src/Cms/S.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Kirby\Session\Session;
|
||||
use Kirby\Toolkit\Facade;
|
||||
|
||||
/**
|
||||
* Shortcut to the session object
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class S extends Facade
|
||||
{
|
||||
public static function instance(): Session
|
||||
{
|
||||
return App::instance()->session();
|
||||
}
|
||||
}
|
||||
51
kirby/src/Cms/Search.php
Normal file
51
kirby/src/Cms/Search.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
/**
|
||||
* The Search class extracts the
|
||||
* search logic from collections, to
|
||||
* provide a more globally usable interface
|
||||
* for any searches.
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Search
|
||||
{
|
||||
public static function files(
|
||||
string|null $query = null,
|
||||
array $params = []
|
||||
): Files {
|
||||
return App::instance()->site()->index()->files()->search($query, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Native search method to search for anything within the collection
|
||||
*/
|
||||
public static function collection(
|
||||
Collection $collection,
|
||||
string|null $query = null,
|
||||
string|array $params = []
|
||||
): Collection {
|
||||
$kirby = App::instance();
|
||||
return ($kirby->component('search'))($kirby, $collection, $query, $params);
|
||||
}
|
||||
|
||||
public static function pages(
|
||||
string|null $query = null,
|
||||
array $params = []
|
||||
): Pages {
|
||||
return App::instance()->site()->index()->search($query, $params);
|
||||
}
|
||||
|
||||
public static function users(
|
||||
string|null $query = null,
|
||||
array $params = []
|
||||
): Users {
|
||||
return App::instance()->users()->search($query, $params);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue