1
0

downgrade to kirby v3

This commit is contained in:
Philip Wagner
2024-09-01 10:47:15 +02:00
parent a4b2aece7b
commit af86acb7a1
1085 changed files with 54743 additions and 65042 deletions

View File

@@ -4,16 +4,13 @@ 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\I18n;
use Kirby\Toolkit\Pagination;
use Kirby\Toolkit\Properties;
use Kirby\Toolkit\Str;
use Throwable;
@@ -31,83 +28,86 @@ use Throwable;
*/
class Api
{
use Properties;
/**
* Authentication callback
*
* @var \Closure
*/
protected Closure|null $authentication = null;
protected $authentication;
/**
* Debugging flag
*
* @var bool
*/
protected bool $debug = false;
protected $debug = false;
/**
* Collection definition
*
* @var array
*/
protected array $collections = [];
protected $collections = [];
/**
* Injected data/dependencies
*
* @var array
*/
protected array $data = [];
protected $data = [];
/**
* Model definitions
*
* @var array
*/
protected array $models = [];
protected $models = [];
/**
* The current route
*
* @var \Kirby\Http\Route
*/
protected Route|null $route = null;
protected $route;
/**
* The Router instance
*
* @var \Kirby\Http\Router
*/
protected Router|null $router = null;
protected $router;
/**
* Route definition
*
* @var array
*/
protected array $routes = [];
protected $routes = [];
/**
* Request data
* [query, body, files]
*
* @var array
*/
protected array $requestData = [];
protected $requestData = [];
/**
* The applied request method
* (GET, POST, PATCH, etc.)
*
* @var string
*/
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);
}
protected $requestMethod;
/**
* Magic accessor for any given data
*
* @param string $method
* @param array $args
* @return mixed
* @throws \Kirby\Exception\NotFoundException
*/
public function __call(string $method, array $args = [])
@@ -115,19 +115,37 @@ class Api
return $this->data($method, ...$args);
}
/**
* Creates a new API instance
*
* @param array $props
*/
public function __construct(array $props)
{
$this->setProperties($props);
}
/**
* Runs the authentication method
* if set
*
* @return mixed
*/
public function authenticate()
{
return $this->authentication()?->call($this) ?? true;
if ($auth = $this->authentication()) {
return $auth->call($this);
}
return true;
}
/**
* Returns the authentication callback
*
* @return \Closure|null
*/
public function authentication(): Closure|null
public function authentication()
{
return $this->authentication;
}
@@ -136,14 +154,15 @@ class Api
* Execute an API call for the given path,
* request method and optional request data
*
* @param string|null $path
* @param string $method
* @param array $requestData
* @return mixed
* @throws \Kirby\Exception\NotFoundException
* @throws \Exception
*/
public function call(
string|null $path = null,
string $method = 'GET',
array $requestData = []
): mixed {
public function call(string $path = null, string $method = 'GET', array $requestData = [])
{
$path = rtrim($path ?? '', '/');
$this->setRequestMethod($method);
@@ -151,18 +170,19 @@ class Api
$this->router = new Router($this->routes());
$this->route = $this->router->find($path, $method);
$auth = $this->route?->attributes()['auth'] ?? true;
$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) {
if (is_a($user, 'Kirby\Cms\User') === true) {
$language = $user->language();
// get the locale from the translation
$locale = $user->kirby()->translation($language)->locale();
$translation = $user->kirby()->translation($language);
$locale = ($translation !== null) ? $translation->locale() : $language;
// provide some variants as fallbacks to be
// compatible with as many systems as possible
@@ -188,17 +208,14 @@ class Api
$validate = Pagination::$validate;
Pagination::$validate = false;
$output = $this->route?->action()->call(
$this,
...$this->route->arguments()
);
$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
is_a($output, 'Kirby\\Http\\Response') !== true
) {
return $this->resolve($output)->toResponse();
}
@@ -206,34 +223,17 @@ class Api
return $output;
}
/**
* Creates a new instance while
* merging initial and new properties
*/
public function clone(array $props = []): static
{
return new static(array_merge([
'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
*
* @param string $name
* @param array|null $collection
* @return \Kirby\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 {
public function collection(string $name, $collection = null)
{
if (isset($this->collections[$name]) === false) {
throw new NotFoundException(sprintf('The collection "%s" does not exist', $name));
}
@@ -243,6 +243,8 @@ class Api
/**
* Returns the collections definition
*
* @return array
*/
public function collections(): array
{
@@ -253,9 +255,13 @@ class Api
* Returns the injected data array
* or certain parts of it by key
*
* @param string|null $key
* @param mixed ...$args
* @return mixed
*
* @throws \Kirby\Exception\NotFoundException If no data for `$key` exists
*/
public function data(string|null $key = null, ...$args): mixed
public function data($key = null, ...$args)
{
if ($key === null) {
return $this->data;
@@ -266,7 +272,7 @@ class Api
}
// lazy-load data wrapped in Closures
if ($this->data[$key] instanceof Closure) {
if (is_a($this->data[$key], 'Closure') === true) {
return $this->data[$key]->call($this, ...$args);
}
@@ -275,6 +281,8 @@ class Api
/**
* Returns the debugging flag
*
* @return bool
*/
public function debug(): bool
{
@@ -283,6 +291,9 @@ class Api
/**
* Checks if injected data exists for the given key
*
* @param string $key
* @return bool
*/
public function hasData(string $key): bool
{
@@ -294,35 +305,39 @@ class Api
* based on the `type` field
*
* @param array models or collections
* @return string|null key of match
* @param mixed $object
*
* @return string key of match
*/
protected function match(
array $array,
$object = null
): string|null {
protected function match(array $array, $object = null)
{
foreach ($array as $definition => $model) {
if ($object instanceof $model['type']) {
if (is_a($object, $model['type']) === true) {
return $definition;
}
}
return null;
}
/**
* Returns an API model instance by name
*
* @param string|null $name
* @param mixed $object
* @return \Kirby\Api\Model
*
* @throws \Kirby\Exception\NotFoundException If no model for `$name` exists
*/
public function model(
string|null $name = null,
$object = null
): Model {
public function model(string $name = null, $object = null)
{
// Try to auto-match object with API models
$name ??= $this->match($this->models, $object);
if ($name === null) {
if ($model = $this->match($this->models, $object)) {
$name = $model;
}
}
if (isset($this->models[$name]) === false) {
throw new NotFoundException(sprintf('The model "%s" does not exist', $name ?? 'NULL'));
throw new NotFoundException(sprintf('The model "%s" does not exist', $name));
}
return new Model($this, $object, $this->models[$name]);
@@ -330,6 +345,8 @@ class Api
/**
* Returns all model definitions
*
* @return array
*/
public function models(): array
{
@@ -340,12 +357,14 @@ class Api
* Getter for request data
* Can either get all the data
* or certain parts of it.
*
* @param string|null $type
* @param string|null $key
* @param mixed $default
* @return mixed
*/
public function requestData(
string|null $type = null,
string|null $key = null,
mixed $default = null
): mixed {
public function requestData(string $type = null, string $key = null, $default = null)
{
if ($type === null) {
return $this->requestData;
}
@@ -362,49 +381,59 @@ class Api
/**
* Returns the request body if available
*
* @param string|null $key
* @param mixed $default
* @return mixed
*/
public function requestBody(
string|null $key = null,
mixed $default = null
): mixed {
public function requestBody(string $key = null, $default = null)
{
return $this->requestData('body', $key, $default);
}
/**
* Returns the files from the request if available
*
* @param string|null $key
* @param mixed $default
* @return mixed
*/
public function requestFiles(
string|null $key = null,
mixed $default = null
): mixed {
public function requestFiles(string $key = null, $default = null)
{
return $this->requestData('files', $key, $default);
}
/**
* Returns all headers from the request if available
*
* @param string|null $key
* @param mixed $default
* @return mixed
*/
public function requestHeaders(
string|null $key = null,
mixed $default = null
): mixed {
public function requestHeaders(string $key = null, $default = null)
{
return $this->requestData('headers', $key, $default);
}
/**
* Returns the request method
*
* @return string
*/
public function requestMethod(): string|null
public function requestMethod(): string
{
return $this->requestMethod;
}
/**
* Returns the request query if available
*
* @param string|null $key
* @param mixed $default
* @return mixed
*/
public function requestQuery(
string|null $key = null,
mixed $default = null
): mixed {
public function requestQuery(string $key = null, $default = null)
{
return $this->requestData('query', $key, $default);
}
@@ -412,14 +441,14 @@ class Api
* Turns a Kirby object into an
* API model or collection representation
*
* @param mixed $object
* @return \Kirby\Api\Model|\Kirby\Api\Collection
*
* @throws \Kirby\Exception\NotFoundException If `$object` cannot be resolved
*/
public function resolve($object): Model|Collection
public function resolve($object)
{
if (
$object instanceof Model ||
$object instanceof Collection
) {
if (is_a($object, 'Kirby\Api\Model') === true || is_a($object, 'Kirby\Api\Collection') === true) {
return $object;
}
@@ -436,6 +465,8 @@ class Api
/**
* Returns all defined routes
*
* @return array
*/
public function routes(): array
{
@@ -443,25 +474,135 @@ class Api
}
/**
* Renders the API call
* Setter for the authentication callback
*
* @param \Closure|null $authentication
* @return $this
*/
public function render(
string $path,
string $method = 'GET',
array $requestData = []
): mixed {
protected function setAuthentication(Closure $authentication = null)
{
$this->authentication = $authentication;
return $this;
}
/**
* Setter for the collections definition
*
* @param array|null $collections
* @return $this
*/
protected function setCollections(array $collections = null)
{
if ($collections !== null) {
$this->collections = array_change_key_case($collections);
}
return $this;
}
/**
* Setter for the injected data
*
* @param array|null $data
* @return $this
*/
protected function setData(array $data = null)
{
$this->data = $data ?? [];
return $this;
}
/**
* Setter for the debug flag
*
* @param bool $debug
* @return $this
*/
protected function setDebug(bool $debug = false)
{
$this->debug = $debug;
return $this;
}
/**
* Setter for the model definitions
*
* @param array|null $models
* @return $this
*/
protected function setModels(array $models = null)
{
if ($models !== null) {
$this->models = array_change_key_case($models);
}
return $this;
}
/**
* Setter for the request data
*
* @param array|null $requestData
* @return $this
*/
protected function setRequestData(array $requestData = null)
{
$defaults = [
'query' => [],
'body' => [],
'files' => []
];
$this->requestData = array_merge($defaults, (array)$requestData);
return $this;
}
/**
* Setter for the request method
*
* @param string|null $requestMethod
* @return $this
*/
protected function setRequestMethod(string $requestMethod = null)
{
$this->requestMethod = $requestMethod ?? 'GET';
return $this;
}
/**
* Setter for the route definitions
*
* @param array|null $routes
* @return $this
*/
protected function setRoutes(array $routes = null)
{
$this->routes = $routes ?? [];
return $this;
}
/**
* Renders the API call
*
* @param string $path
* @param string $method
* @param array $requestData
* @return mixed
*/
public function render(string $path, $method = 'GET', array $requestData = [])
{
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 ($result === null) {
$result = $this->responseFor404();
} elseif ($result === false) {
$result = $this->responseFor400();
} elseif ($result === true) {
$result = $this->responseFor200();
}
if (is_array($result) === false) {
return $result;
@@ -487,6 +628,8 @@ class Api
/**
* Returns a 200 - ok
* response array.
*
* @return array
*/
public function responseFor200(): array
{
@@ -500,6 +643,8 @@ class Api
/**
* Returns a 400 - bad request
* response array.
*
* @return array
*/
public function responseFor400(): array
{
@@ -513,6 +658,8 @@ class Api
/**
* Returns a 404 - not found
* response array.
*
* @return array
*/
public function responseFor404(): array
{
@@ -527,6 +674,9 @@ class Api
* Creates the response array for
* an exception. Kirby exceptions will
* have more information
*
* @param \Throwable $e
* @return array
*/
public function responseForException(Throwable $e): array
{
@@ -546,11 +696,11 @@ class Api
'file' => F::relativepath($e->getFile(), $docRoot),
'line' => $e->getLine(),
'details' => [],
'route' => $this->route?->pattern()
'route' => $this->route ? $this->route->pattern() : null
];
// extend the information for Kirby Exceptions
if ($e instanceof ExceptionException) {
if (is_a($e, 'Kirby\Exception\Exception') === true) {
$result['key'] = $e->getKey();
$result['details'] = $e->getDetails();
$result['code'] = $e->getHttpCode();
@@ -570,47 +720,21 @@ class Api
return $result;
}
/**
* Setter for the request data
* @return $this
*/
protected function setRequestData(
array|null $requestData = []
): static {
$defaults = [
'query' => [],
'body' => [],
'files' => []
];
$this->requestData = array_merge($defaults, (array)$requestData);
return $this;
}
/**
* Setter for the request method
* @return $this
*/
protected function setRequestMethod(
string $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
*
* @param \Closure $callback
* @param bool $single
* @param bool $debug
* @return array
*
* @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 {
public function upload(Closure $callback, $single = false, $debug = false): array
{
$trials = 0;
$uploads = [];
$errors = [];
@@ -632,27 +756,14 @@ class Api
$uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));
if ($postMaxSize < $uploadMaxFileSize) {
throw new Exception(
I18n::translate(
'upload.error.iniPostSize',
'The uploaded file exceeds the post_max_size directive in php.ini'
)
);
throw new Exception(I18n::translate('upload.error.iniPostSize'));
} else {
throw new Exception(I18n::translate('upload.error.noFiles'));
}
throw new Exception(
I18n::translate(
'upload.error.noFiles',
'No files were uploaded'
)
);
}
foreach ($files as $upload) {
if (
isset($upload['tmp_name']) === false &&
is_array($upload) === true
) {
if (isset($upload['tmp_name']) === false && is_array($upload)) {
continue;
}
@@ -660,10 +771,8 @@ class Api
try {
if ($upload['error'] !== 0) {
throw new Exception(
$errorMessages[$upload['error']] ??
I18n::translate('upload.error.default', 'The file could not be uploaded')
);
$errorMessage = $errorMessages[$upload['error']] ?? I18n::translate('upload.error.default');
throw new Exception($errorMessage);
}
// get the extension of the uploaded file
@@ -671,10 +780,7 @@ class Api
// 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
) {
if (empty($extension) === true || in_array($extension, ['tmp', 'temp'])) {
$mime = F::mime($upload['tmp_name']);
$extension = F::mimeToExtension($mime);
$filename = F::name($upload['name']) . '.' . $extension;
@@ -686,13 +792,8 @@ class Api
// move the file to a location including the extension,
// for better mime detection
if (
$debug === false &&
move_uploaded_file($upload['tmp_name'], $source) === false
) {
throw new Exception(
I18n::translate('upload.error.cantMove')
);
if ($debug === false && move_uploaded_file($upload['tmp_name'], $source) === false) {
throw new Exception(I18n::translate('upload.error.cantMove'));
}
$data = $callback($source, $filename);

View File

@@ -2,9 +2,7 @@
namespace Kirby\Api;
use Closure;
use Exception;
use Kirby\Toolkit\Collection as BaseCollection;
use Kirby\Toolkit\Str;
/**
@@ -21,25 +19,48 @@ use Kirby\Toolkit\Str;
*/
class Collection
{
protected string|null $model;
protected array|null $select = null;
protected string|null $view;
/**
* @var \Kirby\Api\Api
*/
protected $api;
/**
* @var mixed|null
*/
protected $data;
/**
* @var mixed|null
*/
protected $model;
/**
* @var mixed|null
*/
protected $select;
/**
* @var mixed|null
*/
protected $view;
/**
* Collection constructor
*
* @param \Kirby\Api\Api $api
* @param mixed|null $data
* @param array $schema
* @throws \Exception
*/
public function __construct(
protected Api $api,
protected BaseCollection|array|null $data,
array $schema
) {
public function __construct(Api $api, $data, array $schema)
{
$this->api = $api;
$this->data = $data;
$this->model = $schema['model'] ?? null;
$this->view = $schema['view'] ?? null;
if ($data === null) {
if (($schema['default'] ?? null) instanceof Closure === false) {
if (is_a($schema['default'] ?? null, 'Closure') === false) {
throw new Exception('Missing collection data');
}
@@ -48,17 +69,18 @@ class Collection
if (
isset($schema['type']) === true &&
$this->data instanceof $schema['type'] === false
is_a($this->data, $schema['type']) === false
) {
throw new Exception('Invalid collection type');
}
}
/**
* @param string|array|null $keys
* @return $this
* @throws \Exception
*/
public function select($keys = null): static
public function select($keys = null)
{
if ($keys === false) {
return $this;
@@ -77,6 +99,7 @@ class Collection
}
/**
* @return array
* @throws \Kirby\Exception\NotFoundException
* @throws \Exception
*/
@@ -102,6 +125,7 @@ class Collection
}
/**
* @return array
* @throws \Kirby\Exception\NotFoundException
* @throws \Exception
*/
@@ -143,9 +167,10 @@ class Collection
}
/**
* @param string $view
* @return $this
*/
public function view(string $view): static
public function view(string $view)
{
$this->view = $view;
return $this;

View File

@@ -2,7 +2,6 @@
namespace Kirby\Api;
use Closure;
use Exception;
use Kirby\Toolkit\Str;
@@ -22,33 +21,53 @@ use Kirby\Toolkit\Str;
*/
class Model
{
protected array $fields;
protected array|null $select;
protected array $views;
/**
* @var \Kirby\Api\Api
*/
protected $api;
/**
* @var mixed|null
*/
protected $data;
/**
* @var array|mixed
*/
protected $fields;
/**
* @var mixed|null
*/
protected $select;
/**
* @var array|mixed
*/
protected $views;
/**
* Model constructor
*
* @param \Kirby\Api\Api $api
* @param mixed $data
* @param array $schema
* @throws \Exception
*/
public function __construct(
protected Api $api,
protected object|array|string|null $data,
array $schema
) {
public function __construct(Api $api, $data, array $schema)
{
$this->api = $api;
$this->data = $data;
$this->fields = $schema['fields'] ?? [];
$this->select = $schema['select'] ?? null;
$this->views = $schema['views'] ?? [];
if (
$this->select === null &&
array_key_exists('default', $this->views)
) {
if ($this->select === null && array_key_exists('default', $this->views)) {
$this->view('default');
}
if ($data === null) {
if (($schema['default'] ?? null) instanceof Closure === false) {
if (is_a($schema['default'] ?? null, 'Closure') === false) {
throw new Exception('Missing model data');
}
@@ -57,21 +76,18 @@ class Model
if (
isset($schema['type']) === true &&
$this->data instanceof $schema['type'] === false
is_a($this->data, $schema['type']) === false
) {
$class = match ($this->data) {
null => 'null',
default => get_class($this->data),
};
throw new Exception(sprintf('Invalid model type "%s" expected: "%s"', $class, $schema['type']));
throw new Exception(sprintf('Invalid model type "%s" expected: "%s"', get_class($this->data), $schema['type']));
}
}
/**
* @param null $keys
* @return $this
* @throws \Exception
*/
public function select($keys = null): static
public function select($keys = null)
{
if ($keys === false) {
return $this;
@@ -90,12 +106,17 @@ class Model
}
/**
* @return array
* @throws \Exception
*/
public function selection(): array
{
$select = $this->select;
$select ??= array_keys($this->fields);
$select = $this->select;
if ($select === null) {
$select = array_keys($this->fields);
}
$selection = [];
foreach ($select as $key => $value) {
@@ -132,6 +153,7 @@ class Model
}
/**
* @return array
* @throws \Kirby\Exception\NotFoundException
* @throws \Exception
*/
@@ -141,10 +163,7 @@ class Model
$result = [];
foreach ($this->fields as $key => $resolver) {
if (
array_key_exists($key, $select) === false ||
$resolver instanceof Closure === false
) {
if (array_key_exists($key, $select) === false || is_a($resolver, 'Closure') === false) {
continue;
}
@@ -155,8 +174,8 @@ class Model
}
if (
$value instanceof Collection ||
$value instanceof self
is_a($value, 'Kirby\Api\Collection') === true ||
is_a($value, 'Kirby\Api\Model') === true
) {
$selection = $select[$key];
@@ -180,6 +199,7 @@ class Model
}
/**
* @return array
* @throws \Kirby\Exception\NotFoundException
* @throws \Exception
*/
@@ -204,10 +224,11 @@ class Model
}
/**
* @param string $name
* @return $this
* @throws \Exception
*/
public function view(string $name): static
public function view(string $name)
{
if ($name === 'any') {
return $this->select(null);

View File

@@ -1,97 +0,0 @@
<?php
namespace Kirby\Blueprint;
use Kirby\Cms\Collection as BaseCollection;
use Kirby\Cms\ModelWithContent;
use Kirby\Toolkit\A;
use TypeError;
/**
* Typed collection
*
* @package Kirby Blueprint
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* // TODO: include in test coverage once blueprint refactoring is done
* @codeCoverageIgnore
*/
class Collection extends BaseCollection
{
/**
* The expected object type
*/
public const TYPE = Node::class;
public function __construct(array $objects = [])
{
foreach ($objects as $object) {
$this->__set($object->id, $object);
}
}
/**
* The Kirby Collection class only shows the key to
* avoid huge tress with dump, but for the blueprint
* collections this is really not useful
*
* @codeCoverageIgnore
*/
public function __debugInfo(): array
{
return A::map($this->data, fn ($item) => (array)$item);
}
/**
* Validate the type of every item that is being
* added to the collection. They need to have
* the class defined by static::TYPE.
*/
public function __set(string $key, $value): void
{
if (is_a($value, static::TYPE) === false) {
throw new TypeError('Each value in the collection must be an instance of ' . static::TYPE);
}
parent::__set($key, $value);
}
/**
* Creates a collection from a nested array structure
*/
public static function factory(array $items): static
{
$collection = new static();
$className = static::TYPE;
foreach ($items as $id => $item) {
if (is_array($item) === true) {
$item['id'] ??= $id;
$item = $className::factory($item);
$collection->__set($item->id, $item);
} else {
$collection->__set($id, $className::factory($item));
}
}
return $collection;
}
/**
* Renders each item with a model and returns
* an array of all rendered results
*/
public function render(ModelWithContent $model): array
{
$props = [];
foreach ($this->data as $key => $item) {
$props[$key] = $item->render($model);
}
return $props;
}
}

View File

@@ -1,75 +0,0 @@
<?php
namespace Kirby\Blueprint;
use Closure;
use Kirby\Cms\App;
use Kirby\Data\Yaml;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\F;
/**
* Config
*
* @package Kirby Blueprint
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* // TODO: include in test coverage once blueprint refactoring is done
* @codeCoverageIgnore
*/
class Config
{
public string $file;
public string $id;
public string|array|Closure|null $plugin;
public string $root;
public function __construct(
public string $path
) {
$kirby = App::instance();
$this->id = basename($this->path);
$this->root = $kirby->root('blueprints');
$this->file = $this->root . '/' . $this->path . '.yml';
$this->plugin = $kirby->extension('blueprints', $this->path);
}
public function read(): array
{
if (F::exists($this->file, $this->root) === true) {
return $this->unpack($this->file);
}
return $this->unpack($this->plugin);
}
public function write(array $props): bool
{
return Yaml::write($this->file, $props);
}
public function unpack(string|array|Closure|null $extension): array
{
return match (true) {
// extension does not exist
is_null($extension)
=> throw new NotFoundException('"' . $this->path . '" could not be found'),
// extension is stored as a file path
is_string($extension)
=> Yaml::read($extension),
// extension is a callback to be resolved
is_callable($extension)
=> $extension(App::instance()),
// extension is already defined as array
default
=> $extension
};
}
}

View File

@@ -1,65 +0,0 @@
<?php
namespace Kirby\Blueprint;
/**
* Extension
*
* @package Kirby Blueprint
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* // TODO: include in test coverage once blueprint refactoring is done
* @codeCoverageIgnore
*/
class Extension
{
public function __construct(
public string $path
) {
}
public static function apply(array $props): array
{
if (isset($props['extends']) === false) {
return $props;
}
// already extended
if (is_a($props['extends'], Extension::class) === true) {
return $props;
}
$extension = new static($props['extends']);
return $extension->extend($props);
}
public function extend(array $props): array
{
$props = array_replace_recursive(
$this->read(),
$props
);
$props['extends'] = $this;
return $props;
}
public static function factory(string|array $path): static
{
if (is_string($path) === true) {
return new static(path: $path);
}
return new static(...$path);
}
public function read(): array
{
$config = new Config($this->path);
return $config->read();
}
}

View File

@@ -1,119 +0,0 @@
<?php
namespace Kirby\Blueprint;
use ReflectionException;
use ReflectionNamedType;
use ReflectionProperty;
use ReflectionUnionType;
/**
* Factory
*
* @package Kirby Blueprint
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* // TODO: include in test coverage once blueprint refactoring is done
* @codeCoverageIgnore
*/
class Factory
{
/**
* Resolves the properties by
* applying a map of factories (propName => class)
*/
public static function apply(array $properties, array $factories): array
{
foreach ($factories as $property => $class) {
// skip non-existing properties, empty properties
// or properties that are matching objects
if (
isset($properties[$property]) === false ||
$properties[$property] === null ||
is_a($properties[$property], $class) === true
) {
continue;
}
$properties[$property] = $class::factory($properties[$property]);
}
return $properties;
}
public static function forNamedType(ReflectionNamedType|null $type, $value)
{
// get the class name for the single type
$className = $type->getName();
// check if there's a factory for the value
if (method_exists($className, 'factory') === true) {
return $className::factory($value);
}
// try to assign the value directly and trust
// in PHP's type system.
return $value;
}
public static function forProperties(string $class, array $properties): array
{
foreach ($properties as $property => $value) {
try {
$properties[$property] = static::forProperty($class, $property, $value);
} catch (ReflectionException $e) {
// the property does not exist
unset($properties[$property]);
}
}
return $properties;
}
public static function forProperty(string $class, string $property, $value)
{
if (is_null($value) === true) {
return $value;
}
// instantly assign objects
// PHP's type system will find issues automatically
if (is_object($value) === true) {
return $value;
}
// get the type for the property
$reflection = new ReflectionProperty($class, $property);
$propType = $reflection->getType();
// no type given
if ($propType === null) {
return $value;
}
// union types
if ($propType instanceof ReflectionUnionType) {
return static::forUnionType($propType, $value);
}
return static::forNamedType($propType, $value);
}
/**
* For properties with union types,
* the first named type is used to create
* the factory or pass a built-in value
*/
public static function forUnionType(ReflectionUnionType $type, $value)
{
return static::forNamedType($type->getTypes()[0], $value);
}
public static function make(string $class, array $properties): object
{
return new $class(...static::forProperties($class, $properties));
}
}

View File

@@ -1,117 +0,0 @@
<?php
namespace Kirby\Blueprint;
use Kirby\Cms\ModelWithContent;
/**
* A node of the blueprint
*
* @package Kirby Blueprint
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* // TODO: include in test coverage once blueprint refactoring is done
* @codeCoverageIgnore
*/
class Node
{
public const TYPE = 'node';
public function __construct(
public string $id,
public Extension|null $extends = null,
) {
}
/**
* Dynamic getter for properties
*/
public function __call(string $name, array $args)
{
$this->defaults();
return $this->$name;
}
/**
* Apply default values
*/
public function defaults(): static
{
return $this;
}
/**
* Creates an instance by a set of array properties.
*/
public static function factory(array $props): static
{
$props = Extension::apply($props);
$props = static::polyfill($props);
return Factory::make(static::class, $props);
}
public static function load(string|array $props): static
{
// load by path
if (is_string($props) === true) {
$props = static::loadProps($props);
}
return static::factory($props);
}
public static function loadProps(string $path): array
{
$config = new Config($path);
$props = $config->read();
// add the id if it's not set yet
$props['id'] ??= basename($path);
return $props;
}
/**
* Optional method that runs before static::factory sends
* its properties to the instance. This is perfect to clean
* up props or keep deprecated props compatible.
*/
public static function polyfill(array $props): array
{
return $props;
}
public function render(ModelWithContent $model)
{
// apply default values
$this->defaults();
$array = [];
// go through all public properties
foreach (get_object_vars($this) as $key => $value) {
if (is_object($value) === false && is_resource($value) === false) {
$array[$key] = $value;
continue;
}
if (method_exists($value, 'render') === true) {
$array[$key] = $value->render($model);
}
}
return $array;
}
/**
* Universal setter for properties
*/
public function set(string $property, $value): static
{
$this->$property = Factory::forProperty(static::class, $property, $value);
return $this;
}
}

View File

@@ -1,44 +0,0 @@
<?php
namespace Kirby\Blueprint;
use Kirby\Cms\ModelWithContent;
use Kirby\Toolkit\I18n;
/**
* Translatable node property
*
* @package Kirby Blueprint
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* // TODO: include in test coverage once blueprint refactoring is done
* @codeCoverageIgnore
*/
class NodeI18n extends NodeProperty
{
public function __construct(
public array $translations,
) {
}
public static function factory($value = null): static|null
{
if ($value === false || $value === null) {
return null;
}
if (is_array($value) === false) {
$value = ['en' => $value];
}
return new static($value);
}
public function render(ModelWithContent $model): string|null
{
return I18n::translate($this->translations, $this->translations);
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace Kirby\Blueprint;
/**
* Custom emoji or icon from the Kirby iconset
*
* @package Kirby Blueprint
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* // TODO: include in test coverage once blueprint refactoring is done
* @codeCoverageIgnore
*/
class NodeIcon extends NodeString
{
public static function field()
{
$field = parent::field();
$field->id = 'icon';
$field->label->translations = ['en' => 'Icon'];
return $field;
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace Kirby\Blueprint;
use Kirby\Cms\ModelWithContent;
/**
* Represents a property for a node
*
* @package Kirby Blueprint
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* // TODO: include in test coverage once blueprint refactoring is done
* @codeCoverageIgnore
*/
abstract class NodeProperty
{
abstract public static function factory($value = null): static|null;
public function render(ModelWithContent $model)
{
return null;
}
}

View File

@@ -1,39 +0,0 @@
<?php
namespace Kirby\Blueprint;
use Kirby\Cms\ModelWithContent;
/**
* Simple string blueprint node
*
* @package Kirby Blueprint
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* // TODO: include in test coverage once blueprint refactoring is done
* @codeCoverageIgnore
*/
class NodeString extends NodeProperty
{
public function __construct(
public string $value,
) {
}
public static function factory($value = null): static|null
{
if ($value === null) {
return null;
}
return new static($value);
}
public function render(ModelWithContent $model): string|null
{
return $this->value;
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace Kirby\Blueprint;
use Kirby\Cms\ModelWithContent;
/**
* The text node is translatable
* and will parse query template strings
*
* @package Kirby Blueprint
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* // TODO: include in test coverage once blueprint refactoring is done
* @codeCoverageIgnore
*/
class NodeText extends NodeI18n
{
public function render(ModelWithContent $model): string|null
{
if ($text = parent::render($model)) {
return $model->toSafeString($text);
}
return $text;
}
}

View File

@@ -15,17 +15,11 @@ use APCUIterator;
*/
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
*
* @param string $key
* @return bool
*/
public function exists(string $key): bool
{
@@ -35,19 +29,24 @@ class ApcuCache extends Cache
/**
* Flushes the entire cache and returns
* whether the operation was successful
*
* @return bool
*/
public function flush(): bool
{
if (empty($this->options['prefix']) === false) {
return apcu_delete(new APCUIterator('!^' . preg_quote($this->options['prefix']) . '!'));
} else {
return apcu_clear_cache();
}
return apcu_clear_cache();
}
/**
* Removes an item from the cache and returns
* whether the operation was successful
*
* @param string $key
* @return bool
*/
public function remove(string $key): bool
{
@@ -57,11 +56,13 @@ class ApcuCache extends Cache
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*
* @param string $key
* @return \Kirby\Cache\Value|null
*/
public function retrieve(string $key): Value|null
public function retrieve(string $key)
{
$value = apcu_fetch($this->key($key));
return Value::fromJson($value);
return Value::fromJson(apcu_fetch($this->key($key)));
}
/**
@@ -72,12 +73,14 @@ class ApcuCache extends Cache
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
*
* @param string $key
* @param mixed $value
* @param int $minutes
* @return bool
*/
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);
return apcu_store($this->key($key), (new Value($value, $minutes))->toJson(), $this->expiration($minutes));
}
}

View File

@@ -2,8 +2,6 @@
namespace Kirby\Cache;
use Closure;
/**
* Cache foundation
* This abstract class is used as
@@ -20,11 +18,14 @@ abstract class Cache
{
/**
* Stores all options for the driver
* @var array
*/
protected array $options = [];
protected $options = [];
/**
* Sets all parameters which are needed to connect to the cache storage
*
* @param array $options
*/
public function __construct(array $options = [])
{
@@ -32,47 +33,87 @@ abstract class Cache
}
/**
* Checks when the cache has been created;
* returns the creation timestamp on success
* and false if the item does not exist
* 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
*
* <code>
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
*
* @param string $key
* @param mixed $value
* @param int $minutes
* @return bool
*/
public function created(string $key): int|false
{
// get the Value object
$value = $this->retrieve($key);
abstract public function set(string $key, $value, int $minutes = 0): bool;
// check for a valid Value object
if ($value instanceof Value === false) {
return false;
/**
* Adds the prefix to the key if given
*
* @param string $key
* @return string
*/
protected function key(string $key): string
{
if (empty($this->options['prefix']) === false) {
$key = $this->options['prefix'] . '/' . $key;
}
// return the expires timestamp
return $value->created();
return $key;
}
/**
* Returns whether the cache is ready to
* store values
* 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
*
* @param string $key
* @return \Kirby\Cache\Value|null
*/
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;
}
abstract public function retrieve(string $key);
/**
* Determines if an item exists in the cache
* Gets an item from the cache
*
* <code>
* // 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');
* </code>
*
* @param string $key
* @param mixed $default
* @return mixed
*/
public function exists(string $key): bool
public function get(string $key, $default = null)
{
return $this->expired($key) === false;
}
// get the Value
$value = $this->retrieve($key);
// check for a valid cache value
if (!is_a($value, 'Kirby\Cache\Value')) {
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();
}
/**
* Calculates the expiration timestamp
*
* @param int $minutes
* @return int
*/
protected function expiration(int $minutes = 0): int
{
@@ -89,14 +130,17 @@ abstract class Cache
* 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
*
* @param string $key
* @return int|null|false
*/
public function expires(string $key): int|false|null
public function expires(string $key)
{
// get the Value object
$value = $this->retrieve($key);
// check for a valid Value object
if ($value instanceof Value === false) {
if (!is_a($value, 'Kirby\Cache\Value')) {
return false;
}
@@ -106,6 +150,9 @@ abstract class Cache
/**
* Checks if an item in the cache is expired
*
* @param string $key
* @return bool
*/
public function expired(string $key): bool
{
@@ -113,126 +160,83 @@ abstract class Cache
if ($expires === null) {
return false;
}
if (is_int($expires) === false) {
} elseif (!is_int($expires)) {
return true;
} else {
return time() >= $expires;
}
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
* Checks when the cache has been created;
* returns the creation timestamp on success
* and false if the item does not exist
*
* <code>
* // 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');
* </code>
* @param string $key
* @return int|false
*/
public function get(string $key, $default = null)
public function created(string $key)
{
// get the Value
// get the Value object
$value = $this->retrieve($key);
// check for a valid cache value
if ($value instanceof Value === false) {
return $default;
// check for a valid Value object
if (!is_a($value, 'Kirby\Cache\Value')) {
return false;
}
// 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;
// return the expires timestamp
return $value->created();
}
/**
* Alternate version for Cache::created($key)
*
* @param string $key
* @return int|false
*/
public function modified(string $key): int|false
public function modified(string $key)
{
return static::created($key);
}
/**
* Returns all passed cache options
* Determines if an item exists in the cache
*
* @param string $key
* @return bool
*/
public function options(): array
public function exists(string $key): bool
{
return $this->options;
return $this->expired($key) === false;
}
/**
* Removes an item from the cache and returns
* whether the operation was successful;
* this needs to be defined by the driver
*
* @param string $key
* @return bool
*/
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;
* Flushes the entire cache and returns
* whether the operation was successful;
* this needs to be defined by the driver
*
* <code>
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
* @return bool
*/
abstract public function set(string $key, $value, int $minutes = 0): bool;
abstract public function flush(): bool;
/**
* Returns all passed cache options
*
* @return array
*/
public function options(): array
{
return $this->options;
}
}

View File

@@ -20,8 +20,10 @@ class FileCache extends Cache
{
/**
* Full root including prefix
*
* @var string
*/
protected string $root;
protected $root;
/**
* Sets all parameters which are needed for the file cache
@@ -42,7 +44,6 @@ class FileCache extends Cache
// build the full root including prefix
$this->root = $this->options['root'];
if (empty($this->options['prefix']) === false) {
$this->root .= '/' . $this->options['prefix'];
}
@@ -51,17 +52,10 @@ class FileCache extends Cache
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
*
* @return string
*/
public function root(): string
{
@@ -70,6 +64,9 @@ class FileCache extends Cache
/**
* Returns the full path to a file for a given key
*
* @param string $key
* @return string
*/
protected function file(string $key): string
{
@@ -111,9 +108,9 @@ class FileCache extends Cache
if (isset($this->options['extension'])) {
return $file . '.' . $this->options['extension'];
} else {
return $file;
}
return $file;
}
/**
@@ -124,6 +121,11 @@ class FileCache extends Cache
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
*
* @param string $key
* @param mixed $value
* @param int $minutes
* @return bool
*/
public function set(string $key, $value, int $minutes = 0): bool
{
@@ -135,8 +137,11 @@ class FileCache extends Cache
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*
* @param string $key
* @return \Kirby\Cache\Value|null
*/
public function retrieve(string $key): Value|null
public function retrieve(string $key)
{
$file = $this->file($key);
$value = F::read($file);
@@ -148,8 +153,11 @@ class FileCache extends Cache
* Checks when the cache has been created;
* returns the creation timestamp on success
* and false if the item does not exist
*
* @param string $key
* @return mixed
*/
public function created(string $key): int|false
public function created(string $key)
{
// use the modification timestamp
// as indicator when the cache has been created/overwritten
@@ -157,12 +165,15 @@ class FileCache extends Cache
// get the file for this cache key
$file = $this->file($key);
return file_exists($file) ? filemtime($file) : false;
return file_exists($file) ? filemtime($this->file($key)) : false;
}
/**
* Removes an item from the cache and returns
* whether the operation was successful
*
* @param string $key
* @return bool
*/
public function remove(string $key): bool
{
@@ -179,6 +190,9 @@ class FileCache extends Cache
/**
* Removes empty directories safely by checking each directory
* up to the root directory
*
* @param string $dir
* @return void
*/
protected function removeEmptyDirectories(string $dir): void
{
@@ -188,13 +202,7 @@ class FileCache extends Cache
// 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, ['.', '..']);
$files = array_diff(scandir($dir) ?? [], ['.', '..']);
if (empty($files) === true && Dir::remove($dir) === true) {
// continue with the next level up
@@ -204,7 +212,7 @@ class FileCache extends Cache
break;
}
}
} catch (Exception) { // @codeCoverageIgnore
} catch (Exception $e) { // @codeCoverageIgnore
// silently stops the process
}
}
@@ -212,13 +220,12 @@ class FileCache extends Cache
/**
* Flushes the entire cache and returns
* whether the operation was successful
*
* @return bool
*/
public function flush(): bool
{
if (
Dir::remove($this->root) === true &&
Dir::make($this->root) === true
) {
if (Dir::remove($this->root) === true && Dir::make($this->root) === true) {
return true;
}

View File

@@ -16,14 +16,10 @@ use Memcached as MemcachedExt;
class MemCached extends Cache
{
/**
* Store for the memcache connection
* store for the memcache connection
* @var \Memcached
*/
protected MemcachedExt $connection;
/**
* Stores whether the connection was successful
*/
protected bool $enabled;
protected $connection;
/**
* Sets all parameters which are needed to connect to Memcached
@@ -43,19 +39,7 @@ class MemCached extends Cache
parent::__construct(array_merge($defaults, $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;
$this->connection->addServer($this->options['host'], $this->options['port']);
}
/**
@@ -66,28 +50,35 @@ class MemCached extends Cache
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
*
* @param string $key
* @param mixed $value
* @param int $minutes
* @return bool
*/
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);
return $this->connection->set($this->key($key), (new Value($value, $minutes))->toJson(), $this->expiration($minutes));
}
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*
* @param string $key
* @return \Kirby\Cache\Value|null
*/
public function retrieve(string $key): Value|null
public function retrieve(string $key)
{
$value = $this->connection->get($this->key($key));
return Value::fromJson($value);
return Value::fromJson($this->connection->get($this->key($key)));
}
/**
* Removes an item from the cache and returns
* whether the operation was successful
*
* @param string $key
* @return bool
*/
public function remove(string $key): bool
{
@@ -98,6 +89,8 @@ class MemCached extends Cache
* Flushes the entire cache and returns
* whether the operation was successful;
* WARNING: Memcached only supports flushing the whole cache at once!
*
* @return bool
*/
public function flush(): bool
{

View File

@@ -15,17 +15,9 @@ class MemoryCache extends Cache
{
/**
* Cache data
* @var array
*/
protected array $store = [];
/**
* Returns whether the cache is ready to
* store values
*/
public function enabled(): bool
{
return true;
}
protected $store = [];
/**
* Writes an item to the cache for a given number of minutes and
@@ -35,6 +27,11 @@ class MemoryCache extends Cache
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
*
* @param string $key
* @param mixed $value
* @param int $minutes
* @return bool
*/
public function set(string $key, $value, int $minutes = 0): bool
{
@@ -45,8 +42,11 @@ class MemoryCache extends Cache
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*
* @param string $key
* @return \Kirby\Cache\Value|null
*/
public function retrieve(string $key): Value|null
public function retrieve(string $key)
{
return $this->store[$key] ?? null;
}
@@ -54,20 +54,25 @@ class MemoryCache extends Cache
/**
* Removes an item from the cache and returns
* whether the operation was successful
*
* @param string $key
* @return bool
*/
public function remove(string $key): bool
{
if (isset($this->store[$key])) {
unset($this->store[$key]);
return true;
} else {
return false;
}
return false;
}
/**
* Flushes the entire cache and returns
* whether the operation was successful
*
* @return bool
*/
public function flush(): bool
{

View File

@@ -13,15 +13,6 @@ namespace Kirby\Cache;
*/
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
@@ -30,6 +21,11 @@ class NullCache extends Cache
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
*
* @param string $key
* @param mixed $value
* @param int $minutes
* @return bool
*/
public function set(string $key, $value, int $minutes = 0): bool
{
@@ -39,8 +35,11 @@ class NullCache extends Cache
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*
* @param string $key
* @return \Kirby\Cache\Value|null
*/
public function retrieve(string $key): Value|null
public function retrieve(string $key)
{
return null;
}
@@ -48,6 +47,9 @@ class NullCache extends Cache
/**
* Removes an item from the cache and returns
* whether the operation was successful
*
* @param string $key
* @return bool
*/
public function remove(string $key): bool
{
@@ -57,6 +59,8 @@ class NullCache extends Cache
/**
* Flushes the entire cache and returns
* whether the operation was successful
*
* @return bool
*/
public function flush(): bool
{

View File

@@ -19,6 +19,7 @@ class Value
{
/**
* Cached value
* @var mixed
*/
protected $value;
@@ -26,31 +27,35 @@ class Value
* the number of minutes until the value expires
* @todo Rename this property to $expiry to reflect
* both minutes and absolute timestamps
* @var int
*/
protected int $minutes;
protected $minutes;
/**
* Creation timestamp
* @var int
*/
protected int $created;
protected $created;
/**
* Constructor
*
* @param mixed $value
* @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)
* @param int $created the UNIX timestamp when the value has been created
*/
public function __construct($value, int $minutes = 0, int|null $created = null)
public function __construct($value, int $minutes = 0, int $created = null)
{
$this->value = $value;
$this->minutes = $minutes;
$this->minutes = $minutes ?? 0;
$this->created = $created ?? time();
}
/**
* Returns the creation date as UNIX timestamp
*
* @return int
*/
public function created(): int
{
@@ -60,8 +65,10 @@ class Value
/**
* Returns the expiration date as UNIX timestamp or
* null if the value never expires
*
* @return int|null
*/
public function expires(): int|null
public function expires(): ?int
{
// 0 = keep forever
if ($this->minutes === 0) {
@@ -78,37 +85,41 @@ class Value
/**
* Creates a value object from an array
*
* @param array $array
* @return static
*/
public static function fromArray(array $array): static
public static function fromArray(array $array)
{
return new static(
$array['value'] ?? null,
$array['minutes'] ?? 0,
$array['created'] ?? null
);
return new static($array['value'] ?? null, $array['minutes'] ?? 0, $array['created'] ?? null);
}
/**
* Creates a value object from a JSON string;
* returns null on error
*
* @param string $json
* @return static|null
*/
public static function fromJson(string $json): static|null
public static function fromJson(string $json)
{
try {
$array = json_decode($json, true);
if (is_array($array) === true) {
if (is_array($array)) {
return static::fromArray($array);
} else {
return null;
}
return null;
} catch (Throwable) {
} catch (Throwable $e) {
return null;
}
}
/**
* Converts the object to a JSON string
*
* @return string
*/
public function toJson(): string
{
@@ -117,6 +128,8 @@ class Value
/**
* Converts the object to an array
*
* @return array
*/
public function toArray(): array
{
@@ -129,6 +142,8 @@ class Value
/**
* Returns the pure value
*
* @return mixed
*/
public function value()
{

View File

@@ -5,7 +5,6 @@ namespace Kirby\Cms;
use Kirby\Api\Api as BaseApi;
use Kirby\Exception\NotFoundException;
use Kirby\Form\Form;
use Kirby\Session\Session;
/**
* Api
@@ -18,132 +17,114 @@ use Kirby\Session\Session;
*/
class Api extends BaseApi
{
protected App $kirby;
public function __construct(array $props)
{
$this->kirby = $props['kirby'];
parent::__construct($props);
}
/**
* @var App
*/
protected $kirby;
/**
* Execute an API call for the given path,
* request method and optional request data
*
* @param string|null $path
* @param string $method
* @param array $requestData
* @return mixed
*/
public function call(
string|null $path = null,
string $method = 'GET',
array $requestData = []
): mixed {
public function call(string $path = null, string $method = 'GET', array $requestData = [])
{
$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();
if ($user = $this->kirby->user(null, $allowImpersonation)) {
$translation = $user->language();
} else {
$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(array_merge([
'kirby' => $this->kirby
], $props));
}
/**
* @param mixed $model
* @param string $name
* @param string|null $path
* @return mixed
* @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 {
public function fieldApi($model, string $name, string $path = null)
{
$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()
$fieldApi = new static(
array_merge($this->propertyData, [
'data' => array_merge($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
* @param string|null $path Path to file's parent model
* @param string $filename Filename
* @return \Kirby\Cms\File|null
* @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
public function file(string $path = null, string $filename)
{
return $this->parent($path)->files()->filter('isAccessible', true);
return Find::file($path, $filename);
}
/**
* Returns the model's object for the given path
*
* @param string $path Path to parent model
* @return \Kirby\Cms\Model|null
* @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
public function parent(string $path)
{
return Find::parent($path);
}
/**
* Returns the Kirby instance
*
* @return \Kirby\Cms\App
*/
public function kirby(): App
public function kirby()
{
return $this->kirby;
}
/**
* Returns the language request header
*
* @return string|null
*/
public function language(): string|null
public function language(): ?string
{
return
$this->requestQuery('language') ??
$this->requestHeaders('x-language');
return $this->requestQuery('language') ?? $this->requestHeaders('x-language');
}
/**
* Returns the page object for the given id
*
* @param string $id Page's id
* @return \Kirby\Cms\Page|null
* @throws \Kirby\Exception\NotFoundException if the page cannot be found
*/
public function page(string $id): Page|null
public function page(string $id)
{
return Find::page($id);
}
@@ -152,29 +133,39 @@ class Api extends BaseApi
* Returns the subpages for the given
* parent. The subpages can be filtered
* by status (draft, listed, unlisted, published, all)
*
* @param string|null $parentId
* @param string|null $status
* @return \Kirby\Cms\Pages
*/
public function pages(
string|null $parentId = null,
string|null $status = null
): Pages {
public function pages(string $parentId = null, string $status = null)
{
$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);
switch ($status) {
case 'all':
return $parent->childrenAndDrafts();
case 'draft':
case 'drafts':
return $parent->drafts();
case 'listed':
return $parent->children()->listed();
case 'unlisted':
return $parent->children()->unlisted();
case 'published':
default:
return $parent->children();
}
}
/**
* Search for direct subpages of the
* given parent
*
* @param string|null $parent
* @return \Kirby\Cms\Pages
*/
public function searchPages(string|null $parent = null): Pages
public function searchPages(string $parent = null)
{
$pages = $this->pages($parent, $this->requestQuery('status'));
@@ -185,36 +176,13 @@ class Api extends BaseApi
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('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
* @return \Kirby\Session\Session
*/
public function session(array $options = []): Session
public function session(array $options = [])
{
return $this->kirby->session(array_merge([
'detect' => true
@@ -222,9 +190,23 @@ class Api extends BaseApi
}
/**
* Returns the site object
* Setter for the parent Kirby instance
*
* @param \Kirby\Cms\App $kirby
* @return $this
*/
public function site(): Site
protected function setKirby(App $kirby)
{
$this->kirby = $kirby;
return $this;
}
/**
* Returns the site object
*
* @return \Kirby\Cms\Site
*/
public function site()
{
return $this->kirby->site();
}
@@ -235,9 +217,10 @@ class Api extends BaseApi
* id is passed
*
* @param string|null $id User's id
* @return \Kirby\Cms\User|null
* @throws \Kirby\Exception\NotFoundException if the user for the given id cannot be found
*/
public function user(string|null $id = null): User|null
public function user(string $id = null)
{
try {
return Find::user($id);
@@ -252,8 +235,10 @@ class Api extends BaseApi
/**
* Returns the users collection
*
* @return \Kirby\Cms\Users
*/
public function users(): Users
public function users()
{
return $this->kirby->users();
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@
namespace Kirby\Cms;
use Kirby\Cache\Cache;
use Kirby\Cache\NullCache;
use Kirby\Exception\InvalidArgumentException;
@@ -17,12 +16,15 @@ use Kirby\Exception\InvalidArgumentException;
*/
trait AppCaches
{
protected array $caches = [];
protected $caches = [];
/**
* Returns a cache instance by key
*
* @param string $key
* @return \Kirby\Cache\Cache
*/
public function cache(string $key): Cache
public function cache(string $key)
{
if (isset($this->caches[$key]) === true) {
return $this->caches[$key];
@@ -41,7 +43,7 @@ trait AppCaches
if (array_key_exists($type, $types) === false) {
throw new InvalidArgumentException([
'key' => 'cache.type.invalid',
'key' => 'app.invalid.cacheType',
'data' => ['type' => $type]
]);
}
@@ -52,9 +54,9 @@ trait AppCaches
$cache = new $className($options);
// check if it is a usable cache object
if ($cache instanceof Cache === false) {
if (is_a($cache, 'Kirby\Cache\Cache') !== true) {
throw new InvalidArgumentException([
'key' => 'cache.type.invalid',
'key' => 'app.invalid.cacheType',
'data' => ['type' => $type]
]);
}
@@ -64,11 +66,13 @@ trait AppCaches
/**
* Returns the cache options by key
*
* @param string $key
* @return array
*/
protected function cacheOptions(string $key): array
{
$options = $this->option($this->cacheOptionsKey($key), null);
$options ??= $this->core()->caches()[$key] ?? false;
$options = $this->option($this->cacheOptionsKey($key), false);
if ($options === false) {
return [
@@ -76,10 +80,9 @@ trait AppCaches
];
}
$prefix =
str_replace(['/', ':'], '_', $this->system()->indexUrl()) .
'/' .
str_replace('.', '/', $key);
$prefix = str_replace(['/', ':'], '_', $this->system()->indexUrl()) .
'/' .
str_replace('.', '/', $key);
$defaults = [
'active' => true,
@@ -91,15 +94,18 @@ trait AppCaches
if ($options === true) {
return $defaults;
} else {
return array_merge($defaults, $options);
}
return array_merge($defaults, $options);
}
/**
* Takes care of converting prefixed plugin cache setups
* to the right cache key, while leaving regular cache
* setups untouched.
*
* @param string $key
* @return string
*/
protected function cacheOptionsKey(string $key): string
{

View File

@@ -2,15 +2,11 @@
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;
@@ -26,22 +22,17 @@ use Whoops\Run as Whoops;
*/
trait AppErrors
{
/**
* Allows to disable Whoops globally in CI;
* can be overridden by explicitly setting
* the `whoops` option to `true` or `false`
*
* @internal
*/
public static bool $enableWhoops = true;
/**
* Whoops instance cache
*
* @var \Whoops\Run
*/
protected Whoops $whoops;
protected $whoops;
/**
* Registers the PHP error handler for CLI usage
*
* @return void
*/
protected function handleCliErrors(): void
{
@@ -51,20 +42,11 @@ trait AppErrors
/**
* Registers the PHP error handler
* based on the environment
*
* @return void
*/
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;
@@ -80,35 +62,29 @@ trait AppErrors
/**
* Registers the PHP error handler for HTML output
*
* @return void
*/
protected function handleHtmlErrors(): void
{
$handler = null;
if ($this->option('debug') === true) {
if ($this->option('whoops', true) !== false) {
if ($this->option('whoops', true) === true) {
$handler = new PrettyPageHandler();
$handler->setPageTitle('Kirby CMS Debugger');
$handler->addResourcePath(dirname(__DIR__, 2) . '/assets');
$handler->setResourcesPath(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) {
if (is_a($fatal, 'Closure') === true) {
echo $fatal($this, $exception);
} else {
include $this->root('kirby') . '/views/fatal.php';
@@ -127,15 +103,17 @@ trait AppErrors
/**
* Registers the PHP error handler for JSON output
*
* @return void
*/
protected function handleJsonErrors(): void
{
$handler = new CallbackHandler(function ($exception, $inspector, $run) {
if ($exception instanceof Exception) {
if (is_a($exception, 'Kirby\Exception\Exception') === true) {
$httpCode = $exception->getHttpCode();
$code = $exception->getCode();
$details = $exception->getDetails();
} elseif ($exception instanceof Throwable) {
} elseif (is_a($exception, '\Throwable') === true) {
$httpCode = 500;
$code = $exception->getCode();
$details = null;
@@ -173,31 +151,36 @@ trait AppErrors
/**
* Enables Whoops with the specified handler
*
* @param Callable|\Whoops\Handler\HandlerInterface $handler
* @return void
*/
protected function setWhoopsHandler(callable|HandlerInterface $handler): void
protected function setWhoopsHandler($handler): void
{
$whoops = $this->whoops();
$whoops->clearHandlers();
$whoops->pushHandler($handler);
$whoops->pushHandler($this->getAdditionalWhoopsHandler());
$whoops->pushHandler($this->getExceptionHookWhoopsHandler());
$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)
* Initializes a callback handler for triggering the `system.exception` hook
*
* @return \Whoops\Handler\CallbackHandler
*/
protected function getAdditionalWhoopsHandler(): CallbackHandler
protected function getExceptionHookWhoopsHandler(): CallbackHandler
{
return new CallbackHandler(function ($exception, $inspector, $run) {
$this->trigger('system.exception', compact('exception'));
error_log($exception);
return Handler::DONE;
});
}
/**
* Clears the Whoops handlers and disables Whoops
*
* @return void
*/
protected function unsetWhoopsHandler(): void
{
@@ -208,9 +191,15 @@ trait AppErrors
/**
* Returns the Whoops error handler instance
*
* @return \Whoops\Run
*/
protected function whoops(): Whoops
protected function whoops()
{
return $this->whoops ??= new Whoops();
if ($this->whoops !== null) {
return $this->whoops;
}
return $this->whoops = new Whoops();
}
}

View File

@@ -3,14 +3,13 @@
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\Panel\Panel;
use Kirby\Text\KirbyTag;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Collection as ToolkitCollection;
@@ -29,20 +28,23 @@ trait AppPlugins
{
/**
* A list of all registered plugins
*
* @var array
*/
protected static array $plugins = [];
protected static $plugins = [];
/**
* The extension registry
*
* @var array
*/
protected array $extensions = [
protected $extensions = [
// load options first to make them available for the rest
'options' => [],
// other plugin types
'api' => [],
'areas' => [],
'assetMethods' => [],
'authChallenges' => [],
'blockMethods' => [],
'blockModels' => [],
@@ -50,7 +52,6 @@ trait AppPlugins
'blueprints' => [],
'cacheTypes' => [],
'collections' => [],
'commands' => [],
'components' => [],
'controllers' => [],
'collectionFilters' => [],
@@ -73,8 +74,6 @@ trait AppPlugins
'sections' => [],
'siteMethods' => [],
'snippets' => [],
'structureMethods' => [],
'structureObjectMethods' => [],
'tags' => [],
'templates' => [],
'thirdParty' => [],
@@ -88,19 +87,21 @@ trait AppPlugins
/**
* Flag when plugins have been loaded
* to not load them again
*
* @var bool
*/
protected bool $pluginsAreLoaded = false;
protected $pluginsAreLoaded = false;
/**
* Register all given extensions
*
* @internal
* @param array $extensions
* @param \Kirby\Cms\Plugin $plugin|null The plugin which defined those extensions
* @return array
*/
public function extend(
array $extensions,
Plugin $plugin = null
): array {
public function extend(array $extensions, Plugin $plugin = null): array
{
foreach ($this->extensions as $type => $registered) {
if (isset($extensions[$type]) === true) {
$this->{'extend' . $type}($extensions[$type], $plugin);
@@ -112,43 +113,47 @@ trait AppPlugins
/**
* Registers API extensions
*
* @param array|bool $api
* @return array
*/
protected function extendApi(array|bool $api): array
protected function extendApi($api): array
{
if (is_array($api) === true) {
if (($api['routes'] ?? []) instanceof Closure) {
if (is_a($api['routes'] ?? [], 'Closure') === true) {
$api['routes'] = $api['routes']($this);
}
return $this->extensions['api'] = A::merge($this->extensions['api'], $api, A::MERGE_APPEND);
} else {
return $this->extensions['api'];
}
return $this->extensions['api'];
}
/**
* Registers additional custom Panel areas
*
* @param array $areas
* @return array
*/
protected function extendAreas(array $areas): array
{
foreach ($areas as $id => $area) {
$this->extensions['areas'][$id] ??= [];
if (isset($this->extensions['areas'][$id]) === false) {
$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 = array_merge(Asset::$methods, $methods);
}
/**
* Registers additional authentication challenges
*
* @param array $challenges
* @return array
*/
protected function extendAuthChallenges(array $challenges): array
{
@@ -157,6 +162,9 @@ trait AppPlugins
/**
* Registers additional block methods
*
* @param array $methods
* @return array
*/
protected function extendBlockMethods(array $methods): array
{
@@ -165,6 +173,9 @@ trait AppPlugins
/**
* Registers additional block models
*
* @param array $models
* @return array
*/
protected function extendBlockModels(array $models): array
{
@@ -173,6 +184,9 @@ trait AppPlugins
/**
* Registers additional blocks methods
*
* @param array $methods
* @return array
*/
protected function extendBlocksMethods(array $methods): array
{
@@ -181,6 +195,9 @@ trait AppPlugins
/**
* Registers additional blueprints
*
* @param array $blueprints
* @return array
*/
protected function extendBlueprints(array $blueprints): array
{
@@ -189,22 +206,20 @@ trait AppPlugins
/**
* Registers additional cache types
*
* @param array $cacheTypes
* @return array
*/
protected function extendCacheTypes(array $cacheTypes): array
{
return $this->extensions['cacheTypes'] = array_merge($this->extensions['cacheTypes'], $cacheTypes);
}
/**
* Registers additional CLI commands
*/
protected function extendCommands(array $commands): array
{
return $this->extensions['commands'] = array_merge($this->extensions['commands'], $commands);
}
/**
* Registers additional collection filters
*
* @param array $filters
* @return array
*/
protected function extendCollectionFilters(array $filters): array
{
@@ -213,6 +228,9 @@ trait AppPlugins
/**
* Registers additional collection methods
*
* @param array $methods
* @return array
*/
protected function extendCollectionMethods(array $methods): array
{
@@ -221,6 +239,9 @@ trait AppPlugins
/**
* Registers additional collections
*
* @param array $collections
* @return array
*/
protected function extendCollections(array $collections): array
{
@@ -229,6 +250,9 @@ trait AppPlugins
/**
* Registers core components
*
* @param array $components
* @return array
*/
protected function extendComponents(array $components): array
{
@@ -237,6 +261,9 @@ trait AppPlugins
/**
* Registers additional controllers
*
* @param array $controllers
* @return array
*/
protected function extendControllers(array $controllers): array
{
@@ -245,6 +272,9 @@ trait AppPlugins
/**
* Registers additional file methods
*
* @param array $methods
* @return array
*/
protected function extendFileMethods(array $methods): array
{
@@ -253,6 +283,9 @@ trait AppPlugins
/**
* Registers additional custom file types and mimes
*
* @param array $fileTypes
* @return array
*/
protected function extendFileTypes(array $fileTypes): array
{
@@ -303,6 +336,9 @@ trait AppPlugins
/**
* Registers additional files methods
*
* @param array $methods
* @return array
*/
protected function extendFilesMethods(array $methods): array
{
@@ -311,6 +347,9 @@ trait AppPlugins
/**
* Registers additional field methods
*
* @param array $methods
* @return array
*/
protected function extendFieldMethods(array $methods): array
{
@@ -319,6 +358,9 @@ trait AppPlugins
/**
* Registers Panel fields
*
* @param array $fields
* @return array
*/
protected function extendFields(array $fields): array
{
@@ -327,11 +369,16 @@ trait AppPlugins
/**
* Registers hooks
*
* @param array $hooks
* @return array
*/
protected function extendHooks(array $hooks): array
{
foreach ($hooks as $name => $callbacks) {
$this->extensions['hooks'][$name] ??= [];
if (isset($this->extensions['hooks'][$name]) === false) {
$this->extensions['hooks'][$name] = [];
}
if (is_array($callbacks) === false) {
$callbacks = [$callbacks];
@@ -347,14 +394,20 @@ trait AppPlugins
/**
* Registers markdown component
*
* @param Closure $markdown
* @return Closure
*/
protected function extendMarkdown(Closure $markdown): Closure
protected function extendMarkdown(Closure $markdown)
{
return $this->extensions['markdown'] = $markdown;
}
/**
* Registers additional layout methods
*
* @param array $methods
* @return array
*/
protected function extendLayoutMethods(array $methods): array
{
@@ -363,6 +416,9 @@ trait AppPlugins
/**
* Registers additional layout column methods
*
* @param array $methods
* @return array
*/
protected function extendLayoutColumnMethods(array $methods): array
{
@@ -371,6 +427,9 @@ trait AppPlugins
/**
* Registers additional layouts methods
*
* @param array $methods
* @return array
*/
protected function extendLayoutsMethods(array $methods): array
{
@@ -379,11 +438,13 @@ trait AppPlugins
/**
* Registers additional options
*
* @param array $options
* @param \Kirby\Cms\Plugin|null $plugin
* @return array
*/
protected function extendOptions(
array $options,
Plugin $plugin = null
): array {
protected function extendOptions(array $options, Plugin $plugin = null): array
{
if ($plugin !== null) {
$options = [$plugin->prefix() => $options];
}
@@ -393,6 +454,9 @@ trait AppPlugins
/**
* Registers additional page methods
*
* @param array $methods
* @return array
*/
protected function extendPageMethods(array $methods): array
{
@@ -401,6 +465,9 @@ trait AppPlugins
/**
* Registers additional pages methods
*
* @param array $methods
* @return array
*/
protected function extendPagesMethods(array $methods): array
{
@@ -409,6 +476,9 @@ trait AppPlugins
/**
* Registers additional page models
*
* @param array $models
* @return array
*/
protected function extendPageModels(array $models): array
{
@@ -417,6 +487,9 @@ trait AppPlugins
/**
* Registers pages
*
* @param array $pages
* @return array
*/
protected function extendPages(array $pages): array
{
@@ -425,11 +498,13 @@ trait AppPlugins
/**
* Registers additional permissions
*
* @param array $permissions
* @param \Kirby\Cms\Plugin|null $plugin
* @return array
*/
protected function extendPermissions(
array $permissions,
Plugin $plugin = null
): array {
protected function extendPermissions(array $permissions, Plugin $plugin = null): array
{
if ($plugin !== null) {
$permissions = [$plugin->prefix() => $permissions];
}
@@ -439,10 +514,13 @@ trait AppPlugins
/**
* Registers additional routes
*
* @param array|\Closure $routes
* @return array
*/
protected function extendRoutes(array|Closure $routes): array
protected function extendRoutes($routes): array
{
if ($routes instanceof Closure) {
if (is_a($routes, 'Closure') === true) {
$routes = $routes($this);
}
@@ -451,6 +529,9 @@ trait AppPlugins
/**
* Registers Panel sections
*
* @param array $sections
* @return array
*/
protected function extendSections(array $sections): array
{
@@ -459,6 +540,9 @@ trait AppPlugins
/**
* Registers additional site methods
*
* @param array $methods
* @return array
*/
protected function extendSiteMethods(array $methods): array
{
@@ -467,38 +551,31 @@ trait AppPlugins
/**
* Registers SmartyPants component
*
* @param \Closure $smartypants
* @return \Closure
*/
protected function extendSmartypants(Closure $smartypants): Closure
protected function extendSmartypants(Closure $smartypants)
{
return $this->extensions['smartypants'] = $smartypants;
}
/**
* Registers additional snippets
*
* @param array $snippets
* @return array
*/
protected function extendSnippets(array $snippets): array
{
return $this->extensions['snippets'] = array_merge($this->extensions['snippets'], $snippets);
}
/**
* Registers additional structure methods
*/
protected function extendStructureMethods(array $methods): array
{
return $this->extensions['structureMethods'] = Structure::$methods = array_merge(Structure::$methods, $methods);
}
/**
* Registers additional structure object methods
*/
protected function extendStructureObjectMethods(array $methods): array
{
return $this->extensions['structureObjectMethods'] = StructureObject::$methods = array_merge(StructureObject::$methods, $methods);
}
/**
* Registers additional KirbyTags
*
* @param array $tags
* @return array
*/
protected function extendTags(array $tags): array
{
@@ -507,6 +584,9 @@ trait AppPlugins
/**
* Registers additional templates
*
* @param array $templates
* @return array
*/
protected function extendTemplates(array $templates): array
{
@@ -515,6 +595,9 @@ trait AppPlugins
/**
* Registers translations
*
* @param array $translations
* @return array
*/
protected function extendTranslations(array $translations): array
{
@@ -525,6 +608,9 @@ trait AppPlugins
* Add third party extensions to the registry
* so they can be used as plugins for plugins
* for example.
*
* @param array $extensions
* @return array
*/
protected function extendThirdParty(array $extensions): array
{
@@ -533,6 +619,9 @@ trait AppPlugins
/**
* Registers additional user methods
*
* @param array $methods
* @return array
*/
protected function extendUserMethods(array $methods): array
{
@@ -541,6 +630,9 @@ trait AppPlugins
/**
* Registers additional user models
*
* @param array $models
* @return array
*/
protected function extendUserModels(array $models): array
{
@@ -549,6 +641,9 @@ trait AppPlugins
/**
* Registers additional users methods
*
* @param array $methods
* @return array
*/
protected function extendUsersMethods(array $methods): array
{
@@ -557,6 +652,9 @@ trait AppPlugins
/**
* Registers additional custom validators
*
* @param array $validators
* @return array
*/
protected function extendValidators(array $validators): array
{
@@ -569,12 +667,11 @@ trait AppPlugins
* @internal
* @param string $type i.e. `'hooks'`
* @param string $name i.e. `'page.delete:before'`
* @param mixed $fallback
* @return mixed
*/
public function extension(
string $type,
string $name,
mixed $fallback = null
): mixed {
public function extension(string $type, string $name, $fallback = null)
{
return $this->extensions($type)[$name] ?? $fallback;
}
@@ -582,8 +679,10 @@ trait AppPlugins
* Returns the extensions registry
*
* @internal
* @param string|null $type
* @return array
*/
public function extensions(string $type = null): array
public function extensions(string $type = null)
{
if ($type === null) {
return $this->extensions;
@@ -597,7 +696,7 @@ trait AppPlugins
* This is only used for models for now, but
* could be extended later
*/
protected function extensionsFromFolders(): void
protected function extensionsFromFolders()
{
$models = [];
@@ -606,7 +705,7 @@ trait AppPlugins
$class = str_replace(['.', '-', '_'], '', $name) . 'Page';
// load the model class
F::loadOnce($model, allowOutput: false);
F::loadOnce($model);
if (class_exists($class) === true) {
$models[$name] = $class;
@@ -620,8 +719,10 @@ trait AppPlugins
* Register extensions that could be located in
* the options array. I.e. hooks and routes can be
* setup from the config.
*
* @return void
*/
protected function extensionsFromOptions(): void
protected function extensionsFromOptions()
{
// register routes and hooks from options
$this->extend([
@@ -633,8 +734,10 @@ trait AppPlugins
/**
* Apply all plugin extensions
*
* @return void
*/
protected function extensionsFromPlugins(): void
protected function extensionsFromPlugins()
{
// register all their extensions
foreach ($this->plugins() as $plugin) {
@@ -648,22 +751,22 @@ trait AppPlugins
/**
* Apply all passed extensions
*
* @param array $props
* @return void
*/
protected function extensionsFromProps(array $props): void
protected function extensionsFromProps(array $props)
{
$this->extend($props);
}
/**
* Apply all default extensions
*
* @return void
*/
protected function extensionsFromSystem(): void
protected function extensionsFromSystem()
{
// 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();
@@ -679,8 +782,8 @@ trait AppPlugins
$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->extendFieldMethods($this->core->fieldMethods());
$this->extendSections($this->core->sections());
$this->extendSnippets($this->core->snippets());
$this->extendTags($this->core->kirbyTags());
@@ -690,6 +793,9 @@ trait AppPlugins
/**
* Checks if a native component was extended
* @since 3.7.0
*
* @param string $component
* @return bool
*/
public function isNativeComponent(string $component): bool
{
@@ -699,8 +805,11 @@ trait AppPlugins
/**
* Returns the native implementation
* of a core component
*
* @param string $component
* @return \Closure|false
*/
public function nativeComponent(string $component): Closure|false
public function nativeComponent(string $component)
{
return $this->core->components()[$component] ?? false;
}
@@ -708,30 +817,22 @@ trait AppPlugins
/**
* Kirby plugin factory and getter
*
* @param string $name
* @param array|null $extends If null is passed it will be used as getter. Otherwise as factory.
* @return \Kirby\Cms\Plugin|null
* @throws \Kirby\Exception\DuplicateException
*/
public static function plugin(
string $name,
array $extends = null,
array $info = [],
string|null $root = null,
string|null $version = null
): Plugin|null {
public static function plugin(string $name, array $extends = null)
{
if ($extends === null) {
return static::$plugins[$name] ?? null;
}
$plugin = new Plugin(
name: $name,
extends: $extends,
info: $info,
// TODO: Remove fallback to $extends in v7
root: $root ?? $extends['root'] ?? dirname(debug_backtrace()[0]['file']),
version: $version
);
// get the correct root for the plugin
$extends['root'] = $extends['root'] ?? dirname(debug_backtrace()[0]['file']);
$name = $plugin->name();
$plugin = new Plugin($name, $extends);
$name = $plugin->name();
if (isset(static::$plugins[$name]) === true) {
throw new DuplicateException('The plugin "' . $name . '" has already been registered');
@@ -746,6 +847,7 @@ trait AppPlugins
*
* @internal
* @param array|null $plugins Can be used to overwrite the plugins registry
* @return array
*/
public function plugins(array $plugins = null): array
{
@@ -794,17 +896,13 @@ trait AppPlugins
$styles = $dir . '/index.css';
if (is_file($entry) === true) {
F::loadOnce($entry, allowOutput: false);
F::loadOnce($entry);
} 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
);
static::plugin('plugins/' . $dirname, ['root' => $dir]);
} else {
continue;
}

View File

@@ -3,6 +3,7 @@
namespace Kirby\Cms;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Locale;
use Kirby\Toolkit\Str;
/**
@@ -16,15 +17,21 @@ use Kirby\Toolkit\Str;
*/
trait AppTranslations
{
protected Translations|null $translations = null;
protected $translations;
/**
* Setup internationalization
*
* @return void
*/
protected function i18n(): void
{
I18n::$load = function ($locale): array {
$data = $this->translation($locale)?->data() ?? [];
$data = [];
if ($translation = $this->translation($locale)) {
$data = $translation->data();
}
// inject translations from the current language
if (
@@ -42,9 +49,9 @@ trait AppTranslations
I18n::$locale = function (): string {
if ($this->multilang() === true) {
return $this->defaultLanguage()->code();
} else {
return 'en';
}
return 'en';
};
I18n::$fallback = function (): array {
@@ -64,9 +71,9 @@ trait AppTranslations
$fallback[] = 'en';
return $fallback;
} else {
return ['en'];
}
return ['en'];
};
I18n::$translations = [];
@@ -85,6 +92,8 @@ trait AppTranslations
* 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
*
* @return string
*/
public function panelLanguage(): string
{
@@ -103,10 +112,43 @@ trait AppTranslations
return $this->option('panel.language', $defaultCode);
}
/**
* Load and set the current language if it exists
* Otherwise fall back to the default language
*
* @internal
* @param string|null $languageCode
* @return \Kirby\Cms\Language|null
*/
public function setCurrentLanguage(string $languageCode = null)
{
if ($this->multilang() === false) {
Locale::set($this->option('locale', 'en_US.utf-8'));
return $this->language = null;
}
if ($language = $this->language($languageCode)) {
$this->language = $language;
} else {
$this->language = $this->defaultLanguage();
}
if ($this->language) {
Locale::set($this->language->locale());
}
// add language slug rules to Str class
Str::$language = $this->language->rules();
return $this->language;
}
/**
* Set the current translation
*
* @internal
* @param string|null $translationCode
* @return void
*/
public function setCurrentTranslation(string $translationCode = null): void
{
@@ -117,14 +159,15 @@ trait AppTranslations
* Load a specific translation by locale
*
* @param string|null $locale Locale name or `null` for the current locale
* @return \Kirby\Cms\Translation
*/
public function translation(string|null $locale = null): Translation
public function translation(?string $locale = null)
{
$locale ??= I18n::locale();
$locale = $locale ?? I18n::locale();
$locale = basename($locale);
// prefer loading them from the translations collection
if ($this->translations instanceof Translations) {
if (is_a($this->translations, 'Kirby\Cms\Translations') === true) {
if ($translation = $this->translations()->find($locale)) {
return $translation;
}
@@ -144,10 +187,12 @@ trait AppTranslations
/**
* Returns all available translations
*
* @return \Kirby\Cms\Translations
*/
public function translations(): Translations
public function translations()
{
if ($this->translations instanceof Translations) {
if (is_a($this->translations, 'Kirby\Cms\Translations') === true) {
return $this->translations;
}
@@ -169,6 +214,8 @@ trait AppTranslations
}
}
return $this->translations = Translations::load($this->root('i18n:translations'), $translations);
$this->translations = Translations::load($this->root('i18n:translations'), $translations);
return $this->translations;
}
}

View File

@@ -16,17 +16,22 @@ use Throwable;
*/
trait AppUsers
{
protected Auth|null $auth = null;
protected User|string|null $user = null;
protected Users|null $users = null;
/**
* Cache for the auth auth layer
*
* @var Auth
*/
protected $auth;
/**
* Returns the Authentication layer class
*
* @internal
* @return \Kirby\Cms\Auth
*/
public function auth(): Auth
public function auth()
{
return $this->auth ??= new Auth($this);
return $this->auth = $this->auth ?? new Auth($this);
}
/**
@@ -43,10 +48,8 @@ trait AppUsers
* if called with callback: Return value from the callback
* @throws \Throwable
*/
public function impersonate(
string|null $who = null,
Closure|null $callback = null
): mixed {
public function impersonate(?string $who = null, ?Closure $callback = null)
{
$auth = $this->auth();
$userBefore = $auth->currentUserFromImpersonation();
@@ -57,22 +60,24 @@ trait AppUsers
}
try {
return $callback($userAfter);
// bind the App object to the callback
return $callback->call($this, $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());
$auth->impersonate($userBefore !== null ? $userBefore->id() : null);
}
}
/**
* Set the currently active user id
*
* @return $this
* @param \Kirby\Cms\User|string $user
* @return \Kirby\Cms\App
*/
protected function setUser(User|string $user = null): static
protected function setUser($user = null)
{
$this->user = $user;
return $this;
@@ -81,12 +86,15 @@ trait AppUsers
/**
* Create your own set of app users
*
* @return $this
* @param array|null $users
* @return \Kirby\Cms\App
*/
protected function setUsers(array $users = null): static
protected function setUsers(array $users = null)
{
if ($users !== null) {
$this->users = Users::factory($users);
$this->users = Users::factory($users, [
'kirby' => $this
]);
}
return $this;
@@ -96,36 +104,40 @@ trait AppUsers
* Returns a specific user by id
* or the current user if no id is given
*
* @param string|null $id
* @param bool $allowImpersonation If set to false, only the actually
* logged in user will be returned
* (when `$id` is passed as `null`)
* @return \Kirby\Cms\User|null
*/
public function user(
string|null $id = null,
bool $allowImpersonation = true
): User|null {
public function user(?string $id = null, bool $allowImpersonation = true)
{
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;
} else {
try {
return $this->auth()->user(null, $allowImpersonation);
} catch (Throwable $e) {
return null;
}
}
}
/**
* Returns all users
*
* @return \Kirby\Cms\Users
*/
public function users(): Users
public function users()
{
return $this->users ??= Users::load(
$this->root('accounts'),
);
if (is_a($this->users, 'Kirby\Cms\Users') === true) {
return $this->users;
}
return $this->users = Users::load($this->root('accounts'), ['kirby' => $this]);
}
}

View File

@@ -2,10 +2,8 @@
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;
@@ -13,9 +11,7 @@ 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;
/**
@@ -32,37 +28,55 @@ class Auth
/**
* Available auth challenge classes
* from the core and plugins
*
* @var array
*/
public static array $challenges = [];
public static $challenges = [];
/**
* Currently impersonated user
*
* @var \Kirby\Cms\User|null
*/
protected User|null $impersonate = null;
protected $impersonate;
/**
* Kirby instance
*
* @var \Kirby\Cms\App
*/
protected $kirby;
/**
* Cache of the auth status object
*
* @var \Kirby\Cms\Auth\Status
*/
protected Status|null $status = null;
protected $status;
/**
* Instance of the currently logged in user or
* `false` if the user was not yet determined
*
* @var \Kirby\Cms\User|null|false
*/
protected User|false|null $user = false;
protected $user = false;
/**
* Exception that was thrown while
* determining the current user
*
* @var \Throwable
*/
protected Throwable|null $userException = null;
protected $userException;
/**
* @param \Kirby\Cms\App $kirby
* @codeCoverageIgnore
*/
public function __construct(
protected App $kirby
) {
public function __construct(App $kirby)
{
$this->kirby = $kirby;
}
/**
@@ -70,18 +84,17 @@ class Auth
* (one-time auth code)
* @since 3.5.0
*
* @param string $email
* @param bool $long If `true`, a long session will be created
* @param 'login'|'password-reset'|'2fa' $mode Purpose of the code
* @param string $mode Either 'login' or 'password-reset'
* @return \Kirby\Cms\Auth\Status
*
* @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 {
public function createChallenge(string $email, bool $long = false, string $mode = 'login')
{
$email = Idn::decodeEmail($email);
$session = $this->kirby->session([
@@ -89,8 +102,6 @@ class Auth
'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 {
@@ -99,6 +110,8 @@ class Auth
// rate-limit the number of challenges for DoS/DDoS protection
$this->track($email, false);
$timeout = $this->kirby->option('auth.challenge.timeout', 10 * 60);
// try to find the provided user
$user = $this->kirby->users()->find($email);
if ($user === null) {
@@ -119,7 +132,7 @@ class Auth
if (
$class &&
class_exists($class) === true &&
is_subclass_of($class, Challenge::class) === true &&
is_subclass_of($class, 'Kirby\Cms\Auth\Challenge') === true &&
$class::isAvailable($user, $mode) === true
) {
$challenge = $name;
@@ -128,10 +141,8 @@ class Auth
$session->set('kirby.challenge.type', $challenge);
if ($code !== null) {
$session->set(
'kirby.challenge.code',
password_hash($code, PASSWORD_DEFAULT)
);
$session->set('kirby.challenge.code', password_hash($code, PASSWORD_DEFAULT));
$session->set('kirby.challenge.timeout', time() + $timeout);
}
break;
@@ -147,10 +158,9 @@ class Auth
$this->fail($e);
}
// always set the email and timeout, even if the challenge
// won't be created; this avoids leaking whether the user exists
// always set the email, even if the challenge won't be
// created to avoid leaking whether the user exists
$session->set('kirby.challenge.email', $email);
$session->set('kirby.challenge.timeout', time() + $timeout);
// sleep for a random amount of milliseconds
// to make automated attacks harder and to
@@ -165,8 +175,10 @@ class Auth
/**
* Returns the csrf token if it exists and if it is valid
*
* @return string|false
*/
public function csrf(): string|false
public function csrf()
{
// get the csrf from the header
$fromHeader = $this->kirby->request()->csrf();
@@ -185,6 +197,8 @@ class Auth
/**
* Returns either predefined csrf or the one from session
* @since 3.6.0
*
* @return string
*/
public function csrfFromSession(): string
{
@@ -199,10 +213,11 @@ class Auth
* valid credentials
*
* @param \Kirby\Http\Request\Auth\BasicAuth|null $auth
* @return \Kirby\Cms\User|null
* @throws \Kirby\Exception\InvalidArgumentException if the authorization header is invalid
* @throws \Kirby\Exception\PermissionException if basic authentication is not allowed
*/
public function currentUserFromBasicAuth(BasicAuth $auth = null): User|null
public function currentUserFromBasicAuth(BasicAuth $auth = null)
{
if ($this->kirby->option('api.basicAuth', false) !== true) {
throw new PermissionException('Basic authentication is not activated');
@@ -221,8 +236,8 @@ class Auth
}
}
$request = $this->kirby->request();
$auth ??= $request->auth();
$request = $this->kirby->request();
$auth = $auth ?? $request->auth();
if (!$auth || $auth->type() !== 'basic') {
throw new InvalidArgumentException('Invalid authorization header');
@@ -238,8 +253,10 @@ class Auth
/**
* Returns the currently impersonated user
*
* @return \Kirby\Cms\User|null
*/
public function currentUserFromImpersonation(): User|null
public function currentUserFromImpersonation()
{
return $this->impersonate;
}
@@ -248,10 +265,12 @@ class Auth
* Returns the logged in user by checking
* the current session and finding a valid
* valid user id in there
*
* @param \Kirby\Session\Session|array|null $session
* @return \Kirby\Cms\User|null
*/
public function currentUserFromSession(
Session|array $session = null
): User|null {
public function currentUserFromSession($session = null)
{
$session = $this->session($session);
$id = $session->data()->get('kirby.userId');
@@ -295,12 +314,12 @@ class Auth
* Returns the list of enabled challenges in the
* configured order
* @since 3.5.1
*
* @return array
*/
public function enabledChallenges(): array
{
return A::wrap(
$this->kirby->option('auth.challenges', ['totp', 'email'])
);
return A::wrap($this->kirby->option('auth.challenges', ['email']));
}
/**
@@ -310,32 +329,43 @@ class Auth
* `null` to use the actual user again,
* `'kirby'` for a virtual admin user or
* `'nobody'` to disable the actual user
* @return \Kirby\Cms\User|null
* @throws \Kirby\Exception\NotFoundException if the given user cannot be found
*/
public function impersonate(string|null $who = null): User|null
public function impersonate(?string $who = 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('The user "' . $who . '" cannot be found'))
};
switch ($who) {
case null:
return $this->impersonate = null;
case 'kirby':
return $this->impersonate = new User([
'email' => 'kirby@getkirby.com',
'id' => 'kirby',
'role' => 'admin',
]);
case 'nobody':
return $this->impersonate = new User([
'email' => 'nobody@getkirby.com',
'id' => 'nobody',
'role' => 'nobody',
]);
default:
if ($user = $this->kirby->users()->find($who)) {
return $this->impersonate = $user;
}
throw new NotFoundException('The user "' . $who . '" cannot be found');
}
}
/**
* Returns the hashed ip of the visitor
* which is used to track invalid logins
*
* @return string
*/
public function ipHash(): string
{
@@ -347,6 +377,9 @@ class Auth
/**
* Check if logins are blocked for the current ip or email
*
* @param string $email
* @return bool
*/
public function isBlocked(string $email): bool
{
@@ -374,16 +407,17 @@ class Auth
/**
* Login a user by email and password
*
* @param string $email
* @param string $password
* @param bool $long
* @return \Kirby\Cms\User
*
* @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 {
public function login(string $email, string $password, bool $long = false)
{
// session options
$options = [
'createMode' => 'cookie',
@@ -404,16 +438,17 @@ class Auth
* Login a user by email, password and auth challenge
* @since 3.5.0
*
* @param string $email
* @param string $password
* @param bool $long
* @return \Kirby\Cms\Auth\Status
*
* @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 {
public function login2fa(string $email, string $password, bool $long = false)
{
$this->validatePassword($email, $password);
return $this->createChallenge($email, $long, '2fa');
}
@@ -421,12 +456,16 @@ class Auth
/**
* Sets a user object as the current user in the cache
* @internal
*
* @param \Kirby\Cms\User $user
* @return void
*/
public function setUser(User $user): void
{
// stop impersonating
$this->impersonate = null;
$this->user = $user;
$this->user = $user;
// clear the status cache
$this->status = null;
@@ -436,13 +475,13 @@ class Auth
* Returns the authentication status object
* @since 3.5.1
*
* @param \Kirby\Session\Session|array|null $session
* @param bool $allowImpersonation If set to false, only the actually
* logged in user will be returned
* @return \Kirby\Cms\Auth\Status
*/
public function status(
Session|array $session = null,
bool $allowImpersonation = true
): Status {
public function status($session = null, bool $allowImpersonation = true)
{
// try to return from cache
if ($this->status && $session === null && $allowImpersonation === true) {
return $this->status;
@@ -503,15 +542,16 @@ class Auth
* Validates the user credentials and returns the user object on success;
* otherwise logs the failed attempt
*
* @param string $email
* @param string $password
* @return \Kirby\Cms\User
*
* @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 {
public function validatePassword(string $email, string $password)
{
$email = Idn::decodeEmail($email);
try {
@@ -531,7 +571,7 @@ class Auth
]
]);
} catch (Throwable $e) {
$details = $e instanceof Exception ? $e->getDetails() : [];
$details = is_a($e, 'Kirby\Exception\Exception') === true ? $e->getDetails() : [];
// log invalid login trial unless the rate limit is already active
if (($details['reason'] ?? null) !== 'rate-limited') {
@@ -555,6 +595,8 @@ class Auth
/**
* Returns the absolute path to the logins log
*
* @return string
*/
public function logfile(): string
{
@@ -563,20 +605,22 @@ class Auth
/**
* Read all tracked logins
*
* @return array
*/
public function log(): array
{
try {
$log = Data::read($this->logfile(), 'json');
$read = true;
} catch (Throwable) {
} catch (Throwable $e) {
$log = [];
$read = false;
}
// ensure that the category arrays are defined
$log['by-ip'] ??= [];
$log['by-email'] ??= [];
$log['by-ip'] = $log['by-ip'] ?? [];
$log['by-email'] = $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']));
@@ -605,6 +649,8 @@ class Auth
/**
* Logout the current user
*
* @return void
*/
public function logout(): void
{
@@ -613,7 +659,9 @@ class Auth
$this->impersonate = null;
// logout the current user if it exists
$this->user()?->logout();
if ($user = $this->user()) {
$user->logout();
}
// clear the pending challenge
$session = $this->kirby->session();
@@ -629,6 +677,8 @@ class Auth
/**
* Clears the cached user data after logout
* @internal
*
* @return void
*/
public function flush(): void
{
@@ -640,12 +690,12 @@ class Auth
/**
* Tracks a login
*
* @param string|null $email
* @param bool $triggerHook If `false`, no user.login:failed hook is triggered
* @return bool
*/
public function track(
string|null $email,
bool $triggerHook = true
): bool {
public function track(?string $email, bool $triggerHook = true): bool
{
if ($triggerHook === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
}
@@ -689,43 +739,34 @@ class Auth
* @param bool $allowImpersonation If set to false, 'impersonate' won't
* be returned as authentication type
* even if an impersonation is active
* @return string
*/
public function type(bool $allowImpersonation = true): string
{
$basicAuth = $this->kirby->option('api.basicAuth', false);
$request = $this->kirby->request();
$auth = $this->kirby->request()->auth();
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'
) {
if ($basicAuth === true && $auth && $auth->type() === 'basic') {
return 'basic';
}
if ($allowImpersonation === true && $this->impersonate !== null) {
} elseif ($allowImpersonation === true && $this->impersonate !== null) {
return 'impersonate';
} else {
return 'session';
}
return 'session';
}
/**
* Validates the currently logged in user
*
* @param \Kirby\Session\Session|array|null $session
* @param bool $allowImpersonation If set to false, only the actually
* logged in user will be returned
* @return \Kirby\Cms\User|null
*
* @throws \Throwable If an authentication error occurred
*/
public function user(
Session|array $session = null,
bool $allowImpersonation = true
): User|null {
public function user($session = null, bool $allowImpersonation = true)
{
if ($allowImpersonation === true && $this->impersonate !== null) {
return $this->impersonate;
}
@@ -738,18 +779,16 @@ class Auth
}
return null;
}
if ($this->user !== false) {
} elseif ($this->user !== false) {
return $this->user;
}
try {
if ($this->type() === 'basic') {
return $this->user = $this->currentUserFromBasicAuth();
} else {
return $this->user = $this->currentUserFromSession($session);
}
return $this->user = $this->currentUserFromSession($session);
} catch (Throwable $e) {
$this->user = null;
@@ -775,42 +814,16 @@ class Auth
* @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 {
public function verifyChallenge(string $code)
{
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
// first check if we have an active challenge at all
$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'
]);
throw new InvalidArgumentException('No authentication challenge is active');
}
$user = $this->kirby->users()->find($email);
@@ -826,10 +839,16 @@ class Auth
// rate-limiting
$this->checkRateLimit($email);
// time-limiting
$timeout = $session->get('kirby.challenge.timeout');
if ($timeout !== null && time() > $timeout) {
throw new PermissionException('Authentication challenge timeout');
}
if (
isset(static::$challenges[$challenge]) === true &&
class_exists(static::$challenges[$challenge]) === true &&
is_subclass_of(static::$challenges[$challenge], Challenge::class) === true
is_subclass_of(static::$challenges[$challenge], 'Kirby\Cms\Auth\Challenge') === true
) {
$class = static::$challenges[$challenge];
if ($class::verify($user, $code) === true) {
@@ -840,16 +859,14 @@ class Auth
$this->status = null;
return $user;
} else {
throw new PermissionException(['key' => 'access.code']);
}
throw new PermissionException(['key' => 'access.code']);
}
throw new LogicException(
'Invalid authentication challenge: ' . $challenge
);
throw new LogicException('Invalid authentication challenge: ' . $challenge);
} catch (Throwable $e) {
$details = $e instanceof Exception ? $e->getDetails() : [];
$details = is_a($e, 'Kirby\Exception\Exception') === true ? $e->getDetails() : [];
if (
empty($email) === false &&
@@ -863,14 +880,7 @@ class Auth
// 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'
]);
$fallback = new PermissionException(['key' => 'access.code']);
// keep throwing the original error in debug mode,
// otherwise hide it to avoid leaking security-relevant information
@@ -885,10 +895,8 @@ class Auth
* @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 $fallback = null
): void {
protected function fail(Throwable $exception, Throwable $fallback = null): void
{
$debug = $this->kirby->option('auth.debug', 'log');
// throw the original exception only in debug mode
@@ -910,8 +918,11 @@ class Auth
/**
* Creates a session object from the passed options
*
* @param \Kirby\Session\Session|array|null $session
* @return \Kirby\Session\Session
*/
protected function session(Session|array $session = null): Session
protected function session($session = null)
{
// use passed session options or session object if set
if (is_array($session) === true) {
@@ -919,7 +930,7 @@ class Auth
}
// try session in header or cookie
if ($session instanceof Session === false) {
if (is_a($session, 'Kirby\Session\Session') === false) {
return $this->kirby->session(['detect' => true]);
}

View File

@@ -3,7 +3,6 @@
namespace Kirby\Cms\Auth;
use Kirby\Cms\User;
use SensitiveParameter;
/**
* Template class for authentication challenges
@@ -22,7 +21,8 @@ abstract class Challenge
* 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
* @param string $mode Purpose of the code ('login', 'reset' or '2fa')
* @return bool
*/
abstract public static function isAvailable(User $user, string $mode): bool;
@@ -32,12 +32,12 @@ abstract class Challenge
*
* @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')
* - 'mode': Purpose of the code ('login', '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;
abstract public static function create(User $user, array $options): ?string;
/**
* Verifies the provided code against the created one;
@@ -46,12 +46,10 @@ abstract class Challenge
*
* @param \Kirby\Cms\User $user User to check the code for
* @param string $code Code to verify
* @return bool
*/
public static function verify(
User $user,
#[SensitiveParameter]
string $code
): bool {
public static function verify(User $user, string $code): bool
{
$hash = $user->kirby()->session()->get('kirby.challenge.code');
if (is_string($hash) !== true) {
return false;

View File

@@ -23,7 +23,8 @@ class EmailChallenge extends Challenge
* 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
* @param string $mode Purpose of the code ('login', 'reset' or '2fa')
* @return bool
*/
public static function isAvailable(User $user, string $mode): bool
{
@@ -36,7 +37,7 @@ class EmailChallenge extends Challenge
*
* @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')
* - 'mode': Purpose of the code ('login', 'reset' or '2fa')
* - 'timeout': Number of seconds the code will be valid for
* @return string The generated and sent code
*/

View File

@@ -3,7 +3,6 @@
namespace Kirby\Cms\Auth;
use Kirby\Cms\App;
use Kirby\Cms\User;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Properties;
@@ -20,32 +19,44 @@ use Kirby\Toolkit\Properties;
*/
class Status
{
use Properties;
/**
* Type of the active challenge
*
* @var string|null
*/
protected string|null $challenge = null;
protected $challenge = null;
/**
* Challenge type to use as a fallback
* when $challenge is `null`
*
* @var string|null
*/
protected string|null $challengeFallback = null;
protected $challengeFallback = null;
/**
* Email address of the current/pending user
*
* @var string|null
*/
protected string|null $email;
protected $email = null;
/**
* Kirby instance for user lookup
*
* @var \Kirby\Cms\App
*/
protected App $kirby;
protected $kirby;
/**
* Authentication status:
* `active|impersonated|pending|inactive`
*
* @var string
*/
protected string $status;
protected $status;
/**
* Class constructor
@@ -54,24 +65,13 @@ class Status
*/
public function __construct(array $props)
{
if (in_array($props['status'], ['active', 'impersonated', 'pending', 'inactive']) !== 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->status = $props['status'];
$this->setProperties($props);
}
/**
* Returns the authentication status
*
* @return string
*/
public function __toString(): string
{
@@ -84,8 +84,9 @@ class Status
* @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
* @return string|null
*/
public function challenge(bool $automaticFallback = true): string|null
public function challenge(bool $automaticFallback = true): ?string
{
// never return a challenge type if the status doesn't match
if ($this->status() !== 'pending') {
@@ -94,30 +95,17 @@ class Status
if ($automaticFallback === false) {
return $this->challenge;
} else {
return $this->challenge ?? $this->challengeFallback;
}
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
*
* @return string|null
*/
public function email(): string|null
public function email(): ?string
{
return $this->email;
}
@@ -134,6 +122,8 @@ class Status
/**
* Returns an array with all public status data
*
* @return array
*/
public function toArray(): array
{
@@ -146,8 +136,10 @@ class Status
/**
* Returns the currently logged in user
*
* @return \Kirby\Cms\User
*/
public function user(): User|null
public function user()
{
// for security, only return the user if they are
// already logged in
@@ -157,4 +149,71 @@ class Status
return $this->kirby->user($this->email());
}
/**
* Sets the type of the active challenge
*
* @param string|null $challenge
* @return $this
*/
protected function setChallenge(?string $challenge = null)
{
$this->challenge = $challenge;
return $this;
}
/**
* Sets the challenge type to use as
* a fallback when $challenge is `null`
*
* @param string|null $challengeFallback
* @return $this
*/
protected function setChallengeFallback(?string $challengeFallback = null)
{
$this->challengeFallback = $challengeFallback;
return $this;
}
/**
* Sets the email address of the current/pending user
*
* @param string|null $email
* @return $this
*/
protected function setEmail(?string $email = null)
{
$this->email = $email;
return $this;
}
/**
* Sets the Kirby instance for user lookup
*
* @param \Kirby\Cms\App $kirby
* @return $this
*/
protected function setKirby(App $kirby)
{
$this->kirby = $kirby;
return $this;
}
/**
* Sets the authentication status
*
* @param string $status `active|impersonated|pending|inactive`
* @return $this
*/
protected function setStatus(string $status)
{
if (in_array($status, ['active', 'impersonated', 'pending', 'inactive']) !== true) {
throw new InvalidArgumentException([
'data' => ['argument' => '$props[\'status\']', 'method' => 'Status::__construct']
]);
}
$this->status = $status;
return $this;
}
}

View File

@@ -1,65 +0,0 @@
<?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);
}
}

View File

@@ -2,8 +2,6 @@
namespace Kirby\Cms;
use Kirby\Content\Content;
use Kirby\Content\Field;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Str;
use Throwable;
@@ -24,21 +22,38 @@ class Block extends Item
{
use HasMethods;
public const ITEMS_CLASS = Blocks::class;
public const ITEMS_CLASS = '\Kirby\Cms\Blocks';
/**
* @var \Kirby\Cms\Content
*/
protected $content;
/**
* @var bool
*/
protected $isHidden;
/**
* Registry with all block models
*
* @var array
*/
public static array $models = [];
public static $models = [];
protected Content $content;
protected bool $isHidden;
protected string $type;
/**
* @var string
*/
protected $type;
/**
* Proxy for content fields
*
* @param string $method
* @param array $args
* @return \Kirby\Cms\Field
*/
public function __call(string $method, array $args = []): mixed
public function __call(string $method, array $args = [])
{
// block methods
if ($this->hasMethod($method)) {
@@ -51,19 +66,13 @@ class Block extends Item
/**
* Creates a new block object
*
* @param array $params
* @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('The block type is missing');
}
@@ -74,15 +83,18 @@ class Block extends Item
$params['content'] = [];
}
$this->content = $params['content'];
$this->isHidden = $params['isHidden'] ?? false;
$this->type = $params['type'];
// create the content object
$this->content = new Content($params['content'], $this->parent);
$this->content = new Content($this->content, $this->parent);
}
/**
* Converts the object to a string
*
* @return string
*/
public function __toString(): string
{
@@ -91,14 +103,18 @@ class Block extends Item
/**
* Returns the content object
*
* @return \Kirby\Cms\Content
*/
public function content(): Content
public function content()
{
return $this->content;
}
/**
* Controller for the block snippet
*
* @return array
*/
public function controller(): array
{
@@ -117,38 +133,40 @@ class Block extends Item
* Converts the block to HTML and then
* uses the Str::excerpt method to create
* a non-formatted, shortened excerpt from it
*
* @param mixed ...$args
* @return string
*/
public function excerpt(mixed ...$args): string
public function excerpt(...$args)
{
return Str::excerpt($this->toHtml(), ...$args);
}
/**
* Constructs a block object with registering blocks models
* @internal
*
* @param array $params
* @return static
* @throws \Kirby\Exception\InvalidArgumentException
* @internal
*/
public static function factory(array $params): static
public static function factory(array $params)
{
$type = $params['type'] ?? null;
if (
empty($type) === false &&
$class = (static::$models[$type] ?? null)
) {
if (empty($type) === false && $class = (static::$models[$type] ?? null)) {
$object = new $class($params);
if ($object instanceof self) {
if (is_a($object, 'Kirby\Cms\Block') === true) {
return $object;
}
}
// default model for blocks
if ($class = (static::$models['default'] ?? null)) {
if ($class = (static::$models['Kirby\Cms\Block'] ?? null)) {
$object = new $class($params);
if ($object instanceof self) {
if (is_a($object, 'Kirby\Cms\Block') === true) {
return $object;
}
}
@@ -158,6 +176,8 @@ class Block extends Item
/**
* Checks if the block is empty
*
* @return bool
*/
public function isEmpty(): bool
{
@@ -167,6 +187,8 @@ class Block extends Item
/**
* Checks if the block is hidden
* from being rendered in the frontend
*
* @return bool
*/
public function isHidden(): bool
{
@@ -175,6 +197,8 @@ class Block extends Item
/**
* Checks if the block is not empty
*
* @return bool
*/
public function isNotEmpty(): bool
{
@@ -183,14 +207,18 @@ class Block extends Item
/**
* Returns the sibling collection that filtered by block status
*
* @return \Kirby\Cms\Collection
*/
protected function siblingsCollection(): Blocks
protected function siblingsCollection()
{
return $this->siblings->filter('isHidden', $this->isHidden());
}
/**
* Returns the block type
*
* @return string
*/
public function type(): string
{
@@ -200,6 +228,8 @@ class Block extends Item
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{
@@ -216,24 +246,24 @@ class Block extends Item
* and then places that inside a field
* object. This can be used further
* with all available field methods
*
* @return \Kirby\Cms\Field
*/
public function toField(): Field
public function toField()
{
return new Field($this->parent(), $this->id(), $this->toHtml());
}
/**
* Converts the block to HTML
*
* @return string
*/
public function toHtml(): string
{
try {
$kirby = $this->parent()->kirby();
return (string)$kirby->snippet(
'blocks/' . $this->type(),
$this->controller(),
true
);
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>';

View File

@@ -1,285 +0,0 @@
<?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;
}
$params['type'] = $params['_key'];
$params['content'] = $params;
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 (empty($blocks) === true) {
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) {
$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' => array_merge(
$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;
}
}

View File

@@ -2,12 +2,9 @@
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;
@@ -23,16 +20,13 @@ use Throwable;
*/
class Blocks extends Items
{
public const ITEM_CLASS = Block::class;
/**
* All registered blocks methods
*/
public static array $methods = [];
public const ITEM_CLASS = '\Kirby\Cms\Block';
/**
* Return HTML when the collection is
* converted to a string
*
* @return string
*/
public function __toString(): string
{
@@ -43,8 +37,11 @@ class Blocks extends Items
* Converts the blocks to HTML and then
* uses the Str::excerpt method to create
* a non-formatted, shortened excerpt from it
*
* @param mixed ...$args
* @return string
*/
public function excerpt(mixed ...$args): string
public function excerpt(...$args)
{
return Str::excerpt($this->toHtml(), ...$args);
}
@@ -52,24 +49,23 @@ class Blocks extends Items
/**
* Wrapper around the factory to
* catch blocks from layouts
*
* @param array $items
* @param array $params
* @return \Kirby\Cms\Blocks
*/
public static function factory(
array $items = null,
array $params = []
): static {
public static function factory(array $items = null, array $params = [])
{
$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
*
* @param array $input
* @return array
*/
protected static function extractFromLayouts(array $input): array
{
@@ -77,13 +73,8 @@ class Blocks extends Items
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
) {
// no columns = no layout
if (array_key_exists('columns', $input[0]) === false) {
return $input;
}
@@ -103,6 +94,9 @@ class Blocks extends Items
/**
* Checks if a given block type exists in the collection
* @since 3.6.0
*
* @param string $type
* @return bool
*/
public function hasType(string $type): bool
{
@@ -111,40 +105,18 @@ class Blocks extends Items
/**
* Parse and sanitize various block formats
*
* @param array|string $input
* @return array
*/
public static function parse(array|string|null $input): array
public static function parse($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 (
empty($yaml) === true ||
(
isset($first['_key']) === false &&
isset($first['type']) === false
)
) {
throw new Exception('Invalid YAML');
} else {
$input = $yaml;
}
} catch (Throwable $e) {
// the next 2 lines remain after removing block.converter
// @codeCoverageIgnoreEnd
$parser = new Parsley((string)$input, new BlockSchema());
$input = $parser->blocks();
// @codeCoverageIgnoreStart
}
// @codeCoverageIgnoreEnd
} catch (Throwable $e) {
$parser = new Parsley((string)$input, new BlockSchema());
$input = $parser->blocks();
}
}
@@ -157,10 +129,16 @@ class Blocks extends Items
/**
* Convert all blocks to HTML
*
* @return string
*/
public function toHtml(): string
{
$html = A::map($this->data, fn ($block) => $block->toHtml());
$html = [];
foreach ($this->data as $block) {
$html[] = $block->toHtml();
}
return implode($html);
}

View File

@@ -34,12 +34,14 @@ class Blueprint
protected $sections = [];
protected $tabs = [];
protected array|null $fileTemplates = null;
/**
* Magic getter/caller for any blueprint prop
*
* @param string $key
* @param array|null $arguments
* @return mixed
*/
public function __call(string $key, array $arguments = null): mixed
public function __call(string $key, array $arguments = null)
{
return $this->props[$key] ?? null;
}
@@ -47,6 +49,7 @@ class Blueprint
/**
* Creates a new blueprint object with the given props
*
* @param array $props
* @throws \Kirby\Exception\InvalidArgumentException If the blueprint model is missing
*/
public function __construct(array $props)
@@ -55,7 +58,7 @@ class Blueprint
throw new InvalidArgumentException('A blueprint model is required');
}
if ($props['model'] instanceof ModelWithContent === false) {
if (is_a($props['model'], ModelWithContent::class) === false) {
throw new InvalidArgumentException('Invalid blueprint model');
}
@@ -65,7 +68,7 @@ class Blueprint
unset($props['model']);
// extend the blueprint in general
$props = static::extend($props);
$props = $this->extend($props);
// apply any blueprint preset
$props = $this->preset($props);
@@ -74,8 +77,7 @@ class Blueprint
$props['name'] ??= 'default';
// normalize and translate the title
$props['title'] ??= ucfirst($props['name']);
$props['title'] = $this->i18n($props['title']);
$props['title'] = $this->i18n($props['title'] ?? ucfirst($props['name']));
// convert all shortcuts
$props = $this->convertFieldsToSections('main', $props);
@@ -91,130 +93,23 @@ class Blueprint
/**
* Improved `var_dump` output
*
* @codeCoverageIgnore
* @return array
*/
public function __debugInfo(): array
{
return $this->props ?? [];
}
/**
* Gathers what file templates are allowed in
* this model based on the blueprint
*/
public function acceptedFileTemplates(string $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 = array_merge($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')];
}
/**
* Converts all column definitions, that
* are not wrapped in a tab, into a generic tab
*
* @param string $tabName
* @param array $props
* @return array
*/
protected function convertColumnsToTabs(
string $tabName,
array $props
): array {
protected function convertColumnsToTabs(string $tabName, array $props): array
{
if (isset($props['columns']) === false) {
return $props;
}
@@ -235,11 +130,13 @@ class Blueprint
* Converts all field definitions, that are not
* wrapped in a fields section into a generic
* fields section.
*
* @param string $tabName
* @param array $props
* @return array
*/
protected function convertFieldsToSections(
string $tabName,
array $props
): array {
protected function convertFieldsToSections(string $tabName, array $props): array
{
if (isset($props['fields']) === false) {
return $props;
}
@@ -260,11 +157,13 @@ class Blueprint
/**
* Converts all sections that are not wrapped in
* columns, into a single generic column.
*
* @param string $tabName
* @param array $props
* @return array
*/
protected function convertSectionsToColumns(
string $tabName,
array $props
): array {
protected function convertSectionsToColumns(string $tabName, array $props): array
{
if (isset($props['sections']) === false) {
return $props;
}
@@ -288,6 +187,7 @@ class Blueprint
* props is just a string
*
* @param array|string $props
* @return array
*/
public static function extend($props): array
{
@@ -297,35 +197,41 @@ class Blueprint
];
}
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
}
}
$extends = $props['extends'] ?? null;
// remove the extends flag
unset($props['extends']);
if ($extends === null) {
return $props;
}
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 $e) {
// 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
*
* @param string $name
* @param string|null $fallback
* @param \Kirby\Cms\Model $model
* @return static|null
*/
public static function factory(
string $name,
string $fallback = null,
ModelWithContent $model
): static|null {
public static function factory(string $name, string $fallback = null, Model $model)
{
try {
$props = static::load($name);
} catch (Exception) {
} catch (Exception $e) {
$props = $fallback !== null ? static::load($fallback) : null;
}
@@ -341,14 +247,19 @@ class Blueprint
/**
* Returns a single field definition by name
*
* @param string $name
* @return array|null
*/
public function field(string $name): array|null
public function field(string $name): ?array
{
return $this->fields[$name] ?? null;
}
/**
* Returns all field definitions
*
* @return array
*/
public function fields(): array
{
@@ -358,6 +269,8 @@ class Blueprint
/**
* Find a blueprint by name
*
* @param string $name
* @return array
* @throws \Kirby\Exception\NotFoundException If the blueprint cannot be found
*/
public static function find(string $name): array
@@ -370,10 +283,8 @@ class Blueprint
$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
// first try to find a site blueprint,
// then check in the plugin extensions
if (F::exists($file, $root) !== true) {
$file = $kirby->extension('blueprints', $name);
}
@@ -386,9 +297,7 @@ class Blueprint
// 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) {
} elseif (is_array($file) === true) {
return static::$loaded[$name] = $file;
}
@@ -401,14 +310,20 @@ class Blueprint
/**
* Used to translate any label, heading, etc.
*
* @param mixed $value
* @param mixed $fallback
* @return mixed
*/
protected function i18n(mixed $value, mixed $fallback = null): mixed
protected function i18n($value, $fallback = null)
{
return I18n::translate($value, $fallback) ?? $value;
return I18n::translate($value, $fallback ?? $value);
}
/**
* Checks if this is the default blueprint
*
* @return bool
*/
public function isDefault(): bool
{
@@ -417,33 +332,44 @@ class Blueprint
/**
* Loads a blueprint from file or array
*
* @param string $name
* @return 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 = function ($props) use ($name) {
// inject the filename as name if no name is set
$props['name'] ??= $name;
// normalize the title
$title = $props['title'] ?? ucfirst($props['name']);
// normalize the title
$title = $props['title'] ?? ucfirst($props['name']);
// translate the title
$props['title'] = I18n::translate($title) ?? $title;
// translate the title
$props['title'] = I18n::translate($title, $title);
return $props;
return $props;
};
return $normalize($props);
}
/**
* Returns the parent model
*
* @return \Kirby\Cms\Model
*/
public function model(): ModelWithContent
public function model()
{
return $this->model;
}
/**
* Returns the blueprint name
*
* @return string
*/
public function name(): string
{
@@ -452,6 +378,10 @@ class Blueprint
/**
* Normalizes all required props in a column setup
*
* @param string $tabName
* @param array $columns
* @return array
*/
protected function normalizeColumns(string $tabName, array $columns): array
{
@@ -462,35 +392,32 @@ class Blueprint
continue;
}
$columnProps = $this->convertFieldsToSections(
$tabName . '-col-' . $columnKey,
$columnProps
);
$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'
'headline' => 'Column (' . ($columnProps['width'] ?? '1/1') . ')',
'type' => 'info',
'text' => 'No sections yet'
]
];
}
$columns[$columnKey] = [
...$columnProps,
$columns[$columnKey] = array_merge($columnProps, [
'width' => $columnProps['width'] ?? '1/1',
'sections' => $this->normalizeSections(
$tabName,
$columnProps['sections'] ?? []
)
];
'sections' => $this->normalizeSections($tabName, $columnProps['sections'] ?? [])
]);
}
return $columns;
}
/**
* @param array $items
* @return string
*/
public static function helpList(array $items): string
{
$md = [];
@@ -505,9 +432,11 @@ class Blueprint
/**
* Normalize field props for a single field
*
* @param array|string $props
* @return array
* @throws \Kirby\Exception\InvalidArgumentException If the filed name is missing or the field type is invalid
*/
public static function fieldProps(array|string $props): array
public static function fieldProps($props): array
{
$props = static::extend($props);
@@ -529,34 +458,28 @@ class Blueprint
// 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,
'fields' => $props['fields'],
'name' => $name,
'type' => $type
'type' => $type,
];
}
// add some useful defaults
return [
...$props,
return array_merge($props, [
'label' => $props['label'] ?? ucfirst($name),
'name' => $name,
'type' => $type,
'width' => $props['width'] ?? '1/1',
];
]);
}
/**
* Creates an error field with the given error message
*
* @param string $name
* @param string $message
* @return array
*/
public static function fieldError(string $name, string $message): array
{
@@ -572,6 +495,9 @@ class Blueprint
/**
* Normalizes all fields and adds automatic labels,
* types and widths.
*
* @param array $fields
* @return array
*/
public static function fieldsProps($fields): array
{
@@ -611,16 +537,11 @@ class Blueprint
// resolve field groups
if ($fieldProps['type'] === 'group') {
if (
empty($fieldProps['fields']) === false &&
is_array($fieldProps['fields']) === true
) {
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)
];
$before = array_slice($fields, 0, $index);
$after = array_slice($fields, $index + 1);
$fields = array_merge($before, $fieldProps['fields'] ?? [], $after);
} else {
unset($fields[$fieldName]);
}
@@ -635,12 +556,14 @@ class Blueprint
/**
* Normalizes blueprint options. This must be used in the
* constructor of an extended class, if you want to make use of it.
*
* @param array|true|false|null|string $options
* @param array $defaults
* @param array $aliases
* @return array
*/
protected function normalizeOptions(
array|string|bool|null $options,
array $defaults,
array $aliases = []
): array {
protected function normalizeOptions($options, array $defaults, array $aliases = []): array
{
// return defaults when options are not defined or set to true
if ($options === true) {
return $defaults;
@@ -652,7 +575,7 @@ class Blueprint
}
// extend options if possible
$options = static::extend($options);
$options = $this->extend($options);
foreach ($options as $key => $value) {
$alias = $aliases[$key] ?? null;
@@ -663,16 +586,18 @@ class Blueprint
}
}
return [...$defaults, ...$options];
return array_merge($defaults, $options);
}
/**
* Normalizes all required keys in sections
*
* @param string $tabName
* @param array $sections
* @return array
*/
protected function normalizeSections(
string $tabName,
array $sections
): array {
protected function normalizeSections(string $tabName, array $sections): array
{
foreach ($sections as $sectionName => $sectionProps) {
// unset / remove section if its property is false
if ($sectionProps === false) {
@@ -686,27 +611,26 @@ class Blueprint
}
// inject all section extensions
$sectionProps = static::extend($sectionProps);
$sectionProps = $this->extend($sectionProps);
$sections[$sectionName] = $sectionProps = [
...$sectionProps,
$sections[$sectionName] = $sectionProps = array_merge($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))
'name' => $sectionName,
'headline' => 'Invalid section type for section "' . $sectionName . '"',
'type' => 'info',
'text' => 'The following section types are available: ' . $this->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))
'name' => $sectionName,
'headline' => 'Invalid section type ("' . $type . '")',
'type' => 'info',
'text' => 'The following section types are available: ' . $this->helpList(array_keys(Section::$types))
];
}
@@ -742,13 +666,16 @@ class Blueprint
}
// store all normalized sections
$this->sections = [...$this->sections, ...$sections];
$this->sections = array_merge($this->sections, $sections);
return $sections;
}
/**
* Normalizes all required keys in tabs
*
* @param array $tabs
* @return array
*/
protected function normalizeTabs($tabs): array
{
@@ -764,7 +691,7 @@ class Blueprint
}
// inject all tab extensions
$tabProps = static::extend($tabProps);
$tabProps = $this->extend($tabProps);
// inject a preset if available
$tabProps = $this->preset($tabProps);
@@ -772,14 +699,13 @@ class Blueprint
$tabProps = $this->convertFieldsToSections($tabName, $tabProps);
$tabProps = $this->convertSectionsToColumns($tabName, $tabProps);
$tabs[$tabName] = [
...$tabProps,
$tabs[$tabName] = array_merge($tabProps, [
'columns' => $this->normalizeColumns($tabName, $tabProps['columns'] ?? []),
'icon' => $tabProps['icon'] ?? null,
'label' => $this->i18n($tabProps['label'] ?? ucfirst($tabName)),
'link' => $this->model->panel()->url(true) . '/?tab=' . $tabName,
'name' => $tabName,
];
]);
}
return $this->tabs = $tabs;
@@ -787,6 +713,9 @@ class Blueprint
/**
* Injects a blueprint preset
*
* @param array $props
* @return array
*/
protected function preset(array $props): array
{
@@ -801,7 +730,7 @@ class Blueprint
$preset = static::$presets[$props['preset']];
if (is_string($preset) === true) {
$preset = F::load($preset, allowOutput: false);
$preset = require $preset;
}
return $preset($props);
@@ -809,17 +738,16 @@ class Blueprint
/**
* Returns a single section by name
*
* @param string $name
* @return \Kirby\Cms\Section|null
*/
public function section(string $name): Section|null
public function section(string $name)
{
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];
@@ -827,27 +755,29 @@ class Blueprint
$props['model'] = $this->model();
// create a new section object
return $this->sections[$name] = new Section($props['type'], $props);
return new Section($props['type'], $props);
}
/**
* Returns all sections
*
* @return array
*/
public function sections(): array
{
return A::map(
$this->sections,
fn ($section) => match (true) {
$section instanceof Section => $section,
default => $this->section($section['name'])
}
fn ($section) => $this->section($section['name'])
);
}
/**
* Returns a single tab by name
*
* @param string|null $name
* @return array|null
*/
public function tab(string|null $name = null): array|null
public function tab(?string $name = null): ?array
{
if ($name === null) {
return A::first($this->tabs);
@@ -858,6 +788,8 @@ class Blueprint
/**
* Returns all tabs
*
* @return array
*/
public function tabs(): array
{
@@ -866,6 +798,8 @@ class Blueprint
/**
* Returns the blueprint title
*
* @return string
*/
public function title(): string
{
@@ -874,6 +808,8 @@ class Blueprint
/**
* Converts the blueprint object to a plain array
*
* @return array
*/
public function toArray(): array
{

View File

@@ -6,7 +6,6 @@ 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
@@ -66,29 +65,19 @@ class Collection extends BaseCollection
}
/**
* 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
* Internal setter for each object in the Collection.
* This takes care of Component validation and of setting
* the collection prop on each object correctly.
*
* @param string $id
* @param object $object
* @return void
*/
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($id)
{
unset($this->data[$id]);
}
/**
* Adds a single object or
* an entire second collection to the
@@ -98,12 +87,9 @@ class Collection extends BaseCollection
*/
public function add($object)
{
if ($object instanceof self) {
if (is_a($object, self::class) === true) {
$this->data = array_merge($this->data, $object->data);
} elseif (
is_object($object) === true &&
method_exists($object, 'id') === true
) {
} elseif (is_object($object) === true && method_exists($object, 'id') === true) {
$this->__set($object->id(), $object);
} else {
$this->append($object);
@@ -124,46 +110,26 @@ class Collection extends BaseCollection
{
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
) {
if (is_object($args[0]) === true && is_callable([$args[0], 'id']) === true) {
return parent::append($args[0]->id(), $args[0]);
} else {
return parent::append($args[0]);
}
return parent::append($args[0]);
}
return parent::append(...$args);
}
/**
* Find a single element by an attribute and its value
*
* @param mixed $value
* @return mixed|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
* @param bool $i Ignore upper/lowercase for group names
* @return \Kirby\Cms\Collection
* @throws \Kirby\Exception\Exception
*/
public function group($field, bool $caseInsensitive = true)
public function group($field, bool $i = true)
{
if (is_string($field) === true) {
$groups = new Collection([], $this->parent());
@@ -176,10 +142,8 @@ class Collection extends BaseCollection
throw new InvalidArgumentException('Invalid grouping value for key: ' . $key);
}
$value = (string)$value;
// ignore upper/lowercase for group names
if ($caseInsensitive === true) {
if ($i) {
$value = Str::lower($value);
}
@@ -195,7 +159,7 @@ class Collection extends BaseCollection
return $groups;
}
return parent::group($field, $caseInsensitive);
return parent::group($field, $i);
}
/**
@@ -203,6 +167,7 @@ class Collection extends BaseCollection
* is in the collection
*
* @param string|object $key
* @return bool
*/
public function has($key): bool
{
@@ -219,8 +184,9 @@ class Collection extends BaseCollection
* or ids and then search accordingly.
*
* @param string|object $needle
* @return int
*/
public function indexOf($needle): int|false
public function indexOf($needle): int
{
if (is_string($needle) === true) {
return array_search($needle, $this->keys());
@@ -242,9 +208,7 @@ class Collection extends BaseCollection
foreach ($keys as $key) {
if (is_array($key) === true) {
return $this->not(...$key);
}
if ($key instanceof BaseCollection) {
} elseif (is_a($key, 'Kirby\Toolkit\Collection') === true) {
$collection = $collection->not(...$key->keys());
} elseif (is_object($key) === true) {
$key = $key->id();
@@ -260,31 +224,20 @@ class Collection extends BaseCollection
* Add pagination and return a sliced set of data.
*
* @param mixed ...$arguments
* @return $this|static
* @return \Kirby\Cms\Collection
*/
public function paginate(...$arguments)
{
$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 pagination object
*
* @return \Kirby\Cms\Pagination|null
*/
public function pagination()
{
return $this->pagination;
return $this->slice($this->pagination->offset(), $this->pagination->limit());
}
/**
* Returns the parent model
*
* @return \Kirby\Cms\Model
*/
public function parent()
{
@@ -303,14 +256,11 @@ class Collection extends BaseCollection
{
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
) {
if (is_object($args[0]) === true && is_callable([$args[0], 'id']) === true) {
return parent::prepend($args[0]->id(), $args[0]);
} else {
return parent::prepend($args[0]);
}
return parent::prepend($args[0]);
}
return parent::prepend(...$args);
@@ -321,6 +271,7 @@ class Collection extends BaseCollection
* offset, limit, search and paginate on the collection.
* Any part of the query is optional.
*
* @param array $arguments
* @return static
*/
public function query(array $arguments = [])
@@ -363,11 +314,13 @@ class Collection extends BaseCollection
/**
* Searches the collection
*
* @param string|null $query
* @param array $params
* @return self
*/
public function search(
string $query = null,
string|array $params = []
): static {
public function search(string $query = null, $params = [])
{
return Search::collection($this, $query, $params);
}
@@ -375,6 +328,9 @@ class Collection extends BaseCollection
* Converts all objects in the collection
* to an array. This can also take a callback
* function to further modify the array result.
*
* @param \Closure|null $map
* @return array
*/
public function toArray(Closure $map = null): array
{

View File

@@ -2,7 +2,6 @@
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Controller;
@@ -28,20 +27,25 @@ class Collections
* has been called, to avoid further
* processing on sequential calls to
* the same collection.
*
* @var array
*/
protected array $cache = [];
protected $cache = [];
/**
* Store of all collections
*
* @var array
*/
protected array $collections = [];
protected $collections = [];
/**
* Magic caller to enable something like
* `$collections->myCollection()`
*
* @return \Kirby\Toolkit\Collection|null
* @todo 5.0 Add return type declaration
* @param string $name
* @param array $arguments
* @return \Kirby\Cms\Collection|null
*/
public function __call(string $name, array $arguments = [])
{
@@ -51,19 +55,23 @@ class Collections
/**
* Loads a collection by name if registered
*
* @return \Kirby\Toolkit\Collection|null
* @todo 5.0 Add deprecation warning when anything else than a Collection is returned
* @todo 6.0 Add PHP return type declaration for `Toolkit\Collection`
* @param string $name
* @param array $data
* @return \Kirby\Cms\Collection|null
*/
public function get(string $name, array $data = [])
{
// if not yet loaded
$this->collections[$name] ??= $this->load($name);
if (isset($this->collections[$name]) === false) {
$this->collections[$name] = $this->load($name);
}
// if not yet cached
if (($this->cache[$name]['data'] ?? null) !== $data) {
if (
isset($this->cache[$name]) === false ||
$this->cache[$name]['data'] !== $data
) {
$controller = new Controller($this->collections[$name]);
$this->cache[$name] = [
'result' => $controller->call(null, $data),
'data' => $data
@@ -80,6 +88,9 @@ class Collections
/**
* Checks if a collection exists
*
* @param string $name
* @return bool
*/
public function has(string $name): bool
{
@@ -90,7 +101,7 @@ class Collections
try {
$this->load($name);
return true;
} catch (NotFoundException) {
} catch (NotFoundException $e) {
return false;
}
}
@@ -99,9 +110,11 @@ class Collections
* Loads collection from php file in a
* given directory or from plugin extension.
*
* @param string $name
* @return mixed
* @throws \Kirby\Exception\NotFoundException
*/
public function load(string $name): mixed
public function load(string $name)
{
$kirby = App::instance();
@@ -109,9 +122,9 @@ class Collections
$file = $kirby->root('collections') . '/' . $name . '.php';
if (is_file($file) === true) {
$collection = F::load($file, allowOutput: false);
$collection = F::load($file);
if ($collection instanceof Closure) {
if (is_a($collection, 'Closure')) {
return $collection;
}
}
@@ -119,7 +132,10 @@ class Collections
// fallback to collections from plugins
$collections = $kirby->extensions('collections');
return $collections[$name] ??
throw new NotFoundException('The collection cannot be found');
if (isset($collections[$name]) === true) {
return $collections[$name];
}
throw new NotFoundException('The collection cannot be found');
}
}

View File

@@ -1,16 +1,14 @@
<?php
namespace Kirby\Content;
namespace Kirby\Cms;
use Kirby\Cms\Blueprint;
use Kirby\Cms\ModelWithContent;
use Kirby\Form\Form;
/**
* The Content class handles all fields
* for content from pages, the site and users
*
* @package Kirby Content
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
@@ -20,29 +18,39 @@ class Content
{
/**
* The raw data array
*
* @var array
*/
protected array $data = [];
protected $data = [];
/**
* Cached field objects
* Once a field is being fetched
* it is added to this array for
* later reuse
*
* @var array
*/
protected array $fields = [];
protected $fields = [];
/**
* A potential parent object.
* Not necessarily needed. Especially
* for testing, but field methods might
* need it.
*
* @var Model
*/
protected ModelWithContent|null $parent;
protected $parent;
/**
* Magic getter for content fields
*
* @param string $name
* @param array $arguments
* @return \Kirby\Cms\Field
*/
public function __call(string $name, array $arguments = []): Field
public function __call(string $name, array $arguments = [])
{
return $this->get($name);
}
@@ -50,13 +58,12 @@ class Content
/**
* Creates a new Content object
*
* @param array|null $data
* @param object|null $parent
* @param bool $normalize Set to `false` if the input field keys are already lowercase
*/
public function __construct(
array $data = [],
ModelWithContent $parent = null,
bool $normalize = true
) {
public function __construct(array $data = [], $parent = null, bool $normalize = true)
{
if ($normalize === true) {
$data = array_change_key_case($data, CASE_LOWER);
}
@@ -68,9 +75,9 @@ class Content
/**
* Same as `self::data()` to improve
* `var_dump` output
* @codeCoverageIgnore
*
* @see self::data()
* @return array
*/
public function __debugInfo(): array
{
@@ -79,6 +86,9 @@ class Content
/**
* Converts the content to a new blueprint
*
* @param string $to
* @return array
*/
public function convertTo(string $to): array
{
@@ -89,21 +99,11 @@ class Content
// blueprints
$old = $this->parent->blueprint();
$subfolder = dirname($old->name());
$new = Blueprint::factory(
$subfolder . '/' . $to,
$subfolder . '/default',
$this->parent
);
$new = Blueprint::factory($subfolder . '/' . $to, $subfolder . '/default', $this->parent);
// forms
$oldForm = new Form([
'fields' => $old->fields(),
'model' => $this->parent
]);
$newForm = new Form([
'fields' => $new->fields(),
'model' => $this->parent
]);
$oldForm = new Form(['fields' => $old->fields(), 'model' => $this->parent]);
$newForm = new Form(['fields' => $new->fields(), 'model' => $this->parent]);
// fields
$oldFields = $oldForm->fields();
@@ -115,7 +115,7 @@ class Content
$oldField = $oldFields->get($name);
// field name and type matches with old template
if ($oldField?->type() === $newField->type()) {
if ($oldField && $oldField->type() === $newField->type()) {
$data[$name] = $content->get($name)->value();
} else {
$data[$name] = $newField->default();
@@ -128,6 +128,8 @@ class Content
/**
* Returns the raw data array
*
* @return array
*/
public function data(): array
{
@@ -136,6 +138,8 @@ class Content
/**
* Returns all registered field objects
*
* @return array
*/
public function fields(): array
{
@@ -148,8 +152,11 @@ class Content
/**
* Returns either a single field object
* or all registered fields
*
* @param string|null $key
* @return \Kirby\Cms\Field|array
*/
public function get(string $key = null): Field|array
public function get(string $key = null)
{
if ($key === null) {
return $this->fields();
@@ -157,15 +164,20 @@ class Content
$key = strtolower($key);
return $this->fields[$key] ??= new Field(
$this->parent,
$key,
$this->data()[$key] ?? null
);
if (isset($this->fields[$key])) {
return $this->fields[$key];
}
$value = $this->data()[$key] ?? null;
return $this->fields[$key] = new Field($this->parent, $key, $value);
}
/**
* Checks if a content field is set
*
* @param string $key
* @return bool
*/
public function has(string $key): bool
{
@@ -174,6 +186,8 @@ class Content
/**
* Returns all field keys
*
* @return array
*/
public function keys(): array
{
@@ -184,11 +198,14 @@ class Content
* Returns a clone of the content object
* without the fields, specified by the
* passed key(s)
*
* @param string ...$keys
* @return static
*/
public function not(string ...$keys): static
public function not(...$keys)
{
$copy = clone $this;
$copy->fields = [];
$copy->fields = null;
foreach ($keys as $key) {
unset($copy->data[strtolower($key)]);
@@ -200,8 +217,10 @@ class Content
/**
* Returns the parent
* Site, Page, File or User object
*
* @return \Kirby\Cms\Model
*/
public function parent(): ModelWithContent|null
public function parent()
{
return $this->parent;
}
@@ -209,9 +228,10 @@ class Content
/**
* Set the parent model
*
* @param \Kirby\Cms\Model $parent
* @return $this
*/
public function setParent(ModelWithContent $parent): static
public function setParent(Model $parent)
{
$this->parent = $parent;
return $this;
@@ -221,6 +241,7 @@ class Content
* Returns the raw data array
*
* @see self::data()
* @return array
*/
public function toArray(): array
{
@@ -231,12 +252,12 @@ class Content
* Updates the content and returns
* a cloned object
*
* @param array|null $content
* @param bool $overwrite
* @return $this
*/
public function update(
array $content = null,
bool $overwrite = false
): static {
public function update(array $content = null, bool $overwrite = false)
{
$content = array_change_key_case((array)$content, CASE_LOWER);
$this->data = $overwrite === true ? $content : array_merge($this->data, $content);

View File

@@ -2,9 +2,9 @@
namespace Kirby\Cms;
use Kirby\Exception\AuthException;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\LogicException;
use Kirby\Exception\PermissionException;
/**
* Takes care of content lock and unlock information
@@ -17,16 +17,33 @@ use Kirby\Exception\LogicException;
*/
class ContentLock
{
protected array $data;
/**
* Lock data
*
* @var array
*/
protected $data;
public function __construct(
protected ModelWithContent $model
) {
$this->data = $this->kirby()->locks()->get($model);
/**
* The model to manage locking/unlocking for
*
* @var ModelWithContent
*/
protected $model;
/**
* @param \Kirby\Cms\ModelWithContent $model
*/
public function __construct(ModelWithContent $model)
{
$this->model = $model;
$this->data = $this->kirby()->locks()->get($model);
}
/**
* Clears the lock unconditionally
*
* @return bool
*/
protected function clearLock(): bool
{
@@ -44,6 +61,7 @@ class ContentLock
/**
* Sets lock with the current user
*
* @return bool
* @throws \Kirby\Exception\DuplicateException
*/
public function create(): bool
@@ -68,8 +86,10 @@ class ContentLock
/**
* Returns either `false` or array with `user`, `email`,
* `time` and `unlockable` keys
*
* @return array|bool
*/
public function get(): array|bool
public function get()
{
$data = $this->data['lock'] ?? [];
@@ -94,6 +114,8 @@ class ContentLock
/**
* Returns if the model is locked by another user
*
* @return bool
*/
public function isLocked(): bool
{
@@ -108,6 +130,8 @@ class ContentLock
/**
* Returns if the current user's lock has been removed by another user
*
* @return bool
*/
public function isUnlocked(): bool
{
@@ -118,6 +142,8 @@ class ContentLock
/**
* Returns the app instance
*
* @return \Kirby\Cms\App
*/
protected function kirby(): App
{
@@ -127,6 +153,7 @@ class ContentLock
/**
* Removes lock of current user
*
* @return bool
* @throws \Kirby\Exception\LogicException
*/
public function remove(): bool
@@ -149,6 +176,8 @@ class ContentLock
/**
* Removes unlock information for current user
*
* @return bool
*/
public function resolve(): bool
{
@@ -166,33 +195,10 @@ class ContentLock
return $this->kirby()->locks()->set($this->model, $this->data);
}
/**
* Returns the state for the
* form buttons in the frontend
*/
public function state(): string|null
{
return match (true) {
$this->isUnlocked() => 'unlock',
$this->isLocked() => 'lock',
default => null
};
}
/**
* Returns a usable lock array
* for the frontend
*/
public function toArray(): array
{
return [
'state' => $this->state(),
'data' => $this->get()
];
}
/**
* Removes current lock and adds lock user to unlock data
*
* @return bool
*/
public function unlock(): bool
{
@@ -212,11 +218,15 @@ class ContentLock
* Returns currently authenticated user;
* throws exception if none is authenticated
*
* @return \Kirby\Cms\User
* @throws \Kirby\Exception\PermissionException
*/
protected function user(): User
{
return $this->kirby()->user() ??
throw new AuthException('No user authenticated.');
if ($user = $this->kirby()->user()) {
return $user;
}
throw new PermissionException('No user authenticated.');
}
}

View File

@@ -22,14 +22,18 @@ class ContentLocks
* Data from the `.lock` files
* that have been read so far
* cached by `.lock` file path
*
* @var array
*/
protected array $data = [];
protected $data = [];
/**
* PHP file handles for all currently
* open `.lock` files
*
* @var array
*/
protected array $handles = [];
protected $handles = [];
/**
* Closes the open file handles
@@ -46,9 +50,11 @@ class ContentLocks
/**
* Removes the file lock and closes the file handle
*
* @param string $file
* @return void
* @throws \Kirby\Exception\Exception
*/
protected function closeHandle(string $file): void
protected function closeHandle(string $file)
{
if (isset($this->handles[$file]) === false) {
return;
@@ -66,15 +72,20 @@ class ContentLocks
/**
* Returns the path to a model's lock file
*
* @param \Kirby\Cms\ModelWithContent $model
* @return string
*/
public static function file(ModelWithContent $model): string
{
$root = $model::CLASS_ALIAS === 'file' ? dirname($model->root()) : $model->root();
return $root . '/.lock';
return $model->contentFileDirectory() . '/.lock';
}
/**
* Returns the lock/unlock data for the specified model
*
* @param \Kirby\Cms\ModelWithContent $model
* @return array
*/
public function get(ModelWithContent $model): array
{
@@ -110,6 +121,7 @@ class ContentLocks
/**
* Returns the file handle to a `.lock` file
*
* @param string $file
* @param bool $create Whether to create the file if it does not exist
* @return resource|null File handle
* @throws \Kirby\Exception\Exception
@@ -143,6 +155,9 @@ class ContentLocks
/**
* Returns model ID used as the key for the data array;
* prepended with a slash because the $site otherwise won't have an ID
*
* @param \Kirby\Cms\ModelWithContent $model
* @return string
*/
public static function id(ModelWithContent $model): string
{
@@ -152,6 +167,9 @@ class ContentLocks
/**
* Sets and writes the lock/unlock data for the specified model
*
* @param \Kirby\Cms\ModelWithContent $model
* @param array $data
* @return bool
* @throws \Kirby\Exception\Exception
*/
public function set(ModelWithContent $model, array $data): bool

View File

@@ -0,0 +1,248 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Properties;
/**
* Each page, file or site can have multiple
* translated versions of their content,
* represented by this class
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ContentTranslation
{
use Properties;
/**
* @var string
*/
protected $code;
/**
* @var array
*/
protected $content;
/**
* @var string
*/
protected $contentFile;
/**
* @var Model
*/
protected $parent;
/**
* @var string
*/
protected $slug;
/**
* Creates a new translation object
*
* @param array $props
*/
public function __construct(array $props)
{
$this->setRequiredProperties($props, ['parent', 'code']);
$this->setOptionalProperties($props, ['slug', 'content']);
}
/**
* Improve `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Returns the language code of the
* translation
*
* @return string
*/
public function code(): string
{
return $this->code;
}
/**
* Returns the translation content
* as plain array
*
* @return array
*/
public function content(): array
{
$parent = $this->parent();
if ($this->content === null) {
$this->content = $parent->readContent($this->code());
}
$content = $this->content;
// merge with the default content
if ($this->isDefault() === false && $defaultLanguage = $parent->kirby()->defaultLanguage()) {
$default = [];
if ($defaultTranslation = $parent->translation($defaultLanguage->code())) {
$default = $defaultTranslation->content();
}
$content = array_merge($default, $content);
}
return $content;
}
/**
* Absolute path to the translation content file
*
* @return string
*/
public function contentFile(): string
{
return $this->contentFile = $this->parent->contentFile($this->code, true);
}
/**
* Checks if the translation file exists
*
* @return bool
*/
public function exists(): bool
{
return file_exists($this->contentFile()) === true;
}
/**
* Returns the translation code as id
*
* @return string
*/
public function id(): string
{
return $this->code();
}
/**
* Checks if the this is the default translation
* of the model
*
* @return bool
*/
public function isDefault(): bool
{
if ($defaultLanguage = $this->parent->kirby()->defaultLanguage()) {
return $this->code() === $defaultLanguage->code();
}
return false;
}
/**
* Returns the parent page, file or site object
*
* @return \Kirby\Cms\Model
*/
public function parent()
{
return $this->parent;
}
/**
* @param string $code
* @return $this
*/
protected function setCode(string $code)
{
$this->code = $code;
return $this;
}
/**
* @param array|null $content
* @return $this
*/
protected function setContent(array $content = null)
{
if ($content !== null) {
$this->content = array_change_key_case($content);
} else {
$this->content = null;
}
return $this;
}
/**
* @param \Kirby\Cms\Model $parent
* @return $this
*/
protected function setParent(Model $parent)
{
$this->parent = $parent;
return $this;
}
/**
* @param string|null $slug
* @return $this
*/
protected function setSlug(string $slug = null)
{
$this->slug = $slug;
return $this;
}
/**
* Returns the custom translation slug
*
* @return string|null
*/
public function slug(): ?string
{
return $this->slug ??= ($this->content()['slug'] ?? null);
}
/**
* Merge the old and new data
*
* @param array|null $data
* @param bool $overwrite
* @return $this
*/
public function update(array $data = null, bool $overwrite = false)
{
$data = array_change_key_case((array)$data);
$this->content = $overwrite === true ? $data : array_merge($this->content(), $data);
return $this;
}
/**
* Converts the most important translation
* props to an array
*
* @return array
*/
public function toArray(): array
{
return [
'code' => $this->code(),
'content' => $this->content(),
'exists' => $this->exists(),
'slug' => $this->slug(),
];
}
}

View File

@@ -2,15 +2,6 @@
namespace Kirby\Cms;
use Kirby\Cache\ApcuCache;
use Kirby\Cache\FileCache;
use Kirby\Cache\MemCached;
use Kirby\Cache\MemoryCache;
use Kirby\Cms\Auth\EmailChallenge;
use Kirby\Cms\Auth\TotpChallenge;
use Kirby\Form\Field\BlocksField;
use Kirby\Form\Field\LayoutField;
/**
* The Core class lists all parts of Kirby
* that need to be loaded or initalized in order
@@ -31,16 +22,27 @@ use Kirby\Form\Field\LayoutField;
class Core
{
/**
* Optional override for the auto-detected index root
* @var array
*/
public static string|null $indexRoot = null;
protected $cache = [];
protected array $cache = [];
protected string $root;
/**
* @var \Kirby\Cms\App
*/
protected $kirby;
public function __construct(protected App $kirby)
/**
* @var string
*/
protected $root;
/**
* @param \Kirby\Cms\App $kirby
*/
public function __construct(App $kirby)
{
$this->root = dirname(__DIR__, 2) . '/config';
$this->kirby = $kirby;
$this->root = dirname(__DIR__, 2) . '/config';
}
/**
@@ -48,8 +50,11 @@ class Core
*
* This is a shortcut for `$kirby->core()->load()->area()`
* to give faster access to original area code in plugins.
*
* @param string $name
* @return array|null
*/
public function area(string $name): array|null
public function area(string $name): ?array
{
return $this->load()->area($name);
}
@@ -58,17 +63,17 @@ class Core
* Returns a list of all paths to area definition files
*
* They are located in `/kirby/config/areas`
*
* @return array
*/
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',
@@ -77,12 +82,13 @@ class Core
/**
* Returns a list of all default auth challenge classes
*
* @return array
*/
public function authChallenges(): array
{
return [
'email' => EmailChallenge::class,
'totp' => TotpChallenge::class,
'email' => 'Kirby\Cms\Auth\EmailChallenge'
];
}
@@ -90,6 +96,8 @@ class Core
* Returns a list of all paths to blueprint presets
*
* They are located in `/kirby/config/presets`
*
* @return array
*/
public function blueprintPresets(): array
{
@@ -101,10 +109,12 @@ class Core
}
/**
* Returns a list of paths to core blueprints or
* the blueprint in array form
* Returns a list of all paths to core blueprints
*
* They are located in `/kirby/config/blueprints`.
* Block blueprints are located in `/kirby/config/blocks`
*
* @return array
*/
public function blueprints(): array
{
@@ -123,45 +133,28 @@ class Core
'blocks/video' => $this->root . '/blocks/video/video.yml',
// file blueprints
'files/default' => ['title' => 'File'],
'files/default' => $this->root . '/blueprints/files/default.yml',
// page blueprints
'pages/default' => ['title' => 'Page'],
'pages/default' => $this->root . '/blueprints/pages/default.yml',
// site blueprints
'site' => [
'title' => 'Site',
'sections' => [
'pages' => [
'headline' => ['*' => 'pages'],
'type' => 'pages'
]
]
]
];
}
/**
* Returns a list of all core caches
*/
public function caches(): array
{
return [
'updates' => true,
'uuid' => true,
'site' => $this->root . '/blueprints/site.yml'
];
}
/**
* Returns a list of all cache driver classes
*
* @return array
*/
public function cacheTypes(): array
{
return [
'apcu' => ApcuCache::class,
'file' => FileCache::class,
'memcached' => MemCached::class,
'memory' => MemoryCache::class,
'apcu' => 'Kirby\Cache\ApcuCache',
'file' => 'Kirby\Cache\FileCache',
'memcached' => 'Kirby\Cache\MemCached',
'memory' => 'Kirby\Cache\MemoryCache',
];
}
@@ -170,6 +163,8 @@ class Core
*
* The component functions can be found in
* `/kirby/config/components.php`
*
* @return array
*/
public function components(): array
{
@@ -178,6 +173,8 @@ class Core
/**
* Returns a map of all field method aliases
*
* @return array
*/
public function fieldMethodAliases(): array
{
@@ -202,6 +199,8 @@ class Core
* Returns an array of all field method functions
*
* Field methods are stored in `/kirby/config/methods.php`
*
* @return array
*/
public function fieldMethods(): array
{
@@ -212,6 +211,8 @@ class Core
* Returns an array of paths for field mixins
*
* They are located in `/kirby/config/fields/mixins`
*
* @return array
*/
public function fieldMixins(): array
{
@@ -235,13 +236,14 @@ class Core
*
* The more complex field classes can be found in
* `/kirby/src/Form/Fields`
*
* @return array
*/
public function fields(): array
{
return [
'blocks' => BlocksField::class,
'blocks' => 'Kirby\Form\Field\BlocksField',
'checkboxes' => $this->root . '/fields/checkboxes.php',
'color' => $this->root . '/fields/color.php',
'date' => $this->root . '/fields/date.php',
'email' => $this->root . '/fields/email.php',
'files' => $this->root . '/fields/files.php',
@@ -249,13 +251,11 @@ class Core
'headline' => $this->root . '/fields/headline.php',
'hidden' => $this->root . '/fields/hidden.php',
'info' => $this->root . '/fields/info.php',
'layout' => LayoutField::class,
'layout' => 'Kirby\Form\Field\LayoutField',
'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',
@@ -277,6 +277,8 @@ class Core
/**
* Returns a map of all kirbytag aliases
*
* @return array
*/
public function kirbyTagAliases(): array
{
@@ -290,6 +292,8 @@ class Core
* Returns an array of all kirbytag definitions
*
* They are located in `/kirby/config/tags.php`
*
* @return array
*/
public function kirbyTags(): array
{
@@ -302,8 +306,10 @@ class Core
* The loader is set to not include plugins.
* This way, you can access original Kirby core code
* through this load method.
*
* @return \Kirby\Cms\Loader
*/
public function load(): Loader
public function load()
{
return new Loader($this->kirby, false);
}
@@ -312,6 +318,8 @@ class Core
* Returns all absolute paths to important directories
*
* Roots are resolved and baked in `\Kirby\Cms\App::bakeRoots()`
*
* @return array
*/
public function roots(): array
{
@@ -321,7 +329,7 @@ class Core
'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),
'index' => fn (array $roots) => dirname(__DIR__, 3),
'assets' => fn (array $roots) => $roots['index'] . '/assets',
'content' => fn (array $roots) => $roots['index'] . '/content',
'media' => fn (array $roots) => $roots['index'] . '/media',
@@ -331,7 +339,6 @@ class Core
'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',
@@ -352,6 +359,8 @@ class Core
* Routes are split into `before` and `after` routes.
*
* Plugin routes will be injected inbetween.
*
* @return array
*/
public function routes(): array
{
@@ -362,6 +371,8 @@ class Core
* Returns a list of all paths to core block snippets
*
* They are located in `/kirby/config/blocks`
*
* @return array
*/
public function snippets(): array
{
@@ -384,6 +395,8 @@ class Core
* Returns a list of paths to section mixins
*
* They are located in `/kirby/config/sections/mixins`
*
* @return array
*/
public function sectionMixins(): array
{
@@ -406,6 +419,8 @@ class Core
* Returns a list of all section definitions
*
* They are located in `/kirby/config/sections`
*
* @return array
*/
public function sections(): array
{
@@ -422,6 +437,8 @@ class Core
* Returns a list of paths to all system templates
*
* They are located in `/kirby/config/templates`
*
* @return array
*/
public function templates(): array
{
@@ -435,6 +452,8 @@ class Core
* Returns an array with all system URLs
*
* URLs are resolved and baked in `\Kirby\Cms\App::bakeUrls()`
*
* @return array
*/
public function urls(): array
{
@@ -446,9 +465,9 @@ class Core
if (empty($path) === true) {
return $urls['index'];
} else {
return $urls['base'] . '/' . $path;
}
return $urls['base'] . '/' . $path;
},
'assets' => fn (array $urls) => $urls['base'] . '/assets',
'api' => fn (array $urls) => $urls['base'] . '/' . $this->kirby->option('api.slug', 'api'),

View File

@@ -4,7 +4,6 @@ namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Template\Template;
/**
* Wrapper around our Email package, which
@@ -22,14 +21,18 @@ class Email
{
/**
* Options configured through the `email` CMS option
*
* @var array
*/
protected array $options;
protected $options;
/**
* Props for the email object; will be passed to the
* Kirby\Email\Email class
*
* @var array
*/
protected array $props;
protected $props;
/**
* Class constructor
@@ -37,19 +40,23 @@ class Email
* @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 = [])
public function __construct($preset = [], array $props = [])
{
$this->options = App::instance()->option('email', []);
$this->options = App::instance()->option('email');
// build a prop array based on preset and props
$preset = $this->preset($preset);
$this->props = array_merge($preset, $props);
// add transport settings
$this->props['transport'] ??= $this->options['transport'] ?? [];
if (isset($this->props['transport']) === false) {
$this->props['transport'] = $this->options['transport'] ?? [];
}
// add predefined beforeSend option
$this->props['beforeSend'] ??= $this->options['beforeSend'] ?? null;
if (isset($this->props['beforeSend']) === false) {
$this->props['beforeSend'] = $this->options['beforeSend'] ?? null;
}
// transform model objects to values
$this->transformUserSingle('from', 'fromName');
@@ -68,9 +75,10 @@ class Email
* prop arrays in case a preset is not needed
*
* @param string|array $preset Preset name or simple prop array
* @return array
* @throws \Kirby\Exception\NotFoundException
*/
protected function preset(string|array $preset): array
protected function preset($preset): array
{
// only passed props, not preset name
if (is_array($preset) === true) {
@@ -92,6 +100,7 @@ class Email
* Renders the email template(s) and sets the body props
* to the result
*
* @return void
* @throws \Kirby\Exception\NotFoundException
*/
protected function template(): void
@@ -124,14 +133,20 @@ class Email
/**
* Returns an email template by name and type
*
* @param string $name Template name
* @param string|null $type `html` or `text`
* @return \Kirby\Cms\Template
*/
protected function getTemplate(string $name, string $type = null): Template
protected function getTemplate(string $name, string $type = null)
{
return App::instance()->template('emails/' . $name, $type, 'text');
}
/**
* Returns the prop array
*
* @return array
*/
public function toArray(): array
{
@@ -143,10 +158,11 @@ class Email
* supports simple strings, file objects or collections/arrays of either
*
* @param string $prop Prop to transform
* @return void
*/
protected function transformFile(string $prop): void
{
$this->props[$prop] = $this->transformModel($prop, File::class, 'root');
$this->props[$prop] = $this->transformModel($prop, 'Kirby\Cms\File', 'root');
}
/**
@@ -159,12 +175,8 @@ class Email
* 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 $contentKey = null
): array {
protected function transformModel(string $prop, string $class, string $contentValue, string $contentKey = null): array
{
$value = $this->props[$prop] ?? [];
// ensure consistent input by making everything an iterable value
@@ -181,7 +193,7 @@ class Email
} else {
$result[] = $item;
}
} elseif ($item instanceof $class) {
} elseif (is_a($item, $class) === true) {
// value is a model object, get value through content method(s)
if ($contentKey !== null) {
$result[(string)$item->$contentKey()] = (string)$item->$contentValue();
@@ -204,12 +216,11 @@ class Email
*
* @param string $addressProp Prop with the email address
* @param string $nameProp Prop with the name corresponding to the $addressProp
* @return void
*/
protected function transformUserSingle(
string $addressProp,
string $nameProp
): void {
$result = $this->transformModel($addressProp, User::class, 'name', 'email');
protected function transformUserSingle(string $addressProp, string $nameProp): void
{
$result = $this->transformModel($addressProp, 'Kirby\Cms\User', 'name', 'email');
$address = array_keys($result)[0] ?? null;
$name = $result[$address] ?? null;
@@ -224,7 +235,9 @@ class Email
$this->props[$addressProp] = $address;
// only use the name from the user if no custom name was set
$this->props[$nameProp] ??= $name;
if (isset($this->props[$nameProp]) === false || $this->props[$nameProp] === null) {
$this->props[$nameProp] = $name;
}
}
/**
@@ -232,9 +245,10 @@ class Email
* supports simple strings, user objects or collections/arrays of either
*
* @param string $prop Prop to transform
* @return void
*/
protected function transformUserMultiple(string $prop): void
{
$this->props[$prop] = $this->transformModel($prop, User::class, 'name', 'email');
$this->props[$prop] = $this->transformModel($prop, 'Kirby\Cms\User', 'name', 'email');
}
}

View File

@@ -24,31 +24,41 @@ class Event
/**
* The full event name
* (e.g. `page.create:after`)
*
* @var string
*/
protected string $name;
protected $name;
/**
* The event type
* (e.g. `page` in `page.create:after`)
*
* @var string
*/
protected string $type;
protected $type;
/**
* The event action
* (e.g. `create` in `page.create:after`)
*
* @var string|null
*/
protected string|null $action;
protected $action;
/**
* The event state
* (e.g. `after` in `page.create:after`)
*
* @var string|null
*/
protected string|null $state;
protected $state;
/**
* The event arguments
*
* @var array
*/
protected array $arguments = [];
protected $arguments = [];
/**
* Class constructor
@@ -73,15 +83,20 @@ class Event
/**
* Magic caller for event arguments
*
* @param string $method
* @param array $arguments
* @return mixed
*/
public function __call(string $method, array $arguments = []): mixed
public function __call(string $method, array $arguments = [])
{
return $this->argument($method);
}
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*
* @return array
*/
public function __debugInfo(): array
{
@@ -91,6 +106,8 @@ class Event
/**
* Makes it possible to simply echo
* or stringify the entire object
*
* @return string
*/
public function __toString(): string
{
@@ -100,22 +117,33 @@ class Event
/**
* Returns the action of the event (e.g. `create`)
* or `null` if the event name does not include an action
*
* @return string|null
*/
public function action(): string|null
public function action(): ?string
{
return $this->action;
}
/**
* Returns a specific event argument
*
* @param string $name
* @return mixed
*/
public function argument(string $name): mixed
public function argument(string $name)
{
return $this->arguments[$name] ?? null;
if (isset($this->arguments[$name]) === true) {
return $this->arguments[$name];
}
return null;
}
/**
* Returns the arguments of the event
*
* @return array
*/
public function arguments(): array
{
@@ -127,8 +155,10 @@ class Event
* the hook's return value
*
* @param object|null $bind Optional object to bind to the hook function
* @param \Closure $hook
* @return mixed
*/
public function call(object|null $bind, Closure $hook): mixed
public function call(?object $bind, Closure $hook)
{
// collect the list of possible hook arguments
$data = $this->arguments();
@@ -141,6 +171,8 @@ class Event
/**
* Returns the full name of the event
*
* @return string
*/
public function name(): string
{
@@ -150,16 +182,13 @@ class Event
/**
* Returns the full list of possible wildcard
* event names based on the current event name
*
* @return array
*/
public function nameWildcards(): array
{
// if the event is already a wildcard event,
// no further variation is possible
if (
$this->type === '*' ||
$this->action === '*' ||
$this->state === '*'
) {
// if the event is already a wildcard event, no further variation is possible
if ($this->type === '*' || $this->action === '*' || $this->state === '*') {
return [];
}
@@ -175,9 +204,7 @@ class Event
'*:' . $this->state,
'*'
];
}
if ($this->state !== null) {
} elseif ($this->state !== null) {
// event without action: $type:$state
return [
@@ -185,9 +212,7 @@ class Event
'*:' . $this->state,
'*'
];
}
if ($this->action !== null) {
} elseif ($this->action !== null) {
// event without state: $type.$action
return [
@@ -195,22 +220,27 @@ class Event
'*.' . $this->action,
'*'
];
}
} else {
// event with a simple name
// event with a simple name
return ['*'];
return ['*'];
}
}
/**
* Returns the state of the event (e.g. `after`)
*
* @return string|null
*/
public function state(): string|null
public function state(): ?string
{
return $this->state;
}
/**
* Returns the event data as array
*
* @return array
*/
public function toArray(): array
{
@@ -222,6 +252,8 @@ class Event
/**
* Returns the event name as string
*
* @return string
*/
public function toString(): string
{
@@ -230,6 +262,8 @@ class Event
/**
* Returns the type of the event (e.g. `page`)
*
* @return string
*/
public function type(): string
{
@@ -240,6 +274,9 @@ class Event
* Updates a given argument with a new value
*
* @internal
* @param string $name
* @param mixed $value
* @return void
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function updateArgument(string $name, $value): void

View File

@@ -1,9 +1,8 @@
<?php
namespace Kirby\Content;
namespace Kirby\Cms;
use Closure;
use Kirby\Cms\ModelWithContent;
use Kirby\Exception\InvalidArgumentException;
/**
* Every field in a Kirby content text file
@@ -19,7 +18,7 @@ use Kirby\Cms\ModelWithContent;
* $page->myField()->lower();
* ```
*
* @package Kirby Content
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
@@ -29,48 +28,49 @@ class Field
{
/**
* Field method aliases
*
* @var array
*/
public static array $aliases = [];
public static $aliases = [];
/**
* The field name
*
* @var string
*/
protected string $key;
protected $key;
/**
* Registered field methods
*
* @var array
*/
public static array $methods = [];
public static $methods = [];
/**
* The parent object if available.
* This will be the page, site, user or file
* to which the content belongs
*
* @var Model
*/
protected ModelWithContent|null $parent;
protected $parent;
/**
* The value of the field
*
* @var mixed
*/
public mixed $value;
/**
* Creates a new field object
*/
public function __construct(
ModelWithContent|null $parent,
string $key,
mixed $value
) {
$this->key = $key;
$this->value = $value;
$this->parent = $parent;
}
public $value;
/**
* Magic caller for field methods
*
* @param string $method
* @param array $arguments
* @return mixed
*/
public function __call(string $method, array $arguments = []): mixed
public function __call(string $method, array $arguments = [])
{
$method = strtolower($method);
@@ -89,13 +89,27 @@ class Field
return $this;
}
/**
* Creates a new field object
*
* @param object|null $parent
* @param string $key
* @param mixed $value
*/
public function __construct(?object $parent, string $key, $value)
{
$this->key = $key;
$this->value = $value;
$this->parent = $parent;
}
/**
* Simplifies the var_dump result
* @codeCoverageIgnore
*
* @see Field::toArray
* @return array
*/
public function __debugInfo(): array
public function __debugInfo()
{
return $this->toArray();
}
@@ -105,6 +119,7 @@ class Field
* or stringify the entire object
*
* @see Field::toString
* @return string
*/
public function __toString(): string
{
@@ -113,6 +128,8 @@ class Field
/**
* Checks if the field exists in the content data array
*
* @return bool
*/
public function exists(): bool
{
@@ -121,16 +138,18 @@ class Field
/**
* Checks if the field content is empty
*
* @return bool
*/
public function isEmpty(): bool
{
return
empty($this->value) === true &&
in_array($this->value, [0, '0', false], true) === false;
return empty($this->value) === true && in_array($this->value, [0, '0', false], true) === false;
}
/**
* Checks if the field content is not empty
*
* @return bool
*/
public function isNotEmpty(): bool
{
@@ -139,6 +158,8 @@ class Field
/**
* Returns the name of the field
*
* @return string
*/
public function key(): string
{
@@ -147,8 +168,9 @@ class Field
/**
* @see Field::parent()
* @return \Kirby\Cms\Model|null
*/
public function model(): ModelWithContent|null
public function model()
{
return $this->parent;
}
@@ -156,15 +178,16 @@ class Field
/**
* Provides a fallback if the field value is empty
*
* @param mixed $fallback
* @return $this|static
*/
public function or(mixed $fallback = null): static
public function or($fallback = null)
{
if ($this->isNotEmpty()) {
return $this;
}
if ($fallback instanceof self) {
if (is_a($fallback, 'Kirby\Cms\Field') === true) {
return $fallback;
}
@@ -175,14 +198,18 @@ class Field
/**
* Returns the parent object of the field
*
* @return \Kirby\Cms\Model|null
*/
public function parent(): ModelWithContent|null
public function parent()
{
return $this->parent;
}
/**
* Converts the Field object to an array
*
* @return array
*/
public function toArray(): array
{
@@ -191,6 +218,8 @@ class Field
/**
* Returns the field value as string
*
* @return string
*/
public function toString(): string
{
@@ -201,19 +230,27 @@ class Field
* Returns the field content. If a new value is passed,
* the modified field will be returned. Otherwise it
* will return the field value.
*
* @param string|\Closure $value
* @return mixed
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function value(string|Closure $value = null): mixed
public function value($value = null)
{
if ($value === null) {
return $this->value;
}
if ($value instanceof Closure) {
$value = $value->call($this, $this->value);
if (is_scalar($value)) {
$value = (string)$value;
} elseif (is_callable($value)) {
$value = (string)$value->call($this, $this->value);
} else {
throw new InvalidArgumentException('Invalid field value type: ' . gettype($value));
}
$clone = clone $this;
$clone->value = (string)$value;
$clone->value = $value;
return $clone;
}

View File

@@ -19,23 +19,26 @@ use Kirby\Toolkit\Str;
*/
class Fieldset extends Item
{
public const ITEMS_CLASS = Fieldsets::class;
public const ITEMS_CLASS = '\Kirby\Cms\Fieldsets';
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;
protected $disabled;
protected $editable;
protected $fields = [];
protected $icon;
protected $label;
protected $model;
protected $name;
protected $preview;
protected $tabs;
protected $translate;
protected $type;
protected $unset;
protected $wysiwyg;
/**
* Creates a new Fieldset object
*
* @param array $params
*/
public function __construct(array $params = [])
{
@@ -47,17 +50,17 @@ class Fieldset extends Item
parent::__construct($params);
$this->disabled = $params['disabled'] ?? false;
$this->editable = $params['editable'] ?? true;
$this->icon = $params['icon'] ?? null;
$params['title'] ??= $params['name'] ?? Str::ucfirst($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;
$this->disabled = $params['disabled'] ?? false;
$this->editable = $params['editable'] ?? true;
$this->icon = $params['icon'] ?? null;
$this->model = $this->parent;
$this->name = $this->createName($params['title'] ?? $params['name'] ?? Str::ucfirst($this->type));
$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 &&
@@ -70,6 +73,10 @@ class Fieldset extends Item
}
}
/**
* @param array $fields
* @return array
*/
protected function createFields(array $fields = []): array
{
$fields = Blueprint::fieldsProps($fields);
@@ -81,16 +88,28 @@ class Fieldset extends Item
return $fields;
}
protected function createName(array|string $name): string|null
/**
* @param array|string $name
* @return string|null
*/
protected function createName($name): ?string
{
return I18n::translate($name, $name);
}
protected function createLabel(array|string|null $label = null): string|null
/**
* @param array|string $label
* @return string|null
*/
protected function createLabel($label = null): ?string
{
return I18n::translate($label, $label);
}
/**
* @param array $params
* @return array
*/
protected function createTabs(array $params = []): array
{
$tabs = $params['tabs'] ?? [];
@@ -114,10 +133,9 @@ class Fieldset extends Item
$tab = Blueprint::extend($tab);
$tab['fields'] = $this->createFields($tab['fields'] ?? []);
$tab['label'] ??= Str::ucfirst($name);
$tab['label'] = $this->createLabel($tab['label']);
$tab['name'] = $name;
$tab['fields'] = $this->createFields($tab['fields'] ?? []);
$tab['label'] = $this->createLabel($tab['label'] ?? Str::ucfirst($name));
$tab['name'] = $name;
$tabs[$name] = $tab;
}
@@ -125,11 +143,17 @@ class Fieldset extends Item
return $tabs;
}
/**
* @return bool
*/
public function disabled(): bool
{
return $this->disabled;
}
/**
* @return bool
*/
public function editable(): bool
{
if ($this->editable === false) {
@@ -143,6 +167,9 @@ class Fieldset extends Item
return true;
}
/**
* @return array
*/
public function fields(): array
{
return $this->fields;
@@ -150,57 +177,88 @@ class Fieldset extends Item
/**
* Creates a form for the given fields
*
* @param array $fields
* @param array $input
* @return \Kirby\Form\Form
*/
public function form(array $fields, array $input = []): Form
public function form(array $fields, array $input = [])
{
return new Form([
'fields' => $fields,
'model' => $this->parent,
'model' => $this->model,
'strict' => true,
'values' => $input,
]);
}
public function icon(): string|null
/**
* @return string|null
*/
public function icon(): ?string
{
return $this->icon;
}
public function label(): string|null
/**
* @return string|null
*/
public function label(): ?string
{
return $this->label;
}
public function model(): ModelWithContent
/**
* @return \Kirby\Cms\ModelWithContent
*/
public function model()
{
return $this->parent;
return $this->model;
}
/**
* @return string
*/
public function name(): string
{
return $this->name;
}
public function preview(): string|bool|null
/**
* @return string|bool
*/
public function preview()
{
return $this->preview;
}
/**
* @return array
*/
public function tabs(): array
{
return $this->tabs;
}
/**
* @return bool
*/
public function translate(): bool
{
return $this->translate;
}
/**
* @return string
*/
public function type(): string
{
return $this->type;
}
/**
* @return array
*/
public function toArray(): array
{
return [
@@ -218,11 +276,17 @@ class Fieldset extends Item
];
}
/**
* @return bool
*/
public function unset(): bool
{
return $this->unset;
}
/**
* @return bool
*/
public function wysiwyg(): bool
{
return $this->wysiwyg;

View File

@@ -19,17 +19,12 @@ use Kirby\Toolkit\Str;
*/
class Fieldsets extends Items
{
public const ITEM_CLASS = Fieldset::class;
public const ITEM_CLASS = '\Kirby\Cms\Fieldset';
/**
* All registered fieldsets methods
*/
public static array $methods = [];
protected static function createFieldsets(array $params): array
protected static function createFieldsets($params)
{
$fieldsets = [];
$groups = [];
$groups = [];
foreach ($params as $type => $fieldset) {
if (is_int($type) === true && is_string($fieldset)) {
@@ -73,10 +68,8 @@ class Fieldsets extends Items
];
}
public static function factory(
array $items = null,
array $params = []
): static {
public static function factory(array $items = null, array $params = [])
{
$items ??= App::instance()->option('blocks.fieldsets', [
'code' => 'blocks/code',
'gallery' => 'blocks/gallery',
@@ -92,10 +85,7 @@ class Fieldsets extends Items
$result = static::createFieldsets($items);
return parent::factory(
$result['fieldsets'],
['groups' => $result['groups']] + $params
);
return parent::factory($result['fieldsets'], ['groups' => $result['groups']] + $params);
}
public function groups(): array
@@ -103,7 +93,7 @@ class Fieldsets extends Items
return $this->options['groups'] ?? [];
}
public function toArray(Closure|null $map = null): array
public function toArray(?Closure $map = null): array
{
return A::map(
$this->data,

View File

@@ -2,12 +2,10 @@
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\A;
use Kirby\Toolkit\Str;
/**
@@ -41,64 +39,64 @@ class File extends ModelWithContent
public const CLASS_ALIAS = 'file';
/**
* All registered file methods
* @todo Remove when support for PHP 8.2 is dropped
* Cache for the initialized blueprint object
*
* @var \Kirby\Cms\FileBlueprint
*/
public static array $methods = [];
protected $blueprint;
/**
* Cache for the initialized blueprint object
* @var string
*/
protected FileBlueprint|null $blueprint = null;
protected $filename;
protected string $filename;
/**
* @var string
*/
protected $id;
protected string $id;
/**
* All registered file methods
*
* @var array
*/
public static $methods = [];
/**
* The parent object
*
* @var \Kirby\Cms\Model
*/
protected Page|Site|User|null $parent = null;
protected $parent;
/**
* The absolute path to the file
*
* @var string|null
*/
protected string|null $root;
protected $root;
protected string|null $template;
/**
* @var string
*/
protected $template;
/**
* The public file Url
*
* @var string
*/
protected string|null $url;
/**
* Creates a new File object
*/
public function __construct(array $props)
{
parent::__construct($props);
if (isset($props['filename'], $props['parent']) === false) {
throw new InvalidArgumentException('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;
$this->setBlueprint($props['blueprint'] ?? null);
}
protected $url;
/**
* Magic caller for file methods
* and content fields. (in this order)
*
* @param string $method
* @param array $arguments
* @return mixed
*/
public function __call(string $method, array $arguments = []): mixed
public function __call(string $method, array $arguments = [])
{
// public property access
if (isset($this->$method) === true) {
@@ -119,8 +117,25 @@ class File extends ModelWithContent
return $this->content()->get($method);
}
/**
* Creates a new File object
*
* @param array $props
*/
public function __construct(array $props)
{
// set filename as the most important prop first
// TODO: refactor later to avoid redundant prop setting
$this->setProperty('filename', $props['filename'] ?? null, true);
// set other properties
$this->setProperties($props);
}
/**
* Improved `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
@@ -132,7 +147,10 @@ class File extends ModelWithContent
/**
* Returns the url to api endpoint
*
* @internal
* @param bool $relative
* @return string
*/
public function apiUrl(bool $relative = false): string
{
@@ -141,144 +159,73 @@ class File extends ModelWithContent
/**
* Returns the FileBlueprint object for the file
*
* @return \Kirby\Cms\FileBlueprint
*/
public function blueprint(): FileBlueprint
public function blueprint()
{
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 $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
if (is_a($this->blueprint, 'Kirby\Cms\FileBlueprint') === true) {
return $this->blueprint;
}
// 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;
return $this->blueprint = FileBlueprint::factory('files/' . $this->template(), 'files/default', $this);
}
/**
* Store the template in addition to the
* other content.
*
* @internal
* @param array $data
* @param string|null $languageCode
* @return array
*/
public function contentFileData(
array $data,
string $languageCode = null
): array {
// only add the template in, if the $data array
// doesn't explicitly unsets it
if (
array_key_exists('template', $data) === false &&
$template = $this->template()
) {
$data['template'] = $template;
}
return $data;
public function contentFileData(array $data, string $languageCode = null): array
{
return A::append($data, [
'template' => $this->template(),
]);
}
/**
* Returns the directory in which
* the content file is located
*
* @internal
* @deprecated 4.0.0
* @todo Remove in v5
* @codeCoverageIgnore
* @return string
*/
public function contentFileDirectory(): string
{
Helpers::deprecated('The internal $model->contentFileDirectory() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file');
return dirname($this->root());
}
/**
* Filename for the content file
*
* @internal
* @deprecated 4.0.0
* @todo Remove in v5
* @codeCoverageIgnore
* @return string
*/
public function contentFileName(): string
{
Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file');
return $this->filename();
}
/**
* Constructs a File object
*
* @internal
* @param mixed $props
* @return static
*/
public static function factory(array $props): static
public static function factory($props)
{
return new static($props);
}
/**
* Returns the filename with extension
*
* @return string
*/
public function filename(): string
{
@@ -287,14 +234,19 @@ class File extends ModelWithContent
/**
* Returns the parent Files collection
*
* @return \Kirby\Cms\Files
*/
public function files(): Files
public function files()
{
return $this->siblingsCollection();
}
/**
* Converts the file to html
*
* @param array $attr
* @return string
*/
public function html(array $attr = []): string
{
@@ -306,91 +258,58 @@ class File extends ModelWithContent
/**
* Returns the id
*
* @return string
*/
public function id(): string
{
if (
$this->parent() instanceof Page ||
$this->parent() instanceof User
) {
return $this->id ??= $this->parent()->id() . '/' . $this->filename();
if ($this->id !== null) {
return $this->id;
}
return $this->id ??= $this->filename();
if (is_a($this->parent(), 'Kirby\Cms\Page') === true) {
return $this->id = $this->parent()->id() . '/' . $this->filename();
} elseif (is_a($this->parent(), 'Kirby\Cms\User') === true) {
return $this->id = $this->parent()->id() . '/' . $this->filename();
}
return $this->id = $this->filename();
}
/**
* Compares the current object with the given file object
*
* @param \Kirby\Cms\File $file
* @return bool
*/
public function is(File $file): bool
{
return $this->id() === $file->id();
}
/**
* Checks if the files is accessible.
* This permission depends on the `read` option until v5
*/
public function isAccessible(): bool
{
// TODO: remove this check when `read` option deprecated in v5
if ($this->isReadable() === false) {
return false;
}
static $accessible = [];
if ($template = $this->template()) {
return $accessible[$template] ??= $this->permissions()->can('access');
}
return $accessible['__none__'] ??= $this->permissions()->can('access');
}
/**
* Check if the file can be listable by the current user
* This permission depends on the `read` option until v5
*/
public function isListable(): bool
{
// TODO: remove this check when `read` option deprecated in v5
if ($this->isReadable() === false) {
return false;
}
// not accessible also means not listable
if ($this->isAccessible() === false) {
return false;
}
static $listable = [];
if ($template = $this->template()) {
return $listable[$template] ??= $this->permissions()->can('list');
}
return $listable['__none__'] ??= $this->permissions()->can('list');
}
/**
* Check if the file can be read by the current user
*
* @todo Deprecate `read` option in v5 and make the necessary changes for `access` and `list` options.
* @return bool
*/
public function isReadable(): bool
{
static $readable = [];
if ($template = $this->template()) {
return $readable[$template] ??= $this->permissions()->can('read');
$template = $this->template();
if (isset($readable[$template]) === true) {
return $readable[$template];
}
return $readable['__none__'] ??= $this->permissions()->can('read');
return $readable[$template] = $this->permissions()->can('read');
}
/**
* Creates a unique media hash
*
* @internal
* @return string
*/
public function mediaHash(): string
{
@@ -399,7 +318,9 @@ class File extends ModelWithContent
/**
* Returns the absolute path to the file in the public media folder
*
* @internal
* @return string
*/
public function mediaRoot(): string
{
@@ -408,7 +329,9 @@ class File extends ModelWithContent
/**
* Creates a non-guessable token string for this file
*
* @internal
* @return string
*/
public function mediaToken(): string
{
@@ -418,7 +341,9 @@ class File extends ModelWithContent
/**
* Returns the absolute Url to the file in the public media folder
*
* @internal
* @return string
*/
public function mediaUrl(): string
{
@@ -428,16 +353,17 @@ class File extends ModelWithContent
/**
* Get the file's last modification time.
*
* @param string|\IntlDateFormatter|null $format
* @param string|null $handler date, intl or strftime
* @param string|null $languageCode
* @return mixed
*/
public function modified(
string|IntlDateFormatter|null $format = null,
string|null $handler = null,
string|null $languageCode = null
): string|int|false {
public function modified($format = null, string $handler = null, string $languageCode = null)
{
$file = $this->modifiedFile();
$content = $this->modifiedContent($languageCode);
$modified = max($file, $content);
$handler ??= $this->kirby()->option('date.handler', 'date');
return Str::date($modified, $format, $handler);
}
@@ -445,15 +371,20 @@ class File extends ModelWithContent
/**
* Timestamp of the last modification
* of the content file
*
* @param string|null $languageCode
* @return int
*/
protected function modifiedContent(string $languageCode = null): int
{
return $this->storage()->modified('published', $languageCode) ?? 0;
return F::modified($this->contentFile($languageCode));
}
/**
* Timestamp of the last modification
* of the source file
*
* @return int
*/
protected function modifiedFile(): int
{
@@ -462,35 +393,39 @@ class File extends ModelWithContent
/**
* Returns the parent Page object
*
* @return \Kirby\Cms\Page|null
*/
public function page(): Page|null
public function page()
{
if ($this->parent() instanceof Page) {
return $this->parent();
}
return null;
return is_a($this->parent(), 'Kirby\Cms\Page') === true ? $this->parent() : null;
}
/**
* Returns the panel info object
*
* @return \Kirby\Panel\File
*/
public function panel(): Panel
public function panel()
{
return new Panel($this);
}
/**
* Returns the parent object
* Returns the parent Model object
*
* @return \Kirby\Cms\Model
*/
public function parent(): Page|Site|User
public function parent()
{
return $this->parent ??= $this->kirby()->site();
}
/**
* Returns the parent id if a parent exists
*
* @internal
* @return string
*/
public function parentId(): string
{
@@ -499,40 +434,34 @@ class File extends ModelWithContent
/**
* Returns a collection of all parent pages
*
* @return \Kirby\Cms\Pages
*/
public function parents(): Pages
public function parents()
{
if ($this->parent() instanceof Page) {
return $this->parent()->parents()->prepend(
$this->parent()->id(),
$this->parent()
);
if (is_a($this->parent(), 'Kirby\Cms\Page') === true) {
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()?->url();
}
/**
* Returns the permissions object for this file
*
* @return \Kirby\Cms\FilePermissions
*/
public function permissions(): FilePermissions
public function permissions()
{
return new FilePermissions($this);
}
/**
* Returns the absolute root to the file
*
* @return string|null
*/
public function root(): string|null
public function root(): ?string
{
return $this->root ??= $this->parent()->root() . '/' . $this->filename();
}
@@ -540,8 +469,10 @@ class File extends ModelWithContent
/**
* Returns the FileRules class to
* validate any important action.
*
* @return \Kirby\Cms\FileRules
*/
protected function rules(): FileRules
protected function rules()
{
return new FileRules();
}
@@ -549,9 +480,10 @@ class File extends ModelWithContent
/**
* Sets the Blueprint object
*
* @param array|null $blueprint
* @return $this
*/
protected function setBlueprint(array $blueprint = null): static
protected function setBlueprint(array $blueprint = null)
{
if ($blueprint !== null) {
$blueprint['model'] = $this;
@@ -561,39 +493,103 @@ class File extends ModelWithContent
return $this;
}
/**
* Sets the filename
*
* @param string $filename
* @return $this
*/
protected function setFilename(string $filename)
{
$this->filename = $filename;
return $this;
}
/**
* Sets the parent model object
*
* @param \Kirby\Cms\Model $parent
* @return $this
*/
protected function setParent(Model $parent)
{
$this->parent = $parent;
return $this;
}
/**
* Always set the root to null, to invoke
* auto root detection
*
* @param string|null $root
* @return $this
*/
protected function setRoot(string $root = null)
{
$this->root = null;
return $this;
}
/**
* @param string|null $template
* @return $this
*/
protected function setTemplate(string $template = null)
{
$this->template = $template;
return $this;
}
/**
* Sets the url
*
* @param string|null $url
* @return $this
*/
protected function setUrl(string $url = null)
{
$this->url = $url;
return $this;
}
/**
* Returns the parent Files collection
* @internal
*
* @return \Kirby\Cms\Files
*/
protected function siblingsCollection(): Files
protected function siblingsCollection()
{
return $this->parent()->files();
}
/**
* Returns the parent Site object
*
* @return \Kirby\Cms\Site
*/
public function site(): Site
public function site()
{
if ($this->parent() instanceof Site) {
return $this->parent();
}
return $this->kirby()->site();
return is_a($this->parent(), 'Kirby\Cms\Site') === true ? $this->parent() : $this->kirby()->site();
}
/**
* Returns the final template
*
* @return string|null
*/
public function template(): string|null
public function template(): ?string
{
return $this->template ??= $this->content()->get('template')->value();
}
/**
* Returns siblings with the same template
*
* @param bool $self
* @return \Kirby\Cms\Files
*/
public function templateSiblings(bool $self = true): Files
public function templateSiblings(bool $self = true)
{
return $this->siblings($self)->filter('template', $this->template());
}
@@ -602,27 +598,122 @@ class File extends ModelWithContent
* Extended info for the array export
* by injecting the information from
* the asset.
*
* @return array
*/
public function toArray(): array
{
return array_merge(parent::toArray(), $this->asset()->toArray(), [
'id' => $this->id(),
'template' => $this->template(),
]);
return array_merge($this->asset()->toArray(), parent::toArray());
}
/**
* Returns the Url
*
* @return string
*/
public function url(): string
{
return $this->url ??= ($this->kirby()->component('file::url'))($this->kirby(), $this);
}
/**
* Deprecated!
*/
/**
* Provides a kirbytag or markdown
* tag for the file, which will be
* used in the panel, when the file
* gets dragged onto a textarea
*
* @todo Remove in 3.8.0
*
* @internal
* @param string|null $type (null|auto|kirbytext|markdown)
* @param bool $absolute
* @return string
* @codeCoverageIgnore
*/
public function dragText(string $type = null, bool $absolute = false): string
{
Helpers::deprecated('Cms\File::dragText() has been deprecated and will be removed in Kirby 3.8.0. Use $file->panel()->dragText() instead.');
return $this->panel()->dragText($type, $absolute);
}
/**
* Returns an array of all actions
* that can be performed in the Panel
*
* @todo Remove in 3.8.0
*
* @since 3.3.0 This also checks for the lock status
* @since 3.5.1 This also checks for matching accept settings
*
* @param array $unlock An array of options that will be force-unlocked
* @return array
* @codeCoverageIgnore
*/
public function panelOptions(array $unlock = []): array
{
Helpers::deprecated('Cms\File::panelOptions() has been deprecated and will be removed in Kirby 3.8.0. Use $file->panel()->options() instead.');
return $this->panel()->options($unlock);
}
/**
* Returns the full path without leading slash
*
* @todo Remove in 3.8.0
*
* @internal
* @return string
* @codeCoverageIgnore
*/
public function panelPath(): string
{
Helpers::deprecated('Cms\File::panelPath() has been deprecated and will be removed in Kirby 3.8.0. Use $file->panel()->path() instead.');
return $this->panel()->path();
}
/**
* Prepares the response data for file pickers
* and file fields
*
* @todo Remove in 3.8.0
*
* @param array|null $params
* @return array
* @codeCoverageIgnore
*/
public function panelPickerData(array $params = []): array
{
Helpers::deprecated('Cms\File::panelPickerData() has been deprecated and will be removed in Kirby 3.8.0. Use $file->panel()->pickerData() instead.');
return $this->panel()->pickerData($params);
}
/**
* Returns the url to the editing view
* in the panel
*
* @todo Remove in 3.8.0
*
* @internal
* @param bool $relative
* @return string
* @codeCoverageIgnore
*/
public function panelUrl(bool $relative = false): string
{
Helpers::deprecated('Cms\File::panelUrl() has been deprecated and will be removed in Kirby 3.8.0. Use $file->panel()->url() instead.');
return $this->panel()->url($relative);
}
/**
* Simplified File URL that uses the parent
* Page URL and the filename as a more stable
* alternative for the media URLs.
*
* @return string
*/
public function previewUrl(): string
{

View File

@@ -7,8 +7,6 @@ use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\F;
use Kirby\Form\Form;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
/**
* FileActions
@@ -21,56 +19,31 @@ use Kirby\Uuid\Uuids;
*/
trait FileActions
{
protected function changeExtension(
File $file,
string|null $extension = null
): File {
if (
$extension === null ||
$extension === $file->extension()
) {
return $file;
}
return $file->changeName($file->name(), false, $extension);
}
/**
* Renames the file (optionally also the extension).
* Renames the file without touching the extension
* The store is used to actually execute this.
*
* @param string $name
* @param bool $sanitize
* @return $this|static
* @throws \Kirby\Exception\LogicException
*/
public function changeName(
string $name,
bool $sanitize = true,
string|null $extension = null
): static {
public function changeName(string $name, bool $sanitize = true)
{
if ($sanitize === true) {
// sanitize the basename part only
// as the extension isn't included in $name
$name = F::safeBasename($name, false);
$name = F::safeName($name);
}
// 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()
) {
if ($name === $this->name()) {
return $this;
}
return $this->commit('changeName', ['file' => $this, 'name' => $name, 'extension' => $extension], function ($oldFile, $name, $extension) {
return $this->commit('changeName', ['file' => $this, 'name' => $name], function ($oldFile, $name) {
$newFile = $oldFile->clone([
'filename' => $name . '.' . $extension,
'filename' => $name . '.' . $oldFile->extension(),
]);
// remove all public versions, lock and clear UUID cache
$oldFile->unpublish();
if ($oldFile->exists() === false) {
return $newFile;
}
@@ -79,17 +52,29 @@ trait FileActions
throw new LogicException('The new file exists and cannot be overwritten');
}
// remove the lock of the old file
if ($lock = $oldFile->lock()) {
$lock->remove();
}
// remove all public versions
$oldFile->unpublish();
// rename the main file
F::move($oldFile->root(), $newFile->root());
// move the content storage versions
foreach ($oldFile->storage()->all() as $version => $lang) {
$content = $oldFile->storage()->read($version, $lang);
$oldFile->storage()->delete($version, $lang);
$newFile->storage()->create($version, $lang, $content);
if ($newFile->kirby()->multilang() === true) {
foreach ($newFile->translations() as $translation) {
$translationCode = $translation->code();
// rename the content file
F::move($oldFile->contentFile($translationCode), $newFile->contentFile($translationCode));
}
} else {
// rename the content file
F::move($oldFile->contentFile(), $newFile->contentFile());
}
// update collections
$newFile->parent()->files()->remove($oldFile->id());
$newFile->parent()->files()->set($newFile->id(), $newFile);
@@ -99,14 +84,12 @@ trait FileActions
/**
* Changes the file's sorting number in the meta file
*
* @param int $sort
* @return static
*/
public function changeSort(int $sort): static
public function changeSort(int $sort)
{
// skip if the sort number stays the same
if ($this->sort()->value() === $sort) {
return $this;
}
return $this->commit(
'changeSort',
['file' => $this, 'position' => $sort],
@@ -114,40 +97,6 @@ trait FileActions
);
}
/**
* @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);
// update template, prefer unset over writing `default`
if ($template === 'default') {
$template = null;
}
$file = $file->update(['template' => $template]);
// rename and/or resize the file if configured by new blueprint
$create = $file->blueprint()->create();
$file = $file->manipulate($create);
$file = $file->changeExtension($file, $create['format'] ?? null);
return $file;
});
}
/**
* Commits a file action, by following these steps
*
@@ -156,12 +105,14 @@ trait FileActions
* 3. commits the store action
* 4. sends the after hook
* 5. returns the result
*
* @param string $action
* @param array $arguments
* @param Closure $callback
* @return mixed
*/
protected function commit(
string $action,
array $arguments,
Closure $callback
): mixed {
protected function commit(string $action, array $arguments, Closure $callback)
{
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
@@ -171,12 +122,13 @@ trait FileActions
$result = $callback(...$argumentValues);
$argumentsAfter = match ($action) {
'create' => ['file' => $result],
'delete' => ['status' => $result, 'file' => $old],
default => ['newFile' => $result, 'oldFile' => $old]
};
if ($action === 'create') {
$argumentsAfter = ['file' => $result];
} elseif ($action === 'delete') {
$argumentsAfter = ['status' => $result, 'file' => $old];
} else {
$argumentsAfter = ['newFile' => $result, 'oldFile' => $old];
}
$kirby->trigger('file.' . $action . ':after', $argumentsAfter);
$kirby->cache('pages')->flush();
@@ -185,27 +137,25 @@ trait FileActions
/**
* Copy the file to the given page
*
* @param \Kirby\Cms\Page $page
* @return \Kirby\Cms\File
*/
public function copy(Page $page): static
public function copy(Page $page)
{
F::copy($this->root(), $page->root() . '/' . $this->filename());
$copy = $page->clone()->file($this->filename());
foreach ($this->storage()->all() as $version => $lang) {
$content = $this->storage()->read($version, $lang);
$copy->storage()->create($version, $lang, $content);
if ($this->kirby()->multilang() === true) {
foreach ($this->kirby()->languages() as $language) {
$contentFile = $this->contentFile($language->code());
F::copy($contentFile, $page->root() . '/' . basename($contentFile));
}
} else {
$contentFile = $this->contentFile();
F::copy($contentFile, $page->root() . '/' . basename($contentFile));
}
// ensure the content is re-read after copying it
// @todo find a more elegant way
$copy = $page->clone()->file($this->filename());
// overwrite with new UUID (remove old, add new)
if (Uuids::enabled() === true) {
$copy = $copy->save(['uuid' => Uuid::generate()]);
}
return $copy;
return $page->clone()->file($this->filename());
}
/**
@@ -214,11 +164,12 @@ trait FileActions
* 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
* @param array $props
* @return static
* @throws \Kirby\Exception\InvalidArgumentException
* @throws \Kirby\Exception\LogicException
*/
public static function create(array $props, bool $move = false): File
public static function create(array $props)
{
if (isset($props['source'], $props['parent']) === false) {
throw new InvalidArgumentException('Please provide the "source" and "parent" props for the File');
@@ -233,66 +184,33 @@ trait FileActions
$file = static::factory($props);
$upload = $file->asset($props['source']);
// gather content
$content = $props['content'] ?? [];
// make sure that a UUID gets generated
// and added to content right away
if (
Uuids::enabled() === true &&
empty($content['uuid']) === true
) {
// sets the current uuid if it is the exact same file
if ($file->exists() === true) {
$existing = $file->parent()->file($file->filename());
if (
$file->sha1() === $upload->sha1() &&
$file->template() === $existing->template()
) {
// use existing content data if it is the exact same file
$content = $existing->content()->toArray();
}
}
$content['uuid'] ??= Uuid::generate();
}
// create a form for the file
$form = Form::for($file, ['values' => $content]);
$form = Form::for($file, [
'values' => $props['content'] ?? []
]);
// inject the content
$file = $file->clone(['content' => $form->strings(true)]);
// 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) {
// remove all public versions, lock and clear UUID cache
return $file->commit('create', compact('file', 'upload'), function ($file, $upload) {
// delete all public versions
$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) {
if (F::copy($upload->root(), $file->root(), true) !== true) {
throw new LogicException('The file could not be created');
}
// resize the file on upload if configured
$file = $file->manipulate($create);
$file = $file->changeExtension($file, $create['format'] ?? null);
// always create pages in the default language
if ($file->kirby()->multilang() === true) {
$languageCode = $file->kirby()->defaultLanguage()->code();
} else {
$languageCode = null;
}
// store the content if necessary
// (always create files in the default language)
$file->save(
$file->content()->toArray(),
$file->kirby()->defaultLanguage()?->code()
);
$file->save($file->content()->toArray(), $languageCode);
// add the file to the list of siblings
$file->siblings()->append($file->id(), $file);
@@ -305,15 +223,26 @@ trait FileActions
/**
* Deletes the file. The store is used to
* manipulate the filesystem or whatever you prefer.
*
* @return bool
*/
public function delete(): bool
{
return $this->commit('delete', ['file' => $this], function ($file) {
// remove all public versions, lock and clear UUID cache
// remove all versions in the media folder
$file->unpublish();
foreach ($file->storage()->all() as $version => $lang) {
$file->storage()->delete($version, $lang);
// remove the lock of the old file
if ($lock = $file->lock()) {
$lock->remove();
}
if ($file->kirby()->multilang() === true) {
foreach ($file->translations() as $translation) {
F::remove($file->contentFile($translation->code()));
}
} else {
F::remove($file->contentFile());
}
F::remove($file->root());
@@ -325,29 +254,13 @@ trait FileActions
});
}
/**
* 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);
return $this->clone([]);
}
/**
* Move the file to the public media folder
* if it's not already there.
*
* @return $this
*/
public function publish(): static
public function publish()
{
Media::publish($this, $this->mediaRoot());
return $this;
@@ -360,10 +273,11 @@ trait FileActions
* finally decides what it will support as
* source.
*
* @param bool $move If set to `true`, the source will be deleted
* @param string $source
* @return static
* @throws \Kirby\Exception\LogicException
*/
public function replace(string $source, bool $move = false): static
public function replace(string $source)
{
$file = $this->clone();
@@ -372,82 +286,28 @@ trait FileActions
'upload' => $file->asset($source)
];
return $this->commit('replace', $arguments, function ($file, $upload) use ($move) {
return $this->commit('replace', $arguments, function ($file, $upload) {
// delete all public versions
$file->unpublish(true);
// only move the original source if intended
$method = $move === true ? 'move' : 'copy';
$file->unpublish();
// overwrite the original
if (F::$method($upload->root(), $file->root(), true) !== true) {
if (F::copy($upload->root(), $file->root(), true) !== true) {
throw new LogicException('The file could not be created');
}
// apply the resizing/crop options from the blueprint
$create = $file->blueprint()->create();
$file = $file->manipulate($create);
$file = $file->changeExtension($file, $create['format'] ?? null);
// return a fresh clone
return $file->clone();
});
}
/**
* Stores the content on disk
* @internal
*/
public function save(
array $data = null,
string $languageCode = null,
bool $overwrite = false
): static {
$file = parent::save($data, $languageCode, $overwrite);
// update model in siblings collection
$file->parent()->files()->set($file->id(), $file);
return $file;
}
/**
* Remove all public versions of this file
*
* @return $this
*/
public function unpublish(bool $onlyMedia = false): static
public function unpublish()
{
// unpublish media files
Media::unpublish($this->parent()->mediaRoot(), $this);
if ($onlyMedia !== true) {
// remove the lock
$this->lock()?->remove();
// 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 $input = null,
string $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);
}
}

View File

@@ -3,7 +3,6 @@
namespace Kirby\Cms;
use Kirby\Filesystem\F;
use Kirby\Filesystem\Mime;
use Kirby\Toolkit\Str;
/**
@@ -21,8 +20,10 @@ class FileBlueprint extends Blueprint
/**
* `true` if the default accepted
* types are being used
*
* @var bool
*/
protected bool $defaultTypes = false;
protected $defaultTypes = false;
public function __construct(array $props)
{
@@ -33,15 +34,12 @@ class FileBlueprint extends Blueprint
$this->props['options'] ?? true,
// defaults
[
'access' => null,
'changeName' => null,
'changeTemplate' => null,
'create' => null,
'delete' => null,
'list' => null,
'read' => null,
'replace' => null,
'update' => null,
'changeName' => null,
'create' => null,
'delete' => null,
'read' => null,
'replace' => null,
'update' => null,
]
);
@@ -49,6 +47,9 @@ class FileBlueprint extends Blueprint
$this->props['accept'] = $this->normalizeAccept($this->props['accept'] ?? []);
}
/**
* @return array
*/
public function accept(): array
{
return $this->props['accept'];
@@ -58,8 +59,7 @@ class FileBlueprint extends Blueprint
* 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 5.0.0 Remove method
* @return string
*/
public function acceptMime(): string
{
@@ -81,10 +81,7 @@ class FileBlueprint extends Blueprint
if (is_array($accept['extension']) === true) {
// determine the main MIME type for each extension
$restrictions[] = array_map(
[Mime::class, 'fromExtension'],
$accept['extension']
);
$restrictions[] = array_map(['Kirby\Filesystem\Mime', 'fromExtension'], $accept['extension']);
}
if (is_array($accept['type']) === true) {
@@ -92,10 +89,7 @@ class FileBlueprint extends Blueprint
$mimes = [];
foreach ($accept['type'] as $type) {
if ($extensions = F::typeToExtensions($type)) {
$mimes[] = array_map(
[Mime::class, 'fromExtension'],
$extensions
);
$mimes[] = array_map(['Kirby\Filesystem\Mime', 'fromExtension'], $extensions);
}
}
@@ -120,84 +114,24 @@ class FileBlueprint extends Blueprint
}
/**
* 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
* @param mixed $accept
* @return array
*/
public function acceptAttribute(): string
protected function normalizeAccept($accept = null): array
{
// 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],
if (is_string($accept) === true) {
$accept = [
'mime' => $accept
];
} elseif ($accept === true) {
// explicitly no restrictions at all
$accept === true => ['mime' => null],
$accept = [
'mime' => null
];
} elseif (empty($accept) === true) {
// no custom restrictions
empty($accept) === true => [],
// custom restrictions
default => $accept
};
$accept = [];
}
$accept = array_change_key_case($accept);

View File

@@ -2,9 +2,7 @@
namespace Kirby\Cms;
use Kirby\Content\Field;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Filesystem\Asset;
/**
* Trait for image resizing, blurring etc.
@@ -19,36 +17,43 @@ trait FileModifications
{
/**
* Blurs the image by the given amount of pixels
*
* @param bool $pixels
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
*/
public function blur(int|bool $pixels = true): FileVersion|File|Asset
public function blur($pixels = true)
{
return $this->thumb(['blur' => $pixels]);
}
/**
* Converts the image to black and white
*
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
*/
public function bw(): FileVersion|File|Asset
public function bw()
{
return $this->thumb(['grayscale' => true]);
}
/**
* Crops the image by the given width and height
*
* @param int $width
* @param int|null $height
* @param string|array $options
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
*/
public function crop(
int $width,
int $height = null,
$options = null
): FileVersion|File|Asset {
public function crop(int $width, int $height = null, $options = null)
{
$quality = null;
$crop = true;
$crop = 'center';
if (is_int($options) === true) {
$quality = $options;
} elseif (is_string($options)) {
$crop = $options;
} elseif ($options instanceof Field) {
} elseif (is_a($options, 'Kirby\Cms\Field') === true) {
$crop = $options->value();
} elseif (is_array($options)) {
$quality = $options['quality'] ?? $quality;
@@ -65,24 +70,31 @@ trait FileModifications
/**
* Alias for File::bw()
*
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
*/
public function grayscale(): FileVersion|File|Asset
public function grayscale()
{
return $this->thumb(['grayscale' => true]);
}
/**
* Alias for File::bw()
*
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
*/
public function greyscale(): FileVersion|File|Asset
public function greyscale()
{
return $this->thumb(['grayscale' => true]);
}
/**
* Sets the JPEG compression quality
*
* @param int $quality
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
*/
public function quality(int $quality): FileVersion|File|Asset
public function quality(int $quality)
{
return $this->thumb(['quality' => $quality]);
}
@@ -91,13 +103,14 @@ trait FileModifications
* Resizes the file with the given width and height
* while keeping the aspect ratio.
*
* @param int|null $width
* @param int|null $height
* @param int|null $quality
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function resize(
int $width = null,
int $height = null,
int $quality = null
): FileVersion|File|Asset {
public function resize(int $width = null, int $height = null, int $quality = null)
{
return $this->thumb([
'width' => $width,
'height' => $height,
@@ -105,21 +118,16 @@ trait FileModifications
]);
}
/**
* 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
*
* @param array|string|null $sizes
* @return string|null
*/
public function srcset(array|string|null $sizes = null): string|null
public function srcset($sizes = null): ?string
{
if (empty($sizes) === true) {
$sizes = $this->kirby()->option('thumbs.srcsets.default', []);
@@ -166,11 +174,12 @@ trait FileModifications
* could potentially also be a CDN or any other
* place.
*
* @param array|null|string $options
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function thumb(
array|string|null $options = null
): FileVersion|File|Asset {
public function thumb($options = null)
{
// thumb presets
if (empty($options) === true) {
$options = $this->kirby()->option('thumbs.presets.default');
@@ -182,15 +191,6 @@ trait FileModifications
return $this;
}
// fallback to content file options
if (($options['crop'] ?? false) === true) {
if ($this instanceof ModelWithContent === true) {
$options['crop'] = $this->focus()->value() ?? 'center';
} else {
$options['crop'] = 'center';
}
}
// fallback to global config options
if (isset($options['format']) === false) {
if ($format = $this->kirby()->option('thumbs.format')) {
@@ -202,9 +202,9 @@ trait FileModifications
$result = $component($this->kirby(), $this, $options);
if (
$result instanceof FileVersion === false &&
$result instanceof File === false &&
$result instanceof Asset === false
is_a($result, 'Kirby\Cms\FileVersion') === false &&
is_a($result, 'Kirby\Cms\File') === false &&
is_a($result, 'Kirby\Filesystem\Asset') === false
) {
throw new InvalidArgumentException('The file::version component must return a File, FileVersion or Asset object');
}

View File

@@ -13,14 +13,5 @@ namespace Kirby\Cms;
*/
class FilePermissions extends ModelPermissions
{
protected string $category = 'files';
protected function canChangeTemplate(): bool
{
if (count($this->model->blueprints()) <= 1) {
return false;
}
return true;
}
protected $category = 'files';
}

View File

@@ -19,6 +19,8 @@ class FilePicker extends Picker
{
/**
* Extends the basic defaults
*
* @return array
*/
public function defaults(): array
{
@@ -31,21 +33,21 @@ class FilePicker extends Picker
/**
* Search all files for the picker
*
* @return \Kirby\Cms\Files|null
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function items(): Files|null
public function items()
{
$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'
};
if (empty($this->options['query']) === false) {
$query = $this->options['query'];
} elseif (is_a($model, 'Kirby\Cms\File') === true) {
$query = 'file.siblings';
} else {
$query = $model::CLASS_ALIAS . '.files';
}
// fetch all files for the picker
$files = $model->query($query);
@@ -53,17 +55,15 @@ class FilePicker extends Picker
// 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('Your query must return a set of files')
};
// filter protected and hidden pages
$files = $files->filter('isListable', true);
if (is_a($files, 'Kirby\Cms\Site') === true) {
$files = $files->files();
} elseif (is_a($files, 'Kirby\Cms\Page') === true) {
$files = $files->files();
} elseif (is_a($files, 'Kirby\Cms\User') === true) {
$files = $files->files();
} elseif (is_a($files, 'Kirby\Cms\Files') === false) {
throw new InvalidArgumentException('Your query must return a set of files');
}
// search
$files = $this->search($files);

View File

@@ -4,7 +4,6 @@ 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;
@@ -24,6 +23,9 @@ class FileRules
/**
* Validates if the filename can be changed
*
* @param \Kirby\Cms\File $file
* @param string $name
* @return bool
* @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
*/
@@ -57,51 +59,22 @@ class FileRules
/**
* Validates if the file can be sorted
*
* @param \Kirby\Cms\File $file
* @param int $sort
* @return bool
*/
public static function changeSort(File $file, int $sort): bool
{
return true;
}
/**
* 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): bool
{
if ($file->permissions()->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')) === false
) {
throw new LogicException([
'key' => 'file.changeTemplate.invalid',
'data' => [
'id' => $file->id(),
'template' => $template,
'blueprints' => implode(', ', array_column($blueprints, 'name'))
]
]);
}
return true;
}
/**
* Validates if the file can be created
*
* @param \Kirby\Cms\File $file
* @param \Kirby\Filesystem\File $upload
* @return bool
* @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
*/
@@ -148,6 +121,8 @@ class FileRules
/**
* Validates if the file can be deleted
*
* @param \Kirby\Cms\File $file
* @return bool
* @throws \Kirby\Exception\PermissionException If the user is not allowed to delete the file
*/
public static function delete(File $file): bool
@@ -162,6 +137,9 @@ class FileRules
/**
* Validates if the file can be replaced
*
* @param \Kirby\Cms\File $file
* @param \Kirby\Filesystem\File $upload
* @return bool
* @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
*/
@@ -192,6 +170,9 @@ class FileRules
/**
* Validates if the file can be updated
*
* @param \Kirby\Cms\File $file
* @param array $content
* @return bool
* @throws \Kirby\Exception\PermissionException If the user is not allowed to update the file
*/
public static function update(File $file, array $content = []): bool
@@ -206,6 +187,9 @@ class FileRules
/**
* Validates the file extension
*
* @param \Kirby\Cms\File $file
* @param string $extension
* @return bool
* @throws \Kirby\Exception\InvalidArgumentException If the extension is missing or forbidden
*/
public static function validExtension(File $file, string $extension): bool
@@ -223,7 +207,7 @@ class FileRules
if (
Str::contains($extension, 'php') !== false ||
Str::contains($extension, 'phar') !== false ||
Str::contains($extension, 'pht') !== false
Str::contains($extension, 'phtml') !== false
) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
@@ -251,19 +235,20 @@ class FileRules
/**
* Validates the extension, MIME type and filename
*
* @param $mime If not passed, the MIME type is detected from the file,
* if `false`, the MIME type is not validated for performance reasons
* @param \Kirby\Cms\File $file
* @param string|null|false $mime If not passed, the MIME type is detected from the file,
* if `false`, the MIME type is not validated for performance reasons
* @return bool
* @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
): bool {
$validMime = match ($mime) {
public static function validFile(File $file, $mime = null): bool
{
if ($mime === false) {
// request to skip the MIME check for performance reasons
false => true,
default => static::validMime($file, $mime ?? $file->mime())
};
$validMime = true;
} else {
$validMime = static::validMime($file, $mime ?? $file->mime());
}
return
$validMime &&
@@ -274,6 +259,9 @@ class FileRules
/**
* Validates the filename
*
* @param \Kirby\Cms\File $file
* @param string $filename
* @return bool
* @throws \Kirby\Exception\InvalidArgumentException If the filename is missing or forbidden
*/
public static function validFilename(File $file, string $filename): bool
@@ -310,6 +298,9 @@ class FileRules
/**
* Validates the MIME type
*
* @param \Kirby\Cms\File $file
* @param string|null $mime
* @return bool
* @throws \Kirby\Exception\InvalidArgumentException If the MIME type is missing or forbidden
*/
public static function validMime(File $file, string $mime = null): bool

View File

@@ -17,22 +17,18 @@ class FileVersion
{
use IsFile;
protected array $modifications;
protected $modifications;
protected $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
*
* @param string $method
* @param array $arguments
* @return mixed
*/
public function __call(string $method, array $arguments = []): mixed
public function __call(string $method, array $arguments = [])
{
// public property access
if (isset($this->$method) === true) {
@@ -49,13 +45,15 @@ class FileVersion
}
// content fields
if ($this->original() instanceof File) {
return $this->original()->content()->get($method);
if (is_a($this->original(), 'Kirby\Cms\File') === true) {
return $this->original()->content()->get($method, $arguments);
}
}
/**
* Returns the unique ID
*
* @return string
*/
public function id(): string
{
@@ -64,24 +62,30 @@ class FileVersion
/**
* Returns the parent Kirby App instance
*
* @return \Kirby\Cms\App
*/
public function kirby(): App
public function kirby()
{
return $this->original()->kirby();
}
/**
* Returns an array with all applied modifications
*
* @return array
*/
public function modifications(): array
{
return $this->modifications;
return $this->modifications ?? [];
}
/**
* Returns the instance of the original File object
*
* @return mixed
*/
public function original(): mixed
public function original()
{
return $this->original;
}
@@ -92,7 +96,7 @@ class FileVersion
*
* @return $this
*/
public function save(): static
public function save()
{
$this->kirby()->thumb(
$this->original()->root(),
@@ -102,16 +106,36 @@ class FileVersion
return $this;
}
/**
* Setter for modifications
*
* @param array|null $modifications
*/
protected function setModifications(array $modifications = null)
{
$this->modifications = $modifications;
}
/**
* Setter for the original File object
*
* @param $original
*/
protected function setOriginal($original)
{
$this->original = $original;
}
/**
* Converts the object to an array
*
* @return array
*/
public function toArray(): array
{
$array = array_merge(
$this->asset()->toArray(),
['modifications' => $this->modifications()]
);
$array = array_merge($this->asset()->toArray(), [
'modifications' => $this->modifications(),
]);
ksort($array);

View File

@@ -4,7 +4,6 @@ namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Filesystem\F;
use Kirby\Uuid\HasUuids;
/**
* The `$files` object extends the general
@@ -22,12 +21,12 @@ use Kirby\Uuid\HasUuids;
*/
class Files extends Collection
{
use HasUuids;
/**
* All registered files methods
*
* @var array
*/
public static array $methods = [];
public static $methods = [];
/**
* Adds a single file or
@@ -38,21 +37,18 @@ class Files extends Collection
* @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
public function add($object)
{
// add a files collection
if ($object instanceof self) {
if (is_a($object, self::class) === true) {
$this->data = array_merge($this->data, $object->data);
// add a file by id
} elseif (
is_string($object) === true &&
$file = App::instance()->file($object)
) {
} elseif (is_string($object) === true && $file = App::instance()->file($object)) {
$this->__set($file->id(), $file);
// add a file object
} elseif ($object instanceof File) {
} elseif (is_a($object, 'Kirby\Cms\File') === true) {
$this->__set($object->id(), $object);
// give a useful error message on invalid input;
@@ -72,7 +68,7 @@ class Files extends Collection
* @param int $offset Sorting offset
* @return $this
*/
public function changeSort(array $files, int $offset = 0): static
public function changeSort(array $files, int $offset = 0)
{
foreach ($files as $filename) {
if ($file = $this->get($filename)) {
@@ -86,13 +82,19 @@ class Files extends Collection
/**
* Creates a files collection from an array of props
*
* @param array $files
* @param \Kirby\Cms\Model $parent
* @return static
*/
public static function factory(array $files, Page|Site|User $parent): static
public static function factory(array $files, Model $parent)
{
$collection = new static([], $parent);
$kirby = $parent->kirby();
foreach ($files as $props) {
$props['collection'] = $collection;
$props['kirby'] = $kirby;
$props['parent'] = $parent;
$file = File::factory($props);
@@ -103,17 +105,32 @@ class Files extends Collection
return $collection;
}
/**
* Tries to find a file by id/filename
* @deprecated 3.7.0 Use `$files->find()` instead
* @todo 3.8.0 Remove method
* @codeCoverageIgnore
*
* @param string $id
* @return \Kirby\Cms\File|null
*/
public function findById(string $id)
{
Helpers::deprecated('Cms\Files::findById() has been deprecated and will be removed in Kirby 3.8.0. Use $files->find() instead.');
return $this->findByKey($id);
}
/**
* Finds a file by its filename
* @internal Use `$files->find()` instead
*
* @param string $key
* @return \Kirby\Cms\File|null
*/
public function findByKey(string $key): File|null
public function findByKey(string $key)
{
if ($file = $this->findByUuid($key, 'file')) {
return $file;
}
return $this->get(ltrim($this->parent?->id() . '/' . $key, '/'));
return $this->get(ltrim($this->parent->id() . '/' . $key, '/'));
}
/**
@@ -125,6 +142,7 @@ class Files extends Collection
* @param string|null|false $locale Locale for number formatting,
* `null` for the current locale,
* `false` to disable number formatting
* @return string
*/
public function niceSize($locale = null): string
{
@@ -135,6 +153,8 @@ class Files extends Collection
* Returns the raw size for all
* files in the collection
* @since 3.6.0
*
* @return int
*/
public function size(): int
{
@@ -144,8 +164,10 @@ class Files extends Collection
/**
* Returns the collection sorted by
* the sort number and the filename
*
* @return static
*/
public function sorted(): static
public function sorted()
{
return $this->sort('sort', 'asc', 'filename', 'asc');
}
@@ -153,9 +175,10 @@ class Files extends Collection
/**
* Filter all files by the given template
*
* @param null|string|array $template
* @return $this|static
*/
public function template(string|array|null $template): static
public function template($template)
{
if (empty($template) === true) {
return $this;

View File

@@ -23,18 +23,17 @@ class Find
* Returns the file object for the given
* parent path and filename
*
* @param string $path Path to file's parent model
* @param string|null $path Path to file's parent model
* @param string $filename Filename
* @return \Kirby\Cms\File|null
* @throws \Kirby\Exception\NotFoundException if the file cannot be found
*/
public static function file(
string $path,
string $filename
): File|null {
public static function file(string $path = null, string $filename)
{
$filename = urldecode($filename);
$parent = empty($path) ? null : static::parent($path);
$file = App::instance()->file($filename, $parent);
$file = static::parent($path)->file($filename);
if ($file?->isAccessible() === true) {
if ($file && $file->isReadable() === true) {
return $file;
}
@@ -50,9 +49,10 @@ class Find
* Returns the language object for the given code
*
* @param string $code Language code
* @return \Kirby\Cms\Language|null
* @throws \Kirby\Exception\NotFoundException if the language cannot be found
*/
public static function language(string $code): Language|null
public static function language(string $code)
{
if ($language = App::instance()->language($code)) {
return $language;
@@ -70,16 +70,15 @@ class Find
* Returns the page object for the given id
*
* @param string $id Page's id
* @return \Kirby\Cms\Page|null
* @throws \Kirby\Exception\NotFoundException if the page cannot be found
*/
public static function page(string $id): Page|null
public static function page(string $id)
{
// decode API ID encoding
$id = str_replace(['+', ' '], '/', $id);
$kirby = App::instance();
$page = $kirby->page($id, null, true);
$id = str_replace(['+', ' '], '/', $id);
$page = App::instance()->page($id);
if ($page?->isAccessible() === true) {
if ($page && $page->isReadable() === true) {
return $page;
}
@@ -95,10 +94,11 @@ class Find
* Returns the model's object for the given path
*
* @param string $path Path to parent model
* @return \Kirby\Cms\Model|null
* @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
public static function parent(string $path)
{
$path = trim($path, '/');
$modelType = in_array($path, ['site', 'account']) ? $path : trim(dirname($path), '/');
@@ -117,19 +117,31 @@ class Find
$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('Invalid model type: ' . $modelType)
};
switch ($modelName) {
case 'site':
$model = $kirby->site();
break;
case 'account':
$model = static::user();
break;
case 'page':
$model = static::page(basename($path));
break;
case 'file':
$model = static::file(...explode('/files/', $path));
break;
case 'user':
$model = $kirby->user(basename($path));
break;
default:
throw new InvalidArgumentException('Invalid model type: ' . $modelType);
}
return $model ?? throw new NotFoundException([
if ($model) {
return $model;
}
throw new NotFoundException([
'key' => $modelName . '.undefined'
]);
}
@@ -140,9 +152,10 @@ class Find
* id is passed
*
* @param string|null $id User's id
* @return \Kirby\Cms\User|null
* @throws \Kirby\Exception\NotFoundException if the user for the given id cannot be found
*/
public static function user(string $id = null): User|null
public static function user(string $id = null)
{
// account is a reserved word to find the current
// user. It's used in various API and area routes.
@@ -154,18 +167,21 @@ class Find
// get the authenticated user
if ($id === null) {
$user = $kirby->user(
null,
$kirby->option('api.allowImpersonation', false)
);
if ($user = $kirby->user(null, $kirby->option('api.allowImpersonation', false))) {
return $user;
}
return $user ?? throw new NotFoundException([
throw new NotFoundException([
'key' => 'user.undefined'
]);
}
// get a specific user by id
return $kirby->user($id) ?? throw new NotFoundException([
if ($user = $kirby->user($id)) {
return $user;
}
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $id

View File

@@ -18,40 +18,72 @@ trait HasChildren
{
/**
* The list of available published children
*
* @var \Kirby\Cms\Pages|null
*/
public Pages|null $children = null;
public $children;
/**
* The list of available draft children
*
* @var \Kirby\Cms\Pages|null
*/
public Pages|null $drafts = null;
public $drafts;
/**
* The combined list of available published
* and draft children
*
* @var \Kirby\Cms\Pages|null
*/
public Pages|null $childrenAndDrafts = null;
public $childrenAndDrafts;
/**
* Returns all published children
*
* @return \Kirby\Cms\Pages
*/
public function children(): Pages
public function children()
{
return $this->children ??= Pages::factory($this->inventory()['children'], $this);
if (is_a($this->children, 'Kirby\Cms\Pages') === true) {
return $this->children;
}
return $this->children = Pages::factory($this->inventory()['children'], $this);
}
/**
* Returns all published and draft children at the same time
*
* @return \Kirby\Cms\Pages
*/
public function childrenAndDrafts(): Pages
public function childrenAndDrafts()
{
return $this->childrenAndDrafts ??= $this->children()->merge($this->drafts());
if (is_a($this->childrenAndDrafts, 'Kirby\Cms\Pages') === true) {
return $this->childrenAndDrafts;
}
return $this->childrenAndDrafts = $this->children()->merge($this->drafts());
}
/**
* Returns a list of IDs for the model's
* `toArray` method
*
* @return array
*/
protected function convertChildrenToArray(): array
{
return $this->children()->keys();
}
/**
* Searches for a draft child by ID
*
* @param string $path
* @return \Kirby\Cms\Page|null
*/
public function draft(string $path): Page|null
public function draft(string $path)
{
$path = str_replace('_drafts/', '', $path);
@@ -81,10 +113,12 @@ trait HasChildren
/**
* Returns all draft children
*
* @return \Kirby\Cms\Pages
*/
public function drafts(): Pages
public function drafts()
{
if ($this->drafts instanceof Pages) {
if (is_a($this->drafts, 'Kirby\Cms\Pages') === true) {
return $this->drafts;
}
@@ -103,30 +137,40 @@ trait HasChildren
/**
* Finds one or multiple published children by ID
*
* @param string ...$arguments
* @return \Kirby\Cms\Page|\Kirby\Cms\Pages|null
*/
public function find(string|array ...$arguments): Page|Pages|null
public function find(...$arguments)
{
return $this->children()->find(...$arguments);
}
/**
* Finds a single published or draft child
*
* @param string $path
* @return \Kirby\Cms\Page|null
*/
public function findPageOrDraft(string $path): Page|null
public function findPageOrDraft(string $path)
{
return $this->children()->find($path) ?? $this->drafts()->find($path);
}
/**
* Returns a collection of all published children of published children
*
* @return \Kirby\Cms\Pages
*/
public function grandChildren(): Pages
public function grandChildren()
{
return $this->children()->children();
}
/**
* Checks if the model has any published children
*
* @return bool
*/
public function hasChildren(): bool
{
@@ -135,6 +179,8 @@ trait HasChildren
/**
* Checks if the model has any draft children
*
* @return bool
*/
public function hasDrafts(): bool
{
@@ -143,6 +189,8 @@ trait HasChildren
/**
* Checks if the page has any listed children
*
* @return bool
*/
public function hasListedChildren(): bool
{
@@ -151,6 +199,8 @@ trait HasChildren
/**
* Checks if the page has any unlisted children
*
* @return bool
*/
public function hasUnlistedChildren(): bool
{
@@ -161,22 +211,24 @@ trait HasChildren
* Creates a flat child index
*
* @param bool $drafts If set to `true`, draft children are included
* @return \Kirby\Cms\Pages
*/
public function index(bool $drafts = false): Pages
public function index(bool $drafts = false)
{
if ($drafts === true) {
return $this->childrenAndDrafts()->index($drafts);
} else {
return $this->children()->index();
}
return $this->children()->index();
}
/**
* Sets the published children collection
*
* @param array|null $children
* @return $this
*/
protected function setChildren(array $children = null): static
protected function setChildren(array $children = null)
{
if ($children !== null) {
$this->children = Pages::factory($children, $this);
@@ -188,9 +240,10 @@ trait HasChildren
/**
* Sets the draft children collection
*
* @param array|null $drafts
* @return $this
*/
protected function setDrafts(array $drafts = null): static
protected function setDrafts(array $drafts = null)
{
if ($drafts !== null) {
$this->drafts = Pages::factory($drafts, $this, true);

View File

@@ -2,8 +2,6 @@
namespace Kirby\Cms;
use Kirby\Uuid\Uuid;
/**
* HasFiles
*
@@ -17,64 +15,81 @@ trait HasFiles
{
/**
* The Files collection
*
* @var \Kirby\Cms\Files
*/
protected Files|array|null $files = null;
protected $files;
/**
* Filters the Files collection by type audio
*
* @return \Kirby\Cms\Files
*/
public function audio(): Files
public function audio()
{
return $this->files()->filter('type', '==', 'audio');
}
/**
* Filters the Files collection by type code
*
* @return \Kirby\Cms\Files
*/
public function code(): Files
public function code()
{
return $this->files()->filter('type', '==', 'code');
}
/**
* Returns a list of file ids
* for the toArray method of the model
*
* @return array
*/
protected function convertFilesToArray(): array
{
return $this->files()->keys();
}
/**
* Creates a new file
*
* @param bool $move If set to `true`, the source will be deleted
* @param array $props
* @return \Kirby\Cms\File
*/
public function createFile(array $props, bool $move = false): File
public function createFile(array $props)
{
$props = array_merge($props, [
'parent' => $this,
'url' => null
]);
return File::create($props, $move);
return File::create($props);
}
/**
* Filters the Files collection by type documents
*
* @return \Kirby\Cms\Files
*/
public function documents(): Files
public function documents()
{
return $this->files()->filter('type', '==', 'document');
}
/**
* Returns a specific file by filename or the first one
*
* @param string|null $filename
* @param string $in
* @return \Kirby\Cms\File|null
*/
public function file(
string $filename = null,
string $in = 'files'
): File|null {
public function file(string $filename = null, string $in = 'files')
{
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 (strpos($filename, '/') !== false) {
$path = dirname($filename);
$filename = basename($filename);
@@ -91,10 +106,12 @@ trait HasFiles
/**
* Returns the Files collection
*
* @return \Kirby\Cms\Files
*/
public function files(): Files
public function files()
{
if ($this->files instanceof Files) {
if (is_a($this->files, 'Kirby\Cms\Files') === true) {
return $this->files;
}
@@ -103,6 +120,8 @@ trait HasFiles
/**
* Checks if the Files collection has any audio files
*
* @return bool
*/
public function hasAudio(): bool
{
@@ -111,6 +130,8 @@ trait HasFiles
/**
* Checks if the Files collection has any code files
*
* @return bool
*/
public function hasCode(): bool
{
@@ -119,6 +140,8 @@ trait HasFiles
/**
* Checks if the Files collection has any document files
*
* @return bool
*/
public function hasDocuments(): bool
{
@@ -127,6 +150,8 @@ trait HasFiles
/**
* Checks if the Files collection has any files
*
* @return bool
*/
public function hasFiles(): bool
{
@@ -135,6 +160,8 @@ trait HasFiles
/**
* Checks if the Files collection has any images
*
* @return bool
*/
public function hasImages(): bool
{
@@ -143,6 +170,8 @@ trait HasFiles
/**
* Checks if the Files collection has any videos
*
* @return bool
*/
public function hasVideos(): bool
{
@@ -151,16 +180,21 @@ trait HasFiles
/**
* Returns a specific image by filename or the first one
*
* @param string|null $filename
* @return \Kirby\Cms\File|null
*/
public function image(string $filename = null): File|null
public function image(string $filename = null)
{
return $this->file($filename, 'images');
}
/**
* Filters the Files collection by type image
*
* @return \Kirby\Cms\Files
*/
public function images(): Files
public function images()
{
return $this->files()->filter('type', '==', 'image');
}
@@ -168,9 +202,10 @@ trait HasFiles
/**
* Sets the Files collection
*
* @param \Kirby\Cms\Files|null $files
* @return $this
*/
protected function setFiles(array $files = null): static
protected function setFiles(array $files = null)
{
if ($files !== null) {
$this->files = Files::factory($files, $this);
@@ -181,8 +216,10 @@ trait HasFiles
/**
* Filters the Files collection by type videos
*
* @return \Kirby\Cms\Files
*/
public function videos(): Files
public function videos()
{
return $this->files()->filter('type', '==', 'video');
}

View File

@@ -2,7 +2,6 @@
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\BadMethodCallException;
/**
@@ -18,17 +17,22 @@ trait HasMethods
{
/**
* All registered methods
*
* @var array
*/
public static array $methods = [];
public static $methods = [];
/**
* Calls a registered method class with the
* passed arguments
* @internal
*
* @internal
* @param string $method
* @param array $args
* @return mixed
* @throws \Kirby\Exception\BadMethodCallException
*/
public function callMethod(string $method, array $args = []): mixed
public function callMethod(string $method, array $args = [])
{
$closure = $this->getMethod($method);
@@ -41,7 +45,10 @@ trait HasMethods
/**
* Checks if the object has a registered method
*
* @internal
* @param string $method
* @return bool
*/
public function hasMethod(string $method): bool
{
@@ -52,8 +59,11 @@ trait HasMethods
* Returns a registered method by name, either from
* the current class or from a parent class ordered by
* inheritance order (top to bottom)
*
* @param string $method
* @return \Closure|null
*/
protected function getMethod(string $method): Closure|null
protected function getMethod(string $method)
{
if (isset(static::$methods[$method]) === true) {
return static::$methods[$method];

View File

@@ -18,26 +18,31 @@ trait HasSiblings
* Returns the position / index in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return int
*/
public function indexOf($collection = null): int|false
public function indexOf($collection = null): int
{
$collection ??= $this->siblingsCollection();
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->indexOf($this);
}
/**
* 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 \Kirby\Cms\Collection|null $collection
*
* @return static|null
* @return \Kirby\Cms\Model|null
*/
public function next($collection = null)
{
$collection ??= $this->siblingsCollection();
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->nth($this->indexOf($collection) + 1);
}
@@ -50,23 +55,26 @@ trait HasSiblings
*/
public function nextAll($collection = null)
{
$collection ??= $this->siblingsCollection();
if ($collection === null) {
$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 \Kirby\Cms\Collection|null $collection
*
* @return static|null
* @return \Kirby\Cms\Model|null
*/
public function prev($collection = null)
{
$collection ??= $this->siblingsCollection();
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->nth($this->indexOf($collection) - 1);
}
@@ -79,13 +87,17 @@ trait HasSiblings
*/
public function prevAll($collection = null)
{
$collection ??= $this->siblingsCollection();
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->slice(0, $this->indexOf($collection));
}
/**
* Returns all sibling elements
*
* @param bool $self
* @return \Kirby\Cms\Collection
*/
public function siblings(bool $self = true)
@@ -103,6 +115,8 @@ trait HasSiblings
* Checks if there's a next item in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasNext($collection = null): bool
{
@@ -113,6 +127,8 @@ trait HasSiblings
* Checks if there's a previous item in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasPrev($collection = null): bool
{
@@ -123,10 +139,15 @@ trait HasSiblings
* Checks if the item is the first in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function isFirst($collection = null): bool
{
$collection ??= $this->siblingsCollection();
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->first()->is($this);
}
@@ -134,10 +155,15 @@ trait HasSiblings
* Checks if the item is the last in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function isLast($collection = null): bool
{
$collection ??= $this->siblingsCollection();
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->last()->is($this);
}
@@ -145,6 +171,9 @@ trait HasSiblings
* Checks if the item is at a certain position
*
* @param \Kirby\Cms\Collection|null $collection
* @param int $n
*
* @return bool
*/
public function isNth(int $n, $collection = null): bool
{

View File

@@ -18,82 +18,33 @@ use Kirby\Toolkit\Str;
*/
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 $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,
// Passing a single space as value to `Xml::attr()` has been
// deprecated. In a future version, passing a single space won't
// render an empty value anymore but a single space.
// To render an empty value, please pass an empty string.
'xml-attr-single-space' => 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
* @param string $message
* @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;
public static function deprecated(string $message): bool
{
if (App::instance()->option('debug') === true) {
return trigger_error($message, E_USER_DEPRECATED) === true;
}
return trigger_error($message, E_USER_DEPRECATED) === true;
return false;
}
/**
* Simple object and variable dumper
* to help with debugging.
*
* @param mixed $variable
* @param bool $echo
* @return string
*/
public static function dump(mixed $variable, bool $echo = true): string
public static function dump($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;
$kirby = App::instance();
return ($kirby->component('dump'))($kirby, $variable, $echo);
}
/**
@@ -102,56 +53,36 @@ class Helpers
* @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`
* @param \Closure $handler Custom callback like for `set_error_handler()`;
* the first argument is a return value override passed
* by reference, the additional arguments come from
* `set_error_handler()`; returning `false` activates
* error handling by Whoops and/or PHP
* @return mixed Return value of the `$action` closure, possibly overridden by `$handler`
*/
public static function handleErrors(
Closure $action,
Closure $condition,
$fallback = null
) {
$override = null;
public static function handleErrors(Closure $action, Closure $handler)
{
$override = $oldHandler = null;
$oldHandler = set_error_handler(function () use (&$override, &$oldHandler, $handler) {
$handlerResult = $handler($override, ...func_get_args());
/**
* @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) {
if ($handlerResult === false) {
// handle other warnings with Whoops if loaded
if (is_callable($handler) === true) {
return $handler(...func_get_args());
if (is_callable($oldHandler) === true) {
return $oldHandler(...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();
}
$result = $action();
restore_error_handler();
return $override ?? $result;
}
@@ -162,6 +93,7 @@ class Helpers
* @internal
*
* @param string $name Name of the helper
* @return bool
*/
public static function hasOverride(string $name): bool
{
@@ -173,9 +105,11 @@ class Helpers
* Determines the size/length of numbers,
* strings, arrays and countable objects
*
* @param mixed $value
* @return int
* @throws \Kirby\Exception\InvalidArgumentException
*/
public static function size(mixed $value): int
public static function size($value): int
{
if (is_numeric($value)) {
return (int)$value;

View File

@@ -23,20 +23,11 @@ class Html extends \Kirby\Toolkit\Html
* @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
* @param string|array $options Pass an array of attributes for the link tag or a media attribute string
* @return string|null
*/
public static function css(
string|array|Plugin|PluginAssets $url,
string|array|null $options = null
): string|null {
if ($url instanceof Plugin) {
$url = $url->assets();
}
if ($url instanceof PluginAssets) {
$url = $url->css()->values(fn ($asset) => $asset->url());
}
public static function css($url, $options = null): ?string
{
if (is_array($url) === true) {
$links = A::map($url, fn ($url) => static::css($url, $options));
return implode(PHP_EOL, $links);
@@ -54,8 +45,7 @@ class Html extends \Kirby\Toolkit\Html
}
}
// only valid value for 'rel' is 'alternate stylesheet',
// if 'title' is given as well
// only valid value for 'rel' is 'alternate stylesheet', if 'title' is given as well
if (
($options['rel'] ?? '') !== 'alternate stylesheet' ||
($options['title'] ?? '') === ''
@@ -78,31 +68,23 @@ class Html extends \Kirby\Toolkit\Html
* @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.
* @return string
*/
public static function link(
string|null $href = null,
string|array $text = null,
array $attr = []
): string {
public static function link(string $href = 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
*
* @param string|array $url
* @param string|array $options
* @return string|null
*/
public static function js(
string|array|Plugin|PluginAssets $url,
string|array|bool|null $options = null
): string|null {
if ($url instanceof Plugin) {
$url = $url->assets();
}
if ($url instanceof PluginAssets) {
$url = $url->js()->values(fn ($asset) => $asset->url());
}
public static function js($url, $options = null): ?string
{
if (is_array($url) === true) {
$scripts = A::map($url, fn ($url) => static::js($url, $options));
return implode(PHP_EOL, $scripts);
@@ -131,12 +113,15 @@ class Html extends \Kirby\Toolkit\Html
* Includes an SVG file by absolute or
* relative file path.
* @since 3.7.0
*
* @param string|\Kirby\Cms\File $file
* @return string|false
*/
public static function svg(string|File $file): string|false
public static function svg($file)
{
// support for Kirby's file objects
if (
$file instanceof File &&
is_a($file, 'Kirby\Cms\File') === true &&
$file->extension() === 'svg'
) {
return $file->read();

View File

@@ -2,8 +2,6 @@
namespace Kirby\Cms;
use Closure;
/**
* The Ingredients class is the foundation for
* `$kirby->urls()` and `$kirby->roots()` objects.
@@ -25,6 +23,8 @@ class Ingredients
/**
* Creates a new ingredient collection
*
* @param array $ingredients
*/
public function __construct(array $ingredients)
{
@@ -33,15 +33,20 @@ class Ingredients
/**
* Magic getter for single ingredients
*
* @param string $method
* @param array|null $args
* @return mixed
*/
public function __call(string $method, array $args = null): mixed
public function __call(string $method, array $args = null)
{
return $this->ingredients[$method] ?? null;
}
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*
* @return array
*/
public function __debugInfo(): array
{
@@ -50,6 +55,9 @@ class Ingredients
/**
* Get a single ingredient by key
*
* @param string $key
* @return mixed
*/
public function __get(string $key)
{
@@ -59,12 +67,15 @@ class Ingredients
/**
* Resolves all ingredient callbacks
* and creates a plain array
*
* @internal
* @param array $ingredients
* @return static
*/
public static function bake(array $ingredients): static
public static function bake(array $ingredients)
{
foreach ($ingredients as $name => $ingredient) {
if ($ingredient instanceof Closure) {
if (is_a($ingredient, 'Closure') === true) {
$ingredients[$name] = $ingredient($ingredients);
}
}
@@ -74,6 +85,8 @@ class Ingredients
/**
* Returns all ingredients as plain array
*
* @return array
*/
public function toArray(): array
{

View File

@@ -2,7 +2,6 @@
namespace Kirby\Cms;
use Kirby\Content\Field;
use Kirby\Toolkit\Str;
/**
@@ -25,46 +24,58 @@ class Item
{
use HasSiblings;
public const ITEMS_CLASS = Items::class;
public const ITEMS_CLASS = '\Kirby\Cms\Items';
protected Field|null $field;
/**
* @var string
*/
protected $id;
protected string $id;
protected array $params;
protected ModelWithContent $parent;
protected Items $siblings;
/**
* @var array
*/
protected $params;
/**
* @var \Kirby\Cms\Page|\Kirby\Cms\Site|\Kirby\Cms\File|\Kirby\Cms\User
*/
protected $parent;
/**
* @var \Kirby\Cms\Items
*/
protected $siblings;
/**
* Creates a new item
*
* @param array $params
*/
public function __construct(array $params = [])
{
$class = static::ITEMS_CLASS;
$siblingsClass = 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();
$this->siblings = $params['siblings'] ?? new $siblingsClass();
}
/**
* Static Item factory
*
* @param array $params
* @return \Kirby\Cms\Item
*/
public static function factory(array $params): static
public static function factory(array $params)
{
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)
*
* @return string
*/
public function id(): string
{
@@ -73,6 +84,9 @@ class Item
/**
* Compares the item to another one
*
* @param \Kirby\Cms\Item $item
* @return bool
*/
public function is(Item $item): bool
{
@@ -81,16 +95,20 @@ class Item
/**
* Returns the Kirby instance
*
* @return \Kirby\Cms\App
*/
public function kirby(): App
public function kirby()
{
return $this->parent()->kirby();
}
/**
* Returns the parent model
*
* @return \Kirby\Cms\Page|\Kirby\Cms\Site|\Kirby\Cms\File|\Kirby\Cms\User
*/
public function parent(): ModelWithContent
public function parent()
{
return $this->parent;
}
@@ -99,15 +117,18 @@ class Item
* Returns the sibling collection
* This is required by the HasSiblings trait
*
* @return \Kirby\Cms\Items
* @psalm-return self::ITEMS_CLASS
*/
protected function siblingsCollection(): Items
protected function siblingsCollection()
{
return $this->siblings;
}
/**
* Converts the item to an array
*
* @return array
*/
public function toArray(): array
{

View File

@@ -3,8 +3,7 @@
namespace Kirby\Cms;
use Closure;
use Kirby\Content\Field;
use Kirby\Exception\InvalidArgumentException;
use Exception;
/**
* A collection of items
@@ -18,27 +17,28 @@ use Kirby\Exception\InvalidArgumentException;
*/
class Items extends Collection
{
public const ITEM_CLASS = Item::class;
protected Field|null $field;
public const ITEM_CLASS = '\Kirby\Cms\Item';
/**
* All registered items methods
* @var array
*/
public static array $methods = [];
protected array $options;
protected $options;
/**
* @var \Kirby\Cms\ModelWithContent
*/
protected $parent;
/**
* Constructor
*
* @param array $objects
* @param array $options
*/
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);
}
@@ -46,52 +46,49 @@ class Items extends Collection
/**
* Creates a new item collection from a
* an array of item props
*
* @param array $items
* @param array $params
* @return \Kirby\Cms\Items
*/
public static function factory(
array $items = null,
array $params = []
): static {
public static function factory(array $items = null, array $params = [])
{
$options = array_merge([
'options' => [],
'parent' => App::instance()->site(),
], $params);
if (empty($items) === true || is_array($items) === false) {
return new static();
}
if (is_array($params) === false) {
throw new InvalidArgumentException('Invalid item options');
if (is_array($options) === false) {
throw new Exception('Invalid item options');
}
// create a new collection of blocks
$collection = new static([], $params);
$collection = new static([], $options);
foreach ($items as $item) {
if (is_array($item) === false) {
throw new InvalidArgumentException('Invalid data for ' . static::ITEM_CLASS);
foreach ($items as $params) {
if (is_array($params) === false) {
continue;
}
// inject properties from the parent
$item['field'] = $collection->field();
$item['options'] = $params['options'] ?? [];
$item['parent'] = $collection->parent();
$item['siblings'] = $collection;
$item['params'] = $item;
$params['options'] = $options['options'];
$params['parent'] = $options['parent'];
$params['siblings'] = $collection;
$class = static::ITEM_CLASS;
$item = $class::factory($item);
$item = $class::factory($params);
$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
*
* @return array
*/
public function toArray(Closure $map = null): array
{

View File

@@ -4,8 +4,6 @@ namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\PermissionException;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Locale;
@@ -28,54 +26,80 @@ use Throwable;
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Language
class Language extends Model
{
use HasSiblings;
/**
* @var string
*/
protected $code;
/**
* The parent Kirby instance
* @var bool
*/
public static App|null $kirby;
protected $default;
protected string $code;
protected bool $default;
protected string $direction;
protected array $locale;
protected string $name;
protected array $slugs;
protected array $smartypants;
protected array $translations;
protected string|null $url;
/**
* @var string
*/
protected $direction;
/**
* @var array
*/
protected $locale;
/**
* @var string
*/
protected $name;
/**
* @var array|null
*/
protected $slugs;
/**
* @var array|null
*/
protected $smartypants;
/**
* @var array|null
*/
protected $translations;
/**
* @var string
*/
protected $url;
/**
* Creates a new language object
*
* @param array $props
*/
public function __construct(array $props)
{
if (isset($props['code']) === false) {
throw new InvalidArgumentException('The property "code" is required');
}
$this->setRequiredProperties($props, [
'code'
]);
static::$kirby = $props['kirby'] ?? null;
$this->code = trim($props['code']);
$this->default = ($props['default'] ?? false) === true;
$this->direction = ($props['direction'] ?? null) === 'rtl' ? 'rtl' : 'ltr';
$this->name = trim($props['name'] ?? $this->code);
$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];
}
$this->setOptionalProperties($props, [
'default',
'direction',
'locale',
'name',
'slugs',
'smartypants',
'translations',
'url',
]);
}
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*
* @return array
*/
public function __debugInfo(): array
{
@@ -85,6 +109,8 @@ class Language
/**
* Returns the language code
* when the language is converted to a string
*
* @return string
*/
public function __toString(): string
{
@@ -94,6 +120,8 @@ class Language
/**
* Returns the base Url for the language
* without the path or other cruft
*
* @return string
*/
public function baseUrl(): string
{
@@ -111,29 +139,12 @@ class Language
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.
*
* @return string
*/
public function code(): string
{
@@ -141,10 +152,54 @@ class Language
}
/**
* Creates a new language object
* @internal
* Internal converter to create or remove
* translation files.
*
* @param string $from
* @param string $to
* @return bool
*/
public static function create(array $props): static
protected static function converter(string $from, string $to): bool
{
$kirby = App::instance();
$site = $kirby->site();
// convert site
foreach ($site->files() as $file) {
F::move($file->contentFile($from, true), $file->contentFile($to, true));
}
F::move($site->contentFile($from, true), $site->contentFile($to, true));
// convert all pages
foreach ($kirby->site()->index(true) as $page) {
foreach ($page->files() as $file) {
F::move($file->contentFile($from, true), $file->contentFile($to, true));
}
F::move($page->contentFile($from, true), $page->contentFile($to, true));
}
// convert all users
foreach ($kirby->users() as $user) {
foreach ($user->files() as $file) {
F::move($file->contentFile($from, true), $file->contentFile($to, true));
}
F::move($user->contentFile($from, true), $user->contentFile($to, true));
}
return true;
}
/**
* Creates a new language object
*
* @internal
* @param array $props
* @return static
*/
public static function create(array $props)
{
$kirby = App::instance();
$user = $kirby->user();
@@ -157,6 +212,7 @@ class Language
}
$props['code'] = Str::slug($props['code'] ?? null);
$kirby = App::instance();
$languages = $kirby->languages();
// make the first language the default language
@@ -166,40 +222,17 @@ class Language
$language = new static($props);
// trigger before hook
$kirby->trigger(
'language.create:before',
[
'input' => $props,
'language' => $language
]
);
// validate the new language
LanguageRules::create($language);
$language->save();
if ($languages->count() === 0) {
foreach ($kirby->models() as $model) {
$model->storage()->convertLanguage(
'default',
$language->code()
);
}
static::converter('', $language->code());
}
// update the main languages collection in the app instance
$kirby->languages(false)->append($language->code(), $language);
// trigger after hook
$kirby->trigger(
'language.create:after',
[
'input' => $props,
'language' => $language
]
);
App::instance()->languages(false)->append($language->code(), $language);
return $language;
}
@@ -207,15 +240,18 @@ class Language
/**
* Delete the current language and
* all its translation files
* @internal
*
* @internal
* @return bool
* @throws \Kirby\Exception\Exception
*/
public function delete(): bool
{
$kirby = App::instance();
$user = $kirby->user();
$code = $this->code();
$kirby = App::instance();
$user = $kirby->user();
$languages = $kirby->languages();
$code = $this->code();
$isLast = $languages->count() === 1;
if (
$user === null ||
@@ -224,40 +260,59 @@ class Language
throw new PermissionException(['key' => 'language.delete.permission']);
}
if ($this->isDeletable() === false) {
throw new Exception('The language cannot be deleted');
}
// trigger before hook
$kirby->trigger('language.delete:before', [
'language' => $this
]);
if (F::remove($this->root()) !== true) {
throw new Exception('The language could not be deleted');
}
foreach ($kirby->models() as $model) {
if ($this->isLast() === true) {
$model->storage()->convertLanguage($code, 'default');
} else {
$model->storage()->deleteLanguage($code);
}
if ($isLast === true) {
$this->converter($code, '');
} else {
$this->deleteContentFiles($code);
}
// get the original language collection and remove the current language
$kirby->languages(false)->remove($code);
// trigger after hook
$kirby->trigger('language.delete:after', [
'language' => $this
]);
return true;
}
/**
* When the language is deleted, all content files with
* the language code must be removed as well.
*
* @param mixed $code
* @return bool
*/
protected function deleteContentFiles($code): bool
{
$kirby = App::instance();
$site = $kirby->site();
F::remove($site->contentFile($code, true));
foreach ($kirby->site()->index(true) as $page) {
foreach ($page->files() as $file) {
F::remove($file->contentFile($code, true));
}
F::remove($page->contentFile($code, true));
}
foreach ($kirby->users() as $user) {
foreach ($user->files() as $file) {
F::remove($file->contentFile($code, true));
}
F::remove($user->contentFile($code, true));
}
return true;
}
/**
* Reading direction of this language
*
* @return string
*/
public function direction(): string
{
@@ -266,6 +321,8 @@ class Language
/**
* Check if the language file exists
*
* @return bool
*/
public function exists(): bool
{
@@ -275,54 +332,31 @@ class Language
/**
* Checks if this is the default language
* for the site.
*
* @return bool
*/
public function isDefault(): bool
{
return $this->default;
}
/**
* Checks if the language can be deleted
*/
public function isDeletable(): bool
{
// 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;
}
/**
* The id is required for collections
* to work properly. The code is used as id
*
* @return string
*/
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
*
* @param string $code
*/
public static function loadRules(string $code): array
public static function loadRules(string $code)
{
$kirby = App::instance();
$code = Str::contains($code, '.') ? Str::before($code, '.') : $code;
@@ -334,7 +368,7 @@ class Language
try {
return Data::read($file);
} catch (\Exception) {
} catch (\Exception $e) {
return [];
}
}
@@ -343,19 +377,22 @@ class Language
* Returns the PHP locale setting array
*
* @param int $category If passed, returns the locale for the specified category (e.g. LC_ALL) as string
* @return array|string
*/
public function locale(int $category = null): array|string|null
public function locale(int $category = null)
{
if ($category !== null) {
return $this->locale[$category] ?? $this->locale[LC_ALL] ?? null;
} else {
return $this->locale;
}
return $this->locale;
}
/**
* Returns the human-readable name
* of the language
*
* @return string
*/
public function name(): string
{
@@ -364,6 +401,8 @@ class Language
/**
* Returns the URL path for the language
*
* @return string
*/
public function path(): string
{
@@ -376,6 +415,8 @@ class Language
/**
* Returns the routing pattern for the language
*
* @return string
*/
public function pattern(): string
{
@@ -390,6 +431,8 @@ class Language
/**
* Returns the absolute path to the language file
*
* @return string
*/
public function root(): string
{
@@ -400,15 +443,19 @@ class Language
* Returns the LanguageRouter instance
* which is used to handle language specific
* routes.
*
* @return \Kirby\Cms\LanguageRouter
*/
public function router(): LanguageRouter
public function router()
{
return new LanguageRouter($this);
}
/**
* Get slug rules for language
*
* @internal
* @return array
*/
public function rules(): array
{
@@ -419,15 +466,15 @@ class Language
/**
* Saves the language settings in the languages folder
* @internal
*
* @internal
* @return $this
*/
public function save(): static
public function save()
{
try {
$existingData = Data::read($this->root());
} catch (Throwable) {
} catch (Throwable $e) {
$existingData = [];
}
@@ -451,15 +498,104 @@ class Language
}
/**
* Private siblings collector
* @param string $code
* @return $this
*/
protected function siblingsCollection(): Collection
protected function setCode(string $code)
{
return App::instance()->languages();
$this->code = trim($code);
return $this;
}
/**
* @param bool $default
* @return $this
*/
protected function setDefault(bool $default = false)
{
$this->default = $default;
return $this;
}
/**
* @param string $direction
* @return $this
*/
protected function setDirection(string $direction = 'ltr')
{
$this->direction = $direction === 'rtl' ? 'rtl' : 'ltr';
return $this;
}
/**
* @param string|array $locale
* @return $this
*/
protected function setLocale($locale = null)
{
if ($locale === null) {
$this->locale = [LC_ALL => $this->code];
} else {
$this->locale = Locale::normalize($locale);
}
return $this;
}
/**
* @param string $name
* @return $this
*/
protected function setName(string $name = null)
{
$this->name = trim($name ?? $this->code);
return $this;
}
/**
* @param array $slugs
* @return $this
*/
protected function setSlugs(array $slugs = null)
{
$this->slugs = $slugs ?? [];
return $this;
}
/**
* @param array $smartypants
* @return $this
*/
protected function setSmartypants(array $smartypants = null)
{
$this->smartypants = $smartypants ?? [];
return $this;
}
/**
* @param array $translations
* @return $this
*/
protected function setTranslations(array $translations = null)
{
$this->translations = $translations ?? [];
return $this;
}
/**
* @param string $url
* @return $this
*/
protected function setUrl(string $url = null)
{
$this->url = $url;
return $this;
}
/**
* Returns the custom slug rules for this language
*
* @return array
*/
public function slugs(): array
{
@@ -468,6 +604,8 @@ class Language
/**
* Returns the custom SmartyPants options for this language
*
* @return array
*/
public function smartypants(): array
{
@@ -477,6 +615,8 @@ class Language
/**
* Returns the most important
* properties as array
*
* @return array
*/
public function toArray(): array
{
@@ -493,6 +633,8 @@ class Language
/**
* Returns the translation strings for this language
*
* @return array
*/
public function translations(): array
{
@@ -501,19 +643,28 @@ class Language
/**
* Returns the absolute Url for the language
*
* @return string
*/
public function url(): string
{
$url = $this->url;
$url ??= '/' . $this->code;
$url = $this->url;
if ($url === null) {
$url = '/' . $this->code;
}
return Url::makeAbsolute($url, $this->kirby()->url());
}
/**
* Update language properties and save them
*
* @internal
* @param array $props
* @return static
*/
public function update(array $props = null): static
public function update(array $props = null)
{
$kirby = App::instance();
$user = $kirby->user();
@@ -533,67 +684,38 @@ class Language
$updated = $this->clone($props);
if (isset($props['translations']) === true) {
$updated->translations = $props['translations'];
}
// validate the updated language
LanguageRules::update($updated);
// trigger before hook
$kirby->trigger('language.update:before', [
'language' => $this,
'input' => $props
]);
// if language just got promoted to be the new default language…
if ($this->isDefault() === false && $updated->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);
// convert the current default to a non-default language
if ($updated->isDefault() === true) {
if ($oldDefault = $kirby->defaultLanguage()) {
$oldDefault->clone(['default' => false])->save();
}
}
// if language was the default language and got demoted…
if (
$this->isDefault() === true &&
$updated->isDefault() === false &&
$kirby->defaultLanguage()->code() === $this->code()
) {
// ensure another language has already been set as default
throw new LogicException('Please select another language to be the primary language');
$code = $this->code();
$site = $kirby->site();
touch($site->contentFile($code));
foreach ($kirby->site()->index(true) as $page) {
$files = $page->files();
foreach ($files as $file) {
touch($file->contentFile($code));
}
touch($page->contentFile($code));
}
} elseif ($this->isDefault() === true) {
throw new PermissionException('Please select another language to be the primary language');
}
$language = $updated->save();
// make sure the language is also updated in the languages collection
$kirby->languages(false)->set($language->code(), $language);
// trigger after hook
$kirby->trigger('language.update:after', [
'newLanguage' => $language,
'oldLanguage' => $this,
'input' => $props
]);
// make sure the language is also updated in the Kirby language collection
App::instance()->languages(false)->set($language->code(), $language);
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($this, $key);
}
}

View File

@@ -20,21 +20,36 @@ use Kirby\Toolkit\Str;
*/
class LanguageRouter
{
protected Router $router;
/**
* The parent language
*
* @var Language
*/
protected $language;
/**
* The router instance
*
* @var Router
*/
protected $router;
/**
* Creates a new language router instance
* for the given language
*
* @param \Kirby\Cms\Language $language
*/
public function __construct(
protected Language $language
) {
public function __construct(Language $language)
{
$this->language = $language;
}
/**
* Fetches all scoped routes for the
* current language from the Kirby instance
*
* @return array
* @throws \Kirby\Exception\NotFoundException
*/
public function routes(): array
@@ -91,34 +106,28 @@ class LanguageRouter
* Wrapper around the Router::call method
* that injects the Language instance and
* if needed also the Page as arguments.
*
* @param string|null $path
* @return mixed
*/
public function call(string|null $path = null): mixed
public function call(string $path = null)
{
$language = $this->language;
$kirby = $language->kirby();
$this->router ??= new Router($this->routes());
$language = $this->language;
$kirby = $language->kirby();
$router = new Router($this->routes());
try {
return $this->router->call($path, $kirby->request()->method(), function ($route) use ($kirby, $language) {
return $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, $page, ...$route->arguments());
} else {
return $route->action()->call($route, $language, ...$route->arguments());
}
return $route->action()->call(
$route,
$language,
...$route->arguments()
);
});
} catch (Exception) {
} catch (Exception $e) {
return $kirby->resolve($path, $language->code());
}
}

View File

@@ -8,6 +8,9 @@ class LanguageRoutes
{
/**
* Creates all multi-language routes
*
* @param \Kirby\Cms\App $kirby
* @return array
*/
public static function create(App $kirby): array
{
@@ -30,11 +33,7 @@ class LanguageRoutes
'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) {
if ($result = $language->router()->call($path)) {
return $result;
}
@@ -55,6 +54,9 @@ class LanguageRoutes
/**
* Create the fallback route
* for unprefixed default language URLs.
*
* @param \Kirby\Cms\App $kirby
* @return array
*/
public static function fallback(App $kirby): array
{
@@ -67,10 +69,7 @@ class LanguageRoutes
$extension = F::extension($path);
// try to redirect prefixed pages
if (
empty($extension) === true &&
$page = $kirby->page($path)
) {
if (empty($extension) === true && $page = $kirby->page($path)) {
$url = $kirby->request()->url([
'query' => null,
'params' => null,
@@ -78,17 +77,15 @@ class LanguageRoutes
]);
if ($url->toString() !== $page->url()) {
// redirect to translated page directly if translation
// is exists and languages detect is enabled
$lang = $kirby->detectedLanguage()->code();
// redirect to translated page directly
// if translation is exists and languages detect is enabled
if (
$kirby->option('languages.detect') === true &&
$page->translation($lang)->exists() === true
$page->translation($kirby->detectedLanguage()->code())->exists() === true
) {
return $kirby
->response()
->redirect($page->url($lang));
->redirect($page->url($kirby->detectedLanguage()->code()));
}
return $kirby
@@ -104,6 +101,9 @@ class LanguageRoutes
/**
* Create the multi-language home page route
*
* @param \Kirby\Cms\App $kirby
* @return array
*/
public static function home(App $kirby): array
{
@@ -114,10 +114,7 @@ class LanguageRoutes
'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()
);
$languages = $kirby->languages()->filter('baseurl', $kirby->url());
// if there's no language with a matching base url,
// redirect to the default language
@@ -127,8 +124,7 @@ class LanguageRoutes
->redirect($kirby->defaultLanguage()->url());
}
// if there's just one language,
// we take that to render the home page
// if there's just one language, we take that to render the home page
if ($languages->count() === 1) {
$currentLanguage = $languages->first();
} else {

View File

@@ -20,6 +20,8 @@ class LanguageRules
/**
* Validates if the language can be created
*
* @param \Kirby\Cms\Language $language
* @return bool
* @throws \Kirby\Exception\DuplicateException If the language already exists
*/
public static function create(Language $language): bool
@@ -41,8 +43,10 @@ class LanguageRules
/**
* Validates if the language can be updated
*
* @param \Kirby\Cms\Language $language
*/
public static function update(Language $language): void
public static function update(Language $language)
{
static::validLanguageCode($language);
static::validLanguageName($language);
@@ -51,6 +55,8 @@ class LanguageRules
/**
* Validates if the language code is formatted correctly
*
* @param \Kirby\Cms\Language $language
* @return bool
* @throws \Kirby\Exception\InvalidArgumentException If the language code is not valid
*/
public static function validLanguageCode(Language $language): bool
@@ -71,6 +77,8 @@ class LanguageRules
/**
* Validates if the language name is formatted correctly
*
* @param \Kirby\Cms\Language $language
* @return bool
* @throws \Kirby\Exception\InvalidArgumentException If the language name is invalid
*/
public static function validLanguageName(Language $language): bool

View File

@@ -1,122 +0,0 @@
<?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|null $value = null
): static {
if (is_numeric($key) === true) {
throw new InvalidArgumentException('The variable key must not be numeric');
}
if (empty($key) === true) {
throw new InvalidArgumentException('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('The variable already exists');
}
throw new DuplicateException('The variable is part of the core translation and cannot be overwritten');
}
$translations[$key] = trim($value ?? '');
$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;
}
/**
* Returns the unique key for the variable
*/
public function key(): string
{
return $this->key;
}
/**
* Sets a new value for the language variable
*/
public function update(string $value): 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|null
{
return $this->language->translations()[$this->key] ?? null;
}
}

View File

@@ -16,21 +16,15 @@ use Kirby\Filesystem\F;
*/
class Languages extends Collection
{
/**
* All registered languages methods
*/
public static array $methods = [];
/**
* Creates a new collection with the given language objects
*
* @param array $objects `Kirby\Cms\Language` objects
* @param null $parent
* @throws \Kirby\Exception\DuplicateException
*/
public function __construct(
array $objects = [],
$parent = null
) {
public function __construct($objects = [], $parent = null)
{
$defaults = array_filter(
$objects,
fn ($language) => $language->isDefault() === true
@@ -40,45 +34,58 @@ class Languages extends Collection
throw new DuplicateException('You cannot have multiple default languages. Please check your language config files.');
}
parent::__construct($objects, null);
parent::__construct($objects, $parent);
}
/**
* Returns all language codes as array
*
* @return array
*/
public function codes(): array
{
return App::instance()->multilang() ? $this->keys() : ['default'];
return $this->keys();
}
/**
* Creates a new language with the given props
*
* @internal
* @param array $props
* @return \Kirby\Cms\Language
*/
public function create(array $props): Language
public function create(array $props)
{
return Language::create($props);
}
/**
* Returns the default language
*
* @return \Kirby\Cms\Language|null
*/
public function default(): Language|null
public function default()
{
return $this->findBy('isDefault', true) ?? $this->first();
if ($language = $this->findBy('isDefault', true)) {
return $language;
} else {
return $this->first();
}
}
/**
* Convert all defined languages to a collection
*
* @internal
* @return static
*/
public static function load(): static
public static function load()
{
$languages = [];
$files = glob(App::instance()->root('languages') . '/*.php');
foreach ($files as $file) {
$props = F::load($file, allowOutput: false);
$props = F::load($file);
if (is_array($props) === true) {
// inject the language code from the filename

View File

@@ -2,8 +2,6 @@
namespace Kirby\Cms;
use Kirby\Content\Content;
/**
* Represents a single Layout with
* multiple columns
@@ -19,15 +17,26 @@ class Layout extends Item
{
use HasMethods;
public const ITEMS_CLASS = Layouts::class;
public const ITEMS_CLASS = '\Kirby\Cms\Layouts';
protected Content $attrs;
protected LayoutColumns $columns;
/**
* @var \Kirby\Cms\Content
*/
protected $attrs;
/**
* @var \Kirby\Cms\LayoutColumns
*/
protected $columns;
/**
* Proxy for attrs
*
* @param string $method
* @param array $args
* @return \Kirby\Cms\Field
*/
public function __call(string $method, array $args = []): mixed
public function __call(string $method, array $args = [])
{
// layout methods
if ($this->hasMethod($method) === true) {
@@ -39,13 +48,14 @@ class Layout extends Item
/**
* Creates a new Layout object
*
* @param array $params
*/
public function __construct(array $params = [])
{
parent::__construct($params);
$this->columns = LayoutColumns::factory($params['columns'] ?? [], [
'field' => $this->field,
'parent' => $this->parent
]);
@@ -55,16 +65,20 @@ class Layout extends Item
/**
* Returns the attrs object
*
* @return \Kirby\Cms\Content
*/
public function attrs(): Content
public function attrs()
{
return $this->attrs;
}
/**
* Returns the columns in this layout
*
* @return \Kirby\Cms\LayoutColumns
*/
public function columns(): LayoutColumns
public function columns()
{
return $this->columns;
}
@@ -72,18 +86,24 @@ class Layout extends Item
/**
* Checks if the layout is empty
* @since 3.5.2
*
* @return bool
*/
public function isEmpty(): bool
{
return $this
->columns()
->filter('isEmpty', false)
->filter(function ($column) {
return $column->isNotEmpty();
})
->count() === 0;
}
/**
* Checks if the layout is not empty
* @since 3.5.2
*
* @return bool
*/
public function isNotEmpty(): bool
{
@@ -93,6 +113,8 @@ class Layout extends Item
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{

View File

@@ -19,20 +19,28 @@ class LayoutColumn extends Item
{
use HasMethods;
public const ITEMS_CLASS = LayoutColumns::class;
public const ITEMS_CLASS = '\Kirby\Cms\LayoutColumns';
protected Blocks $blocks;
protected string $width;
/**
* @var \Kirby\Cms\Blocks
*/
protected $blocks;
/**
* @var string
*/
protected $width;
/**
* Creates a new LayoutColumn object
*
* @param array $params
*/
public function __construct(array $params = [])
{
parent::__construct($params);
$this->blocks = Blocks::factory($params['blocks'] ?? [], [
'field' => $this->field,
'parent' => $this->parent
]);
@@ -41,8 +49,12 @@ class LayoutColumn extends Item
/**
* Magic getter function
*
* @param string $method
* @param mixed $args
* @return mixed
*/
public function __call(string $method, mixed $args): mixed
public function __call(string $method, $args)
{
// layout column methods
if ($this->hasMethod($method) === true) {
@@ -54,8 +66,9 @@ class LayoutColumn extends Item
* Returns the blocks collection
*
* @param bool $includeHidden Sets whether to include hidden blocks
* @return \Kirby\Cms\Blocks
*/
public function blocks(bool $includeHidden = false): Blocks
public function blocks(bool $includeHidden = false)
{
if ($includeHidden === false) {
return $this->blocks->filter('isHidden', false);
@@ -67,6 +80,8 @@ class LayoutColumn extends Item
/**
* Checks if the column is empty
* @since 3.5.2
*
* @return bool
*/
public function isEmpty(): bool
{
@@ -79,6 +94,8 @@ class LayoutColumn extends Item
/**
* Checks if the column is not empty
* @since 3.5.2
*
* @return bool
*/
public function isNotEmpty(): bool
{
@@ -87,6 +104,9 @@ class LayoutColumn extends Item
/**
* Returns the number of columns this column spans
*
* @param int $columns
* @return int
*/
public function span(int $columns = 12): int
{
@@ -100,6 +120,8 @@ class LayoutColumn extends Item
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{
@@ -112,6 +134,8 @@ class LayoutColumn extends Item
/**
* Returns the width of the column
*
* @return string
*/
public function width(): string
{

View File

@@ -14,10 +14,5 @@ namespace Kirby\Cms;
*/
class LayoutColumns extends Items
{
public const ITEM_CLASS = LayoutColumn::class;
/**
* All registered layout columns methods
*/
public static array $methods = [];
public const ITEM_CLASS = '\Kirby\Cms\LayoutColumn';
}

View File

@@ -2,7 +2,7 @@
namespace Kirby\Cms;
use Kirby\Data\Json;
use Kirby\Data\Data;
use Kirby\Toolkit\Str;
use Throwable;
@@ -18,32 +18,14 @@ use Throwable;
*/
class Layouts extends Items
{
public const ITEM_CLASS = Layout::class;
/**
* All registered layouts methods
*/
public static array $methods = [];
public static function factory(
array $items = null,
array $params = []
): static {
// convert single layout to layouts array
if (
isset($items['columns']) === true ||
isset($items['id']) === true
) {
$items = [$items];
}
public const ITEM_CLASS = '\Kirby\Cms\Layout';
public static function factory(array $items = null, array $params = [])
{
$first = $items[0] ?? [];
// if there are no wrapping layouts for blocks yet …
if (
isset($first['content']) === true ||
isset($first['type']) === true
) {
if (array_key_exists('content', $first) === true || array_key_exists('type', $first) === true) {
$items = [
[
'id' => Str::uuid(),
@@ -63,6 +45,9 @@ class Layouts extends Items
/**
* Checks if a given block type exists in the layouts collection
* @since 3.6.0
*
* @param string $type
* @return bool
*/
public function hasBlockType(string $type): bool
{
@@ -71,16 +56,16 @@ class Layouts extends Items
/**
* Parse layouts data
*
* @param array|string $input
* @return array
*/
public static function parse(array|string|null $input): array
public static function parse($input): array
{
if (
empty($input) === false &&
is_array($input) === false
) {
if (empty($input) === false && is_array($input) === false) {
try {
$input = Json::decode((string)$input);
} catch (Throwable) {
$input = Data::decode($input, 'json');
} catch (Throwable $e) {
return [];
}
}
@@ -97,8 +82,9 @@ class Layouts extends Items
* @since 3.6.0
*
* @param bool $includeHidden Sets whether to include hidden blocks
* @return \Kirby\Cms\Blocks
*/
public function toBlocks(bool $includeHidden = false): Blocks
public function toBlocks(bool $includeHidden = false)
{
$blocks = [];
@@ -112,9 +98,6 @@ class Layouts extends Items
}
}
return Blocks::factory($blocks, [
'field' => $this->field,
'parent' => $this->parent
]);
return Blocks::factory($blocks);
}
}

View File

@@ -1,529 +0,0 @@
<?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
{
protected const HISTORY = [
'3' => '2019-02-05',
'4' => '2023-11-28'
];
protected const SALT = 'kwAHMLyLPBnHEskzH9pPbJsBxQhKXZnX';
// 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,
) {
// normalize the email address
$this->email = $this->email === null ? null : $this->normalizeEmail($this->email);
}
/**
* 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;
}
/**
* 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(App::instance()->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('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(App::instance()->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(App::instance()->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(App::instance()->root('license'));
} 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($message, $response->code());
}
return $response->json();
// @codeCoverageIgnoreEnd
}
/**
* Saves the license in the config folder
*/
public function save(): bool
{
if ($this->status()->activatable() !== true) {
throw new InvalidArgumentException([
'key' => 'license.verification'
]);
}
// where to store the license file
$file = App::instance()->root('license');
// save the license information
return Json::write($file, $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() === true => LicenseStatus::Missing,
$this->isLegacy() === true => LicenseStatus::Legacy,
$this->isInactive() === true => 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('We couldnt 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',
];
}
}

View File

@@ -1,144 +0,0 @@
<?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';
/**
* 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::Missing => 'registration',
static::Demo => null,
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::Missing => 'key',
static::Legacy => 'alert',
static::Inactive => 'clock',
static::Active => 'check',
static::Demo => 'preview',
};
}
/**
* 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::Demo,
static::Active => 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::Missing => 'love',
static::Legacy => 'negative',
static::Inactive => 'notice',
static::Active => 'positive',
static::Demo => 'notice',
};
}
/**
* Returns the status as string value
*/
public function value(): string
{
return $this->value;
}
}

View File

@@ -1,111 +0,0 @@
<?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;
}
}

View File

@@ -50,8 +50,11 @@ class Loader
/**
* Loads the area definition
*
* @param string $name
* @return array|null
*/
public function area(string $name): array|null
public function area(string $name): ?array
{
return $this->areas()[$name] ?? null;
}
@@ -59,14 +62,15 @@ class Loader
/**
* Loads all areas and makes sure that plugins
* are injected properly
*
* @return array
*/
public function areas(): array
{
$areas = [];
$extensions = $this->withPlugins === true ? $this->kirby->extensions('areas') : [];
// load core areas and extend them with elements
// from plugins if they exist
// 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);
@@ -94,14 +98,19 @@ class Loader
/**
* Loads a core component closure
*
* @param string $name
* @return \Closure|null
*/
public function component(string $name): Closure|null
public function component(string $name): ?Closure
{
return $this->extension('components', $name);
}
/**
* Loads all core component closures
*
* @return array
*/
public function components(): array
{
@@ -110,14 +119,21 @@ class Loader
/**
* Loads a particular extension
*
* @param string $type
* @param string $name
* @return mixed
*/
public function extension(string $type, string $name): mixed
public function extension(string $type, string $name)
{
return $this->extensions($type)[$name] ?? null;
}
/**
* Loads all defined extensions
*
* @param string $type
* @return array
*/
public function extensions(string $type): array
{
@@ -136,17 +152,21 @@ class Loader
*
* 3.) closures will be called and the Kirby instance will be
* passed as first argument
*
* @param mixed $item
* @return mixed
*/
public function resolve(mixed $item): mixed
public function resolve($item)
{
if (is_string($item) === true) {
$item = match (F::extension($item)) {
'php' => F::load($item, allowOutput: false),
default => Data::read($item)
};
if (F::extension($item) !== 'php') {
$item = Data::read($item);
} else {
$item = require $item;
}
}
if (is_callable($item) === true) {
if (is_callable($item)) {
$item = $item($this->kirby);
}
@@ -156,6 +176,9 @@ class Loader
/**
* Calls `static::resolve()` on all items
* in the given array
*
* @param array $items
* @return array
*/
public function resolveAll(array $items): array
{
@@ -171,8 +194,11 @@ class Loader
/**
* Areas need a bit of special treatment
* when they are being loaded
*
* @param string|array|Closure $area
* @return array
*/
public function resolveArea(string|array|Closure $area): array
public function resolveArea($area): array
{
$area = $this->resolve($area);
$dropdowns = $area['dropdowns'] ?? [];
@@ -180,7 +206,7 @@ class Loader
// convert closure dropdowns to an array definition
// otherwise they cannot be merged properly later
foreach ($dropdowns as $key => $dropdown) {
if ($dropdown instanceof Closure) {
if (is_a($dropdown, 'Closure') === true) {
$area['dropdowns'][$key] = [
'options' => $dropdown
];
@@ -192,14 +218,19 @@ class Loader
/**
* Loads a particular section definition
*
* @param string $name
* @return array|null
*/
public function section(string $name): array|null
public function section(string $name): ?array
{
return $this->resolve($this->extension('sections', $name));
}
/**
* Loads all section defintions
*
* @return array
*/
public function sections(): array
{
@@ -209,6 +240,8 @@ class Loader
/**
* Returns the status flag, which shows
* if plugins are loaded as well.
*
* @return bool
*/
public function withPlugins(): bool
{

View File

@@ -3,8 +3,6 @@
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;
@@ -25,12 +23,14 @@ class Media
/**
* Tries to find a file by model and filename
* and to copy it to the media folder.
*
* @param \Kirby\Cms\Model|null $model
* @param string $hash
* @param string $filename
* @return \Kirby\Cms\Response|false
*/
public static function link(
Page|Site|User $model = null,
string $hash,
string $filename
): Response|false {
public static function link(Model $model = null, string $hash, string $filename)
{
if ($model === null) {
return false;
}
@@ -46,10 +46,10 @@ class Media
// if at least the token was correct, redirect
if (Str::startsWith($hash, $file->mediaToken() . '-') === true) {
return Response::redirect($file->mediaUrl(), 307);
} else {
// don't leak the correct token, render the error page
return false;
}
// don't leak the correct token, render the error page
return false;
}
// send the file to the browser
@@ -57,16 +57,15 @@ class Media
}
// 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;
}
return static::thumb($model, $hash, $filename);
}
/**
* Copy the file to the final media folder location
*
* @param \Kirby\Cms\File $file
* @param string $dest
* @return bool
*/
public static function publish(File $file, string $dest): bool
{
@@ -88,74 +87,66 @@ class Media
* Tries to find a job file for the
* given filename and then calls the thumb
* component to create a thumbnail accordingly
*
* @param \Kirby\Cms\Model|string $model
* @param string $hash
* @param string $filename
* @return \Kirby\Cms\Response|false
*/
public static function thumb(
File|Page|Site|User|string $model,
string $hash,
string $filename
): Response|false {
public static function thumb($model, string $hash, string $filename)
{
$kirby = App::instance();
$root = match (true) {
// assets
is_string($model)
=> $kirby->root('media') . '/assets/' . $model . '/' . $hash,
// parent files for file model that already included hash
$model instanceof File
=> dirname($model->mediaRoot()),
// model files
default
=> $model->mediaRoot() . '/' . $hash
};
$thumb = $root . '/' . $filename;
$job = $root . '/.jobs/' . $filename . '.json';
// assets
if (is_string($model) === true) {
$root = $kirby->root('media') . '/assets/' . $model . '/' . $hash;
// parent files for file model that already included hash
} elseif (is_a($model, '\Kirby\Cms\File')) {
$root = dirname($model->mediaRoot());
// model files
} else {
$root = $model->mediaRoot() . '/' . $hash;
}
try {
$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('The thumbnail configuration could not be found');
}
if (empty($options['filename']) === true) {
throw new InvalidArgumentException('Incomplete thumbnail configuration');
}
if (empty($options) === true) {
return false;
}
try {
// find the correct source file depending on the model
// this adds support for custom assets
$source = match (true) {
is_string($model) === true
=> $kirby->root('index') . '/' . $model . '/' . $options['filename'],
default
=> $model->file($options['filename'])->root()
};
if (is_string($model) === true) {
$source = $kirby->root('index') . '/' . $model . '/' . $options['filename'];
} else {
$source = $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);
try {
$kirby->thumb($source, $thumb, $options);
F::remove($job);
return Response::file($thumb);
} catch (Throwable $e) {
F::remove($thumb);
return Response::file($source);
}
} catch (Throwable $e) {
// remove potentially broken thumbnails
F::remove($thumb);
throw $e;
return false;
}
}
/**
* Deletes all versions of the given file
* within the parent directory
*
* @param string $directory
* @param \Kirby\Cms\File $file
* @param string|null $ignore
* @return bool
*/
public static function unpublish(
string $directory,
File $file,
string|null $ignore = null
): bool {
public static function unpublish(string $directory, File $file, string $ignore = null): bool
{
if (is_dir($directory) === false) {
return true;
}

View File

@@ -5,7 +5,7 @@ namespace Kirby\Cms;
use Kirby\Toolkit\Properties;
/**
* @deprecated 4.0.0 will be removed in Kirby 5.0
* Foundation for Page, Site, File and User models.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
@@ -53,7 +53,7 @@ abstract class Model
/**
* Each model must return a unique id
*
* @return string|null
* @return string|int
*/
public function id()
{

View File

@@ -15,13 +15,28 @@ use Kirby\Toolkit\A;
*/
abstract class ModelPermissions
{
protected string $category;
protected ModelWithContent $model;
protected array $options;
protected Permissions $permissions;
protected User $user;
protected $category;
protected $model;
protected $options;
protected $permissions;
protected $user;
public function __construct(ModelWithContent $model)
/**
* @param string $method
* @param array $arguments
* @return bool
*/
public function __call(string $method, array $arguments = []): bool
{
return $this->can($method);
}
/**
* ModelPermissions constructor
*
* @param \Kirby\Cms\Model $model
*/
public function __construct(Model $model)
{
$this->model = $model;
$this->options = $model->blueprint()->options();
@@ -29,46 +44,33 @@ abstract class ModelPermissions
$this->permissions = $this->user->role()->permissions();
}
public function __call(string $method, array $arguments = []): bool
{
return $this->can($method);
}
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*
* @return array
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* @param string $action
* @return bool
*/
public function can(string $action): bool
{
$user = $this->user->id();
$role = $this->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
) {
// check for a custom overall can method
if (method_exists($this, 'can' . $action) === true && $this->{'can' . $action}() === false) {
return false;
}
// the almighty `kirby` user can do anything
if ($user === 'kirby' && $role === 'admin') {
return true;
}
// evaluate the blueprint options block
if (isset($this->options[$action]) === true) {
$options = $this->options[$action];
@@ -81,28 +83,26 @@ abstract class ModelPermissions
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['*'];
}
if (is_array($options) === true && A::isAssociative($options) === true) {
return $options[$role] ?? $options['*'] ?? false;
}
}
return $this->permissions->for($this->category, $action);
}
/**
* @param string $action
* @return bool
*/
public function cannot(string $action): bool
{
return $this->can($action) === false;
}
/**
* @return array
*/
public function toArray(): array
{
$array = [];

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,6 @@
namespace Kirby\Cms;
use Kirby\Content\Field;
/**
* The Nest class converts any array type
* into a Kirby style collection/object. This
@@ -20,10 +18,13 @@ use Kirby\Content\Field;
*/
class Nest
{
public static function create(
$data,
object|null $parent = null
): NestCollection|NestObject|Field {
/**
* @param $data
* @param null $parent
* @return mixed
*/
public static function create($data, $parent = null)
{
if (is_scalar($data) === true) {
return new Field($parent, $data, $data);
}
@@ -38,12 +39,10 @@ class Nest
}
}
$key = key($data);
if ($key === null || is_int($key) === true) {
if (is_int(key($data))) {
return new NestCollection($result);
} else {
return new NestObject($result);
}
return new NestObject($result);
}
}

View File

@@ -20,6 +20,9 @@ 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.
*
* @param \Closure|null $map
* @return array
*/
public function toArray(Closure $map = null): array
{

View File

@@ -2,7 +2,6 @@
namespace Kirby\Cms;
use Kirby\Content\Field;
use Kirby\Toolkit\Obj;
/**
@@ -18,21 +17,20 @@ class NestObject extends Obj
{
/**
* Converts the object to an array
*
* @return array
*/
public function toArray(): array
{
$result = [];
foreach ((array)$this as $key => $value) {
if ($value instanceof Field) {
if (is_a($value, 'Kirby\Cms\Field') === true) {
$result[$key] = $value->value();
continue;
}
if (
is_object($value) === true &&
method_exists($value, 'toArray')
) {
if (is_object($value) === true && method_exists($value, 'toArray')) {
$result[$key] = $value->toArray();
continue;
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,11 @@ use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Form\Form;
use Kirby\Toolkit\A;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
/**
* PageActions
@@ -26,76 +25,6 @@ use Kirby\Uuid\Uuids;
*/
trait PageActions
{
/**
* Adapts necessary modifications which page uuid, page slug and files uuid
* of copy objects for single or multilang environments
* @internal
*/
protected function adaptCopy(Page $copy, bool $files = false, bool $children = false): Page
{
if ($this->kirby()->multilang() === true) {
foreach ($this->kirby()->languages() as $language) {
// overwrite with new UUID for the page and files
// for default language (remove old, add new)
if (
Uuids::enabled() === true &&
$language->isDefault() === true
) {
$copy = $copy->save(['uuid' => Uuid::generate()], $language->code());
// regenerate UUIDs of page files
if ($files !== false) {
foreach ($copy->files() as $file) {
$file->save(['uuid' => Uuid::generate()], $language->code());
}
}
// regenerate UUIDs of all page children
if ($children !== false) {
foreach ($copy->index(true) as $child) {
// always adapt files of subpages as they are currently always copied;
// but don't adapt children because we already operate on the index
$this->adaptCopy($child, true);
}
}
}
// remove all translated slugs
if (
$language->isDefault() === false &&
$copy->translation($language)->exists() === true
) {
$copy = $copy->save(['slug' => null], $language->code());
}
}
return $copy;
}
// overwrite with new UUID for the page and files (remove old, add new)
if (Uuids::enabled() === true) {
$copy = $copy->save(['uuid' => Uuid::generate()]);
// regenerate UUIDs of page files
if ($files !== false) {
foreach ($copy->files() as $file) {
$file->save(['uuid' => Uuid::generate()]);
}
}
// regenerate UUIDs of all page children
if ($children !== false) {
foreach ($copy->index(true) as $child) {
// always adapt files of subpages as they are currently always copied;
// but don't adapt children because we already operate on the index
$this->adaptCopy($child, true);
}
}
}
return $copy;
}
/**
* Changes the sorting number.
* The sorting number must already be correct
@@ -103,10 +32,11 @@ trait PageActions
* This only affects this page,
* siblings will not be resorted.
*
* @param int|null $num
* @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
public function changeNum(int $num = null)
{
if ($this->isDraft() === true) {
throw new LogicException('Drafts cannot change their sorting number');
@@ -129,7 +59,7 @@ trait PageActions
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();
$oldPage->setRoot($newPage->root());
} else {
throw new LogicException('The page directory cannot be moved');
}
@@ -145,21 +75,23 @@ trait PageActions
/**
* Changes the slug/uid of the page
*
* @param string $slug
* @param string|null $languageCode
* @return $this|static
* @throws \Kirby\Exception\LogicException If the directory cannot be moved
*/
public function changeSlug(
string $slug,
string|null $languageCode = null
): static {
public function changeSlug(string $slug, string $languageCode = null)
{
// always sanitize the slug
$slug = Str::slug($slug);
// 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 ($this->kirby()->language($languageCode)?->isDefault() === false) {
return $this->changeSlugForLanguage($slug, $languageCode);
if ($language = $this->kirby()->language($languageCode)) {
if ($language->isDefault() === false) {
return $this->changeSlugForLanguage($slug, $languageCode);
}
}
// if the slug stays exactly the same,
@@ -176,12 +108,11 @@ trait PageActions
'root' => null
]);
// clear UUID cache recursively (for children and files as well)
$oldPage->uuid()?->clear(true);
if ($oldPage->exists() === true) {
// remove the lock of the old page
$oldPage->lock()?->remove();
if ($lock = $oldPage->lock()) {
$lock->remove();
}
// actually move stuff on disk
if (Dir::move($oldPage->root(), $newPage->root()) !== true) {
@@ -204,13 +135,14 @@ trait PageActions
/**
* Change the slug for a specific language
*
* @param string $slug
* @param string|null $languageCode
* @return static
* @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 {
protected function changeSlugForLanguage(string $slug, string $languageCode = null)
{
$language = $this->kirby()->language($languageCode);
if (!$language) {
@@ -221,7 +153,7 @@ trait PageActions
throw new InvalidArgumentException('Use the changeSlug method to change the slug for the default language');
}
$arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => $language->code()];
$arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => $languageCode];
return $this->commit('changeSlug', $arguments, function ($page, $slug, $languageCode) {
// remove the slug if it's the same as the folder name
if ($slug === $page->uid()) {
@@ -245,19 +177,27 @@ trait PageActions
*
* @param string $status "draft", "listed" or "unlisted"
* @param int|null $position Optional sorting number
* @return static
* @throws \Kirby\Exception\InvalidArgumentException If an invalid status is being passed
*/
public function changeStatus(string $status, int|null $position = null): static
public function changeStatus(string $status, int $position = null)
{
return match ($status) {
'draft' => $this->changeStatusToDraft(),
'listed' => $this->changeStatusToListed($position),
'unlisted' => $this->changeStatusToUnlisted(),
default => throw new InvalidArgumentException('Invalid status: ' . $status)
};
switch ($status) {
case 'draft':
return $this->changeStatusToDraft();
case 'listed':
return $this->changeStatusToListed($position);
case 'unlisted':
return $this->changeStatusToUnlisted();
default:
throw new InvalidArgumentException('Invalid status: ' . $status);
}
}
protected function changeStatusToDraft(): static
/**
* @return static
*/
protected function changeStatusToDraft()
{
$arguments = ['page' => $this, 'status' => 'draft', 'position' => null];
$page = $this->commit(
@@ -270,9 +210,10 @@ trait PageActions
}
/**
* @param int|null $position
* @return $this|static
*/
protected function changeStatusToListed(int|null $position = null): static
protected function changeStatusToListed(int $position = null)
{
// create a sorting number for the page
$num = $this->createNum($position);
@@ -282,16 +223,10 @@ trait PageActions
return $this;
}
$page = $this->commit(
'changeStatus',
[
'page' => $this,
'status' => 'listed',
'position' => $num
],
fn ($page, $status, $position) =>
$page->publish()->changeNum($position)
);
$arguments = ['page' => $this, 'status' => 'listed', 'position' => $num];
$page = $this->commit('changeStatus', $arguments, function ($page, $status, $position) {
return $page->publish()->changeNum($position);
});
if ($this->blueprint()->num() === 'default') {
$page->resortSiblingsAfterListing($num);
@@ -303,21 +238,16 @@ trait PageActions
/**
* @return $this|static
*/
protected function changeStatusToUnlisted(): static
protected function changeStatusToUnlisted()
{
if ($this->status() === 'unlisted') {
return $this;
}
$page = $this->commit(
'changeStatus',
[
'page' => $this,
'status' => 'unlisted',
'position' => null
],
fn ($page) => $page->publish()->changeNum(null)
);
$arguments = ['page' => $this, 'status' => 'unlisted', 'position' => null];
$page = $this->commit('changeStatus', $arguments, function ($page) {
return $page->publish()->changeNum(null);
});
$this->resortSiblingsAfterUnlisting();
@@ -329,9 +259,10 @@ trait PageActions
* collection. Siblings will be resorted. If the page
* status isn't yet `listed`, it will be changed to it.
*
* @param int|null $position
* @return $this|static
*/
public function changeSort(int|null $position = null): static
public function changeSort(int $position = null)
{
return $this->changeStatus('listed', $position);
}
@@ -339,18 +270,51 @@ trait PageActions
/**
* Changes the page template
*
* @param string $template
* @return $this|static
* @throws \Kirby\Exception\LogicException If the textfile cannot be renamed/moved
*/
public function changeTemplate(string $template): static
public function changeTemplate(string $template)
{
if ($template === $this->intendedTemplate()->name()) {
return $this;
}
return $this->commit('changeTemplate', ['page' => $this, 'template' => $template], function ($oldPage, $template) {
// convert for new template/blueprint
$page = $oldPage->convertTo($template);
if ($this->kirby()->multilang() === true) {
$newPage = $this->clone([
'template' => $template
]);
foreach ($this->kirby()->languages()->codes() as $code) {
if ($oldPage->translation($code)->exists() !== true) {
continue;
}
$content = $oldPage->content($code)->convertTo($template);
if (F::remove($oldPage->contentFile($code)) !== true) {
throw new LogicException('The old text file could not be removed');
}
// save the language file
$newPage->save($content, $code);
}
// return a fresh copy of the object
$page = $newPage->clone();
} else {
$newPage = $this->clone([
'content' => $this->content()->convertTo($template),
'template' => $template
]);
if (F::remove($oldPage->contentFile()) !== true) {
throw new LogicException('The old text file could not be removed');
}
$page = $newPage->save();
}
// update the parent collection
static::updateParentCollections($page, 'set');
@@ -361,24 +325,14 @@ trait PageActions
/**
* Change the page title
*
* @param string $title
* @param string|null $languageCode
* @return static
*/
public function changeTitle(
string $title,
string|null $languageCode = null
): static {
// if the `$languageCode` argument is not set and is not the default language
// the `$languageCode` argument is sent as the current language
if (
$languageCode === null &&
$language = $this->kirby()->language()
) {
if ($language->isDefault() === false) {
$languageCode = $language->code();
}
}
public function changeTitle(string $title, string $languageCode = null)
{
$arguments = ['page' => $this, 'title' => $title, 'languageCode' => $languageCode];
return $this->commit('changeTitle', $arguments, function ($page, $title, $languageCode) {
$page = $page->save(['title' => $title], $languageCode);
@@ -397,12 +351,14 @@ trait PageActions
* 3. commits the store action
* 4. sends the after hook
* 5. returns the result
*
* @param string $action
* @param array $arguments
* @param \Closure $callback
* @return mixed
*/
protected function commit(
string $action,
array $arguments,
Closure $callback
): mixed {
protected function commit(string $action, array $arguments, Closure $callback)
{
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
@@ -430,9 +386,11 @@ trait PageActions
/**
* Copies the page to a new parent
*
* @param array $options
* @return \Kirby\Cms\Page
* @throws \Kirby\Exception\DuplicateException If the page already exists
*/
public function copy(array $options = []): static
public function copy(array $options = [])
{
$slug = $options['slug'] ?? $this->slug();
$isDraft = $options['isDraft'] ?? $this->isDraft();
@@ -471,8 +429,7 @@ trait PageActions
$ignore[] = $file->root();
// append all content files
array_push($ignore, ...$file->storage()->contentFiles('published'));
array_push($ignore, ...$file->storage()->contentFiles('changes'));
array_push($ignore, ...$file->contentFiles());
}
}
@@ -480,8 +437,14 @@ trait PageActions
$copy = $parentModel->clone()->findPageOrDraft($slug);
// normalize copy object
$copy = $this->adaptCopy($copy, $files, $children);
// remove all translated slugs
if ($this->kirby()->multilang() === true) {
foreach ($this->kirby()->languages() as $language) {
if ($language->isDefault() === false && $copy->translation($language)->exists() === true) {
$copy = $copy->save(['slug' => null], $language->code());
}
}
}
// add copy to siblings
static::updateParentCollections($copy, 'append', $parentModel);
@@ -491,62 +454,45 @@ trait PageActions
/**
* Creates and stores a new page
*
* @param array $props
* @return static
*/
public static function create(array $props): Page
public static function create(array $props)
{
// clean up the slug
$props['slug'] = Str::slug($props['slug'] ?? $props['content']['title'] ?? null);
$props['template'] = $props['model'] = strtolower($props['template'] ?? 'default');
$props['isDraft'] ??= $props['draft'] ?? true;
// make sure that a UUID gets generated and
// added to content right away
$props['content'] ??= [];
if (Uuids::enabled() === true) {
$props['content']['uuid'] ??= Uuid::generate();
}
$props['slug'] = Str::slug($props['slug'] ?? $props['content']['title'] ?? null);
$props['template'] = $props['model'] = strtolower($props['template'] ?? 'default');
$props['isDraft'] = ($props['draft'] ?? true);
// create a temporary page object
$page = Page::factory($props);
// always create pages in the default language
if ($page->kirby()->multilang() === true) {
$languageCode = $page->kirby()->defaultLanguage()->code();
} else {
$languageCode = null;
}
// create a form for the page
// use always default language to fill form with default values
$form = Form::for(
$page,
[
'language' => $languageCode,
'values' => $props['content']
]
);
$form = Form::for($page, [
'values' => $props['content'] ?? []
]);
// inject the content
$page = $page->clone(['content' => $form->strings(true)]);
// run the hooks and creation action
$page = $page->commit(
'create',
[
'page' => $page,
'input' => $props
],
function ($page, $props) use ($languageCode) {
// write the content file
$page = $page->save($page->content()->toArray(), $languageCode);
// flush the parent cache to get children and drafts right
static::updateParentCollections($page, 'append');
return $page;
$page = $page->commit('create', ['page' => $page, 'input' => $props], function ($page, $props) {
// always create pages in the default language
if ($page->kirby()->multilang() === true) {
$languageCode = $page->kirby()->defaultLanguage()->code();
} else {
$languageCode = null;
}
);
// write the content file
$page = $page->save($page->content()->toArray(), $languageCode);
// flush the parent cache to get children and drafts right
static::updateParentCollections($page, 'append');
return $page;
});
// publish the new page if a number is given
if (isset($props['num']) === true) {
@@ -558,8 +504,11 @@ trait PageActions
/**
* Creates a child of the current page
*
* @param array $props
* @return static
*/
public function createChild(array $props): Page
public function createChild(array $props)
{
$props = array_merge($props, [
'url' => null,
@@ -568,13 +517,16 @@ trait PageActions
'site' => $this->site(),
]);
$modelClass = Page::$models[$props['template'] ?? null] ?? Page::class;
$modelClass = Page::$models[$props['template']] ?? Page::class;
return $modelClass::create($props);
}
/**
* Create the sorting number for the page
* depending on the blueprint settings
*
* @param int|null $num
* @return int
*/
public function createNum(int $num = null): int
{
@@ -602,7 +554,9 @@ trait PageActions
->count();
// default positioning at the end
$num ??= $max;
if ($num === null) {
$num = $max;
}
// avoid zeros or negative numbers
if ($num < 1) {
@@ -632,13 +586,13 @@ trait PageActions
/**
* Deletes the page
*
* @param bool $force
* @return bool
*/
public function delete(bool $force = false): bool
{
return $this->commit('delete', ['page' => $this, 'force' => $force], function ($page, $force) {
// clear UUID cache
$page->uuid()?->clear();
// delete all files individually
foreach ($page->files() as $file) {
$file->delete();
@@ -681,8 +635,12 @@ trait PageActions
/**
* Duplicates the page with the given
* slug and optionally copies all files
*
* @param string|null $slug
* @param array $options
* @return \Kirby\Cms\Page
*/
public function duplicate(string|null $slug = null, array $options = []): static
public function duplicate(string $slug = null, array $options = [])
{
// create the slug for the duplicate
$slug = Str::slug($slug ?? $this->slug() . '-' . Str::slug(I18n::translate('page.duplicate.appendix')));
@@ -710,60 +668,11 @@ trait PageActions
});
}
/**
* 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
if ($page->isDraft() === true) {
$newRoot = $parent->root() . '/_drafts/' . $page->dirname();
} else {
$newRoot = $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;
});
}
/**
* @return $this|static
* @throws \Kirby\Exception\LogicException If the folder cannot be moved
*/
public function publish(): static
public function publish()
{
if ($this->isDraft() === false) {
return $this;
@@ -804,24 +713,25 @@ trait PageActions
/**
* Clean internal caches
*
* @return $this
*/
public function purge(): static
public function purge()
{
parent::purge();
$this->blueprint = null;
$this->children = null;
$this->childrenAndDrafts = null;
$this->content = null;
$this->drafts = null;
$this->files = null;
$this->inventory = null;
$this->translations = null;
return $this;
}
/**
* @param int|null $position
* @return bool
* @throws \Kirby\Exception\LogicException If the page is not included in the siblings collection
*/
protected function resortSiblingsAfterListing(int $position = null): bool
@@ -855,9 +765,9 @@ trait PageActions
foreach ($sorted as $key => $id) {
if ($id === $this->id()) {
continue;
} elseif ($sibling = $siblings->get($id)) {
$sibling->changeNum($key + 1);
}
$siblings->get($id)?->changeNum($key + 1);
}
$parent = $this->parentModel();
@@ -868,7 +778,7 @@ trait PageActions
}
/**
* @internal
* @return bool
*/
public function resortSiblingsAfterUnlisting(): bool
{
@@ -893,23 +803,6 @@ trait PageActions
return true;
}
/**
* Stores the content on disk
* @internal
*/
public function save(
array|null $data = null,
string|null $languageCode = null,
bool $overwrite = false
): static {
$page = parent::save($data, $languageCode, $overwrite);
// overwrite the updated page in the parent collection
static::updateParentCollections($page, 'set');
return $page;
}
/**
* Convert a page from listed or
* unlisted to draft.
@@ -917,7 +810,7 @@ trait PageActions
* @return $this|static
* @throws \Kirby\Exception\LogicException If the folder cannot be moved
*/
public function unpublish(): static
public function unpublish()
{
if ($this->isDraft() === true) {
return $this;
@@ -954,12 +847,14 @@ trait PageActions
/**
* Updates the page data
*
* @param array|null $input
* @param string|null $languageCode
* @param bool $validate
* @return static
*/
public function update(
array|null $input = null,
string|null $languageCode = null,
bool $validate = false
): static {
public function update(array $input = null, string $languageCode = null, bool $validate = false)
{
if ($this->isDraft() === true) {
$validate = false;
}
@@ -967,10 +862,7 @@ trait PageActions
$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']) === false
) {
if ($page->isListed() === true && in_array($page->blueprint()->num(), ['zero', 'default']) === false) {
$page = $page->changeNum($page->createNum());
}
@@ -987,12 +879,10 @@ trait PageActions
* @param \Kirby\Cms\Page $page
* @param string $method Method to call on the parent collections
* @param \Kirby\Cms\Page|null $parentMdel
* @return void
*/
protected static function updateParentCollections(
$page,
string $method,
$parentModel = null
): void {
protected static function updateParentCollections($page, string $method, $parentModel = null): void
{
$parentModel ??= $page->parentModel();
// method arguments depending on the called method

View File

@@ -16,6 +16,8 @@ class PageBlueprint extends Blueprint
/**
* Creates a new page blueprint object
* with the given props
*
* @param array $props
*/
public function __construct(array $props)
{
@@ -26,7 +28,6 @@ class PageBlueprint extends Blueprint
$this->props['options'] ?? true,
// defaults
[
'access' => null,
'changeSlug' => null,
'changeStatus' => null,
'changeTemplate' => null,
@@ -34,10 +35,8 @@ class PageBlueprint extends Blueprint
'create' => null,
'delete' => null,
'duplicate' => null,
'list' => null,
'move' => null,
'preview' => null,
'read' => null,
'preview' => null,
'sort' => null,
'update' => null,
],
@@ -59,6 +58,8 @@ class PageBlueprint extends Blueprint
/**
* Returns the page numbering mode
*
* @return string
*/
public function num(): string
{
@@ -69,6 +70,7 @@ class PageBlueprint extends Blueprint
* Normalizes the ordering number
*
* @param mixed $num
* @return string
*/
protected function normalizeNum($num): string
{
@@ -77,13 +79,18 @@ class PageBlueprint extends Blueprint
'sort' => 'default',
];
return $aliases[$num] ?? $num;
if (isset($aliases[$num]) === true) {
return $aliases[$num];
}
return $num;
}
/**
* Normalizes the available status options for the page
*
* @param mixed $status
* @return array
*/
protected function normalizeStatus($status): array
{
@@ -137,7 +144,9 @@ class PageBlueprint extends Blueprint
}
// also make sure to have the text field set
$status[$key]['text'] ??= null;
if (isset($status[$key]['text']) === false) {
$status[$key]['text'] = null;
}
// translate text and label if necessary
$status[$key]['label'] = $this->i18n($status[$key]['label'], $status[$key]['label']);
@@ -160,6 +169,8 @@ class PageBlueprint extends Blueprint
/**
* Returns the options object
* that handles page options and permissions
*
* @return array
*/
public function options(): array
{
@@ -171,8 +182,10 @@ class PageBlueprint extends Blueprint
* The preview setting controls the "Open"
* button in the panel and redirects it to a
* different URL if necessary.
*
* @return string|bool
*/
public function preview(): string|bool
public function preview()
{
$preview = $this->props['options']['preview'] ?? true;
@@ -185,6 +198,8 @@ class PageBlueprint extends Blueprint
/**
* Returns the status array
*
* @return array
*/
public function status(): array
{

View File

@@ -13,21 +13,33 @@ namespace Kirby\Cms;
*/
class PagePermissions extends ModelPermissions
{
protected string $category = 'pages';
/**
* @var string
*/
protected $category = 'pages';
/**
* @return bool
*/
protected function canChangeSlug(): bool
{
return $this->model->isHomeOrErrorPage() !== true;
}
/**
* @return bool
*/
protected function canChangeStatus(): bool
{
return $this->model->isErrorPage() !== true;
}
/**
* @return bool
*/
protected function canChangeTemplate(): bool
{
if ($this->model->isErrorPage() === true) {
if ($this->model->isHomeOrErrorPage() === true) {
return false;
}
@@ -38,16 +50,17 @@ class PagePermissions extends ModelPermissions
return true;
}
/**
* @return bool
*/
protected function canDelete(): bool
{
return $this->model->isHomeOrErrorPage() !== true;
}
protected function canMove(): bool
{
return $this->model->isHomeOrErrorPage() !== true;
}
/**
* @return bool
*/
protected function canSort(): bool
{
if ($this->model->isErrorPage() === true) {

View File

@@ -18,14 +18,25 @@ use Kirby\Exception\InvalidArgumentException;
*/
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;
/**
* @var \Kirby\Cms\Pages
*/
protected $items;
/**
* @var \Kirby\Cms\Pages
*/
protected $itemsForQuery;
/**
* @var \Kirby\Cms\Page|\Kirby\Cms\Site|null
*/
protected $parent;
/**
* Extends the basic defaults
*
* @return array
*/
public function defaults(): array
{
@@ -44,8 +55,10 @@ class PagePicker extends Picker
* also be any subpage. When a query is given
* and subpage navigation is deactivated,
* there will be no model available at all.
*
* @return \Kirby\Cms\Page|\Kirby\Cms\Site|null
*/
public function model(): Page|Site|null
public function model()
{
// no subpages navigation = no model
if ($this->options['subpages'] === false) {
@@ -64,29 +77,38 @@ class PagePicker extends Picker
* Returns a model object for the given
* query, depending on the parent and subpages
* options.
*
* @return \Kirby\Cms\Page|\Kirby\Cms\Site|null
*/
public function modelForQuery(): Page|Site|null
public function modelForQuery()
{
if ($this->options['subpages'] === true && empty($this->options['parent']) === false) {
return $this->parent();
}
return $this->items()?->parent();
if ($items = $this->items()) {
return $items->parent();
}
return null;
}
/**
* Returns basic information about the
* parent model that is currently selected
* in the page picker.
*
* @param \Kirby\Cms\Site|\Kirby\Cms\Page|null
* @return array|null
*/
public function modelToArray(Page|Site $model = null): array|null
public function modelToArray($model = null): ?array
{
if ($model === null) {
return null;
}
// the selected model is the site. there's nothing above
if ($model instanceof Site) {
if (is_a($model, 'Kirby\Cms\Site') === true) {
return [
'id' => null,
'parent' => null,
@@ -114,8 +136,10 @@ class PagePicker extends Picker
/**
* Search all pages for the picker
*
* @return \Kirby\Cms\Pages|null
*/
public function items(): Pages|null
public function items()
{
// cache
if ($this->items !== null) {
@@ -137,8 +161,8 @@ class PagePicker extends Picker
$items = $this->itemsForQuery();
}
// filter protected and hidden pages
$items = $items->filter('isListable', true);
// filter protected pages
$items = $items->filter('isReadable', true);
// search
$items = $this->search($items);
@@ -149,8 +173,10 @@ class PagePicker extends Picker
/**
* Search for pages by parent
*
* @return \Kirby\Cms\Pages
*/
public function itemsForParent(): Pages
public function itemsForParent()
{
return $this->parent()->children();
}
@@ -158,9 +184,10 @@ class PagePicker extends Picker
/**
* Search for pages by query string
*
* @return \Kirby\Cms\Pages
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function itemsForQuery(): Pages
public function itemsForQuery()
{
// cache
if ($this->itemsForQuery !== null) {
@@ -173,13 +200,13 @@ class PagePicker extends Picker
// 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('Your query must return a set of pages')
};
if (is_a($items, 'Kirby\Cms\Site') === true) {
$items = $items->children();
} elseif (is_a($items, 'Kirby\Cms\Page') === true) {
$items = $items->children();
} elseif (is_a($items, 'Kirby\Cms\Pages') === false) {
throw new InvalidArgumentException('Your query must return a set of pages');
}
return $this->itemsForQuery = $items;
}
@@ -189,21 +216,33 @@ class PagePicker extends Picker
* The model will be used to fetch
* subpages unless there's a specific
* query to find pages instead.
*
* @return \Kirby\Cms\Page|\Kirby\Cms\Site
*/
public function parent(): Page|Site
public function parent()
{
return $this->parent ??= $this->kirby->page($this->options['parent']) ?? $this->site;
if ($this->parent !== null) {
return $this->parent;
}
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.
*
* @return \Kirby\Cms\Page|\Kirby\Cms\Site
*/
public function start(): Page|Site
public function start()
{
if (empty($this->options['query']) === false) {
return $this->itemsForQuery()?->parent() ?? $this->site;
if ($items = $this->itemsForQuery()) {
return $items->parent();
}
return $this->site;
}
return $this->site;
@@ -213,6 +252,8 @@ class PagePicker extends Picker
* Returns an associative array
* with all information for the picker.
* This will be passed directly to the API.
*
* @return array
*/
public function toArray(): array
{

View File

@@ -6,7 +6,6 @@ use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
@@ -23,6 +22,9 @@ class PageRules
/**
* Validates if the sorting number of the page can be changed
*
* @param \Kirby\Cms\Page $page
* @param int|null $num
* @return bool
* @throws \Kirby\Exception\InvalidArgumentException If the given number is invalid
*/
public static function changeNum(Page $page, int $num = null): bool
@@ -37,6 +39,9 @@ class PageRules
/**
* Validates if the slug for the page can be changed
*
* @param \Kirby\Cms\Page $page
* @param string $slug
* @return bool
* @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
*/
@@ -52,27 +57,30 @@ class PageRules
}
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 ($duplicate = $siblings->find($slug)) {
if ($duplicate->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
]
]);
if ($duplicate = $drafts->find($slug)) {
if ($duplicate->is($page) === false) {
throw new DuplicateException([
'key' => 'page.draft.duplicate',
'data' => [
'slug' => $slug
]
]);
}
}
return true;
@@ -81,31 +89,38 @@ class PageRules
/**
* Validates if the status for the page can be changed
*
* @param \Kirby\Cms\Page $page
* @param string $status
* @param int|null $position
* @return bool
* @throws \Kirby\Exception\InvalidArgumentException If the given status is invalid
*/
public static function changeStatus(
Page $page,
string $status,
int $position = null
): bool {
public static function changeStatus(Page $page, string $status, int $position = null): bool
{
if (isset($page->blueprint()->status()[$status]) === false) {
throw new InvalidArgumentException(['key' => 'page.status.invalid']);
}
return match ($status) {
'draft' => static::changeStatusToDraft($page),
'listed' => static::changeStatusToListed($page, $position),
'unlisted' => static::changeStatusToUnlisted($page),
default => throw new InvalidArgumentException(['key' => 'page.status.invalid'])
};
switch ($status) {
case 'draft':
return static::changeStatusToDraft($page);
case 'listed':
return static::changeStatusToListed($page, $position);
case 'unlisted':
return static::changeStatusToUnlisted($page);
default:
throw new InvalidArgumentException(['key' => 'page.status.invalid']);
}
}
/**
* Validates if a page can be converted to a draft
*
* @param \Kirby\Cms\Page $page
* @return bool
* @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): bool
public static function changeStatusToDraft(Page $page)
{
if ($page->permissions()->changeStatus() !== true) {
throw new PermissionException([
@@ -131,10 +146,13 @@ class PageRules
/**
* Validates if the status of a page can be changed to listed
*
* @param \Kirby\Cms\Page $page
* @param int $position
* @return bool
* @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): bool
public static function changeStatusToListed(Page $page, int $position)
{
// no need to check for status changing permissions,
// instead we need to check for sorting permissions
@@ -163,6 +181,8 @@ class PageRules
/**
* Validates if the status of a page can be changed to unlisted
*
* @param \Kirby\Cms\Page $page
* @return bool
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status
*/
public static function changeStatusToUnlisted(Page $page)
@@ -175,6 +195,9 @@ class PageRules
/**
* Validates if the template of the page can be changed
*
* @param \Kirby\Cms\Page $page
* @param string $template
* @return bool
* @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
*/
@@ -189,12 +212,7 @@ class PageRules
]);
}
$blueprints = $page->blueprints();
if (
count($blueprints) <= 1 ||
in_array($template, array_column($blueprints, 'name')) === false
) {
if (count($page->blueprints()) <= 1) {
throw new LogicException([
'key' => 'page.changeTemplate.invalid',
'data' => ['slug' => $page->slug()]
@@ -207,6 +225,9 @@ class PageRules
/**
* Validates if the title of the page can be changed
*
* @param \Kirby\Cms\Page $page
* @param string $title
* @return bool
* @throws \Kirby\Exception\InvalidArgumentException If the new title is empty
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the title
*/
@@ -221,7 +242,11 @@ class PageRules
]);
}
static::validateTitleLength($title);
if (Str::length($title) === 0) {
throw new InvalidArgumentException([
'key' => 'page.changeTitle.empty',
]);
}
return true;
}
@@ -229,6 +254,8 @@ class PageRules
/**
* Validates if the page can be created
*
* @param \Kirby\Cms\Page $page
* @return bool
* @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
@@ -245,7 +272,6 @@ class PageRules
}
self::validateSlugLength($page->slug());
self::validateSlugProtectedPaths($page, $page->slug());
if ($page->exists() === true) {
throw new DuplicateException([
@@ -280,6 +306,9 @@ class PageRules
/**
* Validates if the page can be deleted
*
* @param \Kirby\Cms\Page $page
* @param bool $force
* @return bool
* @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
*/
@@ -304,13 +333,14 @@ class PageRules
/**
* Validates if the page can be duplicated
*
* @param \Kirby\Cms\Page $page
* @param string $slug
* @param array $options
* @return bool
* @throws \Kirby\Exception\PermissionException If the user is not allowed to duplicate the page
*/
public static function duplicate(
Page $page,
string $slug,
array $options = []
): bool {
public static function duplicate(Page $page, string $slug, array $options = []): bool
{
if ($page->permissions()->duplicate() !== true) {
throw new PermissionException([
'key' => 'page.duplicate.permission',
@@ -325,82 +355,12 @@ class PageRules
return true;
}
/**
* Check if the page can be moved
* to the given parent
*/
public static function move(Page $page, Site|Page $parent): bool
{
// if nothing changes, there's no need for checks
if ($parent->is($page->parent()) === true) {
return true;
}
if ($page->permissions()->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
foreach ($parent->blueprint()->sections() as $section) {
// only take pages sections into consideration
if ($section->type() !== 'pages') {
continue;
}
// only consider page sections that list pages
// of the targeted new parent page
if ($section->parent() !== $parent) {
continue;
}
// go through all allowed blueprints and
// add the name to the allow list
foreach ($section->blueprints() as $blueprint) {
$allowed[] = $blueprint['name'];
}
}
// check if the template of this page is allowed as subpage type
if (in_array($page->intendedTemplate()->name(), $allowed) === false) {
throw new PermissionException([
'key' => 'page.move.template',
'data' => [
'template' => $page->intendedTemplate()->name(),
'parent' => $parent->id() ?? '/',
]
]);
}
return true;
}
/**
* Check if the page can be published
* (status change from draft to listed or unlisted)
*
* @param Page $page
* @return bool
*/
public static function publish(Page $page): bool
{
@@ -426,6 +386,9 @@ class PageRules
/**
* Validates if the page can be updated
*
* @param \Kirby\Cms\Page $page
* @param array $content
* @return bool
* @throws \Kirby\Exception\PermissionException If the user is not allowed to update the page
*/
public static function update(Page $page, array $content = []): bool
@@ -446,9 +409,11 @@ class PageRules
* 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
*
* @param string $slug New slug to check
* @return void
* @throws \Kirby\Exception\InvalidArgumentException If the slug is empty or too long
*/
public static function validateSlugLength(string $slug): void
protected static function validateSlugLength(string $slug): void
{
$slugLength = Str::length($slug);
@@ -471,48 +436,4 @@ class PageRules
}
}
}
/**
* 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',
]);
}
}
}

View File

@@ -18,6 +18,8 @@ trait PageSiblings
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasNextListed($collection = null): bool
{
@@ -29,6 +31,8 @@ trait PageSiblings
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasNextUnlisted($collection = null): bool
{
@@ -40,6 +44,8 @@ trait PageSiblings
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasPrevListed($collection = null): bool
{
@@ -51,6 +57,8 @@ trait PageSiblings
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasPrevUnlisted($collection = null): bool
{
@@ -114,14 +122,15 @@ trait PageSiblings
{
if ($this->isDraft() === true) {
return $this->parentModel()->drafts();
} else {
return $this->parentModel()->children();
}
return $this->parentModel()->children();
}
/**
* Returns siblings with the same template
*
* @param bool $self
* @return \Kirby\Cms\Pages
*/
public function templateSiblings(bool $self = true)

View File

@@ -3,7 +3,6 @@
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Uuid\HasUuids;
/**
* The `$pages` object refers to a
@@ -23,8 +22,6 @@ use Kirby\Uuid\HasUuids;
*/
class Pages extends Collection
{
use HasUuids;
/**
* Cache for the index only listed and unlisted pages
*
@@ -41,8 +38,10 @@ class Pages extends Collection
/**
* All registered pages methods
*
* @var array
*/
public static array $methods = [];
public static $methods = [];
/**
* Adds a single page or
@@ -53,23 +52,20 @@ class Pages extends Collection
* @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
public function add($object)
{
$site = App::instance()->site();
// add a pages collection
if ($object instanceof self) {
if (is_a($object, self::class) === true) {
$this->data = array_merge($this->data, $object->data);
// add a page by id
} elseif (
is_string($object) === true &&
$page = $site->find($object)
) {
} elseif (is_string($object) === true && $page = $site->find($object)) {
$this->__set($page->id(), $page);
// add a page object
} elseif ($object instanceof Page) {
} elseif (is_a($object, 'Kirby\Cms\Page') === true) {
$this->__set($object->id(), $object);
// give a useful error message on invalid input;
@@ -83,16 +79,20 @@ class Pages extends Collection
/**
* Returns all audio files of all children
*
* @return \Kirby\Cms\Files
*/
public function audio(): Files
public function audio()
{
return $this->files()->filter('type', 'audio');
}
/**
* Returns all children for each page in the array
*
* @return \Kirby\Cms\Pages
*/
public function children(): Pages
public function children()
{
$children = new Pages([]);
@@ -107,24 +107,30 @@ class Pages extends Collection
/**
* Returns all code files of all children
*
* @return \Kirby\Cms\Files
*/
public function code(): Files
public function code()
{
return $this->files()->filter('type', 'code');
}
/**
* Returns all documents of all children
*
* @return \Kirby\Cms\Files
*/
public function documents(): Files
public function documents()
{
return $this->files()->filter('type', 'document');
}
/**
* Fetch all drafts for all pages in the collection
*
* @return \Kirby\Cms\Pages
*/
public function drafts(): Pages
public function drafts()
{
$drafts = new Pages([]);
@@ -139,16 +145,19 @@ class Pages extends Collection
/**
* Creates a pages collection from an array of props
*
* @param array $pages
* @param \Kirby\Cms\Model|null $model
* @param bool $draft
* @return static
*/
public static function factory(
array $pages,
Page|Site $model = null,
bool $draft = null
): static {
public static function factory(array $pages, Model $model = null, bool $draft = false)
{
$model ??= App::instance()->site();
$children = new static([], $model);
$kirby = $model->kirby();
if ($model instanceof Page) {
if (is_a($model, 'Kirby\Cms\Page') === true) {
$parent = $model;
$site = $model->site();
} else {
@@ -157,9 +166,10 @@ class Pages extends Collection
}
foreach ($pages as $props) {
$props['kirby'] = $kirby;
$props['parent'] = $parent;
$props['site'] = $site;
$props['isDraft'] = $draft ?? $props['isDraft'] ?? $props['draft'] ?? false;
$props['isDraft'] = $draft;
$page = Page::factory($props);
@@ -171,8 +181,10 @@ class Pages extends Collection
/**
* Returns all files of all children
*
* @return \Kirby\Cms\Files
*/
public function files(): Files
public function files()
{
$files = new Files([], $this->parent);
@@ -185,20 +197,82 @@ class Pages extends Collection
return $files;
}
/**
* Finds a page in the collection by id.
* This works recursively for children and
* children of children, etc.
* @deprecated 3.7.0 Use `$pages->get()` or `$pages->find()` instead
* @todo 3.8.0 Remove method
* @codeCoverageIgnore
*
* @param string|null $id
* @return mixed
*/
public function findById(string $id = null)
{
Helpers::deprecated('Cms\Pages::findById() has been deprecated and will be removed in Kirby 3.8.0. Use $pages->get() or $pages->find() instead.');
return $this->findByKey($id);
}
/**
* Finds a child or child of a child recursively.
* @deprecated 3.7.0 Use `$pages->find()` instead
* @todo 3.8.0 Integrate code into `findByKey()` and remove this method
*
* @param string $id
* @param string|null $startAt
* @param bool $multiLang
* @return mixed
*/
public function findByIdRecursive(string $id, string $startAt = null, bool $multiLang = false, bool $silenceWarning = false)
{
// @codeCoverageIgnoreStart
if ($silenceWarning !== true) {
Helpers::deprecated('Cms\Pages::findByIdRecursive() has been deprecated and will be removed in Kirby 3.8.0. Use $pages->find() instead.');
}
// @codeCoverageIgnoreEnd
$path = explode('/', $id);
$item = null;
$query = $startAt;
foreach ($path as $key) {
$collection = $item ? $item->children() : $this;
$query = ltrim($query . '/' . $key, '/');
$item = $collection->get($query) ?? null;
if ($item === null && $multiLang === true && !App::instance()->language()->isDefault()) {
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 a page by its ID or URI
* @internal Use `$pages->find()` instead
*
* @param string|null $key
* @return \Kirby\Cms\Page|null
*/
public function findByKey(string|null $key = null): Page|null
public function findByKey(?string $key = null)
{
if ($key === null) {
return null;
}
if ($page = $this->findByUuid($key, 'page')) {
return $page;
}
// remove trailing or leading slashes
$key = trim($key, '/');
@@ -218,21 +292,17 @@ class Pages extends Collection
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)) {
// try to find the page by its (translated) URI by stepping through the page tree
$start = is_a($this->parent, 'Kirby\Cms\Page') === true ? $this->parent->id() : '';
if ($page = $this->findByIdRecursive($key, $start, App::instance()->multilang(), true)) {
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 &&
App::instance()->multilang() === true &&
App::instance()->language()->isDefault() === false &&
$page = $this->findBy('uri', $key)
) {
return $page;
@@ -242,51 +312,27 @@ class Pages extends Collection
}
/**
* Finds a child or child of a child recursively
* Alias for `$pages->find()`
* @deprecated 3.7.0 Use `$pages->find()` instead
* @todo 3.8.0 Remove method
* @codeCoverageIgnore
*
* @return mixed
* @param string $id
* @return \Kirby\Cms\Page|null
*/
protected function findByKeyRecursive(
string $id,
string $startAt = null,
bool $multiLang = false
) {
$path = explode('/', $id);
$item = null;
$query = $startAt;
public function findByUri(string $id)
{
Helpers::deprecated('Cms\Pages::findByUri() has been deprecated and will be removed in Kirby 3.8.0. Use $pages->find() instead.');
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;
return $this->findByKey($id);
}
/**
* Finds the currently open page
*
* @return \Kirby\Cms\Page|null
*/
public function findOpen(): Page|null
public function findOpen()
{
return $this->findBy('isOpen', true);
}
@@ -314,8 +360,10 @@ class Pages extends Collection
/**
* Returns all images of all children
*
* @return \Kirby\Cms\Files
*/
public function images(): Files
public function images()
{
return $this->files()->filter('type', 'image');
}
@@ -324,6 +372,7 @@ class Pages extends Collection
* Create a recursive flat index of all
* pages and subpages, etc.
*
* @param bool $drafts
* @return \Kirby\Cms\Pages
*/
public function index(bool $drafts = false)
@@ -331,7 +380,7 @@ class Pages extends Collection
// get object property by cache mode
$index = $drafts === true ? $this->indexWithDrafts : $this->index;
if ($index instanceof self) {
if (is_a($index, 'Kirby\Cms\Pages') === true) {
return $index;
}
@@ -357,16 +406,20 @@ class Pages extends Collection
/**
* Returns all listed pages in the collection
*
* @return \Kirby\Cms\Pages
*/
public function listed(): static
public function listed()
{
return $this->filter('isListed', '==', true);
}
/**
* Returns all unlisted pages in the collection
*
* @return \Kirby\Cms\Pages
*/
public function unlisted(): static
public function unlisted()
{
return $this->filter('isUnlisted', '==', true);
}
@@ -398,14 +451,14 @@ class Pages extends Collection
}
// merge an entire collection
if ($args[0] instanceof self) {
if (is_a($args[0], self::class) === true) {
$collection = clone $this;
$collection->data = array_merge($collection->data, $args[0]->data);
return $collection;
}
// append a single page
if ($args[0] instanceof Page) {
if (is_a($args[0], 'Kirby\Cms\Page') === true) {
$collection = clone $this;
return $collection->set($args[0]->id(), $args[0]);
}
@@ -443,22 +496,27 @@ class Pages extends Collection
$templates = [$templates];
}
return $this->filter(
fn ($page) =>
!in_array($page->intendedTemplate()->name(), $templates)
);
return $this->filter(function ($page) use ($templates) {
return !in_array($page->intendedTemplate()->name(), $templates);
});
}
/**
* Returns an array with all page numbers
*
* @return array
*/
public function nums(): array
{
return $this->pluck('num');
}
// Returns all listed and unlisted pages in the collection
public function published(): static
/*
* Returns all listed and unlisted pages in the collection
*
* @return \Kirby\Cms\Pages
*/
public function published()
{
return $this->filter('isDraft', '==', false);
}
@@ -479,16 +537,17 @@ class Pages extends Collection
$templates = [$templates];
}
return $this->filter(
fn ($page) =>
in_array($page->intendedTemplate()->name(), $templates)
);
return $this->filter(function ($page) use ($templates) {
return in_array($page->intendedTemplate()->name(), $templates);
});
}
/**
* Returns all video files of all children
*
* @return \Kirby\Cms\Files
*/
public function videos(): Files
public function videos()
{
return $this->files()->filter('type', 'video');
}

View File

@@ -60,6 +60,8 @@ class Pagination extends BasePagination
* 'url' => new Uri('https://getkirby.com/blog')
* ]);
* ```
*
* @param array $params
*/
public function __construct(array $params = [])
{
@@ -93,16 +95,20 @@ class Pagination extends BasePagination
/**
* Returns the Url for the first page
*
* @return string|null
*/
public function firstPageUrl(): string|null
public function firstPageUrl(): ?string
{
return $this->pageUrl(1);
}
/**
* Returns the Url for the last page
*
* @return string|null
*/
public function lastPageUrl(): string|null
public function lastPageUrl(): ?string
{
return $this->pageUrl($this->lastPage());
}
@@ -110,8 +116,10 @@ class Pagination extends BasePagination
/**
* Returns the Url for the next page.
* Returns null if there's no next page.
*
* @return string|null
*/
public function nextPageUrl(): string|null
public function nextPageUrl(): ?string
{
if ($page = $this->nextPage()) {
return $this->pageUrl($page);
@@ -124,8 +132,11 @@ class Pagination extends BasePagination
* Returns the URL of the current page.
* If the `$page` variable is set, the URL
* for that page will be returned.
*
* @param int|null $page
* @return string|null
*/
public function pageUrl(int $page = null): string|null
public function pageUrl(int $page = null): ?string
{
if ($page === null) {
return $this->pageUrl($this->page());
@@ -154,8 +165,10 @@ class Pagination extends BasePagination
/**
* Returns the Url for the previous page.
* Returns null if there's no previous page.
*
* @return string|null
*/
public function prevPageUrl(): string|null
public function prevPageUrl(): ?string
{
if ($page = $this->prevPage()) {
return $this->pageUrl($page);

View File

@@ -17,9 +17,15 @@ use Kirby\Exception\InvalidArgumentException;
*/
class Permissions
{
public static array $extendedActions = [];
/**
* @var array
*/
public static $extendedActions = [];
protected array $actions = [
/**
* @var array
*/
protected $actions = [
'access' => [
'account' => true,
'languages' => true,
@@ -29,15 +35,12 @@ class Permissions
'users' => true,
],
'files' => [
'access' => true,
'changeName' => true,
'changeTemplate' => true,
'create' => true,
'delete' => true,
'list' => true,
'read' => true,
'replace' => true,
'update' => true
'changeName' => true,
'create' => true,
'delete' => true,
'read' => true,
'replace' => true,
'update' => true
],
'languages' => [
'create' => true,
@@ -45,7 +48,6 @@ class Permissions
'update' => true
],
'pages' => [
'access' => true,
'changeSlug' => true,
'changeStatus' => true,
'changeTemplate' => true,
@@ -53,8 +55,6 @@ class Permissions
'create' => true,
'delete' => true,
'duplicate' => true,
'list' => true,
'move' => true,
'preview' => true,
'read' => true,
'sort' => true,
@@ -88,9 +88,10 @@ class Permissions
/**
* Permissions constructor
*
* @param array $settings
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function __construct(array|bool|null $settings = [])
public function __construct($settings = [])
{
// dynamically register the extended actions
foreach (static::$extendedActions as $key => $actions) {
@@ -110,6 +111,11 @@ class Permissions
}
}
/**
* @param string|null $category
* @param string|null $action
* @return bool
*/
public function for(string $category = null, string $action = null): bool
{
if ($action === null) {
@@ -127,26 +133,39 @@ class Permissions
return $this->actions[$category][$action];
}
/**
* @param string $category
* @param string $action
* @return bool
*/
protected function hasAction(string $category, string $action): bool
{
return
$this->hasCategory($category) === true &&
array_key_exists($action, $this->actions[$category]) === true;
return $this->hasCategory($category) === true && array_key_exists($action, $this->actions[$category]) === true;
}
/**
* @param string $category
* @return bool
*/
protected function hasCategory(string $category): bool
{
return array_key_exists($category, $this->actions) === true;
}
/**
* @param string $category
* @param string $action
* @param $setting
* @return $this
*/
protected function setAction(
string $category,
string $action,
$setting
): static {
protected function setAction(string $category, string $action, $setting)
{
// deprecated fallback for the settings/system view
// TODO: remove in 3.8.0
if ($category === 'access' && $action === 'settings') {
$action = 'system';
}
// wildcard to overwrite the entire category
if ($action === '*') {
return $this->setCategory($category, $setting);
@@ -158,9 +177,10 @@ class Permissions
}
/**
* @param bool $setting
* @return $this
*/
protected function setAll(bool $setting): static
protected function setAll(bool $setting)
{
foreach ($this->actions as $categoryName => $actions) {
$this->setCategory($categoryName, $setting);
@@ -170,9 +190,10 @@ class Permissions
}
/**
* @param array $settings
* @return $this
*/
protected function setCategories(array $settings): static
protected function setCategories(array $settings)
{
foreach ($settings as $categoryName => $categoryActions) {
if (is_bool($categoryActions) === true) {
@@ -190,10 +211,12 @@ class Permissions
}
/**
* @param string $category
* @param bool $setting
* @return $this
* @throws \Kirby\Exception\InvalidArgumentException
*/
protected function setCategory(string $category, bool $setting): static
protected function setCategory(string $category, bool $setting)
{
if ($this->hasCategory($category) === false) {
throw new InvalidArgumentException('Invalid permissions category');
@@ -206,6 +229,9 @@ class Permissions
return $this;
}
/**
* @return array
*/
public function toArray(): array
{
return $this->actions;

View File

@@ -14,12 +14,25 @@ namespace Kirby\Cms;
*/
abstract class Picker
{
protected App $kirby;
protected array $options;
protected Site $site;
/**
* @var \Kirby\Cms\App
*/
protected $kirby;
/**
* @var array
*/
protected $options;
/**
* @var \Kirby\Cms\Site
*/
protected $site;
/**
* Creates a new Picker instance
*
* @param array $params
*/
public function __construct(array $params = [])
{
@@ -30,6 +43,8 @@ abstract class Picker
/**
* Return the array of default values
*
* @return array
*/
protected function defaults(): array
{
@@ -40,7 +55,7 @@ abstract class Picker
// query template for the info field
'info' => false,
// listing style: list, cards, cardlets
'layout' => 'list',
'layout' =>'list',
// number of users displayed per pagination page
'limit' => 20,
// optional mapping function for the result array
@@ -60,15 +75,20 @@ abstract class Picker
/**
* Fetches all items for the picker
*
* @return \Kirby\Cms\Collection|null
*/
abstract public function items(): Collection|null;
abstract public function items();
/**
* Converts all given items to an associative
* array that is already optimized for the
* panel picker component.
*
* @param \Kirby\Cms\Collection|null $items
* @return array
*/
public function itemsToArray(Collection $items = null): array
public function itemsToArray($items = null): array
{
if ($items === null) {
return [];
@@ -96,8 +116,11 @@ abstract class Picker
/**
* Apply pagination to the collection
* of items according to the options.
*
* @param \Kirby\Cms\Collection $items
* @return \Kirby\Cms\Collection
*/
public function paginate(Collection $items): Collection
public function paginate(Collection $items)
{
return $items->paginate([
'limit' => $this->options['limit'],
@@ -108,6 +131,9 @@ abstract class Picker
/**
* Return the most relevant pagination
* info as array
*
* @param \Kirby\Cms\Pagination $pagination
* @return array
*/
public function paginationToArray(Pagination $pagination): array
{
@@ -121,8 +147,11 @@ abstract class Picker
/**
* Search through the collection of items
* if not deactivate in the options
*
* @param \Kirby\Cms\Collection $items
* @return \Kirby\Cms\Collection
*/
public function search(Collection $items): Collection
public function search(Collection $items)
{
if (empty($this->options['search']) === false) {
return $items->search($this->options['search']);
@@ -135,6 +164,8 @@ abstract class Picker
* Returns an associative array
* with all information for the picker.
* This will be passed directly to the API.
*
* @return array
*/
public function toArray(): array
{

View File

@@ -2,15 +2,10 @@
namespace Kirby\Cms;
use Composer\InstalledVersions;
use Exception;
use Kirby\Cms\System\UpdateStatus;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\V;
use Throwable;
/**
* Represents a Plugin and handles parsing of
@@ -23,84 +18,44 @@ use Throwable;
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Plugin
class Plugin extends Model
{
protected PluginAssets $assets;
protected UpdateStatus|null $updateStatus = null;
protected $extends;
protected $info;
protected $name;
protected $root;
/**
* @param string $name Plugin name within Kirby (`vendor/plugin`)
* @param array $extends Associative array of plugin extensions
*
* @throws \Kirby\Exception\InvalidArgumentException If the plugin name has an invalid format
* @param string $key
* @param array|null $arguments
* @return mixed|null
*/
public function __construct(
protected string $name,
protected array $extends = [],
protected array $info = [],
protected string|null $root = null,
protected string|null $version = null,
) {
static::validateName($name);
// TODO: Remove in v7
if ($root = $extends['root'] ?? null) {
Helpers::deprecated('Plugin "' . $name . '": Passing the `root` inside the `extends` array has been deprecated. Pass it directly as named argument `root`.', 'plugin-extends-root');
$this->root ??= $root;
unset($this->extends['root']);
}
$this->root ??= dirname(debug_backtrace()[0]['file']);
// TODO: Remove in v7
if ($info = $extends['info'] ?? null) {
Helpers::deprecated('Plugin "' . $name . '": Passing an `info` array inside the `extends` array has been deprecated. Pass the individual entries directly as named `info` argument.', 'plugin-extends-root');
if (empty($info) === false && is_array($info) === true) {
$this->info = [...$info, ...$this->info];
}
unset($this->extends['info']);
}
// read composer.json and use as info fallback
try {
$info = Data::read($this->manifest());
} catch (Exception) {
// there is no manifest file or it is invalid
$info = [];
}
$this->info = [...$info, ...$this->info];
}
/**
* Allows access to any composer.json field by method call
*/
public function __call(string $key, array $arguments = null): mixed
public function __call(string $key, array $arguments = null)
{
return $this->info()[$key] ?? null;
}
/**
* Returns the plugin asset object for a specific asset
* Plugin constructor
*
* @param string $name
* @param array $extends
*/
public function asset(string $path): PluginAsset|null
public function __construct(string $name, array $extends = [])
{
return $this->assets()->get($path);
}
$this->setName($name);
$this->extends = $extends;
$this->root = $extends['root'] ?? dirname(debug_backtrace()[0]['file']);
$this->info = empty($extends['info']) === false && is_array($extends['info']) ? $extends['info'] : null;
/**
* Returns the plugin assets collection
*/
public function assets(): PluginAssets
{
return $this->assets ??= PluginAssets::factory($this);
unset($this->extends['root'], $this->extends['info']);
}
/**
* Returns the array with author information
* from the composer.json file
* from the composer file
*
* @return array
*/
public function authors(): array
{
@@ -109,6 +64,8 @@ class Plugin
/**
* Returns a comma-separated list with all author names
*
* @return string
*/
public function authorsNames(): string
{
@@ -122,7 +79,7 @@ class Plugin
}
/**
* Returns the associative array of extensions the plugin bundles
* @return array
*/
public function extends(): array
{
@@ -130,8 +87,9 @@ class Plugin
}
/**
* Returns the unique ID for the plugin
* (alias for the plugin name)
* Returns the unique id for the plugin
*
* @return string
*/
public function id(): string
{
@@ -139,25 +97,30 @@ class Plugin
}
/**
* Returns the info data (from composer.json)
* @return array
*/
public function info(): array
{
return $this->info;
}
if (is_array($this->info) === true) {
return $this->info;
}
/**
* Current $kirby instance
*/
public function kirby(): App
{
return App::instance();
try {
$info = Data::read($this->manifest());
} catch (Exception $e) {
// there is no manifest file or it is invalid
$info = [];
}
return $this->info = $info;
}
/**
* Returns the link to the plugin homepage
*
* @return string|null
*/
public function link(): string|null
public function link(): ?string
{
$info = $this->info();
$homepage = $info['homepage'] ?? null;
@@ -170,7 +133,7 @@ class Plugin
}
/**
* Returns the path to the plugin's composer.json
* @return string
*/
public function manifest(): string
{
@@ -178,7 +141,7 @@ class Plugin
}
/**
* Returns the root where plugin assets are copied to
* @return string
*/
public function mediaRoot(): string
{
@@ -186,7 +149,7 @@ class Plugin
}
/**
* Returns the base URL for plugin assets
* @return string
*/
public function mediaUrl(): string
{
@@ -194,7 +157,7 @@ class Plugin
}
/**
* Returns the plugin name (`vendor/plugin`)
* @return string
*/
public function name(): string
{
@@ -202,7 +165,8 @@ class Plugin
}
/**
* Returns a Kirby option value for this plugin
* @param string $key
* @return mixed
*/
public function option(string $key)
{
@@ -210,7 +174,7 @@ class Plugin
}
/**
* Returns the option prefix (`vendor.plugin`)
* @return string
*/
public function prefix(): string
{
@@ -218,7 +182,7 @@ class Plugin
}
/**
* Returns the root where the plugin files are stored
* @return string
*/
public function root(): string
{
@@ -226,7 +190,22 @@ class Plugin
}
/**
* Returns all available plugin metadata
* @param string $name
* @return $this
* @throws \Kirby\Exception\InvalidArgumentException
*/
protected function setName(string $name)
{
if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) !== 1) {
throw new InvalidArgumentException('The plugin name must follow the format "a-z0-9-/a-z0-9-"');
}
$this->name = $name;
return $this;
}
/**
* @return array
*/
public function toArray(): array
{
@@ -240,102 +219,4 @@ class Plugin
'version' => $this->version()
];
}
/**
* Returns the update status object unless the
* update check has been disabled for the plugin
* @since 3.8.0
*
* @param array|null $data Custom override for the getkirby.com update data
*/
public function updateStatus(array|null $data = null): UpdateStatus|null
{
if ($this->updateStatus !== null) {
return $this->updateStatus;
}
$kirby = $this->kirby();
$option = $kirby->option('updates.plugins');
// specific configuration per plugin
if (is_array($option) === true) {
// filter all option values by glob match
$option = A::filter(
$option,
fn ($value, $key) => fnmatch($key, $this->name()) === true
);
// sort the matches by key length (with longest key first)
$keys = array_map('strlen', array_keys($option));
array_multisort($keys, SORT_DESC, $option);
if (count($option) > 0) {
// use the first and therefore longest key (= most specific match)
$option = reset($option);
} else {
// fallback to the default option value
$option = true;
}
}
$option ??= $kirby->option('updates') ?? true;
if ($option !== true) {
return null;
}
return $this->updateStatus = new UpdateStatus($this, false, $data);
}
/**
* Checks if the name follows the required pattern
* and throws an exception if not
*
* @throws \Kirby\Exception\InvalidArgumentException
*/
public static function validateName(string $name): void
{
if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) !== 1) {
throw new InvalidArgumentException('The plugin name must follow the format "a-z0-9-/a-z0-9-"');
}
}
/**
* Returns the normalized version number
* from the composer.json file
*/
public function version(): string|null
{
$name = $this->info()['name'] ?? null;
try {
// try to get version from "vendor/composer/installed.php",
// this is the most reliable source for the version
$version = InstalledVersions::getPrettyVersion($name);
} catch (Throwable) {
$version = null;
}
// fallback to the version provided in the plugin's index.php: as named
// argument, entry in the info array or from the composer.json file
$version ??= $this->version ?? $this->info()['version'] ?? null;
if (
is_string($version) !== true ||
$version === '' ||
Str::endsWith($version, '+no-version-set')
) {
return null;
}
// normalize the version number to be without leading `v`
$version = ltrim($version, 'vV');
// ensure that the version number now starts with a digit
if (preg_match('/^[0-9]/', $version) !== 1) {
return null;
}
return $version;
}
}

Some files were not shown because too many files have changed in this diff Show More