Initial commit

This commit is contained in:
Paul Nicoué 2022-06-17 17:51:59 +02:00
commit 73c6b816c0
716 changed files with 170045 additions and 0 deletions

835
kirby/src/Api/Api.php Normal file
View file

@ -0,0 +1,835 @@
<?php
namespace Kirby\Api;
use Closure;
use Exception;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\F;
use Kirby\Http\Response;
use Kirby\Http\Router;
use Kirby\Toolkit\Pagination;
use Kirby\Toolkit\Properties;
use Kirby\Toolkit\Str;
use Throwable;
/**
* The API class is a generic container
* for API routes, models and collections and is used
* to run our REST API. You can find our API setup
* in `kirby/config/api.php`.
*
* @package Kirby Api
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Api
{
use Properties;
/**
* Authentication callback
*
* @var \Closure
*/
protected $authentication;
/**
* Debugging flag
*
* @var bool
*/
protected $debug = false;
/**
* Collection definition
*
* @var array
*/
protected $collections = [];
/**
* Injected data/dependencies
*
* @var array
*/
protected $data = [];
/**
* Model definitions
*
* @var array
*/
protected $models = [];
/**
* The current route
*
* @var \Kirby\Http\Route
*/
protected $route;
/**
* The Router instance
*
* @var \Kirby\Http\Router
*/
protected $router;
/**
* Route definition
*
* @var array
*/
protected $routes = [];
/**
* Request data
* [query, body, files]
*
* @var array
*/
protected $requestData = [];
/**
* The applied request method
* (GET, POST, PATCH, etc.)
*
* @var string
*/
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 = [])
{
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()
{
if ($auth = $this->authentication()) {
return $auth->call($this);
}
return true;
}
/**
* Returns the authentication callback
*
* @return \Closure|null
*/
public function authentication()
{
return $this->authentication;
}
/**
* 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 $path = null, string $method = 'GET', array $requestData = [])
{
$path = rtrim($path ?? '', '/');
$this->setRequestMethod($method);
$this->setRequestData($requestData);
$this->router = new Router($this->routes());
$this->route = $this->router->find($path, $method);
$auth = $this->route->attributes()['auth'] ?? true;
if ($auth !== false) {
$user = $this->authenticate();
// set PHP locales based on *user* language
// so that e.g. strftime() gets formatted correctly
if (is_a($user, 'Kirby\Cms\User') === true) {
$language = $user->language();
// get the locale from the translation
$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
$locales = [
$locale . '.UTF-8',
$locale . '.UTF8',
$locale . '.ISO8859-1',
$locale,
$language,
setlocale(LC_ALL, 0) // fall back to the previously defined locale
];
// set the locales that are relevant for string formatting
// *don't* set LC_CTYPE to avoid breaking other parts of the system
setlocale(LC_MONETARY, $locales);
setlocale(LC_NUMERIC, $locales);
setlocale(LC_TIME, $locales);
}
}
// don't throw pagination errors if pagination
// page is out of bounds
$validate = Pagination::$validate;
Pagination::$validate = false;
$output = $this->route->action()->call($this, ...$this->route->arguments());
// restore old pagination validation mode
Pagination::$validate = $validate;
if (
is_object($output) === true &&
is_a($output, 'Kirby\\Http\\Response') !== true
) {
return $this->resolve($output)->toResponse();
}
return $output;
}
/**
* 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, $collection = null)
{
if (isset($this->collections[$name]) === false) {
throw new NotFoundException(sprintf('The collection "%s" does not exist', $name));
}
return new Collection($this, $collection, $this->collections[$name]);
}
/**
* Returns the collections definition
*
* @return array
*/
public function collections(): array
{
return $this->collections;
}
/**
* 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($key = null, ...$args)
{
if ($key === null) {
return $this->data;
}
if ($this->hasData($key) === false) {
throw new NotFoundException(sprintf('Api data for "%s" does not exist', $key));
}
// lazy-load data wrapped in Closures
if (is_a($this->data[$key], 'Closure') === true) {
return $this->data[$key]->call($this, ...$args);
}
return $this->data[$key];
}
/**
* Returns the debugging flag
*
* @return bool
*/
public function debug(): bool
{
return $this->debug;
}
/**
* Checks if injected data exists for the given key
*
* @param string $key
* @return bool
*/
public function hasData(string $key): bool
{
return isset($this->data[$key]) === true;
}
/**
* Matches an object with an array item
* based on the `type` field
*
* @param array models or collections
* @param mixed $object
*
* @return string key of match
*/
protected function match(array $array, $object = null)
{
foreach ($array as $definition => $model) {
if (is_a($object, $model['type']) === true) {
return $definition;
}
}
}
/**
* 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 $name = null, $object = null)
{
// Try to auto-match object with API models
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));
}
return new Model($this, $object, $this->models[$name]);
}
/**
* Returns all model definitions
*
* @return array
*/
public function models(): array
{
return $this->models;
}
/**
* 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 $type = null, string $key = null, $default = null)
{
if ($type === null) {
return $this->requestData;
}
if ($key === null) {
return $this->requestData[$type] ?? [];
}
$data = array_change_key_case($this->requestData($type));
$key = strtolower($key);
return $data[$key] ?? $default;
}
/**
* Returns the request body if available
*
* @param string|null $key
* @param mixed $default
* @return 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 $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 $key = null, $default = null)
{
return $this->requestData('headers', $key, $default);
}
/**
* Returns the request method
*
* @return string
*/
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 $key = null, $default = null)
{
return $this->requestData('query', $key, $default);
}
/**
* 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)
{
if (is_a($object, 'Kirby\Api\Model') === true || is_a($object, 'Kirby\Api\Collection') === true) {
return $object;
}
if ($model = $this->match($this->models, $object)) {
return $this->model($model, $object);
}
if ($collection = $this->match($this->collections, $object)) {
return $this->collection($collection, $object);
}
throw new NotFoundException(sprintf('The object "%s" cannot be resolved', get_class($object)));
}
/**
* Returns all defined routes
*
* @return array
*/
public function routes(): array
{
return $this->routes;
}
/**
* Setter for the authentication callback
*
* @param \Closure|null $authentication
* @return $this
*/
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);
}
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;
}
// pretty print json data
$pretty = (bool)($requestData['query']['pretty'] ?? false) === true;
if (($result['status'] ?? 'ok') === 'error') {
$code = $result['code'] ?? 400;
// sanitize the error code
if ($code < 400 || $code > 599) {
$code = 500;
}
return Response::json($result, $code, $pretty);
}
return Response::json($result, 200, $pretty);
}
/**
* Returns a 200 - ok
* response array.
*
* @return array
*/
public function responseFor200(): array
{
return [
'status' => 'ok',
'message' => 'ok',
'code' => 200
];
}
/**
* Returns a 400 - bad request
* response array.
*
* @return array
*/
public function responseFor400(): array
{
return [
'status' => 'error',
'message' => 'bad request',
'code' => 400,
];
}
/**
* Returns a 404 - not found
* response array.
*
* @return array
*/
public function responseFor404(): array
{
return [
'status' => 'error',
'message' => 'not found',
'code' => 404,
];
}
/**
* Creates the response array for
* an exception. Kirby exceptions will
* have more information
*
* @param \Throwable $e
* @return array
*/
public function responseForException(Throwable $e): array
{
// prepare the result array for all exception types
$result = [
'status' => 'error',
'message' => $e->getMessage(),
'code' => empty($e->getCode()) === true ? 500 : $e->getCode(),
'exception' => get_class($e),
'key' => null,
'file' => F::relativepath($e->getFile(), $_SERVER['DOCUMENT_ROOT'] ?? null),
'line' => $e->getLine(),
'details' => [],
'route' => $this->route ? $this->route->pattern() : null
];
// extend the information for Kirby Exceptions
if (is_a($e, 'Kirby\Exception\Exception') === true) {
$result['key'] = $e->getKey();
$result['details'] = $e->getDetails();
$result['code'] = $e->getHttpCode();
}
// remove critical info from the result set if
// debug mode is switched off
if ($this->debug !== true) {
unset(
$result['file'],
$result['exception'],
$result['line'],
$result['route']
);
}
return $result;
}
/**
* 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, $single = false, $debug = false): array
{
$trials = 0;
$uploads = [];
$errors = [];
$files = $this->requestFiles();
// get error messages from translation
$errorMessages = [
UPLOAD_ERR_INI_SIZE => t('upload.error.iniSize'),
UPLOAD_ERR_FORM_SIZE => t('upload.error.formSize'),
UPLOAD_ERR_PARTIAL => t('upload.error.partial'),
UPLOAD_ERR_NO_FILE => t('upload.error.noFile'),
UPLOAD_ERR_NO_TMP_DIR => t('upload.error.tmpDir'),
UPLOAD_ERR_CANT_WRITE => t('upload.error.cantWrite'),
UPLOAD_ERR_EXTENSION => t('upload.error.extension')
];
if (empty($files) === true) {
$postMaxSize = Str::toBytes(ini_get('post_max_size'));
$uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));
if ($postMaxSize < $uploadMaxFileSize) {
throw new Exception(t('upload.error.iniPostSize'));
} else {
throw new Exception(t('upload.error.noFiles'));
}
}
foreach ($files as $upload) {
if (isset($upload['tmp_name']) === false && is_array($upload)) {
continue;
}
$trials++;
try {
if ($upload['error'] !== 0) {
$errorMessage = $errorMessages[$upload['error']] ?? t('upload.error.default');
throw new Exception($errorMessage);
}
// get the extension of the uploaded file
$extension = F::extension($upload['name']);
// try to detect the correct mime and add the extension
// accordingly. This will avoid .tmp filenames
if (empty($extension) === true || in_array($extension, ['tmp', 'temp'])) {
$mime = F::mime($upload['tmp_name']);
$extension = F::mimeToExtension($mime);
$filename = F::name($upload['name']) . '.' . $extension;
} else {
$filename = basename($upload['name']);
}
$source = dirname($upload['tmp_name']) . '/' . uniqid() . '.' . $filename;
// 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(t('upload.error.cantMove'));
}
$data = $callback($source, $filename);
if (is_object($data) === true) {
$data = $this->resolve($data)->toArray();
}
$uploads[$upload['name']] = $data;
} catch (Exception $e) {
$errors[$upload['name']] = $e->getMessage();
}
if ($single === true) {
break;
}
}
// return a single upload response
if ($trials === 1) {
if (empty($errors) === false) {
return [
'status' => 'error',
'message' => current($errors)
];
}
return [
'status' => 'ok',
'data' => current($uploads)
];
}
if (empty($errors) === false) {
return [
'status' => 'error',
'errors' => $errors
];
}
return [
'status' => 'ok',
'data' => $uploads
];
}
}

View file

@ -0,0 +1,178 @@
<?php
namespace Kirby\Api;
use Exception;
use Kirby\Toolkit\Str;
/**
* The Collection class is a wrapper
* around our Kirby Collections and handles
* stuff like pagination and proper JSON output
* for collections in REST calls.
*
* @package Kirby Api
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Collection
{
/**
* @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(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 (is_a($schema['default'] ?? null, 'Closure') === false) {
throw new Exception('Missing collection data');
}
$this->data = $schema['default']->call($this->api);
}
if (
isset($schema['type']) === true &&
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)
{
if ($keys === false) {
return $this;
}
if (is_string($keys)) {
$keys = Str::split($keys);
}
if ($keys !== null && is_array($keys) === false) {
throw new Exception('Invalid select keys');
}
$this->select = $keys;
return $this;
}
/**
* @return array
* @throws \Kirby\Exception\NotFoundException
* @throws \Exception
*/
public function toArray(): array
{
$result = [];
foreach ($this->data as $item) {
$model = $this->api->model($this->model, $item);
if ($this->view !== null) {
$model = $model->view($this->view);
}
if ($this->select !== null) {
$model = $model->select($this->select);
}
$result[] = $model->toArray();
}
return $result;
}
/**
* @return array
* @throws \Kirby\Exception\NotFoundException
* @throws \Exception
*/
public function toResponse(): array
{
if ($query = $this->api->requestQuery('query')) {
$this->data = $this->data->query($query);
}
if (!$this->data->pagination()) {
$this->data = $this->data->paginate([
'page' => $this->api->requestQuery('page', 1),
'limit' => $this->api->requestQuery('limit', 100)
]);
}
$pagination = $this->data->pagination();
if ($select = $this->api->requestQuery('select')) {
$this->select($select);
}
if ($view = $this->api->requestQuery('view')) {
$this->view($view);
}
return [
'code' => 200,
'data' => $this->toArray(),
'pagination' => [
'page' => $pagination->page(),
'total' => $pagination->total(),
'offset' => $pagination->offset(),
'limit' => $pagination->limit(),
],
'status' => 'ok',
'type' => 'collection'
];
}
/**
* @param string $view
* @return $this
*/
public function view(string $view)
{
$this->view = $view;
return $this;
}
}

248
kirby/src/Api/Model.php Normal file
View file

@ -0,0 +1,248 @@
<?php
namespace Kirby\Api;
use Exception;
use Kirby\Toolkit\Str;
/**
* The API Model class can be wrapped around any
* kind of object. Each model defines a set of properties that
* are available in REST calls. Those properties are defined as
* simple Closures which are resolved on demand. This is inspired
* by GraphQLs architecture and makes it possible to load
* only the model data that is needed for the current API call.
*
* @package Kirby Api
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Model
{
/**
* @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(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)) {
$this->view('default');
}
if ($data === null) {
if (is_a($schema['default'] ?? null, 'Closure') === false) {
throw new Exception('Missing model data');
}
$this->data = $schema['default']->call($this->api);
}
if (
isset($schema['type']) === true &&
is_a($this->data, $schema['type']) === false
) {
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)
{
if ($keys === false) {
return $this;
}
if (is_string($keys)) {
$keys = Str::split($keys);
}
if ($keys !== null && is_array($keys) === false) {
throw new Exception('Invalid select keys');
}
$this->select = $keys;
return $this;
}
/**
* @return array
* @throws \Exception
*/
public function selection(): array
{
$select = $this->select;
if ($select === null) {
$select = array_keys($this->fields);
}
$selection = [];
foreach ($select as $key => $value) {
if (is_int($key) === true) {
$selection[$value] = [
'view' => null,
'select' => null
];
continue;
}
if (is_string($value) === true) {
if ($value === 'any') {
throw new Exception('Invalid sub view: "any"');
}
$selection[$key] = [
'view' => $value,
'select' => null
];
continue;
}
if (is_array($value) === true) {
$selection[$key] = [
'view' => null,
'select' => $value
];
}
}
return $selection;
}
/**
* @return array
* @throws \Kirby\Exception\NotFoundException
* @throws \Exception
*/
public function toArray(): array
{
$select = $this->selection();
$result = [];
foreach ($this->fields as $key => $resolver) {
if (array_key_exists($key, $select) === false || is_a($resolver, 'Closure') === false) {
continue;
}
$value = $resolver->call($this->api, $this->data);
if (is_object($value)) {
$value = $this->api->resolve($value);
}
if (
is_a($value, 'Kirby\Api\Collection') === true ||
is_a($value, 'Kirby\Api\Model') === true
) {
$selection = $select[$key];
if ($subview = $selection['view']) {
$value->view($subview);
}
if ($subselect = $selection['select']) {
$value->select($subselect);
}
$value = $value->toArray();
}
$result[$key] = $value;
}
ksort($result);
return $result;
}
/**
* @return array
* @throws \Kirby\Exception\NotFoundException
* @throws \Exception
*/
public function toResponse(): array
{
$model = $this;
if ($select = $this->api->requestQuery('select')) {
$model = $model->select($select);
}
if ($view = $this->api->requestQuery('view')) {
$model = $model->view($view);
}
return [
'code' => 200,
'data' => $model->toArray(),
'status' => 'ok',
'type' => 'model'
];
}
/**
* @param string $name
* @return $this
* @throws \Exception
*/
public function view(string $name)
{
if ($name === 'any') {
return $this->select(null);
}
if (isset($this->views[$name]) === false) {
$name = 'default';
// try to fall back to the default view at least
if (isset($this->views[$name]) === false) {
throw new Exception(sprintf('The view "%s" does not exist', $name));
}
}
return $this->select($this->views[$name]);
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace Kirby\Cache;
use APCUIterator;
/**
* APCu Cache Driver
*
* @package Kirby Cache
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class ApcuCache extends Cache
{
/**
* Determines if an item exists in the cache
*
* @param string $key
* @return bool
*/
public function exists(string $key): bool
{
return apcu_exists($this->key($key));
}
/**
* 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();
}
}
/**
* Removes an item from the cache and returns
* whether the operation was successful
*
* @param string $key
* @return bool
*/
public function remove(string $key): bool
{
return apcu_delete($this->key($key));
}
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*
* @param string $key
* @return \Kirby\Cache\Value|null
*/
public function retrieve(string $key)
{
return Value::fromJson(apcu_fetch($this->key($key)));
}
/**
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
* <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 set(string $key, $value, int $minutes = 0): bool
{
return apcu_store($this->key($key), (new Value($value, $minutes))->toJson(), $this->expiration($minutes));
}
}

242
kirby/src/Cache/Cache.php Normal file
View file

@ -0,0 +1,242 @@
<?php
namespace Kirby\Cache;
/**
* Cache foundation
* This abstract class is used as
* foundation for other cache drivers
* by extending it
*
* @package Kirby Cache
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
abstract class Cache
{
/**
* Stores all options for the driver
* @var array
*/
protected $options = [];
/**
* Sets all parameters which are needed to connect to the cache storage
*
* @param array $options
*/
public function __construct(array $options = [])
{
$this->options = $options;
}
/**
* 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
*/
abstract public function set(string $key, $value, int $minutes = 0): bool;
/**
* 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 $key;
}
/**
* 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
*/
abstract public function retrieve(string $key);
/**
* 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 get(string $key, $default = null)
{
// 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
{
// 0 = keep forever
if ($minutes === 0) {
return 0;
}
// calculate the time
return time() + ($minutes * 60);
}
/**
* Checks when an item in the cache expires;
* returns the expiry timestamp on success, null if the
* item never expires and false if the item does not exist
*
* @param string $key
* @return int|null|false
*/
public function expires(string $key)
{
// get the Value object
$value = $this->retrieve($key);
// check for a valid Value object
if (!is_a($value, 'Kirby\Cache\Value')) {
return false;
}
// return the expires timestamp
return $value->expires();
}
/**
* Checks if an item in the cache is expired
*
* @param string $key
* @return bool
*/
public function expired(string $key): bool
{
$expires = $this->expires($key);
if ($expires === null) {
return false;
} elseif (!is_int($expires)) {
return true;
} else {
return time() >= $expires;
}
}
/**
* 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 int|false
*/
public function created(string $key)
{
// get the Value object
$value = $this->retrieve($key);
// check for a valid Value object
if (!is_a($value, 'Kirby\Cache\Value')) {
return false;
}
// return the expires timestamp
return $value->created();
}
/**
* Alternate version for Cache::created($key)
*
* @param string $key
* @return int|false
*/
public function modified(string $key)
{
return static::created($key);
}
/**
* Determines if an item exists in the cache
*
* @param string $key
* @return bool
*/
public function exists(string $key): bool
{
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;
/**
* Flushes the entire cache and returns
* whether the operation was successful;
* this needs to be defined by the driver
*
* @return bool
*/
abstract public function flush(): bool;
/**
* Returns all passed cache options
*
* @return array
*/
public function options(): array
{
return $this->options;
}
}

View file

@ -0,0 +1,234 @@
<?php
namespace Kirby\Cache;
use Kirby\Exception\Exception;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Str;
/**
* File System Cache Driver
*
* @package Kirby Cache
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class FileCache extends Cache
{
/**
* Full root including prefix
*
* @var string
*/
protected $root;
/**
* Sets all parameters which are needed for the file cache
*
* @param array $options 'root' (required)
* 'prefix' (default: none)
* 'extension' (file extension for cache files, default: none)
*/
public function __construct(array $options)
{
$defaults = [
'root' => null,
'prefix' => null,
'extension' => null
];
parent::__construct(array_merge($defaults, $options));
// build the full root including prefix
$this->root = $this->options['root'];
if (empty($this->options['prefix']) === false) {
$this->root .= '/' . $this->options['prefix'];
}
// try to create the directory
Dir::make($this->root, true);
}
/**
* Returns the full root including prefix
*
* @return string
*/
public function root(): string
{
return $this->root;
}
/**
* Returns the full path to a file for a given key
*
* @param string $key
* @return string
*/
protected function file(string $key): string
{
// strip out invalid characters in each path segment
// split by slash or backslash
$keyParts = [];
foreach (preg_split('#([\/\\\\])#', $key, 0, PREG_SPLIT_DELIM_CAPTURE) as $part) {
switch ($part) {
// forward slashes don't need special treatment
case '/':
break;
// backslashes get their own marker in the path
// to differentiate the cache key from one with forward slashes
case '\\':
$keyParts[] = '_backslash';
break;
// empty part means two slashes in a row;
// special marker like for backslashes
case '':
$keyParts[] = '_empty';
break;
// an actual path segment
default:
// check if the segment only contains safe characters;
// underscores are *not* safe to guarantee uniqueness
// as they are used in the special cases
if (preg_match('/^[a-zA-Z0-9-]+$/', $part) === 1) {
$keyParts[] = $part;
} else {
$keyParts[] = Str::slug($part) . '_' . sha1($part);
}
}
}
$file = $this->root . '/' . implode('/', $keyParts);
if (isset($this->options['extension'])) {
return $file . '.' . $this->options['extension'];
} else {
return $file;
}
}
/**
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
* <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 set(string $key, $value, int $minutes = 0): bool
{
$file = $this->file($key);
return F::write($file, (new Value($value, $minutes))->toJson());
}
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*
* @param string $key
* @return \Kirby\Cache\Value|null
*/
public function retrieve(string $key)
{
$file = $this->file($key);
$value = F::read($file);
return $value ? Value::fromJson($value) : null;
}
/**
* Checks when the cache has been created;
* returns the creation timestamp on success
* and false if the item does not exist
*
* @param string $key
* @return mixed
*/
public function created(string $key)
{
// use the modification timestamp
// as indicator when the cache has been created/overwritten
clearstatcache();
// get the file for this cache key
$file = $this->file($key);
return file_exists($file) ? filemtime($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
{
$file = $this->file($key);
if (is_file($file) === true && F::remove($file) === true) {
$this->removeEmptyDirectories(dirname($file));
return true;
}
return false;
}
/**
* Removes empty directories safely by checking each directory
* up to the root directory
*
* @param string $dir
* @return void
*/
protected function removeEmptyDirectories(string $dir): void
{
try {
// ensure the path doesn't end with a slash for the next comparison
$dir = rtrim($dir, '/\/');
// checks all directory segments until reaching the root directory
while (Str::startsWith($dir, $this->root()) === true && $dir !== $this->root()) {
$files = array_diff(scandir($dir) ?? [], ['.', '..']);
if (empty($files) === true && Dir::remove($dir) === true) {
// continue with the next level up
$dir = dirname($dir);
} else {
// no need to continue with the next level up as `$dir` was not deleted
break;
}
}
} catch (Exception $e) { // @codeCoverageIgnore
// silently stops the process
}
}
/**
* 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) {
return true;
}
return false; // @codeCoverageIgnore
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace Kirby\Cache;
use Memcached as MemcachedExt;
/**
* Memcached Driver
*
* @package Kirby Cache
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class MemCached extends Cache
{
/**
* store for the memcache connection
* @var \Memcached
*/
protected $connection;
/**
* Sets all parameters which are needed to connect to Memcached
*
* @param array $options 'host' (default: localhost)
* 'port' (default: 11211)
* 'prefix' (default: null)
*/
public function __construct(array $options = [])
{
$defaults = [
'host' => 'localhost',
'port' => 11211,
'prefix' => null,
];
parent::__construct(array_merge($defaults, $options));
$this->connection = new MemcachedExt();
$this->connection->addServer($this->options['host'], $this->options['port']);
}
/**
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
* <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 set(string $key, $value, int $minutes = 0): bool
{
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)
{
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
{
return $this->connection->delete($this->key($key));
}
/**
* Flushes the entire cache and returns
* whether the operation was successful;
* WARNING: Memcached only supports flushing the whole cache at once!
*
* @return bool
*/
public function flush(): bool
{
return $this->connection->flush();
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace Kirby\Cache;
/**
* Memory Cache Driver (cache in memory for current request only)
*
* @package Kirby Cache
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class MemoryCache extends Cache
{
/**
* Cache data
* @var array
*/
protected $store = [];
/**
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
* <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 set(string $key, $value, int $minutes = 0): bool
{
$this->store[$key] = new Value($value, $minutes);
return true;
}
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*
* @param string $key
* @return \Kirby\Cache\Value|null
*/
public function retrieve(string $key)
{
return $this->store[$key] ?? null;
}
/**
* 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;
}
}
/**
* Flushes the entire cache and returns
* whether the operation was successful
*
* @return bool
*/
public function flush(): bool
{
$this->store = [];
return true;
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace Kirby\Cache;
/**
* Dummy Cache Driver (does not do any caching)
*
* @package Kirby Cache
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class NullCache extends Cache
{
/**
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
* <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 set(string $key, $value, int $minutes = 0): bool
{
return true;
}
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*
* @param string $key
* @return \Kirby\Cache\Value|null
*/
public function retrieve(string $key)
{
return null;
}
/**
* Removes an item from the cache and returns
* whether the operation was successful
*
* @param string $key
* @return bool
*/
public function remove(string $key): bool
{
return true;
}
/**
* Flushes the entire cache and returns
* whether the operation was successful
*
* @return bool
*/
public function flush(): bool
{
return true;
}
}

152
kirby/src/Cache/Value.php Normal file
View file

@ -0,0 +1,152 @@
<?php
namespace Kirby\Cache;
use Throwable;
/**
* Cache Value
* Stores the value, creation timestamp and expiration timestamp
* and makes it possible to store all three with a single cache key
*
* @package Kirby Cache
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Value
{
/**
* Cached value
* @var mixed
*/
protected $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 $minutes;
/**
* Creation timestamp
* @var int
*/
protected $created;
/**
* Constructor
*
* @param mixed $value
* @param int $minutes the number of minutes until the value expires
* or an absolute UNIX timestamp
* @param int $created the UNIX timestamp when the value has been created
*/
public function __construct($value, int $minutes = 0, int $created = null)
{
$this->value = $value;
$this->minutes = $minutes ?? 0;
$this->created = $created ?? time();
}
/**
* Returns the creation date as UNIX timestamp
*
* @return int
*/
public function created(): int
{
return $this->created;
}
/**
* Returns the expiration date as UNIX timestamp or
* null if the value never expires
*
* @return int|null
*/
public function expires(): ?int
{
// 0 = keep forever
if ($this->minutes === 0) {
return null;
}
if ($this->minutes > 1000000000) {
// absolute timestamp
return $this->minutes;
}
return $this->created + ($this->minutes * 60);
}
/**
* Creates a value object from an array
*
* @param array $array
* @return static
*/
public static function fromArray(array $array)
{
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)
{
try {
$array = json_decode($json, true);
if (is_array($array)) {
return static::fromArray($array);
} else {
return null;
}
} catch (Throwable $e) {
return null;
}
}
/**
* Converts the object to a JSON string
*
* @return string
*/
public function toJson(): string
{
return json_encode($this->toArray());
}
/**
* Converts the object to an array
*
* @return array
*/
public function toArray(): array
{
return [
'created' => $this->created,
'minutes' => $this->minutes,
'value' => $this->value,
];
}
/**
* Returns the pure value
*
* @return mixed
*/
public function value()
{
return $this->value;
}
}

245
kirby/src/Cms/Api.php Normal file
View file

@ -0,0 +1,245 @@
<?php
namespace Kirby\Cms;
use Kirby\Api\Api as BaseApi;
use Kirby\Exception\NotFoundException;
use Kirby\Form\Form;
/**
* Api
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Api extends BaseApi
{
/**
* @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 $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);
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);
}
/**
* @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($model, string $name, string $path = null)
{
$field = Form::for($model)->field($name);
$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|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 = null, string $filename)
{
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)
{
return Find::parent($path);
}
/**
* Returns the Kirby instance
*
* @return \Kirby\Cms\App
*/
public function kirby()
{
return $this->kirby;
}
/**
* Returns the language request header
*
* @return string|null
*/
public function language(): ?string
{
return get('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)
{
return Find::page($id);
}
/**
* 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 $parentId = null, string $status = null)
{
$parent = $parentId === null ? $this->site() : $this->page($parentId);
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 $parent = null)
{
$pages = $this->pages($parent, $this->requestQuery('status'));
if ($this->requestMethod() === 'GET') {
return $pages->search($this->requestQuery('q'));
}
return $pages->query($this->requestBody());
}
/**
* Returns the current Session instance
*
* @param array $options Additional options, see the session component
* @return \Kirby\Session\Session
*/
public function session(array $options = [])
{
return $this->kirby->session(array_merge([
'detect' => true
], $options));
}
/**
* Setter for the parent Kirby instance
*
* @param \Kirby\Cms\App $kirby
* @return $this
*/
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();
}
/**
* Returns the user object for the given id or
* returns the current authenticated user if no
* id is passed
*
* @param string|null $id User's id
* @return \Kirby\Cms\User|null
* @throws \Kirby\Exception\NotFoundException if the user for the given id cannot be found
*/
public function user(string $id = null)
{
try {
return Find::user($id);
} catch (NotFoundException $e) {
if ($id === null) {
return null;
}
throw $e;
}
}
/**
* Returns the users collection
*
* @return \Kirby\Cms\Users
*/
public function users()
{
return $this->kirby->users();
}
}

1659
kirby/src/Cms/App.php Normal file

File diff suppressed because it is too large Load diff

137
kirby/src/Cms/AppCaches.php Normal file
View file

@ -0,0 +1,137 @@
<?php
namespace Kirby\Cms;
use Kirby\Cache\NullCache;
use Kirby\Exception\InvalidArgumentException;
/**
* AppCaches
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait AppCaches
{
protected $caches = [];
/**
* Returns a cache instance by key
*
* @param string $key
* @return \Kirby\Cache\Cache
*/
public function cache(string $key)
{
if (isset($this->caches[$key]) === true) {
return $this->caches[$key];
}
// get the options for this cache type
$options = $this->cacheOptions($key);
if ($options['active'] === false) {
// use a dummy cache that does nothing
return $this->caches[$key] = new NullCache();
}
$type = strtolower($options['type']);
$types = $this->extensions['cacheTypes'] ?? [];
if (array_key_exists($type, $types) === false) {
throw new InvalidArgumentException([
'key' => 'app.invalid.cacheType',
'data' => ['type' => $type]
]);
}
$className = $types[$type];
// initialize the cache class
$cache = new $className($options);
// check if it is a usable cache object
if (is_a($cache, 'Kirby\Cache\Cache') !== true) {
throw new InvalidArgumentException([
'key' => 'app.invalid.cacheType',
'data' => ['type' => $type]
]);
}
return $this->caches[$key] = $cache;
}
/**
* Returns the cache options by key
*
* @param string $key
* @return array
*/
protected function cacheOptions(string $key): array
{
$options = $this->option($this->cacheOptionsKey($key), false);
if ($options === false) {
return [
'active' => false
];
}
$prefix = str_replace(['/', ':'], '_', $this->system()->indexUrl()) .
'/' .
str_replace('.', '/', $key);
$defaults = [
'active' => true,
'type' => 'file',
'extension' => 'cache',
'root' => $this->root('cache'),
'prefix' => $prefix
];
if ($options === true) {
return $defaults;
} else {
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
{
$prefixedKey = 'cache.' . $key;
if (isset($this->options[$prefixedKey])) {
return $prefixedKey;
}
// plain keys without dots don't need further investigation
// since they can never be from a plugin.
if (strpos($key, '.') === false) {
return $prefixedKey;
}
// try to extract the plugin name
$parts = explode('.', $key);
$pluginName = implode('/', array_slice($parts, 0, 2));
$pluginPrefix = implode('.', array_slice($parts, 0, 2));
$cacheName = implode('.', array_slice($parts, 2));
// check if such a plugin exists
if ($this->plugin($pluginName)) {
return empty($cacheName) === true ? $pluginPrefix . '.cache' : $pluginPrefix . '.cache.' . $cacheName;
}
return $prefixedKey;
}
}

204
kirby/src/Cms/AppErrors.php Normal file
View file

@ -0,0 +1,204 @@
<?php
namespace Kirby\Cms;
use Kirby\Http\Response;
use Kirby\Toolkit\I18n;
use Whoops\Handler\CallbackHandler;
use Whoops\Handler\Handler;
use Whoops\Handler\PlainTextHandler;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Run as Whoops;
/**
* PHP error handling using the Whoops library
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait AppErrors
{
/**
* Whoops instance cache
*
* @var \Whoops\Run
*/
protected $whoops;
/**
* Registers the PHP error handler for CLI usage
*
* @return void
*/
protected function handleCliErrors(): void
{
$this->setWhoopsHandler(new PlainTextHandler());
}
/**
* Registers the PHP error handler
* based on the environment
*
* @return void
*/
protected function handleErrors(): void
{
if ($this->request()->cli() === true) {
$this->handleCliErrors();
return;
}
if ($this->visitor()->prefersJson() === true) {
$this->handleJsonErrors();
return;
}
$this->handleHtmlErrors();
}
/**
* 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) === true) {
$handler = new PrettyPageHandler();
$handler->setPageTitle('Kirby CMS Debugger');
$handler->setResourcesPath(dirname(__DIR__, 2) . '/assets');
$handler->addCustomCss('whoops.css');
if ($editor = $this->option('editor')) {
$handler->setEditor($editor);
}
}
} else {
$handler = new CallbackHandler(function ($exception, $inspector, $run) {
$fatal = $this->option('fatal');
if (is_a($fatal, 'Closure') === true) {
echo $fatal($this, $exception);
} else {
include $this->root('kirby') . '/views/fatal.php';
}
return Handler::QUIT;
});
}
if ($handler !== null) {
$this->setWhoopsHandler($handler);
} else {
$this->unsetWhoopsHandler();
}
}
/**
* Registers the PHP error handler for JSON output
*
* @return void
*/
protected function handleJsonErrors(): void
{
$handler = new CallbackHandler(function ($exception, $inspector, $run) {
if (is_a($exception, 'Kirby\Exception\Exception') === true) {
$httpCode = $exception->getHttpCode();
$code = $exception->getCode();
$details = $exception->getDetails();
} elseif (is_a($exception, '\Throwable') === true) {
$httpCode = 500;
$code = $exception->getCode();
$details = null;
} else {
$httpCode = 500;
$code = 500;
$details = null;
}
if ($this->option('debug') === true) {
echo Response::json([
'status' => 'error',
'exception' => get_class($exception),
'code' => $code,
'message' => $exception->getMessage(),
'details' => $details,
'file' => ltrim($exception->getFile(), $_SERVER['DOCUMENT_ROOT'] ?? ''),
'line' => $exception->getLine(),
], $httpCode);
} else {
echo Response::json([
'status' => 'error',
'code' => $code,
'details' => $details,
'message' => I18n::translate('error.unexpected'),
], $httpCode);
}
return Handler::QUIT;
});
$this->setWhoopsHandler($handler);
$this->whoops()->sendHttpCode(false);
}
/**
* Enables Whoops with the specified handler
*
* @param Callable|\Whoops\Handler\HandlerInterface $handler
* @return void
*/
protected function setWhoopsHandler($handler): void
{
$whoops = $this->whoops();
$whoops->clearHandlers();
$whoops->pushHandler($handler);
$whoops->pushHandler($this->getExceptionHookWhoopsHandler());
$whoops->register(); // will only do something if not already registered
}
/**
* Initializes a callback handler for triggering the `system.exception` hook
*
* @return \Whoops\Handler\CallbackHandler
*/
protected function getExceptionHookWhoopsHandler(): CallbackHandler
{
return new CallbackHandler(function ($exception, $inspector, $run) {
$this->trigger('system.exception', compact('exception'));
return Handler::DONE;
});
}
/**
* Clears the Whoops handlers and disables Whoops
*
* @return void
*/
protected function unsetWhoopsHandler(): void
{
$whoops = $this->whoops();
$whoops->clearHandlers();
$whoops->unregister(); // will only do something if currently registered
}
/**
* Returns the Whoops error handler instance
*
* @return \Whoops\Run
*/
protected function whoops()
{
if ($this->whoops !== null) {
return $this->whoops;
}
return $this->whoops = new Whoops();
}
}

View file

@ -0,0 +1,890 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\DuplicateException;
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;
use Kirby\Toolkit\V;
/**
* AppPlugins
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait AppPlugins
{
/**
* A list of all registered plugins
*
* @var array
*/
protected static $plugins = [];
/**
* The extension registry
*
* @var array
*/
protected $extensions = [
// load options first to make them available for the rest
'options' => [],
// other plugin types
'api' => [],
'areas' => [],
'authChallenges' => [],
'blockMethods' => [],
'blockModels' => [],
'blocksMethods' => [],
'blueprints' => [],
'cacheTypes' => [],
'collections' => [],
'components' => [],
'controllers' => [],
'collectionFilters' => [],
'collectionMethods' => [],
'fieldMethods' => [],
'fileMethods' => [],
'fileTypes' => [],
'filesMethods' => [],
'fields' => [],
'hooks' => [],
'layoutMethods' => [],
'layoutColumnMethods' => [],
'layoutsMethods' => [],
'pages' => [],
'pageMethods' => [],
'pagesMethods' => [],
'pageModels' => [],
'permissions' => [],
'routes' => [],
'sections' => [],
'siteMethods' => [],
'snippets' => [],
'tags' => [],
'templates' => [],
'thirdParty' => [],
'translations' => [],
'userMethods' => [],
'userModels' => [],
'usersMethods' => [],
'validators' => [],
];
/**
* Flag when plugins have been loaded
* to not load them again
*
* @var bool
*/
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
{
foreach ($this->extensions as $type => $registered) {
if (isset($extensions[$type]) === true) {
$this->{'extend' . $type}($extensions[$type], $plugin);
}
}
return $this->extensions;
}
/**
* Registers API extensions
*
* @param array|bool $api
* @return array
*/
protected function extendApi($api): array
{
if (is_array($api) === true) {
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'];
}
}
/**
* Registers additional custom Panel areas
*
* @param array $areas
* @return array
*/
protected function extendAreas(array $areas): array
{
foreach ($areas as $id => $area) {
if (isset($this->extensions['areas'][$id]) === false) {
$this->extensions['areas'][$id] = [];
}
$this->extensions['areas'][$id][] = $area;
}
return $this->extensions['areas'];
}
/**
* Registers additional authentication challenges
*
* @param array $challenges
* @return array
*/
protected function extendAuthChallenges(array $challenges): array
{
return $this->extensions['authChallenges'] = Auth::$challenges = array_merge(Auth::$challenges, $challenges);
}
/**
* Registers additional block methods
*
* @param array $methods
* @return array
*/
protected function extendBlockMethods(array $methods): array
{
return $this->extensions['blockMethods'] = Block::$methods = array_merge(Block::$methods, $methods);
}
/**
* Registers additional block models
*
* @param array $models
* @return array
*/
protected function extendBlockModels(array $models): array
{
return $this->extensions['blockModels'] = Block::$models = array_merge(Block::$models, $models);
}
/**
* Registers additional blocks methods
*
* @param array $methods
* @return array
*/
protected function extendBlocksMethods(array $methods): array
{
return $this->extensions['blockMethods'] = Blocks::$methods = array_merge(Blocks::$methods, $methods);
}
/**
* Registers additional blueprints
*
* @param array $blueprints
* @return array
*/
protected function extendBlueprints(array $blueprints): array
{
return $this->extensions['blueprints'] = array_merge($this->extensions['blueprints'], $blueprints);
}
/**
* 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 collection filters
*
* @param array $filters
* @return array
*/
protected function extendCollectionFilters(array $filters): array
{
return $this->extensions['collectionFilters'] = ToolkitCollection::$filters = array_merge(ToolkitCollection::$filters, $filters);
}
/**
* Registers additional collection methods
*
* @param array $methods
* @return array
*/
protected function extendCollectionMethods(array $methods): array
{
return $this->extensions['collectionMethods'] = Collection::$methods = array_merge(Collection::$methods, $methods);
}
/**
* Registers additional collections
*
* @param array $collections
* @return array
*/
protected function extendCollections(array $collections): array
{
return $this->extensions['collections'] = array_merge($this->extensions['collections'], $collections);
}
/**
* Registers core components
*
* @param array $components
* @return array
*/
protected function extendComponents(array $components): array
{
return $this->extensions['components'] = array_merge($this->extensions['components'], $components);
}
/**
* Registers additional controllers
*
* @param array $controllers
* @return array
*/
protected function extendControllers(array $controllers): array
{
return $this->extensions['controllers'] = array_merge($this->extensions['controllers'], $controllers);
}
/**
* Registers additional file methods
*
* @param array $methods
* @return array
*/
protected function extendFileMethods(array $methods): array
{
return $this->extensions['fileMethods'] = File::$methods = array_merge(File::$methods, $methods);
}
/**
* Registers additional custom file types and mimes
*
* @param array $fileTypes
* @return array
*/
protected function extendFileTypes(array $fileTypes): array
{
// normalize array
foreach ($fileTypes as $ext => $file) {
$extension = $file['extension'] ?? $ext;
$type = $file['type'] ?? null;
$mime = $file['mime'] ?? null;
$resizable = $file['resizable'] ?? false;
$viewable = $file['viewable'] ?? false;
if (is_string($type) === true) {
if (isset(F::$types[$type]) === false) {
F::$types[$type] = [];
}
if (in_array($extension, F::$types[$type]) === false) {
F::$types[$type][] = $extension;
}
}
if ($mime !== null) {
if (array_key_exists($extension, Mime::$types) === true) {
// if `Mime::$types[$extension]` is not already an array, make it one
// and append the new MIME type unless it's already in the list
Mime::$types[$extension] = array_unique(array_merge((array)Mime::$types[$extension], (array)$mime));
} else {
Mime::$types[$extension] = $mime;
}
}
if ($resizable === true && in_array($extension, Image::$resizableTypes) === false) {
Image::$resizableTypes[] = $extension;
}
if ($viewable === true && in_array($extension, Image::$viewableTypes) === false) {
Image::$viewableTypes[] = $extension;
}
}
return $this->extensions['fileTypes'] = [
'type' => F::$types,
'mime' => Mime::$types,
'resizable' => Image::$resizableTypes,
'viewable' => Image::$viewableTypes
];
}
/**
* Registers additional files methods
*
* @param array $methods
* @return array
*/
protected function extendFilesMethods(array $methods): array
{
return $this->extensions['filesMethods'] = Files::$methods = array_merge(Files::$methods, $methods);
}
/**
* Registers additional field methods
*
* @param array $methods
* @return array
*/
protected function extendFieldMethods(array $methods): array
{
return $this->extensions['fieldMethods'] = Field::$methods = array_merge(Field::$methods, array_change_key_case($methods));
}
/**
* Registers Panel fields
*
* @param array $fields
* @return array
*/
protected function extendFields(array $fields): array
{
return $this->extensions['fields'] = FormField::$types = array_merge(FormField::$types, $fields);
}
/**
* Registers hooks
*
* @param array $hooks
* @return array
*/
protected function extendHooks(array $hooks): array
{
foreach ($hooks as $name => $callbacks) {
if (isset($this->extensions['hooks'][$name]) === false) {
$this->extensions['hooks'][$name] = [];
}
if (is_array($callbacks) === false) {
$callbacks = [$callbacks];
}
foreach ($callbacks as $callback) {
$this->extensions['hooks'][$name][] = $callback;
}
}
return $this->extensions['hooks'];
}
/**
* Registers markdown component
*
* @param Closure $markdown
* @return 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
{
return $this->extensions['layoutMethods'] = Layout::$methods = array_merge(Layout::$methods, $methods);
}
/**
* Registers additional layout column methods
*
* @param array $methods
* @return array
*/
protected function extendLayoutColumnMethods(array $methods): array
{
return $this->extensions['layoutColumnMethods'] = LayoutColumn::$methods = array_merge(LayoutColumn::$methods, $methods);
}
/**
* Registers additional layouts methods
*
* @param array $methods
* @return array
*/
protected function extendLayoutsMethods(array $methods): array
{
return $this->extensions['layoutsMethods'] = Layouts::$methods = array_merge(Layouts::$methods, $methods);
}
/**
* Registers additional options
*
* @param array $options
* @param \Kirby\Cms\Plugin|null $plugin
* @return array
*/
protected function extendOptions(array $options, Plugin $plugin = null): array
{
if ($plugin !== null) {
$options = [$plugin->prefix() => $options];
}
return $this->extensions['options'] = $this->options = A::merge($options, $this->options, A::MERGE_REPLACE);
}
/**
* Registers additional page methods
*
* @param array $methods
* @return array
*/
protected function extendPageMethods(array $methods): array
{
return $this->extensions['pageMethods'] = Page::$methods = array_merge(Page::$methods, $methods);
}
/**
* Registers additional pages methods
*
* @param array $methods
* @return array
*/
protected function extendPagesMethods(array $methods): array
{
return $this->extensions['pagesMethods'] = Pages::$methods = array_merge(Pages::$methods, $methods);
}
/**
* Registers additional page models
*
* @param array $models
* @return array
*/
protected function extendPageModels(array $models): array
{
return $this->extensions['pageModels'] = Page::$models = array_merge(Page::$models, $models);
}
/**
* Registers pages
*
* @param array $pages
* @return array
*/
protected function extendPages(array $pages): array
{
return $this->extensions['pages'] = array_merge($this->extensions['pages'], $pages);
}
/**
* Registers additional permissions
*
* @param array $permissions
* @param \Kirby\Cms\Plugin|null $plugin
* @return array
*/
protected function extendPermissions(array $permissions, Plugin $plugin = null): array
{
if ($plugin !== null) {
$permissions = [$plugin->prefix() => $permissions];
}
return $this->extensions['permissions'] = Permissions::$extendedActions = array_merge(Permissions::$extendedActions, $permissions);
}
/**
* Registers additional routes
*
* @param array|\Closure $routes
* @return array
*/
protected function extendRoutes($routes): array
{
if (is_a($routes, 'Closure') === true) {
$routes = $routes($this);
}
return $this->extensions['routes'] = array_merge($this->extensions['routes'], $routes);
}
/**
* Registers Panel sections
*
* @param array $sections
* @return array
*/
protected function extendSections(array $sections): array
{
return $this->extensions['sections'] = Section::$types = array_merge(Section::$types, $sections);
}
/**
* Registers additional site methods
*
* @param array $methods
* @return array
*/
protected function extendSiteMethods(array $methods): array
{
return $this->extensions['siteMethods'] = Site::$methods = array_merge(Site::$methods, $methods);
}
/**
* Registers SmartyPants component
*
* @param \Closure $smartypants
* @return \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 KirbyTags
*
* @param array $tags
* @return array
*/
protected function extendTags(array $tags): array
{
return $this->extensions['tags'] = KirbyTag::$types = array_merge(KirbyTag::$types, array_change_key_case($tags));
}
/**
* Registers additional templates
*
* @param array $templates
* @return array
*/
protected function extendTemplates(array $templates): array
{
return $this->extensions['templates'] = array_merge($this->extensions['templates'], $templates);
}
/**
* Registers translations
*
* @param array $translations
* @return array
*/
protected function extendTranslations(array $translations): array
{
return $this->extensions['translations'] = array_replace_recursive($this->extensions['translations'], $translations);
}
/**
* Add third party extensions to the registry
* so they can be used as plugins for plugins
* for example.
*
* @param array $extensions
* @return array
*/
protected function extendThirdParty(array $extensions): array
{
return $this->extensions['thirdParty'] = array_replace_recursive($this->extensions['thirdParty'], $extensions);
}
/**
* Registers additional user methods
*
* @param array $methods
* @return array
*/
protected function extendUserMethods(array $methods): array
{
return $this->extensions['userMethods'] = User::$methods = array_merge(User::$methods, $methods);
}
/**
* Registers additional user models
*
* @param array $models
* @return array
*/
protected function extendUserModels(array $models): array
{
return $this->extensions['userModels'] = User::$models = array_merge(User::$models, $models);
}
/**
* Registers additional users methods
*
* @param array $methods
* @return array
*/
protected function extendUsersMethods(array $methods): array
{
return $this->extensions['usersMethods'] = Users::$methods = array_merge(Users::$methods, $methods);
}
/**
* Registers additional custom validators
*
* @param array $validators
* @return array
*/
protected function extendValidators(array $validators): array
{
return $this->extensions['validators'] = V::$validators = array_merge(V::$validators, $validators);
}
/**
* Returns a given extension by type and name
*
* @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, $fallback = null)
{
return $this->extensions($type)[$name] ?? $fallback;
}
/**
* Returns the extensions registry
*
* @internal
* @param string|null $type
* @return array
*/
public function extensions(string $type = null)
{
if ($type === null) {
return $this->extensions;
}
return $this->extensions[$type] ?? [];
}
/**
* Load extensions from site folders.
* This is only used for models for now, but
* could be extended later
*/
protected function extensionsFromFolders()
{
$models = [];
foreach (glob($this->root('models') . '/*.php') as $model) {
$name = F::name($model);
$class = str_replace(['.', '-', '_'], '', $name) . 'Page';
// load the model class
F::loadOnce($model);
if (class_exists($class) === true) {
$models[$name] = $class;
}
}
$this->extendPageModels($models);
}
/**
* Register extensions that could be located in
* the options array. I.e. hooks and routes can be
* setup from the config.
*
* @return void
*/
protected function extensionsFromOptions()
{
// register routes and hooks from options
$this->extend([
'api' => $this->options['api'] ?? [],
'routes' => $this->options['routes'] ?? [],
'hooks' => $this->options['hooks'] ?? []
]);
}
/**
* Apply all plugin extensions
*
* @return void
*/
protected function extensionsFromPlugins()
{
// register all their extensions
foreach ($this->plugins() as $plugin) {
$extends = $plugin->extends();
if (empty($extends) === false) {
$this->extend($extends, $plugin);
}
}
}
/**
* Apply all passed extensions
*
* @param array $props
* @return void
*/
protected function extensionsFromProps(array $props)
{
$this->extend($props);
}
/**
* Apply all default extensions
*
* @return void
*/
protected function extensionsFromSystem()
{
// mixins
FormField::$mixins = $this->core->fieldMixins();
Section::$mixins = $this->core->sectionMixins();
// aliases
KirbyTag::$aliases = $this->core->kirbyTagAliases();
Field::$aliases = $this->core->fieldMethodAliases();
// blueprint presets
PageBlueprint::$presets = $this->core->blueprintPresets();
$this->extendAuthChallenges($this->core->authChallenges());
$this->extendCacheTypes($this->core->cacheTypes());
$this->extendComponents($this->core->components());
$this->extendBlueprints($this->core->blueprints());
$this->extendFields($this->core->fields());
$this->extendFieldMethods($this->core->fieldMethods());
$this->extendSections($this->core->sections());
$this->extendSnippets($this->core->snippets());
$this->extendTags($this->core->kirbyTags());
$this->extendTemplates($this->core->templates());
}
/**
* Returns the native implementation
* of a core component
*
* @param string $component
* @return \Closure|false
*/
public function nativeComponent(string $component)
{
return $this->core->components()[$component] ?? false;
}
/**
* 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)
{
if ($extends === null) {
return static::$plugins[$name] ?? null;
}
// get the correct root for the plugin
$extends['root'] = $extends['root'] ?? dirname(debug_backtrace()[0]['file']);
$plugin = new Plugin($name, $extends);
$name = $plugin->name();
if (isset(static::$plugins[$name]) === true) {
throw new DuplicateException('The plugin "' . $name . '" has already been registered');
}
return static::$plugins[$name] = $plugin;
}
/**
* Loads and returns all plugins in the site/plugins directory
* Loading only happens on the first call.
*
* @internal
* @param array|null $plugins Can be used to overwrite the plugins registry
* @return array
*/
public function plugins(array $plugins = null): array
{
// overwrite the existing plugins registry
if ($plugins !== null) {
$this->pluginsAreLoaded = true;
return static::$plugins = $plugins;
}
// don't load plugins twice
if ($this->pluginsAreLoaded === true) {
return static::$plugins;
}
// load all plugins from site/plugins
$this->pluginsLoader();
// mark plugins as loaded to stop doing it twice
$this->pluginsAreLoaded = true;
return static::$plugins;
}
/**
* Loads all plugins from site/plugins
*
* @return array Array of loaded directories
*/
protected function pluginsLoader(): array
{
$root = $this->root('plugins');
$loaded = [];
foreach (Dir::read($root) as $dirname) {
if (in_array(substr($dirname, 0, 1), ['.', '_']) === true) {
continue;
}
$dir = $root . '/' . $dirname;
$entry = $dir . '/index.php';
if (is_dir($dir) !== true || is_file($entry) !== true) {
continue;
}
F::loadOnce($entry);
$loaded[] = $dir;
}
return $loaded;
}
}

View file

@ -0,0 +1,237 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Locale;
use Kirby\Toolkit\Str;
/**
* AppTranslations
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait AppTranslations
{
protected $translations;
/**
* Setup internationalization
*
* @return void
*/
protected function i18n(): void
{
I18n::$load = function ($locale): array {
$data = [];
if ($translation = $this->translation($locale)) {
$data = $translation->data();
}
// inject translations from the current language
if (
$this->multilang() === true &&
$language = $this->languages()->find($locale)
) {
$data = array_merge($data, $language->translations());
}
return $data;
};
// the actual locale is set using $app->setCurrentTranslation()
I18n::$locale = function (): string {
if ($this->multilang() === true) {
return $this->defaultLanguage()->code();
} else {
return 'en';
}
};
I18n::$fallback = function (): array {
if ($this->multilang() === true) {
// first try to fall back to the configured default language
$defaultCode = $this->defaultLanguage()->code();
$fallback = [$defaultCode];
// if the default language is specified with a country code
// (e.g. `en-us`), also try with just the language code
if (preg_match('/^([a-z]{2})-[a-z]+$/i', $defaultCode, $matches) === 1) {
$fallback[] = $matches[1];
}
// fall back to the complete English translation
// as a last resort
$fallback[] = 'en';
return $fallback;
} else {
return ['en'];
}
};
I18n::$translations = [];
// add slug rules based on config option
if ($slugs = $this->option('slugs')) {
// two ways that the option can be defined:
// "slugs" => "de" or "slugs" => ["language" => "de"]
if ($slugs = $slugs['language'] ?? $slugs ?? null) {
Str::$language = Language::loadRules($slugs);
}
}
}
/**
* Returns the language code that will be used
* for the Panel if no user is logged in or if
* no language is configured for the user
*
* @return string
*/
public function panelLanguage(): string
{
if ($this->multilang() === true) {
$defaultCode = $this->defaultLanguage()->code();
// extract the language code from a language that
// contains the country code (e.g. `en-us`)
if (preg_match('/^([a-z]{2})-[a-z]+$/i', $defaultCode, $matches) === 1) {
$defaultCode = $matches[1];
}
} else {
$defaultCode = 'en';
}
return $this->option('panel.language', $defaultCode);
}
/**
* 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
{
I18n::$locale = $translationCode ?? 'en';
}
/**
* Set locale settings
*
* @deprecated 3.5.0 Use `\Kirby\Toolkit\Locale::set()` instead
* @todo Remove in 3.7.0
*
* @param string|array $locale
*/
public function setLocale($locale): void
{
// @codeCoverageIgnoreStart
deprecated('`Kirby\Cms\App::setLocale()` has been deprecated and will be removed in 3.7.0. Use `Kirby\Toolkit\Locale::set()` instead');
Locale::set($locale);
// @codeCoverageIgnoreEnd
}
/**
* 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 $locale = null)
{
$locale = $locale ?? I18n::locale();
$locale = basename($locale);
// prefer loading them from the translations collection
if (is_a($this->translations, 'Kirby\Cms\Translations') === true) {
if ($translation = $this->translations()->find($locale)) {
return $translation;
}
}
// get injected translation data from plugins etc.
$inject = $this->extensions['translations'][$locale] ?? [];
// inject current language translations
if ($language = $this->language($locale)) {
$inject = array_merge($inject, $language->translations());
}
// load from disk instead
return Translation::load($locale, $this->root('i18n:translations') . '/' . $locale . '.json', $inject);
}
/**
* Returns all available translations
*
* @return \Kirby\Cms\Translations
*/
public function translations()
{
if (is_a($this->translations, 'Kirby\Cms\Translations') === true) {
return $this->translations;
}
$translations = $this->extensions['translations'] ?? [];
// injects languages translations
if ($languages = $this->languages()) {
foreach ($languages as $language) {
$languageCode = $language->code();
$languageTranslations = $language->translations();
// merges language translations with extensions translations
if (empty($languageTranslations) === false) {
$translations[$languageCode] = array_merge(
$translations[$languageCode] ?? [],
$languageTranslations
);
}
}
}
$this->translations = Translations::load($this->root('i18n:translations'), $translations);
return $this->translations;
}
}

143
kirby/src/Cms/AppUsers.php Normal file
View file

@ -0,0 +1,143 @@
<?php
namespace Kirby\Cms;
use Closure;
use Throwable;
/**
* AppUsers
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait AppUsers
{
/**
* Cache for the auth auth layer
*
* @var Auth
*/
protected $auth;
/**
* Returns the Authentication layer class
*
* @internal
* @return \Kirby\Cms\Auth
*/
public function auth()
{
return $this->auth = $this->auth ?? new Auth($this);
}
/**
* Become any existing user or disable the current user
*
* @param string|null $who User ID or email address,
* `null` to use the actual user again,
* `'kirby'` for a virtual admin user or
* `'nobody'` to disable the actual user
* @param Closure|null $callback Optional action function that will be run with
* the permissions of the impersonated user; the
* impersonation will be reset afterwards
* @return mixed If called without callback: User that was impersonated;
* if called with callback: Return value from the callback
* @throws \Throwable
*/
public function impersonate(?string $who = null, ?Closure $callback = null)
{
$auth = $this->auth();
$userBefore = $auth->currentUserFromImpersonation();
$userAfter = $auth->impersonate($who);
if ($callback === null) {
return $userAfter;
}
try {
// 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 !== null ? $userBefore->id() : null);
}
}
/**
* Set the currently active user id
*
* @param \Kirby\Cms\User|string $user
* @return \Kirby\Cms\App
*/
protected function setUser($user = null)
{
$this->user = $user;
return $this;
}
/**
* Create your own set of app users
*
* @param array|null $users
* @return \Kirby\Cms\App
*/
protected function setUsers(array $users = null)
{
if ($users !== null) {
$this->users = Users::factory($users, [
'kirby' => $this
]);
}
return $this;
}
/**
* 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 $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);
} else {
try {
return $this->auth()->user(null, $allowImpersonation);
} catch (Throwable $e) {
return null;
}
}
}
/**
* Returns all users
*
* @return \Kirby\Cms\Users
*/
public function users()
{
if (is_a($this->users, 'Kirby\Cms\Users') === true) {
return $this->users;
}
return $this->users = Users::load($this->root('accounts'), ['kirby' => $this]);
}
}

887
kirby/src/Cms/Auth.php Normal file
View file

@ -0,0 +1,887 @@
<?php
namespace Kirby\Cms;
use Kirby\Cms\Auth\Status;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Exception\PermissionException;
use Kirby\Filesystem\F;
use Kirby\Http\Idn;
use Kirby\Http\Request\Auth\BasicAuth;
use Kirby\Toolkit\A;
use Throwable;
/**
* Authentication layer
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Auth
{
/**
* Available auth challenge classes
* from the core and plugins
*
* @var array
*/
public static $challenges = [];
/**
* Currently impersonated user
*
* @var \Kirby\Cms\User|null
*/
protected $impersonate;
/**
* Kirby instance
*
* @var \Kirby\Cms\App
*/
protected $kirby;
/**
* Cache of the auth status object
*
* @var \Kirby\Cms\Auth\Status
*/
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;
/**
* Exception that was thrown while
* determining the current user
*
* @var \Throwable
*/
protected $userException;
/**
* @param \Kirby\Cms\App $kirby
* @codeCoverageIgnore
*/
public function __construct(App $kirby)
{
$this->kirby = $kirby;
}
/**
* Creates an authentication challenge
* (one-time auth code)
* @since 3.5.0
*
* @param string $email
* @param bool $long If `true`, a long session will be created
* @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')
{
$email = $this->validateEmail($email);
// rate-limit the number of challenges for DoS/DDoS protection
$this->track($email, false);
$session = $this->kirby->session([
'createMode' => 'cookie',
'long' => $long === true
]);
$challenge = null;
if ($user = $this->kirby->users()->find($email)) {
$timeout = $this->kirby->option('auth.challenge.timeout', 10 * 60);
foreach ($this->enabledChallenges() as $name) {
$class = static::$challenges[$name] ?? null;
if (
$class &&
class_exists($class) === true &&
is_subclass_of($class, 'Kirby\Cms\Auth\Challenge') === true &&
$class::isAvailable($user, $mode) === true
) {
$challenge = $name;
$code = $class::create($user, compact('mode', 'timeout'));
$session->set('kirby.challenge.type', $challenge);
if ($code !== null) {
$session->set('kirby.challenge.code', password_hash($code, PASSWORD_DEFAULT));
$session->set('kirby.challenge.timeout', time() + $timeout);
}
break;
}
}
// if no suitable challenge was found, `$challenge === null` at this point;
// only leak this in debug mode
if ($challenge === null && $this->kirby->option('debug') === true) {
throw new LogicException('Could not find a suitable authentication challenge');
}
} else {
$this->kirby->trigger('user.login:failed', compact('email'));
// only leak the non-existing user in debug mode
if ($this->kirby->option('debug') === true) {
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
}
}
// 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);
// sleep for a random amount of milliseconds
// to make automated attacks harder and to
// avoid leaking whether the user exists
usleep(random_int(1000, 300000));
// clear the status cache
$this->status = null;
return $this->status($session, false);
}
/**
* Returns the csrf token if it exists and if it is valid
*
* @return string|false
*/
public function csrf()
{
// get the csrf from the header
$fromHeader = $this->kirby->request()->csrf();
// check for a predefined csrf or use the one from session
$fromSession = $this->csrfFromSession();
// compare both tokens
if (hash_equals((string)$fromSession, (string)$fromHeader) !== true) {
return false;
}
return $fromSession;
}
/**
* Returns either predefined csrf or the one from session
* @since 3.6.0
*
* @return string
*/
public function csrfFromSession(): string
{
$isDev = $this->kirby->option('panel.dev', false) !== false;
return $this->kirby->option('api.csrf', $isDev ? 'dev' : csrf());
}
/**
* Returns the logged in user by checking
* for a basic authentication header with
* 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)
{
if ($this->kirby->option('api.basicAuth', false) !== true) {
throw new PermissionException('Basic authentication is not activated');
}
// if logging in with password is disabled, basic auth cannot be possible either
$loginMethods = $this->kirby->system()->loginMethods();
if (isset($loginMethods['password']) !== true) {
throw new PermissionException('Login with password is not enabled');
}
// if any login method requires 2FA, basic auth without 2FA would be a weakness
foreach ($loginMethods as $method) {
if (isset($method['2fa']) === true && $method['2fa'] === true) {
throw new PermissionException('Basic authentication cannot be used with 2FA');
}
}
$request = $this->kirby->request();
$auth = $auth ?? $request->auth();
if (!$auth || $auth->type() !== 'basic') {
throw new InvalidArgumentException('Invalid authorization header');
}
// only allow basic auth when https is enabled or insecure requests permitted
if ($request->ssl() === false && $this->kirby->option('api.allowInsecure', false) !== true) {
throw new PermissionException('Basic authentication is only allowed over HTTPS');
}
return $this->validatePassword($auth->username(), $auth->password());
}
/**
* Returns the currently impersonated user
*
* @return \Kirby\Cms\User|null
*/
public function currentUserFromImpersonation()
{
return $this->impersonate;
}
/**
* 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 = null)
{
$session = $this->session($session);
$id = $session->data()->get('kirby.userId');
if (is_string($id) !== true) {
return null;
}
if ($user = $this->kirby->users()->find($id)) {
// in case the session needs to be updated, do it now
// for better performance
$session->commit();
return $user;
}
return null;
}
/**
* 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', ['email']));
}
/**
* Become any existing user or disable the current user
*
* @param string|null $who User ID or email address,
* `null` to use the actual user again,
* `'kirby'` for a virtual admin user or
* `'nobody'` to disable the actual user
* @return \Kirby\Cms\User|null
* @throws \Kirby\Exception\NotFoundException if the given user cannot be found
*/
public function impersonate(?string $who = null)
{
// clear the status cache
$this->status = null;
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
{
$hash = hash('sha256', $this->kirby->visitor()->ip());
// only use the first 50 chars to ensure privacy
return substr($hash, 0, 50);
}
/**
* Check if logins are blocked for the current ip or email
*
* @param string $email
* @return bool
*/
public function isBlocked(string $email): bool
{
$ip = $this->ipHash();
$log = $this->log();
$trials = $this->kirby->option('auth.trials', 10);
if ($entry = ($log['by-ip'][$ip] ?? null)) {
if ($entry['trials'] >= $trials) {
return true;
}
}
if ($this->kirby->users()->find($email)) {
if ($entry = ($log['by-email'][$email] ?? null)) {
if ($entry['trials'] >= $trials) {
return true;
}
}
}
return false;
}
/**
* Login a user by email and password
*
* @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, string $password, bool $long = false)
{
// session options
$options = [
'createMode' => 'cookie',
'long' => $long === true
];
// validate the user and log in to the session
$user = $this->validatePassword($email, $password);
$user->loginPasswordless($options);
// clear the status cache
$this->status = null;
return $user;
}
/**
* Login a user by email, password and auth challenge
* @since 3.5.0
*
* @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, string $password, bool $long = false)
{
$this->validatePassword($email, $password);
return $this->createChallenge($email, $long, '2fa');
}
/**
* 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;
// clear the status cache
$this->status = null;
}
/**
* 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 = null, bool $allowImpersonation = true)
{
// try to return from cache
if ($this->status && $session === null && $allowImpersonation === true) {
return $this->status;
}
$sessionObj = $this->session($session);
$props = ['kirby' => $this->kirby];
if ($user = $this->user($sessionObj, $allowImpersonation)) {
// a user is currently logged in
if ($allowImpersonation === true && $this->impersonate !== null) {
$props['status'] = 'impersonated';
} else {
$props['status'] = 'active';
}
$props['email'] = $user->email();
} elseif ($email = $sessionObj->get('kirby.challenge.email')) {
// a challenge is currently pending
$props['status'] = 'pending';
$props['email'] = $email;
$props['challenge'] = $sessionObj->get('kirby.challenge.type');
$props['challengeFallback'] = A::last($this->enabledChallenges());
} else {
// no active authentication
$props['status'] = 'inactive';
}
$status = new Status($props);
// only cache the default object
if ($session === null && $allowImpersonation === true) {
$this->status = $status;
}
return $status;
}
/**
* Ensures that email addresses with IDN domains are in Unicode format
* and that the rate limit was not exceeded
*
* @param string $email
* @return string The normalized Unicode email address
*
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded
*/
protected function validateEmail(string $email): string
{
// ensure that email addresses with IDN domains are in Unicode format
$email = Idn::decodeEmail($email);
// check for blocked ips
if ($this->isBlocked($email) === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
if ($this->kirby->option('debug') === true) {
$message = 'Rate limit exceeded';
} else {
// avoid leaking security-relevant information
$message = ['key' => 'access.login'];
}
throw new PermissionException($message);
}
return $email;
}
/**
* 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, string $password)
{
$email = $this->validateEmail($email);
// validate the user
try {
if ($user = $this->kirby->users()->find($email)) {
if ($user->validatePassword($password) === true) {
return $user;
}
}
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
} catch (Throwable $e) {
// log invalid login trial
$this->track($email);
// sleep for a random amount of milliseconds
// to make automated attacks harder
usleep(random_int(1000, 2000000));
// keep throwing the original error in debug mode,
// otherwise hide it to avoid leaking security-relevant information
if ($this->kirby->option('debug') === true) {
throw $e;
} else {
throw new PermissionException(['key' => 'access.login']);
}
}
}
/**
* Returns the absolute path to the logins log
*
* @return string
*/
public function logfile(): string
{
return $this->kirby->root('accounts') . '/.logins';
}
/**
* Read all tracked logins
*
* @return array
*/
public function log(): array
{
try {
$log = Data::read($this->logfile(), 'json');
$read = true;
} catch (Throwable $e) {
$log = [];
$read = false;
}
// ensure that the category arrays are defined
$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']));
// remove entries that are no longer needed
$originalLog = $log;
$time = time() - $this->kirby->option('auth.timeout', 3600);
foreach ($log as $category => $entries) {
$log[$category] = array_filter(
$entries,
fn ($entry) => $entry['time'] > $time
);
}
// write new log to the file system if it changed
if ($read === false || $log !== $originalLog) {
if (count($log['by-ip']) === 0 && count($log['by-email']) === 0) {
F::remove($this->logfile());
} else {
Data::write($this->logfile(), $log, 'json');
}
}
return $log;
}
/**
* Logout the current user
*
* @return void
*/
public function logout(): void
{
// stop impersonating;
// ensures that we log out the actually logged in user
$this->impersonate = null;
// logout the current user if it exists
if ($user = $this->user()) {
$user->logout();
}
// clear the pending challenge
$session = $this->kirby->session();
$session->remove('kirby.challenge.code');
$session->remove('kirby.challenge.email');
$session->remove('kirby.challenge.timeout');
$session->remove('kirby.challenge.type');
// clear the status cache
$this->status = null;
}
/**
* Clears the cached user data after logout
* @internal
*
* @return void
*/
public function flush(): void
{
$this->impersonate = null;
$this->status = null;
$this->user = null;
}
/**
* 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 $email, bool $triggerHook = true): bool
{
if ($triggerHook === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
}
$ip = $this->ipHash();
$log = $this->log();
$time = time();
if (isset($log['by-ip'][$ip]) === true) {
$log['by-ip'][$ip] = [
'time' => $time,
'trials' => ($log['by-ip'][$ip]['trials'] ?? 0) + 1
];
} else {
$log['by-ip'][$ip] = [
'time' => $time,
'trials' => 1
];
}
if ($email !== null && $this->kirby->users()->find($email)) {
if (isset($log['by-email'][$email]) === true) {
$log['by-email'][$email] = [
'time' => $time,
'trials' => ($log['by-email'][$email]['trials'] ?? 0) + 1
];
} else {
$log['by-email'][$email] = [
'time' => $time,
'trials' => 1
];
}
}
return Data::write($this->logfile(), $log, 'json');
}
/**
* Returns the current authentication type
*
* @param bool $allowImpersonation If set to false, 'impersonate' won't
* be returned as authentication type
* even if an impersonation is active
* @return string
*/
public function type(bool $allowImpersonation = true): string
{
$basicAuth = $this->kirby->option('api.basicAuth', false);
$auth = $this->kirby->request()->auth();
if ($basicAuth === true && $auth && $auth->type() === 'basic') {
return 'basic';
} elseif ($allowImpersonation === true && $this->impersonate !== null) {
return 'impersonate';
} else {
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 = null, bool $allowImpersonation = true)
{
if ($allowImpersonation === true && $this->impersonate !== null) {
return $this->impersonate;
}
// return from cache
if ($this->user === null) {
// throw the same Exception again if one was captured before
if ($this->userException !== null) {
throw $this->userException;
}
return null;
} elseif ($this->user !== false) {
return $this->user;
}
try {
if ($this->type() === 'basic') {
return $this->user = $this->currentUserFromBasicAuth();
} else {
return $this->user = $this->currentUserFromSession($session);
}
} catch (Throwable $e) {
$this->user = null;
// capture the Exception for future calls
$this->userException = $e;
throw $e;
}
}
/**
* Verifies an authentication code that was
* requested with the `createChallenge()` method;
* if successful, the user is automatically logged in
* @since 3.5.0
*
* @param string $code User-provided auth code to verify
* @return \Kirby\Cms\User User object of the logged-in user
*
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded, the challenge timed out, the code
* is incorrect or if any other error occurred with debug mode off
* @throws \Kirby\Exception\NotFoundException If the user from the challenge doesn't exist
* @throws \Kirby\Exception\InvalidArgumentException If no authentication challenge is active
* @throws \Kirby\Exception\LogicException If the authentication challenge is invalid
*/
public function verifyChallenge(string $code)
{
try {
$session = $this->kirby->session();
// 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) {
throw new InvalidArgumentException('No authentication challenge is active');
}
$user = $this->kirby->users()->find($email);
if ($user === null) {
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
}
// rate-limiting
if ($this->isBlocked($email) === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
throw new PermissionException('Rate limit exceeded');
}
// 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], 'Kirby\Cms\Auth\Challenge') === true
) {
$class = static::$challenges[$challenge];
if ($class::verify($user, $code) === true) {
$this->logout();
$user->loginPasswordless();
// clear the status cache
$this->status = null;
return $user;
} else {
throw new PermissionException(['key' => 'access.code']);
}
}
throw new LogicException('Invalid authentication challenge: ' . $challenge);
} catch (Throwable $e) {
if (empty($email) === false && $e->getMessage() !== 'Rate limit exceeded') {
$this->track($email);
}
// sleep for a random amount of milliseconds
// to make automated attacks harder and to
// avoid leaking whether the user exists
usleep(random_int(1000, 2000000));
// keep throwing the original error in debug mode,
// otherwise hide it to avoid leaking security-relevant information
if ($this->kirby->option('debug') === true) {
throw $e;
} else {
throw new PermissionException(['key' => 'access.code']);
}
}
}
/**
* Creates a session object from the passed options
*
* @param \Kirby\Session\Session|array|null $session
* @return \Kirby\Session\Session
*/
protected function session($session = null)
{
// use passed session options or session object if set
if (is_array($session) === true) {
return $this->kirby->session($session);
}
// try session in header or cookie
if (is_a($session, 'Kirby\Session\Session') === false) {
return $this->kirby->session(['detect' => true]);
}
return $session;
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace Kirby\Cms\Auth;
use Kirby\Cms\User;
/**
* Template class for authentication challenges
* that create and verify one-time auth codes
*
* @package Kirby Cms
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
abstract class Challenge
{
/**
* Checks whether the challenge is available
* for the passed user and purpose
*
* @param \Kirby\Cms\User $user User the code will be generated for
* @param string $mode Purpose of the code ('login', 'reset' or '2fa')
* @return bool
*/
abstract public static function isAvailable(User $user, string $mode): bool;
/**
* Generates a random one-time auth code and returns that code
* for later verification
*
* @param \Kirby\Cms\User $user User to generate the code for
* @param array $options Details of the challenge request:
* - 'mode': Purpose of the code ('login', '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;
/**
* Verifies the provided code against the created one;
* default implementation that checks the code that was
* returned from the `create()` method
*
* @param \Kirby\Cms\User $user User to check the code for
* @param string $code Code to verify
* @return 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;
}
// normalize the formatting in the user-provided code
$code = str_replace(' ', '', $code);
return password_verify($code, $hash);
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace Kirby\Cms\Auth;
use Kirby\Cms\User;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* Creates and verifies one-time auth codes
* that are sent via email
*
* @package Kirby Cms
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class EmailChallenge extends Challenge
{
/**
* Checks whether the challenge is available
* for the passed user and purpose
*
* @param \Kirby\Cms\User $user User the code will be generated for
* @param string $mode Purpose of the code ('login', 'reset' or '2fa')
* @return bool
*/
public static function isAvailable(User $user, string $mode): bool
{
return true;
}
/**
* Generates a random one-time auth code and returns that code
* for later verification
*
* @param \Kirby\Cms\User $user User to generate the code for
* @param array $options Details of the challenge request:
* - 'mode': Purpose of the code ('login', 'reset' or '2fa')
* - 'timeout': Number of seconds the code will be valid for
* @return string The generated and sent code
*/
public static function create(User $user, array $options): string
{
$code = Str::random(6, 'num');
// insert a space in the middle for easier readability
$formatted = substr($code, 0, 3) . ' ' . substr($code, 3, 3);
// use the login templates for 2FA
$mode = $options['mode'];
if ($mode === '2fa') {
$mode = 'login';
}
$kirby = $user->kirby();
$kirby->email([
'from' => $kirby->option('auth.challenge.email.from', 'noreply@' . $kirby->url('index', true)->host()),
'fromName' => $kirby->option('auth.challenge.email.fromName', $kirby->site()->title()),
'to' => $user,
'subject' => $kirby->option(
'auth.challenge.email.subject',
I18n::translate('login.email.' . $mode . '.subject', null, $user->language())
),
'template' => 'auth/' . $mode,
'data' => [
'user' => $user,
'site' => $kirby->system()->title(),
'code' => $formatted,
'timeout' => round($options['timeout'] / 60)
]
]);
return $code;
}
}

View file

@ -0,0 +1,219 @@
<?php
namespace Kirby\Cms\Auth;
use Kirby\Cms\App;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Properties;
/**
* Information container for the
* authentication status
* @since 3.5.1
*
* @package Kirby Cms
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Status
{
use Properties;
/**
* Type of the active challenge
*
* @var string|null
*/
protected $challenge = null;
/**
* Challenge type to use as a fallback
* when $challenge is `null`
*
* @var string|null
*/
protected $challengeFallback = null;
/**
* Email address of the current/pending user
*
* @var string|null
*/
protected $email = null;
/**
* Kirby instance for user lookup
*
* @var \Kirby\Cms\App
*/
protected $kirby;
/**
* Authentication status:
* `active|impersonated|pending|inactive`
*
* @var string
*/
protected $status;
/**
* Class constructor
*
* @param array $props
*/
public function __construct(array $props)
{
$this->setProperties($props);
}
/**
* Returns the authentication status
*
* @return string
*/
public function __toString(): string
{
return $this->status();
}
/**
* Returns the type of the active challenge
*
* @param bool $automaticFallback If set to `false`, no faked challenge is returned;
* WARNING: never send the resulting `null` value to the
* user to avoid leaking whether the pending user exists
* @return string|null
*/
public function challenge(bool $automaticFallback = true): ?string
{
// never return a challenge type if the status doesn't match
if ($this->status() !== 'pending') {
return null;
}
if ($automaticFallback === false) {
return $this->challenge;
} else {
return $this->challenge ?? $this->challengeFallback;
}
}
/**
* Returns the email address of the current/pending user
*
* @return string|null
*/
public function email(): ?string
{
return $this->email;
}
/**
* Returns the authentication status
*
* @return string `active|impersonated|pending|inactive`
*/
public function status(): string
{
return $this->status;
}
/**
* Returns an array with all public status data
*
* @return array
*/
public function toArray(): array
{
return [
'challenge' => $this->challenge(),
'email' => $this->email(),
'status' => $this->status()
];
}
/**
* Returns the currently logged in user
*
* @return \Kirby\Cms\User
*/
public function user()
{
// for security, only return the user if they are
// already logged in
if (in_array($this->status(), ['active', 'impersonated']) !== true) {
return null;
}
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;
}
}

286
kirby/src/Cms/Block.php Normal file
View file

@ -0,0 +1,286 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Str;
use Throwable;
/**
* Represents a single block
* which can be inspected further or
* converted to HTML
* @since 3.5.0
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Block extends Item
{
use HasMethods;
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 $models = [];
/**
* @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 = [])
{
// block methods
if ($this->hasMethod($method)) {
return $this->callMethod($method, $args);
}
return $this->content()->get($method);
}
/**
* Creates a new block object
*
* @param array $params
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function __construct(array $params)
{
parent::__construct($params);
// import old builder format
$params = BlockConverter::builderBlock($params);
$params = BlockConverter::editorBlock($params);
if (isset($params['type']) === false) {
throw new InvalidArgumentException('The block type is missing');
}
$this->content = $params['content'] ?? [];
$this->isHidden = $params['isHidden'] ?? false;
$this->type = $params['type'];
// create the content object
$this->content = new Content($this->content, $this->parent);
}
/**
* Converts the object to a string
*
* @return string
*/
public function __toString(): string
{
return $this->toHtml();
}
/**
* Deprecated method to return the block type
*
* @deprecated 3.5.0 Use `\Kirby\Cms\Block::type()` instead
* @todo Remove in 3.7.0
*
* @return string
*/
public function _key(): string
{
deprecated('Block::_key() has been deprecated. Use Block::type() instead.');
return $this->type();
}
/**
* Deprecated method to return the block id
*
* @deprecated 3.5.0 Use `\Kirby\Cms\Block::id()` instead
* @todo Remove in 3.7.0
*
* @return string
*/
public function _uid(): string
{
deprecated('Block::_uid() has been deprecated. Use Block::id() instead.');
return $this->id();
}
/**
* Returns the content object
*
* @return \Kirby\Cms\Content
*/
public function content()
{
return $this->content;
}
/**
* Controller for the block snippet
*
* @return array
*/
public function controller(): array
{
return [
'block' => $this,
'content' => $this->content(),
// deprecated block data
'data' => $this,
'id' => $this->id(),
'prev' => $this->prev(),
'next' => $this->next()
];
}
/**
* Converts the block to HTML and then
* uses the Str::excerpt method to create
* a non-formatted, shortened excerpt from it
*
* @param mixed ...$args
* @return string
*/
public function excerpt(...$args)
{
return Str::excerpt($this->toHtml(), ...$args);
}
/**
* Constructs a block object with registering blocks models
*
* @param array $params
* @return static
* @throws \Kirby\Exception\InvalidArgumentException
* @internal
*/
public static function factory(array $params)
{
$type = $params['type'] ?? null;
if (empty($type) === false && $class = (static::$models[$type] ?? null)) {
$object = new $class($params);
if (is_a($object, 'Kirby\Cms\Block') === true) {
return $object;
}
}
// default model for blocks
if ($class = (static::$models['Kirby\Cms\Block'] ?? null)) {
$object = new $class($params);
if (is_a($object, 'Kirby\Cms\Block') === true) {
return $object;
}
}
return new static($params);
}
/**
* Checks if the block is empty
*
* @return bool
*/
public function isEmpty(): bool
{
return empty($this->content()->toArray());
}
/**
* Checks if the block is hidden
* from being rendered in the frontend
*
* @return bool
*/
public function isHidden(): bool
{
return $this->isHidden;
}
/**
* Checks if the block is not empty
*
* @return bool
*/
public function isNotEmpty(): bool
{
return $this->isEmpty() === false;
}
/**
* Returns the block type
*
* @return string
*/
public function type(): string
{
return $this->type;
}
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{
return [
'content' => $this->content()->toArray(),
'id' => $this->id(),
'isHidden' => $this->isHidden(),
'type' => $this->type(),
];
}
/**
* Converts the block to html first
* and then places that inside a field
* object. This can be used further
* with all available field methods
*
* @return \Kirby\Cms\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 {
return (string)snippet('blocks/' . $this->type(), $this->controller(), true);
} catch (Throwable $e) {
return '<p>Block error: "' . $e->getMessage() . '" in block type: "' . $this->type() . '"</p>';
}
}
}

View file

@ -0,0 +1,280 @@
<?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.5.0
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @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(function ($item) {
return '<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): 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;
}
}

166
kirby/src/Cms/Blocks.php Normal file
View file

@ -0,0 +1,166 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Json;
use Kirby\Data\Yaml;
use Kirby\Parsley\Parsley;
use Kirby\Parsley\Schema\Blocks as BlockSchema;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use Throwable;
/**
* A collection of blocks
* @since 3.5.0
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Blocks extends Items
{
public const ITEM_CLASS = '\Kirby\Cms\Block';
/**
* Return HTML when the collection is
* converted to a string
*
* @return string
*/
public function __toString(): string
{
return $this->toHtml();
}
/**
* Converts the blocks to HTML and then
* uses the Str::excerpt method to create
* a non-formatted, shortened excerpt from it
*
* @param mixed ...$args
* @return string
*/
public function excerpt(...$args)
{
return Str::excerpt($this->toHtml(), ...$args);
}
/**
* 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 = [])
{
$items = static::extractFromLayouts($items);
$items = BlockConverter::editorBlocks($items);
return parent::factory($items, $params);
}
/**
* Pull out blocks from layouts
*
* @param array $input
* @return array
*/
protected static function extractFromLayouts(array $input): array
{
if (empty($input) === true) {
return [];
}
if (
// no columns = no layout
array_key_exists('columns', $input[0]) === false ||
// checks if this is a block for the builder plugin
array_key_exists('_key', $input[0]) === true
) {
return $input;
}
$blocks = [];
foreach ($input as $layout) {
foreach (($layout['columns'] ?? []) as $column) {
foreach (($column['blocks'] ?? []) as $block) {
$blocks[] = $block;
}
}
}
return $blocks;
}
/**
* Checks if a given block type exists in the collection
* @since 3.6.0
*
* @param string $type
* @return bool
*/
public function hasType(string $type): bool
{
return $this->filterBy('type', $type)->count() > 0;
}
/**
* Parse and sanitize various block formats
*
* @param array|string $input
* @return array
*/
public static function parse($input): array
{
if (empty($input) === false && is_array($input) === false) {
try {
$input = Json::decode((string)$input);
} catch (Throwable $e) {
try {
// try to import the old YAML format
$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) {
$parser = new Parsley((string)$input, new BlockSchema());
$input = $parser->blocks();
}
}
}
if (empty($input) === true) {
return [];
}
return $input;
}
/**
* Convert all blocks to HTML
*
* @return string
*/
public function toHtml(): string
{
$html = [];
foreach ($this->data as $block) {
$html[] = $block->toHtml();
}
return implode($html);
}
}

816
kirby/src/Cms/Blueprint.php Normal file
View file

@ -0,0 +1,816 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\F;
use Kirby\Form\Field;
use Kirby\Toolkit\A;
use Kirby\Toolkit\I18n;
use Throwable;
/**
* The Blueprint class normalizes an array from a
* blueprint file and converts sections, columns, fields
* etc. into a correct tab layout.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Blueprint
{
public static $presets = [];
public static $loaded = [];
protected $fields = [];
protected $model;
protected $props;
protected $sections = [];
protected $tabs = [];
/**
* Magic getter/caller for any blueprint prop
*
* @param string $key
* @param array|null $arguments
* @return mixed
*/
public function __call(string $key, array $arguments = null)
{
return $this->props[$key] ?? null;
}
/**
* 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)
{
if (empty($props['model']) === true) {
throw new InvalidArgumentException('A blueprint model is required');
}
if (is_a($props['model'], ModelWithContent::class) === false) {
throw new InvalidArgumentException('Invalid blueprint model');
}
$this->model = $props['model'];
// the model should not be included in the props array
unset($props['model']);
// extend the blueprint in general
$props = $this->extend($props);
// apply any blueprint preset
$props = $this->preset($props);
// normalize the name
$props['name'] ??= 'default';
// normalize and translate the title
$props['title'] = $this->i18n($props['title'] ?? ucfirst($props['name']));
// convert all shortcuts
$props = $this->convertFieldsToSections('main', $props);
$props = $this->convertSectionsToColumns('main', $props);
$props = $this->convertColumnsToTabs('main', $props);
// normalize all tabs
$props['tabs'] = $this->normalizeTabs($props['tabs'] ?? []);
$this->props = $props;
}
/**
* Improved `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
return $this->props ?? [];
}
/**
* 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
{
if (isset($props['columns']) === false) {
return $props;
}
// wrap everything in a main tab
$props['tabs'] = [
$tabName => [
'columns' => $props['columns']
]
];
unset($props['columns']);
return $props;
}
/**
* Converts all field definitions, that are not
* wrapped in a fields section into a generic
* fields section.
*
* @param string $tabName
* @param array $props
* @return array
*/
protected function convertFieldsToSections(string $tabName, array $props): array
{
if (isset($props['fields']) === false) {
return $props;
}
// wrap all fields in a section
$props['sections'] = [
$tabName . '-fields' => [
'type' => 'fields',
'fields' => $props['fields']
]
];
unset($props['fields']);
return $props;
}
/**
* Converts all sections that are not wrapped in
* columns, into a single generic column.
*
* @param string $tabName
* @param array $props
* @return array
*/
protected function convertSectionsToColumns(string $tabName, array $props): array
{
if (isset($props['sections']) === false) {
return $props;
}
// wrap everything in one big column
$props['columns'] = [
[
'width' => '1/1',
'sections' => $props['sections']
]
];
unset($props['sections']);
return $props;
}
/**
* Extends the props with props from a given
* mixin, when an extends key is set or the
* props is just a string
*
* @param array|string $props
* @return array
*/
public static function extend($props): array
{
if (is_string($props) === true) {
$props = [
'extends' => $props
];
}
$extends = $props['extends'] ?? null;
if ($extends === null) {
return $props;
}
try {
$mixin = static::find($extends);
$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, Model $model)
{
try {
$props = static::load($name);
} catch (Exception $e) {
$props = $fallback !== null ? static::load($fallback) : null;
}
if ($props === null) {
return null;
}
// inject the parent model
$props['model'] = $model;
return new static($props);
}
/**
* Returns a single field definition by name
*
* @param string $name
* @return array|null
*/
public function field(string $name): ?array
{
return $this->fields[$name] ?? null;
}
/**
* Returns all field definitions
*
* @return array
*/
public function fields(): array
{
return $this->fields;
}
/**
* 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
{
if (isset(static::$loaded[$name]) === true) {
return static::$loaded[$name];
}
$kirby = App::instance();
$root = $kirby->root('blueprints');
$file = $root . '/' . $name . '.yml';
// first try to find a site blueprint,
// then check in the plugin extensions
if (F::exists($file, $root) !== true) {
$file = $kirby->extension('blueprints', $name);
}
// 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);
} elseif (is_array($file) === true) {
return static::$loaded[$name] = $file;
} elseif (is_callable($file) === true) {
return static::$loaded[$name] = $file($kirby);
}
// neither a valid file nor array data
throw new NotFoundException([
'key' => 'blueprint.notFound',
'data' => ['name' => $name]
]);
}
/**
* Used to translate any label, heading, etc.
*
* @param mixed $value
* @param mixed $fallback
* @return mixed
*/
protected function i18n($value, $fallback = null)
{
return I18n::translate($value, $fallback ?? $value);
}
/**
* Checks if this is the default blueprint
*
* @return bool
*/
public function isDefault(): bool
{
return $this->name() === 'default';
}
/**
* Loads a blueprint from file or array
*
* @param string $name
* @return array
*/
public static function load(string $name): array
{
$props = static::find($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']);
// translate the title
$props['title'] = I18n::translate($title, $title);
return $props;
};
return $normalize($props);
}
/**
* Returns the parent model
*
* @return \Kirby\Cms\Model
*/
public function model()
{
return $this->model;
}
/**
* Returns the blueprint name
*
* @return string
*/
public function name(): string
{
return $this->props['name'];
}
/**
* Normalizes all required props in a column setup
*
* @param string $tabName
* @param array $columns
* @return array
*/
protected function normalizeColumns(string $tabName, array $columns): array
{
foreach ($columns as $columnKey => $columnProps) {
// unset/remove column if its property is not array
if (is_array($columnProps) === false) {
unset($columns[$columnKey]);
continue;
}
$columnProps = $this->convertFieldsToSections($tabName . '-col-' . $columnKey, $columnProps);
// inject getting started info, if the sections are empty
if (empty($columnProps['sections']) === true) {
$columnProps['sections'] = [
$tabName . '-info-' . $columnKey => [
'headline' => 'Column (' . ($columnProps['width'] ?? '1/1') . ')',
'type' => 'info',
'text' => 'No sections yet'
]
];
}
$columns[$columnKey] = array_merge($columnProps, [
'width' => $columnProps['width'] ?? '1/1',
'sections' => $this->normalizeSections($tabName, $columnProps['sections'] ?? [])
]);
}
return $columns;
}
/**
* @param array $items
* @return string
*/
public static function helpList(array $items): string
{
$md = [];
foreach ($items as $item) {
$md[] = '- *' . $item . '*';
}
return PHP_EOL . implode(PHP_EOL, $md);
}
/**
* Normalize field props for a single field
*
* @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($props): array
{
$props = static::extend($props);
if (isset($props['name']) === false) {
throw new InvalidArgumentException('The field name is missing');
}
$name = $props['name'];
$type = $props['type'] ?? $name;
if ($type !== 'group' && isset(Field::$types[$type]) === false) {
throw new InvalidArgumentException('Invalid field type ("' . $type . '")');
}
// support for nested fields
if (isset($props['fields']) === true) {
$props['fields'] = static::fieldsProps($props['fields']);
}
// groups don't need all the crap
if ($type === 'group') {
return [
'fields' => $props['fields'],
'name' => $name,
'type' => $type,
];
}
// add some useful defaults
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
{
return [
'label' => 'Error',
'name' => $name,
'text' => strip_tags($message),
'theme' => 'negative',
'type' => 'info',
];
}
/**
* Normalizes all fields and adds automatic labels,
* types and widths.
*
* @param array $fields
* @return array
*/
public static function fieldsProps($fields): array
{
if (is_array($fields) === false) {
$fields = [];
}
foreach ($fields as $fieldName => $fieldProps) {
// extend field from string
if (is_string($fieldProps) === true) {
$fieldProps = [
'extends' => $fieldProps,
'name' => $fieldName
];
}
// use the name as type definition
if ($fieldProps === true) {
$fieldProps = [];
}
// unset / remove field if its property is false
if ($fieldProps === false) {
unset($fields[$fieldName]);
continue;
}
// inject the name
$fieldProps['name'] = $fieldName;
// create all props
try {
$fieldProps = static::fieldProps($fieldProps);
} catch (Throwable $e) {
$fieldProps = static::fieldError($fieldName, $e->getMessage());
}
// resolve field groups
if ($fieldProps['type'] === 'group') {
if (empty($fieldProps['fields']) === false && is_array($fieldProps['fields']) === true) {
$index = array_search($fieldName, array_keys($fields));
$before = array_slice($fields, 0, $index);
$after = array_slice($fields, $index + 1);
$fields = array_merge($before, $fieldProps['fields'] ?? [], $after);
} else {
unset($fields[$fieldName]);
}
} else {
$fields[$fieldName] = $fieldProps;
}
}
return $fields;
}
/**
* Normalizes blueprint options. This must be used in the
* constructor of an extended class, if you want to make use of it.
*
* @param array|true|false|null|string $options
* @param array $defaults
* @param array $aliases
* @return 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;
}
// set all options to false
if ($options === false) {
return array_map(fn () => false, $defaults);
}
// extend options if possible
$options = $this->extend($options);
foreach ($options as $key => $value) {
$alias = $aliases[$key] ?? null;
if ($alias !== null) {
$options[$alias] ??= $value;
unset($options[$key]);
}
}
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
{
foreach ($sections as $sectionName => $sectionProps) {
// unset / remove section if its property is false
if ($sectionProps === false) {
unset($sections[$sectionName]);
continue;
}
// fallback to default props when true is passed
if ($sectionProps === true) {
$sectionProps = [];
}
// inject all section extensions
$sectionProps = $this->extend($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,
'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,
'headline' => 'Invalid section type ("' . $type . '")',
'type' => 'info',
'text' => 'The following section types are available: ' . $this->helpList(array_keys(Section::$types))
];
}
if ($sectionProps['type'] === 'fields') {
$fields = Blueprint::fieldsProps($sectionProps['fields'] ?? []);
// inject guide fields guide
if (empty($fields) === true) {
$fields = [
$tabName . '-info' => [
'label' => 'Fields',
'text' => 'No fields yet',
'type' => 'info'
]
];
} else {
foreach ($fields as $fieldName => $fieldProps) {
if (isset($this->fields[$fieldName]) === true) {
$this->fields[$fieldName] = $fields[$fieldName] = [
'type' => 'info',
'label' => $fieldProps['label'] ?? 'Error',
'text' => 'The field name <strong>"' . $fieldName . '"</strong> already exists in your blueprint.',
'theme' => 'negative'
];
} else {
$this->fields[$fieldName] = $fieldProps;
}
}
}
$sections[$sectionName]['fields'] = $fields;
}
}
// store all normalized sections
$this->sections = array_merge($this->sections, $sections);
return $sections;
}
/**
* Normalizes all required keys in tabs
*
* @param array $tabs
* @return array
*/
protected function normalizeTabs($tabs): array
{
if (is_array($tabs) === false) {
$tabs = [];
}
foreach ($tabs as $tabName => $tabProps) {
// unset / remove tab if its property is false
if ($tabProps === false) {
unset($tabs[$tabName]);
continue;
}
// inject all tab extensions
$tabProps = $this->extend($tabProps);
// inject a preset if available
$tabProps = $this->preset($tabProps);
$tabProps = $this->convertFieldsToSections($tabName, $tabProps);
$tabProps = $this->convertSectionsToColumns($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;
}
/**
* Injects a blueprint preset
*
* @param array $props
* @return array
*/
protected function preset(array $props): array
{
if (isset($props['preset']) === false) {
return $props;
}
if (isset(static::$presets[$props['preset']]) === false) {
return $props;
}
$preset = static::$presets[$props['preset']];
if (is_string($preset) === true) {
$preset = require $preset;
}
return $preset($props);
}
/**
* Returns a single section by name
*
* @param string $name
* @return \Kirby\Cms\Section|null
*/
public function section(string $name)
{
if (empty($this->sections[$name]) === true) {
return null;
}
// get all props
$props = $this->sections[$name];
// inject the blueprint model
$props['model'] = $this->model();
// create a new section object
return new Section($props['type'], $props);
}
/**
* Returns all sections
*
* @return array
*/
public function sections(): array
{
return A::map(
$this->sections,
fn ($section) => $this->section($section['name'])
);
}
/**
* Returns a single tab by name
*
* @param string|null $name
* @return array|null
*/
public function tab(?string $name = null): ?array
{
if ($name === null) {
return A::first($this->tabs);
}
return $this->tabs[$name] ?? null;
}
/**
* Returns all tabs
*
* @return array
*/
public function tabs(): array
{
return array_values($this->tabs);
}
/**
* Returns the blueprint title
*
* @return string
*/
public function title(): string
{
return $this->props['title'];
}
/**
* Converts the blueprint object to a plain array
*
* @return array
*/
public function toArray(): array
{
return $this->props;
}
}

View file

@ -0,0 +1,338 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Collection as BaseCollection;
use Kirby\Toolkit\Str;
/**
* The Collection class serves as foundation
* for the Pages, Files, Users and Structure
* classes. It handles object validation and sets
* the parent collection property for each object.
* The `getAttribute` method is also adjusted to
* handle values from Field objects correctly, so
* those can be used in filters as well.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Collection extends BaseCollection
{
use HasMethods;
/**
* Stores the parent object, which is needed
* in some collections to get the finder methods right.
*
* @var object
*/
protected $parent;
/**
* Magic getter function
*
* @param string $key
* @param mixed $arguments
* @return mixed
*/
public function __call(string $key, $arguments)
{
// collection methods
if ($this->hasMethod($key) === true) {
return $this->callMethod($key, $arguments);
}
}
/**
* Creates a new Collection with the given objects
*
* @param array $objects
* @param object|null $parent
*/
public function __construct($objects = [], $parent = null)
{
$this->parent = $parent;
foreach ($objects as $object) {
$this->add($object);
}
}
/**
* 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
*/
public function __set(string $id, $object)
{
$this->data[$id] = $object;
}
/**
* Adds a single object or
* an entire second collection to the
* current collection
*
* @param mixed $object
*/
public function add($object)
{
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) {
$this->__set($object->id(), $object);
} else {
$this->append($object);
}
return $this;
}
/**
* Appends an element to the data array
*
* @param mixed ...$args
* @param mixed $key Optional collection key, will be determined from the item if not given
* @param mixed $item
* @return \Kirby\Cms\Collection
*/
public function append(...$args)
{
if (count($args) === 1) {
// try to determine the key from the provided item
if (is_object($args[0]) === true && is_callable([$args[0], 'id']) === true) {
return parent::append($args[0]->id(), $args[0]);
} else {
return parent::append($args[0]);
}
}
return parent::append(...$args);
}
/**
* 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 $i Ignore upper/lowercase for group names
* @return \Kirby\Cms\Collection
* @throws \Kirby\Exception\Exception
*/
public function group($field, bool $i = true)
{
if (is_string($field) === true) {
$groups = new Collection([], $this->parent());
foreach ($this->data as $key => $item) {
$value = $this->getAttribute($item, $field);
// make sure that there's always a proper value to group by
if (!$value) {
throw new InvalidArgumentException('Invalid grouping value for key: ' . $key);
}
// ignore upper/lowercase for group names
if ($i) {
$value = Str::lower($value);
}
if (isset($groups->data[$value]) === false) {
// create a new entry for the group if it does not exist yet
$groups->data[$value] = new static([$key => $item]);
} else {
// add the item to an existing group
$groups->data[$value]->set($key, $item);
}
}
return $groups;
}
return parent::group($field, $i);
}
/**
* Checks if the given object or id
* is in the collection
*
* @param string|object $key
* @return bool
*/
public function has($key): bool
{
if (is_object($key) === true) {
$key = $key->id();
}
return parent::has($key);
}
/**
* Correct position detection for objects.
* The method will automatically detect objects
* or ids and then search accordingly.
*
* @param string|object $needle
* @return int
*/
public function indexOf($needle): int
{
if (is_string($needle) === true) {
return array_search($needle, $this->keys());
}
return array_search($needle->id(), $this->keys());
}
/**
* Returns a Collection without the given element(s)
*
* @param mixed ...$keys any number of keys, passed as individual arguments
* @return \Kirby\Cms\Collection
*/
public function not(...$keys)
{
$collection = $this->clone();
foreach ($keys as $key) {
if (is_array($key) === true) {
return $this->not(...$key);
} elseif (is_a($key, 'Kirby\Toolkit\Collection') === true) {
$collection = $collection->not(...$key->keys());
} elseif (is_object($key) === true) {
$key = $key->id();
}
unset($collection->{$key});
}
return $collection;
}
/**
* Add pagination and return a sliced set of data.
*
* @param mixed ...$arguments
* @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());
}
/**
* Returns the parent model
*
* @return \Kirby\Cms\Model
*/
public function parent()
{
return $this->parent;
}
/**
* Prepends an element to the data array
*
* @param mixed ...$args
* @param mixed $key Optional collection key, will be determined from the item if not given
* @param mixed $item
* @return \Kirby\Cms\Collection
*/
public function prepend(...$args)
{
if (count($args) === 1) {
// try to determine the key from the provided item
if (is_object($args[0]) === true && is_callable([$args[0], 'id']) === true) {
return parent::prepend($args[0]->id(), $args[0]);
} else {
return parent::prepend($args[0]);
}
}
return parent::prepend(...$args);
}
/**
* Runs a combination of filter, sort, not,
* offset, limit, search and paginate on the collection.
* Any part of the query is optional.
*
* @param array $arguments
* @return static
*/
public function query(array $arguments = [])
{
$paginate = $arguments['paginate'] ?? null;
$search = $arguments['search'] ?? null;
unset($arguments['paginate']);
$result = parent::query($arguments);
if (empty($search) === false) {
if (is_array($search) === true) {
$result = $result->search($search['query'] ?? null, $search['options'] ?? []);
} else {
$result = $result->search($search);
}
}
if (empty($paginate) === false) {
$result = $result->paginate($paginate);
}
return $result;
}
/**
* Removes an object
*
* @param mixed $key the name of the key
*/
public function remove($key)
{
if (is_object($key) === true) {
$key = $key->id();
}
return parent::remove($key);
}
/**
* Searches the collection
*
* @param string|null $query
* @param array $params
* @return self
*/
public function search(string $query = null, $params = [])
{
return Search::collection($this, $query, $params);
}
/**
* Converts all objects in the collection
* to an array. This can also take a callback
* function to further modify the array result.
*
* @param \Closure|null $map
* @return array
*/
public function toArray(Closure $map = null): array
{
return parent::toArray($map ?? fn ($object) => $object->toArray());
}
}

View file

@ -0,0 +1,141 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Controller;
/**
* Manages and loads all collections
* in site/collections, which can then
* be reused in controllers, templates, etc
*
* This class is mainly used in the `$kirby->collection()`
* method to provide easy access to registered collections
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Collections
{
/**
* Each collection is cached once it
* has been called, to avoid further
* processing on sequential calls to
* the same collection.
*
* @var array
*/
protected $cache = [];
/**
* Store of all collections
*
* @var array
*/
protected $collections = [];
/**
* Magic caller to enable something like
* `$collections->myCollection()`
*
* @param string $name
* @param array $arguments
* @return \Kirby\Cms\Collection|null
*/
public function __call(string $name, array $arguments = [])
{
return $this->get($name, ...$arguments);
}
/**
* Loads a collection by name if registered
*
* @param string $name
* @param array $data
* @return \Kirby\Cms\Collection|null
*/
public function get(string $name, array $data = [])
{
// if not yet loaded
if (isset($this->collections[$name]) === false) {
$this->collections[$name] = $this->load($name);
}
// if not yet cached
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
];
}
// return cloned object
if (is_object($this->cache[$name]['result']) === true) {
return clone $this->cache[$name]['result'];
}
return $this->cache[$name]['result'];
}
/**
* Checks if a collection exists
*
* @param string $name
* @return bool
*/
public function has(string $name): bool
{
if (isset($this->collections[$name]) === true) {
return true;
}
try {
$this->load($name);
return true;
} catch (NotFoundException $e) {
return false;
}
}
/**
* 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)
{
$kirby = App::instance();
// first check for collection file
$file = $kirby->root('collections') . '/' . $name . '.php';
if (is_file($file) === true) {
$collection = F::load($file);
if (is_a($collection, 'Closure')) {
return $collection;
}
}
// fallback to collections from plugins
$collections = $kirby->extensions('collections');
if (isset($collections[$name]) === true) {
return $collections[$name];
}
throw new NotFoundException('The collection cannot be found');
}
}

268
kirby/src/Cms/Content.php Normal file
View file

@ -0,0 +1,268 @@
<?php
namespace Kirby\Cms;
use Kirby\Form\Form;
/**
* The Content class handles all fields
* for content from pages, the site and users
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Content
{
/**
* The raw data array
*
* @var array
*/
protected $data = [];
/**
* Cached field objects
* Once a field is being fetched
* it is added to this array for
* later reuse
*
* @var array
*/
protected $fields = [];
/**
* A potential parent object.
* Not necessarily needed. Especially
* for testing, but field methods might
* need it.
*
* @var Model
*/
protected $parent;
/**
* Magic getter for content fields
*
* @param string $name
* @param array $arguments
* @return \Kirby\Cms\Field
*/
public function __call(string $name, array $arguments = [])
{
return $this->get($name);
}
/**
* Creates a new Content object
*
* @param array|null $data
* @param object|null $parent
*/
public function __construct(array $data = [], $parent = null)
{
$this->data = $data;
$this->parent = $parent;
}
/**
* Same as `self::data()` to improve
* `var_dump` output
*
* @see self::data()
* @return array
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Converts the content to a new blueprint
*
* @param string $to
* @return array
*/
public function convertTo(string $to): array
{
// prepare data
$data = [];
$content = $this;
// blueprints
$old = $this->parent->blueprint();
$subfolder = dirname($old->name());
$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]);
// fields
$oldFields = $oldForm->fields();
$newFields = $newForm->fields();
// go through all fields of new template
foreach ($newFields as $newField) {
$name = $newField->name();
$oldField = $oldFields->get($name);
// field name and type matches with old template
if ($oldField && $oldField->type() === $newField->type()) {
$data[$name] = $content->get($name)->value();
} else {
$data[$name] = $newField->default();
}
}
// preserve existing fields
return array_merge($this->data, $data);
}
/**
* Returns the raw data array
*
* @return array
*/
public function data(): array
{
return $this->data;
}
/**
* Returns all registered field objects
*
* @return array
*/
public function fields(): array
{
foreach ($this->data as $key => $value) {
$this->get($key);
}
return $this->fields;
}
/**
* 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)
{
if ($key === null) {
return $this->fields();
}
$key = strtolower($key);
if (isset($this->fields[$key])) {
return $this->fields[$key];
}
// fetch the value no matter the case
$data = $this->data();
$value = $data[$key] ?? array_change_key_case($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
{
$key = strtolower($key);
$data = array_change_key_case($this->data);
return isset($data[$key]) === true;
}
/**
* Returns all field keys
*
* @return array
*/
public function keys(): array
{
return array_keys($this->data());
}
/**
* Returns a clone of the content object
* without the fields, specified by the
* passed key(s)
*
* @param string ...$keys
* @return static
*/
public function not(...$keys)
{
$copy = clone $this;
$copy->fields = null;
foreach ($keys as $key) {
unset($copy->data[$key]);
}
return $copy;
}
/**
* Returns the parent
* Site, Page, File or User object
*
* @return \Kirby\Cms\Model
*/
public function parent()
{
return $this->parent;
}
/**
* Set the parent model
*
* @param \Kirby\Cms\Model $parent
* @return $this
*/
public function setParent(Model $parent)
{
$this->parent = $parent;
return $this;
}
/**
* Returns the raw data array
*
* @see self::data()
* @return array
*/
public function toArray(): array
{
return $this->data();
}
/**
* 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)
{
$this->data = $overwrite === true ? (array)$content : array_merge($this->data, (array)$content);
// clear cache of Field objects
$this->fields = [];
return $this;
}
}

View file

@ -0,0 +1,232 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\LogicException;
use Kirby\Exception\PermissionException;
/**
* Takes care of content lock and unlock information
*
* @package Kirby Cms
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ContentLock
{
/**
* Lock data
*
* @var array
*/
protected $data;
/**
* 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
{
// if no lock exists, skip
if (isset($this->data['lock']) === false) {
return true;
}
// remove lock
unset($this->data['lock']);
return $this->kirby()->locks()->set($this->model, $this->data);
}
/**
* Sets lock with the current user
*
* @return bool
* @throws \Kirby\Exception\DuplicateException
*/
public function create(): bool
{
// check if model is already locked by another user
if (
isset($this->data['lock']) === true &&
$this->data['lock']['user'] !== $this->user()->id()
) {
$id = ContentLocks::id($this->model);
throw new DuplicateException($id . ' is already locked');
}
$this->data['lock'] = [
'user' => $this->user()->id(),
'time' => time()
];
return $this->kirby()->locks()->set($this->model, $this->data);
}
/**
* Returns either `false` or array with `user`, `email`,
* `time` and `unlockable` keys
*
* @return array|bool
*/
public function get()
{
$data = $this->data['lock'] ?? [];
if (empty($data) === false && $data['user'] !== $this->user()->id()) {
if ($user = $this->kirby()->user($data['user'])) {
$time = (int)($data['time']);
return [
'user' => $user->id(),
'email' => $user->email(),
'time' => $time,
'unlockable' => ($time + 60) <= time()
];
}
// clear lock if user not found
$this->clearLock();
}
return false;
}
/**
* Returns if the model is locked by another user
*
* @return bool
*/
public function isLocked(): bool
{
$lock = $this->get();
if ($lock !== false && $lock['user'] !== $this->user()->id()) {
return true;
}
return false;
}
/**
* Returns if the current user's lock has been removed by another user
*
* @return bool
*/
public function isUnlocked(): bool
{
$data = $this->data['unlock'] ?? [];
return in_array($this->user()->id(), $data) === true;
}
/**
* Returns the app instance
*
* @return \Kirby\Cms\App
*/
protected function kirby(): App
{
return $this->model->kirby();
}
/**
* Removes lock of current user
*
* @return bool
* @throws \Kirby\Exception\LogicException
*/
public function remove(): bool
{
// if no lock exists, skip
if (isset($this->data['lock']) === false) {
return true;
}
// check if lock was set by another user
if ($this->data['lock']['user'] !== $this->user()->id()) {
throw new LogicException([
'fallback' => 'The content lock can only be removed by the user who created it. Use unlock instead.',
'httpCode' => 409
]);
}
return $this->clearLock();
}
/**
* Removes unlock information for current user
*
* @return bool
*/
public function resolve(): bool
{
// if no unlocks exist, skip
if (isset($this->data['unlock']) === false) {
return true;
}
// remove user from unlock array
$this->data['unlock'] = array_diff(
$this->data['unlock'],
[$this->user()->id()]
);
return $this->kirby()->locks()->set($this->model, $this->data);
}
/**
* Removes current lock and adds lock user to unlock data
*
* @return bool
*/
public function unlock(): bool
{
// if no lock exists, skip
if (isset($this->data['lock']) === false) {
return true;
}
// add lock user to unlocked data
$this->data['unlock'] ??= [];
$this->data['unlock'][] = $this->data['lock']['user'];
return $this->clearLock();
}
/**
* Returns currently authenticated user;
* throws exception if none is authenticated
*
* @return \Kirby\Cms\User
* @throws \Kirby\Exception\PermissionException
*/
protected function user(): User
{
if ($user = $this->kirby()->user()) {
return $user;
}
throw new PermissionException('No user authenticated.');
}
}

View file

@ -0,0 +1,228 @@
<?php
namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Filesystem\F;
/**
* Manages all content lock files
*
* @package Kirby Cms
* @author Nico Hoffmann <nico@getkirby.com>,
* Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ContentLocks
{
/**
* Data from the `.lock` files
* that have been read so far
* cached by `.lock` file path
*
* @var array
*/
protected $data = [];
/**
* PHP file handles for all currently
* open `.lock` files
*
* @var array
*/
protected $handles = [];
/**
* Closes the open file handles
*
* @codeCoverageIgnore
*/
public function __destruct()
{
foreach ($this->handles as $file => $handle) {
$this->closeHandle($file);
}
}
/**
* Removes the file lock and closes the file handle
*
* @param string $file
* @return void
* @throws \Kirby\Exception\Exception
*/
protected function closeHandle(string $file)
{
if (isset($this->handles[$file]) === false) {
return;
}
$handle = $this->handles[$file];
$result = flock($handle, LOCK_UN) && fclose($handle);
if ($result !== true) {
throw new Exception('Unexpected file system error.'); // @codeCoverageIgnore
}
unset($this->handles[$file]);
}
/**
* Returns the path to a model's lock file
*
* @param \Kirby\Cms\ModelWithContent $model
* @return string
*/
public static function file(ModelWithContent $model): string
{
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
{
$file = static::file($model);
$id = static::id($model);
// return from cache if file was already loaded
if (isset($this->data[$file]) === true) {
return $this->data[$file][$id] ?? [];
}
// first get a handle to ensure a file system lock
$handle = $this->handle($file);
if (is_resource($handle) === true) {
// read data from file
clearstatcache();
$filesize = filesize($file);
if ($filesize > 0) {
// always read the whole file
rewind($handle);
$string = fread($handle, $filesize);
$data = Data::decode($string, 'yaml');
}
}
$this->data[$file] = $data ?? [];
return $this->data[$file][$id] ?? [];
}
/**
* 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
*/
protected function handle(string $file, bool $create = false)
{
// check for an already open handle
if (isset($this->handles[$file]) === true) {
return $this->handles[$file];
}
// don't create a file if not requested
if (is_file($file) !== true && $create !== true) {
return null;
}
$handle = @fopen($file, 'c+b');
if (is_resource($handle) === false) {
throw new Exception('Lock file ' . $file . ' could not be opened.'); // @codeCoverageIgnore
}
// lock the lock file exclusively to prevent changes by other threads
$result = flock($handle, LOCK_EX);
if ($result !== true) {
throw new Exception('Unexpected file system error.'); // @codeCoverageIgnore
}
return $this->handles[$file] = $handle;
}
/**
* 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
{
return '/' . $model->id();
}
/**
* 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
{
$file = static::file($model);
$id = static::id($model);
$handle = $this->handle($file, true);
$this->data[$file][$id] = $data;
// make sure to unset model id entries,
// if no lock data for the model exists
foreach ($this->data[$file] as $id => $data) {
// there is no data for that model whatsoever
if (
isset($data['lock']) === false &&
(isset($data['unlock']) === false ||
count($data['unlock']) === 0)
) {
unset($this->data[$file][$id]);
// there is empty unlock data, but still lock data
} elseif (
isset($data['unlock']) === true &&
count($data['unlock']) === 0
) {
unset($this->data[$file][$id]['unlock']);
}
}
// there is no data left in the file whatsoever, delete the file
if (count($this->data[$file]) === 0) {
unset($this->data[$file]);
// close the file handle, otherwise we can't delete it on Windows
$this->closeHandle($file);
return F::remove($file);
}
$yaml = Data::encode($this->data[$file], 'yaml');
// delete all file contents first
if (rewind($handle) !== true || ftruncate($handle, 0) !== true) {
throw new Exception('Could not write lock file ' . $file . '.'); // @codeCoverageIgnore
}
// write the new contents
$result = fwrite($handle, $yaml);
if (is_int($result) === false || $result === 0) {
throw new Exception('Could not write lock file ' . $file . '.'); // @codeCoverageIgnore
}
return true;
}
}

View file

@ -0,0 +1,242 @@
<?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)
{
$this->content = $content;
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)
{
$this->content = $overwrite === true ? (array)$data : array_merge($this->content(), (array)$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(),
];
}
}

472
kirby/src/Cms/Core.php Normal file
View file

@ -0,0 +1,472 @@
<?php
namespace Kirby\Cms;
/**
* The Core class lists all parts of Kirby
* that need to be loaded or initalized in order
* to make the system work. Most core parts can
* be overwritten by plugins.
*
* You can get such lists as kirbytags, components,
* areas, etc. by accessing them through `$kirby->core()`
*
* I.e. `$kirby->core()->areas()`
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Core
{
/**
* @var array
*/
protected $cache = [];
/**
* @var \Kirby\Cms\App
*/
protected $kirby;
/**
* @var string
*/
protected $root;
/**
* @param \Kirby\Cms\App $kirby
*/
public function __construct(App $kirby)
{
$this->kirby = $kirby;
$this->root = dirname(__DIR__, 2) . '/config';
}
/**
* Fetches the definition array of a particular area.
*
* This is a shortcut for `$kirby->core()->load()->area()`
* to give faster access to original area code in plugins.
*
* @param string $name
* @return array|null
*/
public function area(string $name): ?array
{
return $this->load()->area($name);
}
/**
* 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',
'languages' => $this->root . '/areas/languages.php',
'login' => $this->root . '/areas/login.php',
'site' => $this->root . '/areas/site.php',
'system' => $this->root . '/areas/system.php',
'users' => $this->root . '/areas/users.php',
];
}
/**
* Returns a list of all default auth challenge classes
*
* @return array
*/
public function authChallenges(): array
{
return [
'email' => 'Kirby\Cms\Auth\EmailChallenge'
];
}
/**
* Returns a list of all paths to blueprint presets
*
* They are located in `/kirby/config/presets`
*
* @return array
*/
public function blueprintPresets(): array
{
return [
'pages' => $this->root . '/presets/pages.php',
'page' => $this->root . '/presets/page.php',
'files' => $this->root . '/presets/files.php',
];
}
/**
* Returns a list of 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
{
return [
// blocks
'blocks/code' => $this->root . '/blocks/code/code.yml',
'blocks/gallery' => $this->root . '/blocks/gallery/gallery.yml',
'blocks/heading' => $this->root . '/blocks/heading/heading.yml',
'blocks/image' => $this->root . '/blocks/image/image.yml',
'blocks/line' => $this->root . '/blocks/line/line.yml',
'blocks/list' => $this->root . '/blocks/list/list.yml',
'blocks/markdown' => $this->root . '/blocks/markdown/markdown.yml',
'blocks/quote' => $this->root . '/blocks/quote/quote.yml',
'blocks/table' => $this->root . '/blocks/table/table.yml',
'blocks/text' => $this->root . '/blocks/text/text.yml',
'blocks/video' => $this->root . '/blocks/video/video.yml',
// file blueprints
'files/default' => $this->root . '/blueprints/files/default.yml',
// page blueprints
'pages/default' => $this->root . '/blueprints/pages/default.yml',
// site blueprints
'site' => $this->root . '/blueprints/site.yml'
];
}
/**
* Returns a list of all cache driver classes
*
* @return array
*/
public function cacheTypes(): array
{
return [
'apcu' => 'Kirby\Cache\ApcuCache',
'file' => 'Kirby\Cache\FileCache',
'memcached' => 'Kirby\Cache\MemCached',
'memory' => 'Kirby\Cache\MemoryCache',
];
}
/**
* Returns an array of all core component functions
*
* The component functions can be found in
* `/kirby/config/components.php`
*
* @return array
*/
public function components(): array
{
return $this->cache['components'] ??= include $this->root . '/components.php';
}
/**
* Returns a map of all field method aliases
*
* @return array
*/
public function fieldMethodAliases(): array
{
return [
'bool' => 'toBool',
'esc' => 'escape',
'excerpt' => 'toExcerpt',
'float' => 'toFloat',
'h' => 'html',
'int' => 'toInt',
'kt' => 'kirbytext',
'kti' => 'kirbytextinline',
'link' => 'toLink',
'md' => 'markdown',
'sp' => 'smartypants',
'v' => 'isValid',
'x' => 'xml'
];
}
/**
* Returns an array of all field method functions
*
* Field methods are stored in `/kirby/config/methods.php`
*
* @return array
*/
public function fieldMethods(): array
{
return $this->cache['fieldMethods'] ??= (include $this->root . '/methods.php')($this->kirby);
}
/**
* Returns an array of paths for field mixins
*
* They are located in `/kirby/config/fields/mixins`
*
* @return array
*/
public function fieldMixins(): array
{
return [
'datetime' => $this->root . '/fields/mixins/datetime.php',
'filepicker' => $this->root . '/fields/mixins/filepicker.php',
'layout' => $this->root . '/fields/mixins/layout.php',
'min' => $this->root . '/fields/mixins/min.php',
'options' => $this->root . '/fields/mixins/options.php',
'pagepicker' => $this->root . '/fields/mixins/pagepicker.php',
'picker' => $this->root . '/fields/mixins/picker.php',
'upload' => $this->root . '/fields/mixins/upload.php',
'userpicker' => $this->root . '/fields/mixins/userpicker.php',
];
}
/**
* Returns an array of all paths and class names of panel fields
*
* Traditional panel fields are located in `/kirby/config/fields`
*
* The more complex field classes can be found in
* `/kirby/src/Form/Fields`
*
* @return array
*/
public function fields(): array
{
return [
'blocks' => 'Kirby\Form\Field\BlocksField',
'checkboxes' => $this->root . '/fields/checkboxes.php',
'date' => $this->root . '/fields/date.php',
'email' => $this->root . '/fields/email.php',
'files' => $this->root . '/fields/files.php',
'gap' => $this->root . '/fields/gap.php',
'headline' => $this->root . '/fields/headline.php',
'hidden' => $this->root . '/fields/hidden.php',
'info' => $this->root . '/fields/info.php',
'layout' => 'Kirby\Form\Field\LayoutField',
'line' => $this->root . '/fields/line.php',
'list' => $this->root . '/fields/list.php',
'multiselect' => $this->root . '/fields/multiselect.php',
'number' => $this->root . '/fields/number.php',
'pages' => $this->root . '/fields/pages.php',
'radio' => $this->root . '/fields/radio.php',
'range' => $this->root . '/fields/range.php',
'select' => $this->root . '/fields/select.php',
'slug' => $this->root . '/fields/slug.php',
'structure' => $this->root . '/fields/structure.php',
'tags' => $this->root . '/fields/tags.php',
'tel' => $this->root . '/fields/tel.php',
'text' => $this->root . '/fields/text.php',
'textarea' => $this->root . '/fields/textarea.php',
'time' => $this->root . '/fields/time.php',
'toggle' => $this->root . '/fields/toggle.php',
'url' => $this->root . '/fields/url.php',
'users' => $this->root . '/fields/users.php',
'writer' => $this->root . '/fields/writer.php'
];
}
/**
* Returns a map of all kirbytag aliases
*
* @return array
*/
public function kirbyTagAliases(): array
{
return [
'youtube' => 'video',
'vimeo' => 'video'
];
}
/**
* Returns an array of all kirbytag definitions
*
* They are located in `/kirby/config/tags.php`
*
* @return array
*/
public function kirbyTags(): array
{
return $this->cache['kirbytags'] ??= include $this->root . '/tags.php';
}
/**
* Loads a core part of Kirby
*
* The loader is set to not include plugins.
* This way, you can access original Kirby core code
* through this load method.
*
* @return \Kirby\Cms\Loader
*/
public function load()
{
return new Loader($this->kirby, false);
}
/**
* Returns all absolute paths to important directories
*
* Roots are resolved and baked in `\Kirby\Cms\App::bakeRoots()`
*
* @return array
*/
public function roots(): array
{
return $this->cache['roots'] ??= [
'kirby' => fn (array $roots) => dirname(__DIR__, 2),
'i18n' => fn (array $roots) => $roots['kirby'] . '/i18n',
'i18n:translations' => fn (array $roots) => $roots['i18n'] . '/translations',
'i18n:rules' => fn (array $roots) => $roots['i18n'] . '/rules',
'index' => fn (array $roots) => dirname(__DIR__, 3),
'assets' => fn (array $roots) => $roots['index'] . '/assets',
'content' => fn (array $roots) => $roots['index'] . '/content',
'media' => fn (array $roots) => $roots['index'] . '/media',
'panel' => fn (array $roots) => $roots['kirby'] . '/panel',
'site' => fn (array $roots) => $roots['index'] . '/site',
'accounts' => fn (array $roots) => $roots['site'] . '/accounts',
'blueprints' => fn (array $roots) => $roots['site'] . '/blueprints',
'cache' => fn (array $roots) => $roots['site'] . '/cache',
'collections' => fn (array $roots) => $roots['site'] . '/collections',
'config' => fn (array $roots) => $roots['site'] . '/config',
'controllers' => fn (array $roots) => $roots['site'] . '/controllers',
'languages' => fn (array $roots) => $roots['site'] . '/languages',
'license' => fn (array $roots) => $roots['config'] . '/.license',
'logs' => fn (array $roots) => $roots['site'] . '/logs',
'models' => fn (array $roots) => $roots['site'] . '/models',
'plugins' => fn (array $roots) => $roots['site'] . '/plugins',
'sessions' => fn (array $roots) => $roots['site'] . '/sessions',
'snippets' => fn (array $roots) => $roots['site'] . '/snippets',
'templates' => fn (array $roots) => $roots['site'] . '/templates',
'roles' => fn (array $roots) => $roots['blueprints'] . '/users',
];
}
/**
* Returns an array of all routes for Kirbys router
*
* Routes are split into `before` and `after` routes.
*
* Plugin routes will be injected inbetween.
*
* @return array
*/
public function routes(): array
{
return $this->cache['routes'] ??= (include $this->root . '/routes.php')($this->kirby);
}
/**
* Returns a list of all paths to core block snippets
*
* They are located in `/kirby/config/blocks`
*
* @return array
*/
public function snippets(): array
{
return [
'blocks/code' => $this->root . '/blocks/code/code.php',
'blocks/gallery' => $this->root . '/blocks/gallery/gallery.php',
'blocks/heading' => $this->root . '/blocks/heading/heading.php',
'blocks/image' => $this->root . '/blocks/image/image.php',
'blocks/line' => $this->root . '/blocks/line/line.php',
'blocks/list' => $this->root . '/blocks/list/list.php',
'blocks/markdown' => $this->root . '/blocks/markdown/markdown.php',
'blocks/quote' => $this->root . '/blocks/quote/quote.php',
'blocks/table' => $this->root . '/blocks/table/table.php',
'blocks/text' => $this->root . '/blocks/text/text.php',
'blocks/video' => $this->root . '/blocks/video/video.php',
];
}
/**
* Returns a list of paths to section mixins
*
* They are located in `/kirby/config/sections/mixins`
*
* @return array
*/
public function sectionMixins(): array
{
return [
'empty' => $this->root . '/sections/mixins/empty.php',
'headline' => $this->root . '/sections/mixins/headline.php',
'help' => $this->root . '/sections/mixins/help.php',
'layout' => $this->root . '/sections/mixins/layout.php',
'max' => $this->root . '/sections/mixins/max.php',
'min' => $this->root . '/sections/mixins/min.php',
'pagination' => $this->root . '/sections/mixins/pagination.php',
'parent' => $this->root . '/sections/mixins/parent.php',
];
}
/**
* Returns a list of all section definitions
*
* They are located in `/kirby/config/sections`
*
* @return array
*/
public function sections(): array
{
return [
'fields' => $this->root . '/sections/fields.php',
'files' => $this->root . '/sections/files.php',
'info' => $this->root . '/sections/info.php',
'pages' => $this->root . '/sections/pages.php',
];
}
/**
* Returns a list of paths to all system templates
*
* They are located in `/kirby/config/templates`
*
* @return array
*/
public function templates(): array
{
return [
'emails/auth/login' => $this->root . '/templates/emails/auth/login.php',
'emails/auth/password-reset' => $this->root . '/templates/emails/auth/password-reset.php'
];
}
/**
* Returns an array with all system URLs
*
* URLs are resolved and baked in `\Kirby\Cms\App::bakeUrls()`
*
* @return array
*/
public function urls(): array
{
return $this->cache['urls'] ??= [
'index' => fn () => $this->kirby->environment()->url(),
'base' => fn (array $urls) => rtrim($urls['index'], '/'),
'current' => function (array $urls) {
$path = trim($this->kirby->path(), '/');
if (empty($path) === true) {
return $urls['index'];
} else {
return $urls['base'] . '/' . $path;
}
},
'assets' => fn (array $urls) => $urls['base'] . '/assets',
'api' => fn (array $urls) => $urls['base'] . '/' . $this->kirby->option('api.slug', 'api'),
'media' => fn (array $urls) => $urls['base'] . '/media',
'panel' => fn (array $urls) => $urls['base'] . '/' . $this->kirby->option('panel.slug', 'panel')
];
}
}

255
kirby/src/Cms/Email.php Normal file
View file

@ -0,0 +1,255 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
/**
* Wrapper around our Email package, which
* handles all the magic connections between Kirby
* and sending emails, like email templates, file
* attachments, etc.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Email
{
/**
* Options configured through the `email` CMS option
*
* @var array
*/
protected $options;
/**
* Props for the email object; will be passed to the
* Kirby\Email\Email class
*
* @var array
*/
protected $props;
/**
* Class constructor
*
* @param string|array $preset Preset name from the config or a simple props array
* @param array $props Props array to override the $preset
*/
public function __construct($preset = [], array $props = [])
{
$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
if (isset($this->props['transport']) === false) {
$this->props['transport'] = $this->options['transport'] ?? [];
}
// add predefined beforeSend option
if (isset($this->props['beforeSend']) === false) {
$this->props['beforeSend'] = $this->options['beforeSend'] ?? null;
}
// transform model objects to values
$this->transformUserSingle('from', 'fromName');
$this->transformUserSingle('replyTo', 'replyToName');
$this->transformUserMultiple('to');
$this->transformUserMultiple('cc');
$this->transformUserMultiple('bcc');
$this->transformFile('attachments');
// load template for body text
$this->template();
}
/**
* Grabs a preset from the options; supports fixed
* prop arrays in case a preset is not needed
*
* @param string|array $preset Preset name or simple prop array
* @return array
* @throws \Kirby\Exception\NotFoundException
*/
protected function preset($preset): array
{
// only passed props, not preset name
if (is_array($preset) === true) {
return $preset;
}
// preset does not exist
if (isset($this->options['presets'][$preset]) !== true) {
throw new NotFoundException([
'key' => 'email.preset.notFound',
'data' => ['name' => $preset]
]);
}
return $this->options['presets'][$preset];
}
/**
* Renders the email template(s) and sets the body props
* to the result
*
* @return void
* @throws \Kirby\Exception\NotFoundException
*/
protected function template(): void
{
if (isset($this->props['template']) === true) {
// prepare data to be passed to template
$data = $this->props['data'] ?? [];
// check if html/text templates exist
$html = $this->getTemplate($this->props['template'], 'html');
$text = $this->getTemplate($this->props['template'], 'text');
if ($html->exists()) {
$this->props['body'] = [
'html' => $html->render($data)
];
if ($text->exists()) {
$this->props['body']['text'] = $text->render($data);
}
// fallback to single email text template
} elseif ($text->exists()) {
$this->props['body'] = $text->render($data);
} else {
throw new NotFoundException('The email template "' . $this->props['template'] . '" cannot be found');
}
}
}
/**
* 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)
{
return App::instance()->template('emails/' . $name, $type, 'text');
}
/**
* Returns the prop array
*
* @return array
*/
public function toArray(): array
{
return $this->props;
}
/**
* Transforms file object(s) to an array of file roots;
* supports simple strings, file objects or collections/arrays of either
*
* @param string $prop Prop to transform
* @return void
*/
protected function transformFile(string $prop): void
{
$this->props[$prop] = $this->transformModel($prop, 'Kirby\Cms\File', 'root');
}
/**
* Transforms Kirby models to a simplified collection
*
* @param string $prop Prop to transform
* @param string $class Fully qualified class name of the supported model
* @param string $contentValue Model method that returns the array value
* @param string|null $contentKey Optional model method that returns the array key;
* returns a simple value-only array if not given
* @return array Simple key-value or just value array with the transformed prop data
*/
protected function transformModel(string $prop, string $class, string $contentValue, string $contentKey = null): array
{
$value = $this->props[$prop] ?? [];
// ensure consistent input by making everything an iterable value
if (is_iterable($value) !== true) {
$value = [$value];
}
$result = [];
foreach ($value as $key => $item) {
if (is_string($item) === true) {
// value is already a string
if ($contentKey !== null && is_string($key) === true) {
$result[$key] = $item;
} else {
$result[] = $item;
}
} elseif (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();
} else {
$result[] = (string)$item->$contentValue();
}
} else {
// invalid input
throw new InvalidArgumentException('Invalid input for prop "' . $prop . '", expected string or "' . $class . '" object or collection');
}
}
return $result;
}
/**
* Transforms an user object to the email address and name;
* supports simple strings, user objects or collections/arrays of either
* (note: only the first item in a collection/array will be used)
*
* @param string $addressProp Prop with the email address
* @param string $nameProp Prop with the name corresponding to the $addressProp
* @return void
*/
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;
// if the array is non-associative, the value is the address
if (is_int($address) === true) {
$address = $name;
$name = null;
}
// always use the address as we have transformed that prop above
$this->props[$addressProp] = $address;
// only use the name from the user if no custom name was set
if (isset($this->props[$nameProp]) === false || $this->props[$nameProp] === null) {
$this->props[$nameProp] = $name;
}
}
/**
* Transforms user object(s) to the email address(es) and name(s);
* supports simple strings, user objects or collections/arrays of either
*
* @param string $prop Prop to transform
* @return void
*/
protected function transformUserMultiple(string $prop): void
{
$this->props[$prop] = $this->transformModel($prop, 'Kirby\Cms\User', 'name', 'email');
}
}

View file

@ -0,0 +1,222 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Filesystem\F;
use Kirby\Http\Server;
use Kirby\Http\Uri;
/**
* The environment object takes care of
* secure host and base URL detection, as
* well as loading the dedicated
* environment options.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Environment
{
/**
* @var string
*/
protected $root;
/**
* @var \Kirby\Http\Uri
*/
protected $uri;
/**
* @param string $root
* @param bool|string|array|null $allowed
*/
public function __construct(string $root, $allowed = null)
{
$this->root = $root;
if (is_string($allowed) === true) {
$this->setupFromString($allowed);
return;
}
if (is_array($allowed) === true) {
$this->setupFromArray($allowed);
return;
}
if (is_int($allowed) === true) {
$this->setupFromFlag($allowed);
return;
}
if (is_null($allowed) === true) {
$this->setupFromFlag(Server::HOST_FROM_SERVER | Server::HOST_ALLOW_EMPTY);
return;
}
throw new InvalidArgumentException('Invalid allow list setup for base URLs');
}
/**
* Throw an exception if the host in the URI
* object is empty
*
* @throws \Kirby\Exception\InvalidArgumentException
* @return void
*/
protected function blockEmptyHost(): void
{
if (empty($this->uri->host()) === true) {
throw new InvalidArgumentException('Invalid host setup. The detected host is not allowed.');
}
}
/**
* Returns the detected host name
*
* @return string|null
*/
public function host(): ?string
{
return $this->uri->host();
}
/**
* Loads and returns the environment options
*
* @return array
*/
public function options(): array
{
$configHost = [];
$configAddr = [];
$host = $this->host();
$addr = Server::address();
// load the config for the host
if (empty($host) === false) {
$configHost = F::load($this->root . '/config.' . $host . '.php', []);
}
// load the config for the server IP
if (empty($addr) === false) {
$configAddr = F::load($this->root . '/config.' . $addr . '.php', []);
}
return array_replace_recursive($configHost, $configAddr);
}
/**
* The current URL should be auto detected from a host allowlist
*
* @param array $allowed
* @return \Kirby\Http\Uri
*/
public function setupFromArray(array $allowed)
{
$allowedStrings = [];
$allowedUris = [];
$hosts = [];
foreach ($allowed as $url) {
$allowedUris[] = $uri = new Uri($url, ['slash' => false]);
$allowedStrings[] = $uri->toString();
$hosts[] = $uri->host();
}
// register all allowed hosts
Server::hosts($hosts);
// get the index URL, including the subfolder if it exists
$this->uri = Uri::index();
// empty URLs don't make sense in an allow list
$this->blockEmptyHost();
// validate against the list of allowed base URLs
if (in_array($this->uri->toString(), $allowedStrings) === false) {
throw new InvalidArgumentException('The subfolder is not in the allowed base URL list');
}
return $this->uri;
}
/**
* The URL option receives a set of Server constant flags
*
* Server::HOST_FROM_SERVER
* Server::HOST_FROM_SERVER | Server::HOST_ALLOW_EMPTY
* Server::HOST_FROM_HOST
* Server::HOST_FROM_HOST | Server::HOST_ALLOW_EMPTY
*
* @param int $allowed
* @return \Kirby\Http\Uri
*/
public function setupFromFlag(int $allowed)
{
// allow host detection from host headers
if ($allowed & Server::HOST_FROM_HEADER) {
Server::hosts(Server::HOST_FROM_HEADER);
// detect host only from server name
} else {
Server::hosts(Server::HOST_FROM_SERVER);
}
// get the base URL
$this->uri = Uri::index();
// accept empty hosts
if ($allowed & Server::HOST_ALLOW_EMPTY) {
return $this->uri;
}
// block empty hosts
$this->blockEmptyHost();
return $this->uri;
}
/**
* The current URL is predefined with a single string
* and not detected automatically.
*
* If the url option is relative (i.e. '/' or '/some/subfolder')
* The host will be empty and that's totally fine.
* No need to block an empty host here
*
* @param string $allowed
* @return \Kirby\Http\Uri
*/
public function setupFromString(string $allowed)
{
// create the URI object directly from the given option
// without any form of detection from the server
$this->uri = new Uri($allowed);
// only create an allow list from absolute URLs
// otherwise the default secure host detection
// behavior will be used
if (empty($host = $this->uri->host()) === false) {
Server::hosts([$host]);
}
return $this->uri;
}
/**
* Returns the base URL for the environment
*
* @return string
*/
public function url(): string
{
return $this->uri;
}
}

290
kirby/src/Cms/Event.php Normal file
View file

@ -0,0 +1,290 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Controller;
/**
* The Event object is created whenever the `$kirby->trigger()`
* or `$kirby->apply()` methods are called. It collects all
* event information and handles calling the individual hooks.
* @since 3.4.0
*
* @package Kirby Cms
* @author Lukas Bestle <lukas@getkirby.com>,
* Ahmet Bora
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Event
{
/**
* The full event name
* (e.g. `page.create:after`)
*
* @var string
*/
protected $name;
/**
* The event type
* (e.g. `page` in `page.create:after`)
*
* @var string
*/
protected $type;
/**
* The event action
* (e.g. `create` in `page.create:after`)
*
* @var string|null
*/
protected $action;
/**
* The event state
* (e.g. `after` in `page.create:after`)
*
* @var string|null
*/
protected $state;
/**
* The event arguments
*
* @var array
*/
protected $arguments = [];
/**
* Class constructor
*
* @param string $name Full event name
* @param array $arguments Associative array of named event arguments
*/
public function __construct(string $name, array $arguments = [])
{
// split the event name into `$type.$action:$state`
// $action and $state are optional;
// if there is more than one dot, $type will be greedy
$regex = '/^(?<type>.+?)(?:\.(?<action>[^.]*?))?(?:\:(?<state>.*))?$/';
preg_match($regex, $name, $matches, PREG_UNMATCHED_AS_NULL);
$this->name = $name;
$this->type = $matches['type'];
$this->action = $matches['action'] ?? null;
$this->state = $matches['state'] ?? null;
$this->arguments = $arguments;
}
/**
* Magic caller for event arguments
*
* @param string $method
* @param array $arguments
* @return mixed
*/
public function __call(string $method, array $arguments = [])
{
return $this->argument($method);
}
/**
* Improved `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Makes it possible to simply echo
* or stringify the entire object
*
* @return string
*/
public function __toString(): string
{
return $this->toString();
}
/**
* Returns the action of the event (e.g. `create`)
* or `null` if the event name does not include an action
*
* @return string|null
*/
public function action(): ?string
{
return $this->action;
}
/**
* Returns a specific event argument
*
* @param string $name
* @return mixed
*/
public function argument(string $name)
{
if (isset($this->arguments[$name]) === true) {
return $this->arguments[$name];
}
return null;
}
/**
* Returns the arguments of the event
*
* @return array
*/
public function arguments(): array
{
return $this->arguments;
}
/**
* Calls a hook with the event data and returns
* the hook's return value
*
* @param object|null $bind Optional object to bind to the hook function
* @param \Closure $hook
* @return mixed
*/
public function call(?object $bind, Closure $hook)
{
// collect the list of possible hook arguments
$data = $this->arguments();
$data['event'] = $this;
// magically call the hook with the arguments it requested
$hook = new Controller($hook);
return $hook->call($bind, $data);
}
/**
* Returns the full name of the event
*
* @return string
*/
public function name(): string
{
return $this->name;
}
/**
* 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 === '*') {
return [];
}
if ($this->action !== null && $this->state !== null) {
// full $type.$action:$state event
return [
$this->type . '.*:' . $this->state,
$this->type . '.' . $this->action . ':*',
$this->type . '.*:*',
'*.' . $this->action . ':' . $this->state,
'*.' . $this->action . ':*',
'*:' . $this->state,
'*'
];
} elseif ($this->state !== null) {
// event without action: $type:$state
return [
$this->type . ':*',
'*:' . $this->state,
'*'
];
} elseif ($this->action !== null) {
// event without state: $type.$action
return [
$this->type . '.*',
'*.' . $this->action,
'*'
];
} else {
// event with a simple name
return ['*'];
}
}
/**
* Returns the state of the event (e.g. `after`)
*
* @return string|null
*/
public function state(): ?string
{
return $this->state;
}
/**
* Returns the event data as array
*
* @return array
*/
public function toArray(): array
{
return [
'name' => $this->name,
'arguments' => $this->arguments
];
}
/**
* Returns the event name as string
*
* @return string
*/
public function toString(): string
{
return $this->name;
}
/**
* Returns the type of the event (e.g. `page`)
*
* @return string
*/
public function type(): string
{
return $this->type;
}
/**
* 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
{
if (array_key_exists($name, $this->arguments) !== true) {
throw new InvalidArgumentException('The argument ' . $name . ' does not exist');
}
$this->arguments[$name] = $value;
}
}

257
kirby/src/Cms/Field.php Normal file
View file

@ -0,0 +1,257 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
/**
* Every field in a Kirby content text file
* is being converted into such a Field object.
*
* Field methods can be registered for those Field
* objects, which can then be used to transform or
* convert the field value. This enables our
* daisy-chaining API for templates and other components
*
* ```php
* // Page field example with lowercase conversion
* $page->myField()->lower();
* ```
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Field
{
/**
* Field method aliases
*
* @var array
*/
public static $aliases = [];
/**
* The field name
*
* @var string
*/
protected $key;
/**
* Registered field methods
*
* @var array
*/
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 $parent;
/**
* The value of the field
*
* @var mixed
*/
public $value;
/**
* Magic caller for field methods
*
* @param string $method
* @param array $arguments
* @return mixed
*/
public function __call(string $method, array $arguments = [])
{
$method = strtolower($method);
if (isset(static::$methods[$method]) === true) {
return (static::$methods[$method])(clone $this, ...$arguments);
}
if (isset(static::$aliases[$method]) === true) {
$method = strtolower(static::$aliases[$method]);
if (isset(static::$methods[$method]) === true) {
return (static::$methods[$method])(clone $this, ...$arguments);
}
}
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
*
* @see Field::toArray
* @return array
*/
public function __debugInfo()
{
return $this->toArray();
}
/**
* Makes it possible to simply echo
* or stringify the entire object
*
* @see Field::toString
* @return string
*/
public function __toString(): string
{
return $this->toString();
}
/**
* Checks if the field exists in the content data array
*
* @return bool
*/
public function exists(): bool
{
return $this->parent->content()->has($this->key);
}
/**
* 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;
}
/**
* Checks if the field content is not empty
*
* @return bool
*/
public function isNotEmpty(): bool
{
return $this->isEmpty() === false;
}
/**
* Returns the name of the field
*
* @return string
*/
public function key(): string
{
return $this->key;
}
/**
* @see Field::parent()
* @return \Kirby\Cms\Model|null
*/
public function model()
{
return $this->parent;
}
/**
* Provides a fallback if the field value is empty
*
* @param mixed $fallback
* @return $this|static
*/
public function or($fallback = null)
{
if ($this->isNotEmpty()) {
return $this;
}
if (is_a($fallback, 'Kirby\Cms\Field') === true) {
return $fallback;
}
$field = clone $this;
$field->value = $fallback;
return $field;
}
/**
* Returns the parent object of the field
*
* @return \Kirby\Cms\Model|null
*/
public function parent()
{
return $this->parent;
}
/**
* Converts the Field object to an array
*
* @return array
*/
public function toArray(): array
{
return [$this->key => $this->value];
}
/**
* Returns the field value as string
*
* @return string
*/
public function toString(): string
{
return (string)$this->value;
}
/**
* 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($value = null)
{
if ($value === null) {
return $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 = $value;
return $clone;
}
}

294
kirby/src/Cms/Fieldset.php Normal file
View file

@ -0,0 +1,294 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Form\Form;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* Represents a single Fieldset
* @since 3.5.0
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Fieldset extends Item
{
public const ITEMS_CLASS = '\Kirby\Cms\Fieldsets';
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 = [])
{
if (empty($params['type']) === true) {
throw new InvalidArgumentException('The fieldset type is missing');
}
$this->type = $params['id'] = $params['type'];
parent::__construct($params);
$this->disabled = $params['disabled'] ?? false;
$this->editable = $params['editable'] ?? true;
$this->icon = $params['icon'] ?? null;
$this->model = $this->parent;
$this->name = $this->createName($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 &&
$this->kirby()->multilang() === true &&
$this->kirby()->language()->isDefault() === false
) {
// disable and unset the fieldset if it's not translatable
$this->unset = true;
$this->disabled = true;
}
}
/**
* @param array $fields
* @return array
*/
protected function createFields(array $fields = []): array
{
$fields = Blueprint::fieldsProps($fields);
$fields = $this->form($fields)->fields()->toArray();
// collect all fields
$this->fields = array_merge($this->fields, $fields);
return $fields;
}
/**
* @param array|string $name
* @return string|null
*/
protected function createName($name): ?string
{
return I18n::translate($name, $name);
}
/**
* @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'] ?? [];
// return a single tab if there are only fields
if (empty($tabs) === true) {
return [
'content' => [
'fields' => $this->createFields($params['fields'] ?? []),
]
];
}
// normalize tabs props
foreach ($tabs as $name => $tab) {
// unset/remove tab if its property is false
if ($tab === false) {
unset($tabs[$name]);
continue;
}
$tab = Blueprint::extend($tab);
$tab['fields'] = $this->createFields($tab['fields'] ?? []);
$tab['label'] = $this->createLabel($tab['label'] ?? Str::ucfirst($name));
$tab['name'] = $name;
$tabs[$name] = $tab;
}
return $tabs;
}
/**
* @return bool
*/
public function disabled(): bool
{
return $this->disabled;
}
/**
* @return bool
*/
public function editable(): bool
{
if ($this->editable === false) {
return false;
}
if (count($this->fields) === 0) {
return false;
}
return true;
}
/**
* @return array
*/
public function fields(): array
{
return $this->fields;
}
/**
* Creates a form for the given fields
*
* @param array $fields
* @param array $input
* @return \Kirby\Form\Form
*/
public function form(array $fields, array $input = [])
{
return new Form([
'fields' => $fields,
'model' => $this->model,
'strict' => true,
'values' => $input,
]);
}
/**
* @return string|null
*/
public function icon(): ?string
{
return $this->icon;
}
/**
* @return string|null
*/
public function label(): ?string
{
return $this->label;
}
/**
* @return \Kirby\Cms\ModelWithContent
*/
public function model()
{
return $this->model;
}
/**
* @return string
*/
public function name(): string
{
return $this->name;
}
/**
* @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 [
'disabled' => $this->disabled(),
'editable' => $this->editable(),
'icon' => $this->icon(),
'label' => $this->label(),
'name' => $this->name(),
'preview' => $this->preview(),
'tabs' => $this->tabs(),
'translate' => $this->translate(),
'type' => $this->type(),
'unset' => $this->unset(),
'wysiwyg' => $this->wysiwyg(),
];
}
/**
* @return bool
*/
public function unset(): bool
{
return $this->unset;
}
/**
* @return bool
*/
public function wysiwyg(): bool
{
return $this->wysiwyg;
}
}

103
kirby/src/Cms/Fieldsets.php Normal file
View file

@ -0,0 +1,103 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Toolkit\A;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* A collection of fieldsets
* @since 3.5.0
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Fieldsets extends Items
{
public const ITEM_CLASS = '\Kirby\Cms\Fieldset';
protected static function createFieldsets($params)
{
$fieldsets = [];
$groups = [];
foreach ($params as $type => $fieldset) {
if (is_int($type) === true && is_string($fieldset)) {
$type = $fieldset;
$fieldset = 'blocks/' . $type;
}
if ($fieldset === false) {
continue;
}
if ($fieldset === true) {
$fieldset = 'blocks/' . $type;
}
$fieldset = Blueprint::extend($fieldset);
// make sure the type is always set
$fieldset['type'] ??= $type;
// extract groups
if ($fieldset['type'] === 'group') {
$result = static::createFieldsets($fieldset['fieldsets'] ?? []);
$fieldsets = array_merge($fieldsets, $result['fieldsets']);
$label = $fieldset['label'] ?? Str::ucfirst($type);
$groups[$type] = [
'label' => I18n::translate($label, $label),
'name' => $type,
'open' => $fieldset['open'] ?? true,
'sets' => array_column($result['fieldsets'], 'type'),
];
} else {
$fieldsets[$fieldset['type']] = $fieldset;
}
}
return [
'fieldsets' => $fieldsets,
'groups' => $groups
];
}
public static function factory(array $items = null, array $params = [])
{
$items ??= option('blocks.fieldsets', [
'code' => 'blocks/code',
'gallery' => 'blocks/gallery',
'heading' => 'blocks/heading',
'image' => 'blocks/image',
'line' => 'blocks/line',
'list' => 'blocks/list',
'markdown' => 'blocks/markdown',
'quote' => 'blocks/quote',
'text' => 'blocks/text',
'video' => 'blocks/video',
]);
$result = static::createFieldsets($items);
return parent::factory($result['fieldsets'], ['groups' => $result['groups']] + $params);
}
public function groups(): array
{
return $this->options['groups'] ?? [];
}
public function toArray(?Closure $map = null): array
{
return A::map(
$this->data,
$map ?? fn ($fieldset) => $fieldset->toArray()
);
}
}

762
kirby/src/Cms/File.php Normal file
View file

@ -0,0 +1,762 @@
<?php
namespace Kirby\Cms;
use Kirby\Filesystem\F;
use Kirby\Filesystem\IsFile;
use Kirby\Panel\File as Panel;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* The `$file` object provides a set
* of methods that can be used when
* dealing with a single image or
* other media file, like getting the
* URL or resizing an image. It also
* handles file meta data.
*
* The File class proxies the `Kirby\Filesystem\File`
* or `Kirby\Image\Image` class, which
* is used to handle all asset file methods.
* In addition the File class handles
* meta data via `Kirby\Cms\Content`.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class File extends ModelWithContent
{
use FileActions;
use FileModifications;
use HasMethods;
use HasSiblings;
use IsFile;
public const CLASS_ALIAS = 'file';
/**
* Cache for the initialized blueprint object
*
* @var \Kirby\Cms\FileBlueprint
*/
protected $blueprint;
/**
* @var string
*/
protected $filename;
/**
* @var string
*/
protected $id;
/**
* All registered file methods
*
* @var array
*/
public static $methods = [];
/**
* The parent object
*
* @var \Kirby\Cms\Model
*/
protected $parent;
/**
* The absolute path to the file
*
* @var string|null
*/
protected $root;
/**
* @var string
*/
protected $template;
/**
* The public file Url
*
* @var string
*/
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 = [])
{
// public property access
if (isset($this->$method) === true) {
return $this->$method;
}
// asset method proxy
if (method_exists($this->asset(), $method)) {
return $this->asset()->$method(...$arguments);
}
// file methods
if ($this->hasMethod($method)) {
return $this->callMethod($method, $arguments);
}
// content fields
return $this->content()->get($method);
}
/**
* 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
{
return array_merge($this->toArray(), [
'content' => $this->content(),
'siblings' => $this->siblings(),
]);
}
/**
* Returns the url to api endpoint
*
* @internal
* @param bool $relative
* @return string
*/
public function apiUrl(bool $relative = false): string
{
return $this->parent()->apiUrl($relative) . '/files/' . $this->filename();
}
/**
* Returns the FileBlueprint object for the file
*
* @return \Kirby\Cms\FileBlueprint
*/
public function blueprint()
{
if (is_a($this->blueprint, 'Kirby\Cms\FileBlueprint') === true) {
return $this->blueprint;
}
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
{
return A::append($data, [
'template' => $this->template(),
]);
}
/**
* Returns the directory in which
* the content file is located
*
* @internal
* @return string
*/
public function contentFileDirectory(): string
{
return dirname($this->root());
}
/**
* Filename for the content file
*
* @internal
* @return string
*/
public function contentFileName(): string
{
return $this->filename();
}
/**
* Constructs a File object
*
* @internal
* @param mixed $props
* @return static
*/
public static function factory($props)
{
return new static($props);
}
/**
* Returns the filename with extension
*
* @return string
*/
public function filename(): string
{
return $this->filename;
}
/**
* Returns the parent Files collection
*
* @return \Kirby\Cms\Files
*/
public function files()
{
return $this->siblingsCollection();
}
/**
* Converts the file to html
*
* @param array $attr
* @return string
*/
public function html(array $attr = []): string
{
return $this->asset()->html(array_merge(
['alt' => $this->alt()],
$attr
));
}
/**
* Returns the id
*
* @return string
*/
public function id(): string
{
if ($this->id !== null) {
return $this->id;
}
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();
}
/**
* Check if the file can be read by the current user
*
* @return bool
*/
public function isReadable(): bool
{
static $readable = [];
$template = $this->template();
if (isset($readable[$template]) === true) {
return $readable[$template];
}
return $readable[$template] = $this->permissions()->can('read');
}
/**
* Creates a unique media hash
*
* @internal
* @return string
*/
public function mediaHash(): string
{
return $this->mediaToken() . '-' . $this->modifiedFile();
}
/**
* Returns the absolute path to the file in the public media folder
*
* @internal
* @return string
*/
public function mediaRoot(): string
{
return $this->parent()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->filename();
}
/**
* Creates a non-guessable token string for this file
*
* @internal
* @return string
*/
public function mediaToken(): string
{
$token = $this->kirby()->contentToken($this, $this->id());
return substr($token, 0, 10);
}
/**
* Returns the absolute Url to the file in the public media folder
*
* @internal
* @return string
*/
public function mediaUrl(): string
{
return $this->parent()->mediaUrl() . '/' . $this->mediaHash() . '/' . $this->filename();
}
/**
* Get the file's last modification time.
*
* @param string|null $format
* @param string|null $handler date or strftime
* @param string|null $languageCode
* @return mixed
*/
public function modified(string $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);
}
/**
* Timestamp of the last modification
* of the content file
*
* @param string|null $languageCode
* @return int
*/
protected function modifiedContent(string $languageCode = null): int
{
return F::modified($this->contentFile($languageCode));
}
/**
* Timestamp of the last modification
* of the source file
*
* @return int
*/
protected function modifiedFile(): int
{
return F::modified($this->root());
}
/**
* Returns the parent Page object
*
* @return \Kirby\Cms\Page|null
*/
public function page()
{
return is_a($this->parent(), 'Kirby\Cms\Page') === true ? $this->parent() : null;
}
/**
* Returns the panel info object
*
* @return \Kirby\Panel\File
*/
public function panel()
{
return new Panel($this);
}
/**
* Returns the parent Model object
*
* @return \Kirby\Cms\Model
*/
public function parent()
{
return $this->parent ??= $this->kirby()->site();
}
/**
* Returns the parent id if a parent exists
*
* @internal
* @todo 3.7.0 When setParent() is changed, the if check is not needed anymore
* @return string|null
*/
public function parentId(): ?string
{
if ($parent = $this->parent()) {
return $parent->id();
}
return null;
}
/**
* Returns a collection of all parent pages
*
* @return \Kirby\Cms\Pages
*/
public function parents()
{
if (is_a($this->parent(), 'Kirby\Cms\Page') === true) {
return $this->parent()->parents()->prepend($this->parent()->id(), $this->parent());
}
return new Pages();
}
/**
* Returns the permissions object for this file
*
* @return \Kirby\Cms\FilePermissions
*/
public function permissions()
{
return new FilePermissions($this);
}
/**
* Returns the absolute root to the file
*
* @return string|null
*/
public function root(): ?string
{
return $this->root ??= $this->parent()->root() . '/' . $this->filename();
}
/**
* Returns the FileRules class to
* validate any important action.
*
* @return \Kirby\Cms\FileRules
*/
protected function rules()
{
return new FileRules();
}
/**
* Sets the Blueprint object
*
* @param array|null $blueprint
* @return $this
*/
protected function setBlueprint(array $blueprint = null)
{
if ($blueprint !== null) {
$blueprint['model'] = $this;
$this->blueprint = new FileBlueprint($blueprint);
}
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;
* this property is required for `File::create()` and
* will be generally required starting with Kirby 3.7.0
*
* @param \Kirby\Cms\Model|null $parent
* @return $this
* @todo make property required in 3.7.0
*/
protected function setParent(Model $parent = null)
{
// @codeCoverageIgnoreStart
if ($parent === null) {
deprecated('You are creating a `Kirby\Cms\File` object without passing the `parent` property. While unsupported, this hasn\'t caused any direct errors so far. To fix inconsistencies, the `parent` property will be required when creating a `Kirby\Cms\File` object in Kirby 3.7.0 and higher. Not passing this property will start throwing a breaking error.');
}
// @codeCoverageIgnoreEnd
$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()
{
return $this->parent()->files();
}
/**
* Returns the parent Site object
*
* @return \Kirby\Cms\Site
*/
public function 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
{
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)
{
return $this->siblings($self)->filter('template', $this->template());
}
/**
* Extended info for the array export
* by injecting the information from
* the asset.
*
* @return array
*/
public function toArray(): array
{
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 Add `deprecated()` helper warning in 3.7.0
* @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
{
return $this->panel()->dragText($type, $absolute);
}
/**
* Returns an array of all actions
* that can be performed in the Panel
*
* @todo Add `deprecated()` helper warning in 3.7.0
* @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
{
return $this->panel()->options($unlock);
}
/**
* Returns the full path without leading slash
*
* @todo Add `deprecated()` helper warning in 3.7.0
* @todo Remove in 3.8.0
*
* @internal
* @return string
* @codeCoverageIgnore
*/
public function panelPath(): string
{
return $this->panel()->path();
}
/**
* Prepares the response data for file pickers
* and file fields
*
* @todo Add `deprecated()` helper warning in 3.7.0
* @todo Remove in 3.8.0
*
* @param array|null $params
* @return array
* @codeCoverageIgnore
*/
public function panelPickerData(array $params = []): array
{
return $this->panel()->pickerData($params);
}
/**
* Returns the url to the editing view
* in the panel
*
* @todo Add `deprecated()` helper warning in 3.7.0
* @todo Remove in 3.8.0
*
* @internal
* @param bool $relative
* @return string
* @codeCoverageIgnore
*/
public function panelUrl(bool $relative = false): string
{
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
{
$parent = $this->parent();
$url = url($this->id());
switch ($parent::CLASS_ALIAS) {
case 'page':
$preview = $parent->blueprint()->preview();
// the page has a custom preview setting,
// thus the file is only accessible through
// the direct media URL
if ($preview !== true) {
return $this->url();
}
// it's more stable to access files for drafts
// through their direct URL to avoid conflicts
// with draft token verification
if ($parent->isDraft() === true) {
return $this->url();
}
return $url;
case 'user':
return $this->url();
default:
return $url;
}
}
}

View file

@ -0,0 +1,316 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\F;
use Kirby\Form\Form;
/**
* FileActions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait FileActions
{
/**
* 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)
{
if ($sanitize === true) {
$name = F::safeName($name);
}
// don't rename if not necessary
if ($name === $this->name()) {
return $this;
}
return $this->commit('changeName', ['file' => $this, 'name' => $name], function ($oldFile, $name) {
$newFile = $oldFile->clone([
'filename' => $name . '.' . $oldFile->extension(),
]);
if ($oldFile->exists() === false) {
return $newFile;
}
if ($newFile->exists() === true) {
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());
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());
}
$newFile->parent()->files()->remove($oldFile->id());
$newFile->parent()->files()->set($newFile->id(), $newFile);
return $newFile;
});
}
/**
* Changes the file's sorting number in the meta file
*
* @param int $sort
* @return static
*/
public function changeSort(int $sort)
{
return $this->commit(
'changeSort',
['file' => $this, 'position' => $sort],
fn ($file, $sort) => $file->save(['sort' => $sort])
);
}
/**
* Commits a file action, by following these steps
*
* 1. checks the action rules
* 2. sends the before hook
* 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)
{
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
$this->rules()->$action(...$argumentValues);
$kirby->trigger('file.' . $action . ':before', $arguments);
$result = $callback(...$argumentValues);
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();
return $result;
}
/**
* Copy the file to the given page
*
* @param \Kirby\Cms\Page $page
* @return \Kirby\Cms\File
*/
public function copy(Page $page)
{
F::copy($this->root(), $page->root() . '/' . $this->filename());
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));
}
return $page->clone()->file($this->filename());
}
/**
* Creates a new file on disk and returns the
* File object. The store is used to handle file
* writing, so it can be replaced by any other
* way of generating files.
*
* @param array $props
* @return static
* @throws \Kirby\Exception\InvalidArgumentException
* @throws \Kirby\Exception\LogicException
*/
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');
}
// prefer the filename from the props
$props['filename'] = F::safeName($props['filename'] ?? basename($props['source']));
$props['model'] = strtolower($props['template'] ?? 'default');
// create the basic file and a test upload object
$file = static::factory($props);
$upload = $file->asset($props['source']);
// create a form for the file
$form = Form::for($file, [
'values' => $props['content'] ?? []
]);
// inject the content
$file = $file->clone(['content' => $form->strings(true)]);
// run the hook
return $file->commit('create', compact('file', 'upload'), function ($file, $upload) {
// delete all public versions
$file->unpublish();
// overwrite the original
if (F::copy($upload->root(), $file->root(), true) !== true) {
throw new LogicException('The file could not be created');
}
// 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
$file->save($file->content()->toArray(), $languageCode);
// add the file to the list of siblings
$file->siblings()->append($file->id(), $file);
// return a fresh clone
return $file->clone();
});
}
/**
* 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 versions in the media folder
$file->unpublish();
// 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());
// remove the file from the sibling collection
$file->parent()->files()->remove($file);
return true;
});
}
/**
* Move the file to the public media folder
* if it's not already there.
*
* @return $this
*/
public function publish()
{
Media::publish($this, $this->mediaRoot());
return $this;
}
/**
* Replaces the file. The source must
* be an absolute path to a file or a Url.
* The store handles the replacement so it
* finally decides what it will support as
* source.
*
* @param string $source
* @return static
* @throws \Kirby\Exception\LogicException
*/
public function replace(string $source)
{
$file = $this->clone();
$arguments = [
'file' => $file,
'upload' => $file->asset($source)
];
return $this->commit('replace', $arguments, function ($file, $upload) {
// delete all public versions
$file->unpublish();
// overwrite the original
if (F::copy($upload->root(), $file->root(), true) !== true) {
throw new LogicException('The file could not be created');
}
// return a fresh clone
return $file->clone();
});
}
/**
* Remove all public versions of this file
*
* @return $this
*/
public function unpublish()
{
Media::unpublish($this->parent()->mediaRoot(), $this);
return $this;
}
}

View file

@ -0,0 +1,188 @@
<?php
namespace Kirby\Cms;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Str;
/**
* Extension of the basic blueprint class
* to handle all blueprints for files.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class FileBlueprint extends Blueprint
{
/**
* `true` if the default accepted
* types are being used
*
* @var bool
*/
protected $defaultTypes = false;
public function __construct(array $props)
{
parent::__construct($props);
// normalize all available page options
$this->props['options'] = $this->normalizeOptions(
$this->props['options'] ?? true,
// defaults
[
'changeName' => null,
'create' => null,
'delete' => null,
'read' => null,
'replace' => null,
'update' => null,
]
);
// normalize the accept settings
$this->props['accept'] = $this->normalizeAccept($this->props['accept'] ?? []);
}
/**
* @return array
*/
public function accept(): array
{
return $this->props['accept'];
}
/**
* Returns the list of all accepted MIME types for
* file upload or `*` if all MIME types are allowed
*
* @return string
*/
public function acceptMime(): string
{
// don't disclose the specific default types
if ($this->defaultTypes === true) {
return '*';
}
$accept = $this->accept();
$restrictions = [];
if (is_array($accept['mime']) === true) {
$restrictions[] = $accept['mime'];
} else {
// only fall back to the extension or type if
// no explicit MIME types were defined
// (allows to set custom MIME types for the frontend
// check but still restrict the extension and/or type)
if (is_array($accept['extension']) === true) {
// determine the main MIME type for each extension
$restrictions[] = array_map(['Kirby\Filesystem\Mime', 'fromExtension'], $accept['extension']);
}
if (is_array($accept['type']) === true) {
// determine the MIME types of each file type
$mimes = [];
foreach ($accept['type'] as $type) {
if ($extensions = F::typeToExtensions($type)) {
$mimes[] = array_map(['Kirby\Filesystem\Mime', 'fromExtension'], $extensions);
}
}
$restrictions[] = array_merge(...$mimes);
}
}
if ($restrictions !== []) {
if (count($restrictions) > 1) {
// only return the MIME types that are allowed by all restrictions
$mimes = array_intersect(...$restrictions);
} else {
$mimes = $restrictions[0];
}
// filter out empty MIME types and duplicates
return implode(', ', array_filter(array_unique($mimes)));
}
// no restrictions, accept everything
return '*';
}
/**
* @param mixed $accept
* @return array
*/
protected function normalizeAccept($accept = null): array
{
if (is_string($accept) === true) {
$accept = [
'mime' => $accept
];
} elseif ($accept === true) {
// explicitly no restrictions at all
$accept = [
'mime' => null
];
} elseif (empty($accept) === true) {
// no custom restrictions
$accept = [];
}
$accept = array_change_key_case($accept);
$defaults = [
'extension' => null,
'mime' => null,
'maxheight' => null,
'maxsize' => null,
'maxwidth' => null,
'minheight' => null,
'minsize' => null,
'minwidth' => null,
'orientation' => null,
'type' => null
];
// default type restriction if none are configured;
// this ensures that no unexpected files are uploaded
if (
array_key_exists('mime', $accept) === false &&
array_key_exists('extension', $accept) === false &&
array_key_exists('type', $accept) === false
) {
$defaults['type'] = ['image', 'document', 'archive', 'audio', 'video'];
$this->defaultTypes = true;
}
$accept = array_merge($defaults, $accept);
// normalize the MIME, extension and type from strings into arrays
if (is_string($accept['mime']) === true) {
$accept['mime'] = array_map(
fn ($mime) => $mime['value'],
Str::accepted($accept['mime'])
);
}
if (is_string($accept['extension']) === true) {
$accept['extension'] = array_map(
'trim',
explode(',', $accept['extension'])
);
}
if (is_string($accept['type']) === true) {
$accept['type'] = array_map(
'trim',
explode(',', $accept['type'])
);
}
return $accept;
}
}

View file

@ -0,0 +1,214 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
/**
* Trait for image resizing, blurring etc.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait FileModifications
{
/**
* Blurs the image by the given amount of pixels
*
* @param bool $pixels
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
*/
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()
{
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)
{
$quality = null;
$crop = 'center';
if (is_int($options) === true) {
$quality = $options;
} elseif (is_string($options)) {
$crop = $options;
} elseif (is_a($options, 'Kirby\Cms\Field') === true) {
$crop = $options->value();
} elseif (is_array($options)) {
$quality = $options['quality'] ?? $quality;
$crop = $options['crop'] ?? $crop;
}
return $this->thumb([
'width' => $width,
'height' => $height,
'quality' => $quality,
'crop' => $crop
]);
}
/**
* Alias for File::bw()
*
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
*/
public function grayscale()
{
return $this->thumb(['grayscale' => true]);
}
/**
* Alias for File::bw()
*
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
*/
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)
{
return $this->thumb(['quality' => $quality]);
}
/**
* 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)
{
return $this->thumb([
'width' => $width,
'height' => $height,
'quality' => $quality
]);
}
/**
* 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($sizes = null): ?string
{
if (empty($sizes) === true) {
$sizes = $this->kirby()->option('thumbs.srcsets.default', []);
}
if (is_string($sizes) === true) {
$sizes = $this->kirby()->option('thumbs.srcsets.' . $sizes, []);
}
if (is_array($sizes) === false || empty($sizes) === true) {
return null;
}
$set = [];
foreach ($sizes as $key => $value) {
if (is_array($value)) {
$options = $value;
$condition = $key;
} elseif (is_string($value) === true) {
$options = [
'width' => $key
];
$condition = $value;
} else {
$options = [
'width' => $value
];
$condition = $value . 'w';
}
$set[] = $this->thumb($options)->url() . ' ' . $condition;
}
return implode(', ', $set);
}
/**
* Creates a modified version of images
* The media manager takes care of generating
* those modified versions and putting them
* in the right place. This is normally the
* `/media` folder of your installation, but
* could potentially also be a CDN or any other
* place.
*
* @param array|null|string $options
* @return \Kirby\Cms\FileVersion|\Kirby\Cms\File
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function thumb($options = null)
{
// thumb presets
if (empty($options) === true) {
$options = $this->kirby()->option('thumbs.presets.default');
} elseif (is_string($options) === true) {
$options = $this->kirby()->option('thumbs.presets.' . $options);
}
if (empty($options) === true || is_array($options) === false) {
return $this;
}
// fallback to global config options
if (isset($options['format']) === false) {
if ($format = $this->kirby()->option('thumbs.format')) {
$options['format'] = $format;
}
}
$component = $this->kirby()->component('file::version');
$result = $component($this->kirby(), $this, $options);
if (
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');
}
return $result;
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace Kirby\Cms;
/**
* FilePermissions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class FilePermissions extends ModelPermissions
{
protected $category = 'files';
}

View file

@ -0,0 +1,74 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
/**
* The FilePicker class helps to
* fetch the right files for the API calls
* for the file picker component in the panel.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class FilePicker extends Picker
{
/**
* Extends the basic defaults
*
* @return array
*/
public function defaults(): array
{
$defaults = parent::defaults();
$defaults['text'] = '{{ file.filename }}';
return $defaults;
}
/**
* Search all files for the picker
*
* @return \Kirby\Cms\Files|null
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function items()
{
$model = $this->options['model'];
// find the right default query
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);
// help mitigate some typical query usage issues
// by converting site and page objects to proper
// pages by returning their children
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);
// paginate
return $this->paginate($files);
}
}

319
kirby/src/Cms/FileRules.php Normal file
View file

@ -0,0 +1,319 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\PermissionException;
use Kirby\Filesystem\File as BaseFile;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\V;
/**
* Validators for all file actions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class FileRules
{
/**
* Validates if the filename can be changed
*
* @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
*/
public static function changeName(File $file, string $name): bool
{
if ($file->permissions()->changeName() !== true) {
throw new PermissionException([
'key' => 'file.changeName.permission',
'data' => ['filename' => $file->filename()]
]);
}
if (Str::length($name) === 0) {
throw new InvalidArgumentException([
'key' => 'file.changeName.empty'
]);
}
$parent = $file->parent();
$duplicate = $parent->files()->not($file)->findBy('filename', $name . '.' . $file->extension());
if ($duplicate) {
throw new DuplicateException([
'key' => 'file.duplicate',
'data' => ['filename' => $duplicate->filename()]
]);
}
return true;
}
/**
* 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 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
*/
public static function create(File $file, BaseFile $upload): bool
{
if ($file->exists() === true) {
if ($file->sha1() !== $upload->sha1()) {
throw new DuplicateException([
'key' => 'file.duplicate',
'data' => [
'filename' => $file->filename()
]
]);
}
}
if ($file->permissions()->create() !== true) {
throw new PermissionException('The file cannot be created');
}
static::validFile($file, $upload->mime());
$upload->match($file->blueprint()->accept());
$upload->validateContents(true);
return true;
}
/**
* 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
{
if ($file->permissions()->delete() !== true) {
throw new PermissionException('The file cannot be deleted');
}
return true;
}
/**
* 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
*/
public static function replace(File $file, BaseFile $upload): bool
{
if ($file->permissions()->replace() !== true) {
throw new PermissionException('The file cannot be replaced');
}
static::validMime($file, $upload->mime());
if (
(string)$upload->mime() !== (string)$file->mime() &&
(string)$upload->extension() !== (string)$file->extension()
) {
throw new InvalidArgumentException([
'key' => 'file.mime.differs',
'data' => ['mime' => $file->mime()]
]);
}
$upload->match($file->blueprint()->accept());
$upload->validateContents(true);
return true;
}
/**
* 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
{
if ($file->permissions()->update() !== true) {
throw new PermissionException('The file cannot be updated');
}
return true;
}
/**
* 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
{
// make it easier to compare the extension
$extension = strtolower($extension);
if (empty($extension) === true) {
throw new InvalidArgumentException([
'key' => 'file.extension.missing',
'data' => ['filename' => $file->filename()]
]);
}
if (
Str::contains($extension, 'php') !== false ||
Str::contains($extension, 'phar') !== false ||
Str::contains($extension, 'phtml') !== false
) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
'data' => ['type' => 'PHP']
]);
}
if (Str::contains($extension, 'htm') !== false) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
'data' => ['type' => 'HTML']
]);
}
if (V::in($extension, ['exe', App::instance()->contentExtension()]) !== false) {
throw new InvalidArgumentException([
'key' => 'file.extension.forbidden',
'data' => ['extension' => $extension]
]);
}
return true;
}
/**
* Validates the extension, MIME type and filename
*
* @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, $mime = null): bool
{
if ($mime === false) {
// request to skip the MIME check for performance reasons
$validMime = true;
} else {
$validMime = static::validMime($file, $mime ?? $file->mime());
}
return
$validMime &&
static::validExtension($file, $file->extension()) &&
static::validFilename($file, $file->filename());
}
/**
* 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
{
// make it easier to compare the filename
$filename = strtolower($filename);
// check for missing filenames
if (empty($filename)) {
throw new InvalidArgumentException([
'key' => 'file.name.missing'
]);
}
// Block htaccess files
if (Str::startsWith($filename, '.ht')) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
'data' => ['type' => 'Apache config']
]);
}
// Block invisible files
if (Str::startsWith($filename, '.')) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
'data' => ['type' => 'invisible']
]);
}
return true;
}
/**
* 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
{
// make it easier to compare the mime
$mime = strtolower($mime);
if (empty($mime)) {
throw new InvalidArgumentException([
'key' => 'file.mime.missing',
'data' => ['filename' => $file->filename()]
]);
}
if (Str::contains($mime, 'php')) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
'data' => ['type' => 'PHP']
]);
}
if (V::in($mime, ['text/html', 'application/x-msdownload'])) {
throw new InvalidArgumentException([
'key' => 'file.mime.forbidden',
'data' => ['mime' => $mime]
]);
}
return true;
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace Kirby\Cms;
use Kirby\Filesystem\IsFile;
/**
* FileVersion
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class FileVersion
{
use IsFile;
protected $modifications;
protected $original;
/**
* 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 = [])
{
// public property access
if (isset($this->$method) === true) {
return $this->$method;
}
// asset method proxy
if (method_exists($this->asset(), $method)) {
if ($this->exists() === false) {
$this->save();
}
return $this->asset()->$method(...$arguments);
}
// content fields
if (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
{
return dirname($this->original()->id()) . '/' . $this->filename();
}
/**
* Returns the parent Kirby App instance
*
* @return \Kirby\Cms\App
*/
public function kirby()
{
return $this->original()->kirby();
}
/**
* Returns an array with all applied modifications
*
* @return array
*/
public function modifications(): array
{
return $this->modifications ?? [];
}
/**
* Returns the instance of the original File object
*
* @return mixed
*/
public function original()
{
return $this->original;
}
/**
* Applies the stored modifications and
* saves the file on disk
*
* @return $this
*/
public function save()
{
$this->kirby()->thumb(
$this->original()->root(),
$this->root(),
$this->modifications()
);
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(),
]);
ksort($array);
return $array;
}
}

193
kirby/src/Cms/Files.php Normal file
View file

@ -0,0 +1,193 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Filesystem\F;
/**
* The `$files` object extends the general
* `Collection` class and refers to a
* collection of files, i.e. images, documents
* etc. Files can be filtered, searched,
* converted, modified or evaluated with the
* following methods:
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Files extends Collection
{
/**
* All registered files methods
*
* @var array
*/
public static $methods = [];
/**
* Adds a single file or
* an entire second collection to the
* current collection
*
* @param \Kirby\Cms\Files|\Kirby\Cms\File|string $object
* @return $this
* @throws \Kirby\Exception\InvalidArgumentException When no `File` or `Files` object or an ID of an existing file is passed
*/
public function add($object)
{
// add a files collection
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)) {
$this->__set($file->id(), $file);
// add a file object
} elseif (is_a($object, 'Kirby\Cms\File') === true) {
$this->__set($object->id(), $object);
// give a useful error message on invalid input;
// silently ignore "empty" values for compatibility with existing setups
} elseif (in_array($object, [null, false, true], true) !== true) {
throw new InvalidArgumentException('You must pass a Files or File object or an ID of an existing file to the Files collection');
}
return $this;
}
/**
* Sort all given files by the
* order in the array
*
* @param array $files List of file ids
* @param int $offset Sorting offset
* @return $this
*/
public function changeSort(array $files, int $offset = 0)
{
foreach ($files as $filename) {
if ($file = $this->get($filename)) {
$offset++;
$file->changeSort($offset);
}
}
return $this;
}
/**
* 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, 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);
$collection->data[$file->id()] = $file;
}
return $collection;
}
/**
* Tries to find a file by id/filename
*
* @param string $id
* @return \Kirby\Cms\File|null
*/
public function findById(string $id)
{
return $this->get(ltrim($this->parent->id() . '/' . $id, '/'));
}
/**
* Alias for FilesFinder::findById() which is
* used internally in the Files collection to
* map the get method correctly.
*
* @param string $key
* @return \Kirby\Cms\File|null
*/
public function findByKey(string $key)
{
return $this->findById($key);
}
/**
* Returns the file size for all
* files in the collection in a
* human-readable format
* @since 3.6.0
*
* @param string|null|false $locale Locale for number formatting,
* `null` for the current locale,
* `false` to disable number formatting
* @return string
*/
public function niceSize($locale = null): string
{
return F::niceSize($this->size(), $locale);
}
/**
* Returns the raw size for all
* files in the collection
* @since 3.6.0
*
* @return int
*/
public function size(): int
{
return F::size($this->values(fn ($file) => $file->root()));
}
/**
* Returns the collection sorted by
* the sort number and the filename
*
* @return static
*/
public function sorted()
{
return $this->sort('sort', 'asc', 'filename', 'asc');
}
/**
* Filter all files by the given template
*
* @param null|string|array $template
* @return $this|static
*/
public function template($template)
{
if (empty($template) === true) {
return $this;
}
if ($template === 'default') {
$template = ['default', ''];
}
return $this->filter(
'template',
is_array($template) ? 'in' : '==',
$template
);
}
}

191
kirby/src/Cms/Find.php Normal file
View file

@ -0,0 +1,191 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Toolkit\Str;
/**
* The Find class is used in the API and
* the Panel to find models and parents
* based on request paths
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Find
{
/**
* Returns the file object for the given
* parent path and filename
*
* @param string|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 = null, string $filename)
{
$filename = urldecode($filename);
$file = static::parent($path)->file($filename);
if ($file && $file->isReadable() === true) {
return $file;
}
throw new NotFoundException([
'key' => 'file.notFound',
'data' => [
'filename' => $filename
]
]);
}
/**
* Returns the language object for the given code
*
* @param string $code Language code
* @return \Kirby\Cms\Language|null
* @throws \Kirby\Exception\NotFoundException if the language cannot be found
*/
public static function language(string $code)
{
if ($language = App::instance()->language($code)) {
return $language;
}
throw new NotFoundException([
'key' => 'language.notFound',
'data' => [
'code' => $code
]
]);
}
/**
* Returns the page object for the given id
*
* @param string $id Page's id
* @return \Kirby\Cms\Page|null
* @throws \Kirby\Exception\NotFoundException if the page cannot be found
*/
public static function page(string $id)
{
$id = str_replace(['+', ' '], '/', $id);
$page = App::instance()->page($id);
if ($page && $page->isReadable() === true) {
return $page;
}
throw new NotFoundException([
'key' => 'page.notFound',
'data' => [
'slug' => $id
]
]);
}
/**
* Returns the model's object for the given path
*
* @param string $path Path to parent model
* @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)
{
$path = trim($path, '/');
$modelType = in_array($path, ['site', 'account']) ? $path : trim(dirname($path), '/');
$modelTypes = [
'site' => 'site',
'users' => 'user',
'pages' => 'page',
'account' => 'account'
];
$modelName = $modelTypes[$modelType] ?? null;
if (Str::endsWith($modelType, '/files') === true) {
$modelName = 'file';
}
$kirby = App::instance();
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);
}
if ($model) {
return $model;
}
throw new NotFoundException([
'key' => $modelName . '.undefined'
]);
}
/**
* Returns the user object for the given id or
* returns the current authenticated user if no
* id is passed
*
* @param string|null $id User's id
* @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)
{
// account is a reserved word to find the current
// user. It's used in various API and area routes.
if ($id === 'account') {
$id = null;
}
$kirby = App::instance();
// get the authenticated user
if ($id === null) {
if ($user = $kirby->user(null, $kirby->option('api.allowImpersonation', false))) {
return $user;
}
throw new NotFoundException([
'key' => 'user.undefined'
]);
}
// get a specific user by id
if ($user = $kirby->user($id)) {
return $user;
}
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $id
]
]);
}
}

View file

@ -0,0 +1,242 @@
<?php
namespace Kirby\Cms;
use Kirby\Filesystem\Dir;
use Kirby\Toolkit\Str;
/**
* HasChildren
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait HasChildren
{
/**
* The list of available published children
*
* @var \Kirby\Cms\Pages
*/
public $children;
/**
* The list of available draft children
*
* @var \Kirby\Cms\Pages
*/
public $drafts;
/**
* Returns all published children
*
* @return \Kirby\Cms\Pages
*/
public function children()
{
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()
{
return $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)
{
$path = str_replace('_drafts/', '', $path);
if (Str::contains($path, '/') === false) {
return $this->drafts()->find($path);
}
$parts = explode('/', $path);
$parent = $this;
foreach ($parts as $slug) {
if ($page = $parent->find($slug)) {
$parent = $page;
continue;
}
if ($draft = $parent->drafts()->find($slug)) {
$parent = $draft;
continue;
}
return null;
}
return $parent;
}
/**
* Returns all draft children
*
* @return \Kirby\Cms\Pages
*/
public function drafts()
{
if (is_a($this->drafts, 'Kirby\Cms\Pages') === true) {
return $this->drafts;
}
$kirby = $this->kirby();
// create the inventory for all drafts
$inventory = Dir::inventory(
$this->root() . '/_drafts',
$kirby->contentExtension(),
$kirby->contentIgnore(),
$kirby->multilang()
);
return $this->drafts = Pages::factory($inventory['children'], $this, true);
}
/**
* Finds one or multiple published children by ID
*
* @param string ...$arguments
* @return \Kirby\Cms\Page|\Kirby\Cms\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)
{
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()
{
return $this->children()->children();
}
/**
* Checks if the model has any published children
*
* @return bool
*/
public function hasChildren(): bool
{
return $this->children()->count() > 0;
}
/**
* Checks if the model has any draft children
*
* @return bool
*/
public function hasDrafts(): bool
{
return $this->drafts()->count() > 0;
}
/**
* Checks if the page has any listed children
*
* @return bool
*/
public function hasListedChildren(): bool
{
return $this->children()->listed()->count() > 0;
}
/**
* Checks if the page has any unlisted children
*
* @return bool
*/
public function hasUnlistedChildren(): bool
{
return $this->children()->unlisted()->count() > 0;
}
/**
* Creates a flat child index
*
* @param bool $drafts If set to `true`, draft children are included
* @return \Kirby\Cms\Pages
*/
public function index(bool $drafts = false)
{
if ($drafts === true) {
return $this->childrenAndDrafts()->index($drafts);
} else {
return $this->children()->index();
}
}
/**
* Sets the published children collection
*
* @param array|null $children
* @return $this
*/
protected function setChildren(array $children = null)
{
if ($children !== null) {
$this->children = Pages::factory($children, $this);
}
return $this;
}
/**
* Sets the draft children collection
*
* @param array|null $drafts
* @return $this
*/
protected function setDrafts(array $drafts = null)
{
if ($drafts !== null) {
$this->drafts = Pages::factory($drafts, $this, true);
}
return $this;
}
}

226
kirby/src/Cms/HasFiles.php Normal file
View file

@ -0,0 +1,226 @@
<?php
namespace Kirby\Cms;
/**
* HasFiles
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait HasFiles
{
/**
* The Files collection
*
* @var \Kirby\Cms\Files
*/
protected $files;
/**
* Filters the Files collection by type audio
*
* @return \Kirby\Cms\Files
*/
public function audio()
{
return $this->files()->filter('type', '==', 'audio');
}
/**
* Filters the Files collection by type code
*
* @return \Kirby\Cms\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 array $props
* @return \Kirby\Cms\File
*/
public function createFile(array $props)
{
$props = array_merge($props, [
'parent' => $this,
'url' => null
]);
return File::create($props);
}
/**
* Filters the Files collection by type documents
*
* @return \Kirby\Cms\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')
{
if ($filename === null) {
return $this->$in()->first();
}
if (strpos($filename, '/') !== false) {
$path = dirname($filename);
$filename = basename($filename);
if ($page = $this->find($path)) {
return $page->$in()->find($filename);
}
return null;
}
return $this->$in()->find($filename);
}
/**
* Returns the Files collection
*
* @return \Kirby\Cms\Files
*/
public function files()
{
if (is_a($this->files, 'Kirby\Cms\Files') === true) {
return $this->files;
}
return $this->files = Files::factory($this->inventory()['files'], $this);
}
/**
* Checks if the Files collection has any audio files
*
* @return bool
*/
public function hasAudio(): bool
{
return $this->audio()->count() > 0;
}
/**
* Checks if the Files collection has any code files
*
* @return bool
*/
public function hasCode(): bool
{
return $this->code()->count() > 0;
}
/**
* Checks if the Files collection has any document files
*
* @return bool
*/
public function hasDocuments(): bool
{
return $this->documents()->count() > 0;
}
/**
* Checks if the Files collection has any files
*
* @return bool
*/
public function hasFiles(): bool
{
return $this->files()->count() > 0;
}
/**
* Checks if the Files collection has any images
*
* @return bool
*/
public function hasImages(): bool
{
return $this->images()->count() > 0;
}
/**
* Checks if the Files collection has any videos
*
* @return bool
*/
public function hasVideos(): bool
{
return $this->videos()->count() > 0;
}
/**
* 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)
{
return $this->file($filename, 'images');
}
/**
* Filters the Files collection by type image
*
* @return \Kirby\Cms\Files
*/
public function images()
{
return $this->files()->filter('type', '==', 'image');
}
/**
* Sets the Files collection
*
* @param \Kirby\Cms\Files|null $files
* @return $this
*/
protected function setFiles(array $files = null)
{
if ($files !== null) {
$this->files = Files::factory($files, $this);
}
return $this;
}
/**
* Filters the Files collection by type videos
*
* @return \Kirby\Cms\Files
*/
public function videos()
{
return $this->files()->filter('type', '==', 'video');
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\BadMethodCallException;
/**
* HasMethods
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait HasMethods
{
/**
* All registered methods
*
* @var array
*/
public static $methods = [];
/**
* Calls a registered method class with the
* passed arguments
*
* @internal
* @param string $method
* @param array $args
* @return mixed
* @throws \Kirby\Exception\BadMethodCallException
*/
public function callMethod(string $method, array $args = [])
{
$closure = $this->getMethod($method);
if ($closure === null) {
throw new BadMethodCallException('The method ' . $method . ' does not exist');
}
return $closure->call($this, ...$args);
}
/**
* Checks if the object has a registered method
*
* @internal
* @param string $method
* @return bool
*/
public function hasMethod(string $method): bool
{
return $this->getMethod($method) !== null;
}
/**
* Returns a registered method by name, either from
* the current class or from a parent class ordered by
* inheritance order (top to bottom)
*
* @param string $method
* @return \Closure|null
*/
protected function getMethod(string $method)
{
if (isset(static::$methods[$method]) === true) {
return static::$methods[$method];
}
foreach (class_parents($this) as $parent) {
if (isset($parent::$methods[$method]) === true) {
return $parent::$methods[$method];
}
}
return null;
}
}

View file

@ -0,0 +1,182 @@
<?php
namespace Kirby\Cms;
/**
* This trait is used by pages, files and users
* to handle navigation through parent collections
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait HasSiblings
{
/**
* Returns the position / index in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return int
*/
public function indexOf($collection = null): int
{
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->indexOf($this);
}
/**
* Returns the next item in the collection if available
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Model|null
*/
public function next($collection = null)
{
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->nth($this->indexOf($collection) + 1);
}
/**
* Returns the end of the collection starting after the current item
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Collection
*/
public function nextAll($collection = null)
{
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->slice($this->indexOf($collection) + 1);
}
/**
* Returns the previous item in the collection if available
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Model|null
*/
public function prev($collection = null)
{
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->nth($this->indexOf($collection) - 1);
}
/**
* Returns the beginning of the collection before the current item
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Collection
*/
public function prevAll($collection = null)
{
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)
{
$siblings = $this->siblingsCollection();
if ($self === false) {
return $siblings->not($this);
}
return $siblings;
}
/**
* Checks if there's a next item in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasNext($collection = null): bool
{
return $this->next($collection) !== null;
}
/**
* Checks if there's a previous item in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasPrev($collection = null): bool
{
return $this->prev($collection) !== null;
}
/**
* Checks if the item is the first in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function isFirst($collection = null): bool
{
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->first()->is($this);
}
/**
* Checks if the item is the last in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function isLast($collection = null): bool
{
if ($collection === null) {
$collection = $this->siblingsCollection();
}
return $collection->last()->is($this);
}
/**
* 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
{
return $this->indexOf($collection) === $n;
}
}

30
kirby/src/Cms/Html.php Normal file
View file

@ -0,0 +1,30 @@
<?php
namespace Kirby\Cms;
/**
* The `Html` class provides methods for building
* common HTML tags and also contains some helper
* methods.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Html extends \Kirby\Toolkit\Html
{
/**
* Generates an `a` tag with an absolute Url
*
* @param string|null $href Relative or absolute Url
* @param string|array|null $text If `null`, the link will be used as link text. If an array is passed, each element will be added unencoded
* @param array $attr Additional attributes for the a tag.
* @return string
*/
public static function link(string $href = null, $text = null, array $attr = []): string
{
return parent::link(Url::to($href), $text, $attr);
}
}

View file

@ -0,0 +1,95 @@
<?php
namespace Kirby\Cms;
/**
* The Ingredients class is the foundation for
* `$kirby->urls()` and `$kirby->roots()` objects.
* Those are configured in `kirby/config/urls.php`
* and `kirby/config/roots.php`
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Ingredients
{
/**
* @var array
*/
protected $ingredients = [];
/**
* Creates a new ingredient collection
*
* @param array $ingredients
*/
public function __construct(array $ingredients)
{
$this->ingredients = $ingredients;
}
/**
* Magic getter for single ingredients
*
* @param string $method
* @param array|null $args
* @return mixed
*/
public function __call(string $method, array $args = null)
{
return $this->ingredients[$method] ?? null;
}
/**
* Improved `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
return $this->ingredients;
}
/**
* Get a single ingredient by key
*
* @param string $key
* @return mixed
*/
public function __get(string $key)
{
return $this->ingredients[$key] ?? null;
}
/**
* Resolves all ingredient callbacks
* and creates a plain array
*
* @internal
* @param array $ingredients
* @return static
*/
public static function bake(array $ingredients)
{
foreach ($ingredients as $name => $ingredient) {
if (is_a($ingredient, 'Closure') === true) {
$ingredients[$name] = $ingredient($ingredients);
}
}
return new static($ingredients);
}
/**
* Returns all ingredients as plain array
*
* @return array
*/
public function toArray(): array
{
return $this->ingredients;
}
}

137
kirby/src/Cms/Item.php Normal file
View file

@ -0,0 +1,137 @@
<?php
namespace Kirby\Cms;
/**
* The Item class is the foundation
* for every object in context with
* other objects. I.e.
*
* - a Block in a collection of Blocks
* - a Layout in a collection of Layouts
* - a Column in a collection of Columns
* @since 3.5.0
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Item
{
use HasSiblings;
public const ITEMS_CLASS = '\Kirby\Cms\Items';
/**
* @var string
*/
protected $id;
/**
* @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 = [])
{
$siblingsClass = static::ITEMS_CLASS;
$this->id = $params['id'] ?? uuid();
$this->params = $params;
$this->parent = $params['parent'] ?? site();
$this->siblings = $params['siblings'] ?? new $siblingsClass();
}
/**
* Static Item factory
*
* @param array $params
* @return \Kirby\Cms\Item
*/
public static function factory(array $params)
{
return new static($params);
}
/**
* Returns the unique item id (UUID v4)
*
* @return string
*/
public function id(): string
{
return $this->id;
}
/**
* Compares the item to another one
*
* @param \Kirby\Cms\Item $item
* @return bool
*/
public function is(Item $item): bool
{
return $this->id() === $item->id();
}
/**
* Returns the Kirby instance
*
* @return \Kirby\Cms\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()
{
return $this->parent;
}
/**
* Returns the sibling collection
* This is required by the HasSiblings trait
*
* @return \Kirby\Cms\Items
* @psalm-return self::ITEMS_CLASS
*/
protected function siblingsCollection()
{
return $this->siblings;
}
/**
* Converts the item to an array
*
* @return array
*/
public function toArray(): array
{
return [
'id' => $this->id(),
];
}
}

97
kirby/src/Cms/Items.php Normal file
View file

@ -0,0 +1,97 @@
<?php
namespace Kirby\Cms;
use Closure;
use Exception;
/**
* A collection of items
* @since 3.5.0
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Items extends Collection
{
public const ITEM_CLASS = '\Kirby\Cms\Item';
/**
* @var array
*/
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'] ?? site();
parent::__construct($objects, $this->parent);
}
/**
* 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 = [])
{
$options = array_merge([
'options' => [],
'parent' => site(),
], $params);
if (empty($items) === true || is_array($items) === false) {
return new static();
}
if (is_array($options) === false) {
throw new Exception('Invalid item options');
}
// create a new collection of blocks
$collection = new static([], $options);
foreach ($items as $params) {
if (is_array($params) === false) {
continue;
}
$params['options'] = $options['options'];
$params['parent'] = $options['parent'];
$params['siblings'] = $collection;
$class = static::ITEM_CLASS;
$item = $class::factory($params);
$collection->append($item->id(), $item);
}
return $collection;
}
/**
* Convert the items to an array
*
* @return array
*/
public function toArray(Closure $map = null): array
{
return array_values(parent::toArray($map));
}
}

694
kirby/src/Cms/Language.php Normal file
View file

@ -0,0 +1,694 @@
<?php
namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Exception\PermissionException;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Locale;
use Kirby\Toolkit\Str;
use Throwable;
/**
* The `$language` object represents
* a single language in a multi-language
* Kirby setup. You can, for example,
* use the methods of this class to get
* the name or locale of a language,
* check for the default language,
* get translation strings and many
* more things.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Language extends Model
{
/**
* @var string
*/
protected $code;
/**
* @var bool
*/
protected $default;
/**
* @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)
{
$this->setRequiredProperties($props, [
'code'
]);
$this->setOptionalProperties($props, [
'default',
'direction',
'locale',
'name',
'slugs',
'smartypants',
'translations',
'url',
]);
}
/**
* Improved `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Returns the language code
* when the language is converted to a string
*
* @return string
*/
public function __toString(): string
{
return $this->code();
}
/**
* Returns the base Url for the language
* without the path or other cruft
*
* @return string
*/
public function baseUrl(): string
{
$kirbyUrl = $this->kirby()->url();
$languageUrl = $this->url();
if (empty($this->url)) {
return $kirbyUrl;
}
if (Str::startsWith($languageUrl, $kirbyUrl) === true) {
return $kirbyUrl;
}
return Url::base($languageUrl) ?? $kirbyUrl;
}
/**
* Returns the language code/id.
* The language code is used in
* text file names as appendix.
*
* @return string
*/
public function code(): string
{
return $this->code;
}
/**
* Internal converter to create or remove
* translation files.
*
* @param string $from
* @param string $to
* @return bool
*/
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)
{
$props['code'] = Str::slug($props['code'] ?? null);
$kirby = App::instance();
$languages = $kirby->languages();
// make the first language the default language
if ($languages->count() === 0) {
$props['default'] = true;
}
$language = new static($props);
// validate the new language
LanguageRules::create($language);
$language->save();
if ($languages->count() === 0) {
static::converter('', $language->code());
}
// update the main languages collection in the app instance
App::instance()->languages(false)->append($language->code(), $language);
return $language;
}
/**
* Delete the current language and
* all its translation files
*
* @internal
* @return bool
* @throws \Kirby\Exception\Exception
*/
public function delete(): bool
{
$kirby = App::instance();
$languages = $kirby->languages();
$code = $this->code();
$isLast = $languages->count() === 1;
if (F::remove($this->root()) !== true) {
throw new Exception('The language could not be deleted');
}
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);
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
{
return $this->direction;
}
/**
* Check if the language file exists
*
* @return bool
*/
public function exists(): bool
{
return file_exists($this->root());
}
/**
* Checks if this is the default language
* for the site.
*
* @return bool
*/
public function isDefault(): bool
{
return $this->default;
}
/**
* The id is required for collections
* to work properly. The code is used as id
*
* @return string
*/
public function id(): string
{
return $this->code;
}
/**
* Loads the language rules for provided locale code
*
* @param string $code
*/
public static function loadRules(string $code)
{
$kirby = kirby();
$code = Str::contains($code, '.') ? Str::before($code, '.') : $code;
$file = $kirby->root('i18n:rules') . '/' . $code . '.json';
if (F::exists($file) === false) {
$file = $kirby->root('i18n:rules') . '/' . Str::before($code, '_') . '.json';
}
try {
return Data::read($file);
} catch (\Exception $e) {
return [];
}
}
/**
* 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)
{
if ($category !== null) {
return $this->locale[$category] ?? $this->locale[LC_ALL] ?? null;
} else {
return $this->locale;
}
}
/**
* Returns the human-readable name
* of the language
*
* @return string
*/
public function name(): string
{
return $this->name;
}
/**
* Returns the URL path for the language
*
* @return string
*/
public function path(): string
{
if ($this->url === null) {
return $this->code;
}
return Url::path($this->url);
}
/**
* Returns the routing pattern for the language
*
* @return string
*/
public function pattern(): string
{
$path = $this->path();
if (empty($path) === true) {
return '(:all)';
}
return $path . '/(:all?)';
}
/**
* Returns the absolute path to the language file
*
* @return string
*/
public function root(): string
{
return App::instance()->root('languages') . '/' . $this->code() . '.php';
}
/**
* Returns the LanguageRouter instance
* which is used to handle language specific
* routes.
*
* @return \Kirby\Cms\LanguageRouter
*/
public function router()
{
return new LanguageRouter($this);
}
/**
* Get slug rules for language
*
* @internal
* @return array
*/
public function rules(): array
{
$code = $this->locale(LC_CTYPE);
$data = static::loadRules($code);
return array_merge($data, $this->slugs());
}
/**
* Saves the language settings in the languages folder
*
* @internal
* @return $this
*/
public function save()
{
try {
$existingData = Data::read($this->root());
} catch (Throwable $e) {
$existingData = [];
}
$props = [
'code' => $this->code(),
'default' => $this->isDefault(),
'direction' => $this->direction(),
'locale' => Locale::export($this->locale()),
'name' => $this->name(),
'translations' => $this->translations(),
'url' => $this->url,
];
$data = array_merge($existingData, $props);
ksort($data);
Data::write($this->root(), $data);
return $this;
}
/**
* @param string $code
* @return $this
*/
protected function setCode(string $code)
{
$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
{
return $this->slugs;
}
/**
* Returns the custom SmartyPants options for this language
*
* @return array
*/
public function smartypants(): array
{
return $this->smartypants;
}
/**
* Returns the most important
* properties as array
*
* @return array
*/
public function toArray(): array
{
return [
'code' => $this->code(),
'default' => $this->isDefault(),
'direction' => $this->direction(),
'locale' => $this->locale(),
'name' => $this->name(),
'rules' => $this->rules(),
'url' => $this->url()
];
}
/**
* Returns the translation strings for this language
*
* @return array
*/
public function translations(): array
{
return $this->translations;
}
/**
* Returns the absolute Url for the language
*
* @return string
*/
public function url(): string
{
$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)
{
// don't change the language code
unset($props['code']);
// make sure the slug is nice and clean
$props['slug'] = Str::slug($props['slug'] ?? null);
$kirby = App::instance();
$updated = $this->clone($props);
// validate the updated language
LanguageRules::update($updated);
// convert the current default to a non-default language
if ($updated->isDefault() === true) {
if ($oldDefault = $kirby->defaultLanguage()) {
$oldDefault->clone(['default' => false])->save();
}
$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 Kirby language collection
App::instance()->languages(false)->set($language->code(), $language);
return $language;
}
}

View file

@ -0,0 +1,136 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Exception\NotFoundException;
use Kirby\Http\Router;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* The language router is used internally
* to handle language-specific (scoped) routes
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class LanguageRouter
{
/**
* 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(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
{
$language = $this->language;
$kirby = $language->kirby();
$routes = $kirby->routes();
// only keep the scoped language routes
$routes = array_values(array_filter($routes, function ($route) use ($language) {
// no language scope
if (empty($route['language']) === true) {
return false;
}
// wildcard
if ($route['language'] === '*') {
return true;
}
// get all applicable languages
$languages = Str::split(strtolower($route['language']), '|');
// validate the language
return in_array($language->code(), $languages) === true;
}));
// add the page-scope if necessary
foreach ($routes as $index => $route) {
if ($pageId = ($route['page'] ?? null)) {
if ($page = $kirby->page($pageId)) {
// convert string patterns to arrays
$patterns = A::wrap($route['pattern']);
// prefix all patterns with the page slug
$patterns = A::map(
$patterns,
fn ($pattern) => $page->uri($language) . '/' . $pattern
);
// re-inject the pattern and the full page object
$routes[$index]['pattern'] = $patterns;
$routes[$index]['page'] = $page;
} else {
throw new NotFoundException('The page "' . $pageId . '" does not exist');
}
}
}
return $routes;
}
/**
* 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 $path = null)
{
$language = $this->language;
$kirby = $language->kirby();
$router = new Router($this->routes());
try {
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());
} else {
return $route->action()->call($route, $language, ...$route->arguments());
}
});
} catch (Exception $e) {
return $kirby->resolve($path, $language->code());
}
}
}

View file

@ -0,0 +1,155 @@
<?php
namespace Kirby\Cms;
use Kirby\Filesystem\F;
class LanguageRoutes
{
/**
* Creates all multi-language routes
*
* @param \Kirby\Cms\App $kirby
* @return array
*/
public static function create(App $kirby): array
{
$routes = [];
// add the route for the home page
$routes[] = static::home($kirby);
// Kirby's base url
$baseurl = $kirby->url();
foreach ($kirby->languages() as $language) {
// ignore languages with a different base url
if ($language->baseurl() !== $baseurl) {
continue;
}
$routes[] = [
'pattern' => $language->pattern(),
'method' => 'ALL',
'env' => 'site',
'action' => function ($path = null) use ($language) {
if ($result = $language->router()->call($path)) {
return $result;
}
// jump through to the fallback if nothing
// can be found for this language
/** @var \Kirby\Http\Route $this */
$this->next();
}
];
}
$routes[] = static::fallback($kirby);
return $routes;
}
/**
* Create the fallback route
* for unprefixed default language URLs.
*
* @param \Kirby\Cms\App $kirby
* @return array
*/
public static function fallback(App $kirby): array
{
return [
'pattern' => '(:all)',
'method' => 'ALL',
'env' => 'site',
'action' => function (string $path) use ($kirby) {
// check for content representations or files
$extension = F::extension($path);
// try to redirect prefixed pages
if (empty($extension) === true && $page = $kirby->page($path)) {
$url = $kirby->request()->url([
'query' => null,
'params' => null,
'fragment' => null
]);
if ($url->toString() !== $page->url()) {
// redirect to translated page directly
// if translation is exists and languages detect is enabled
if (
$kirby->option('languages.detect') === true &&
$page->translation($kirby->detectedLanguage()->code())->exists() === true
) {
return $kirby
->response()
->redirect($page->url($kirby->detectedLanguage()->code()));
}
return $kirby
->response()
->redirect($page->url());
}
}
return $kirby->language()->router()->call($path);
}
];
}
/**
* Create the multi-language home page route
*
* @param \Kirby\Cms\App $kirby
* @return array
*/
public static function home(App $kirby): array
{
// Multi-language home
return [
'pattern' => '',
'method' => 'ALL',
'env' => 'site',
'action' => function () use ($kirby) {
// find all languages with the same base url as the current installation
$languages = $kirby->languages()->filter('baseurl', $kirby->url());
// if there's no language with a matching base url,
// redirect to the default language
if ($languages->count() === 0) {
return $kirby
->response()
->redirect($kirby->defaultLanguage()->url());
}
// if there's just one language, we take that to render the home page
if ($languages->count() === 1) {
$currentLanguage = $languages->first();
} else {
$currentLanguage = $kirby->defaultLanguage();
}
// language detection on the home page with / as URL
if ($kirby->url() !== $currentLanguage->url()) {
if ($kirby->option('languages.detect') === true) {
return $kirby
->response()
->redirect($kirby->detectedLanguage()->url());
}
return $kirby
->response()
->redirect($currentLanguage->url());
}
// render the home page of the current language
return $currentLanguage->router()->call();
}
];
}
}

View file

@ -0,0 +1,98 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Str;
/**
* Validators for all language actions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class LanguageRules
{
/**
* Validates if the language can be created
*
* @param \Kirby\Cms\Language $language
* @return bool
* @throws \Kirby\Exception\DuplicateException If the language already exists
*/
public static function create(Language $language): bool
{
static::validLanguageCode($language);
static::validLanguageName($language);
if ($language->exists() === true) {
throw new DuplicateException([
'key' => 'language.duplicate',
'data' => [
'code' => $language->code()
]
]);
}
return true;
}
/**
* Validates if the language can be updated
*
* @param \Kirby\Cms\Language $language
*/
public static function update(Language $language)
{
static::validLanguageCode($language);
static::validLanguageName($language);
}
/**
* 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
{
if (Str::length($language->code()) < 2) {
throw new InvalidArgumentException([
'key' => 'language.code',
'data' => [
'code' => $language->code(),
'name' => $language->name()
]
]);
}
return true;
}
/**
* 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
{
if (Str::length($language->name()) < 1) {
throw new InvalidArgumentException([
'key' => 'language.name',
'data' => [
'code' => $language->code(),
'name' => $language->name()
]
]);
}
return true;
}
}

101
kirby/src/Cms/Languages.php Normal file
View file

@ -0,0 +1,101 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\DuplicateException;
use Kirby\Filesystem\F;
/**
* A collection of all defined site languages
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Languages extends Collection
{
/**
* 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($objects = [], $parent = null)
{
$defaults = array_filter(
$objects,
fn ($language) => $language->isDefault() === true
);
if (count($defaults) > 1) {
throw new DuplicateException('You cannot have multiple default languages. Please check your language config files.');
}
parent::__construct($objects, $parent);
}
/**
* Returns all language codes as array
*
* @return array
*/
public function codes(): array
{
return $this->keys();
}
/**
* Creates a new language with the given props
*
* @internal
* @param array $props
* @return \Kirby\Cms\Language
*/
public function create(array $props)
{
return Language::create($props);
}
/**
* Returns the default language
*
* @return \Kirby\Cms\Language|null
*/
public function default()
{
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()
{
$languages = [];
$files = glob(App::instance()->root('languages') . '/*.php');
foreach ($files as $file) {
$props = F::load($file);
if (is_array($props) === true) {
// inject the language code from the filename
// if it does not exist
$props['code'] ??= F::name($file);
$languages[] = new Language($props);
}
}
return new static($languages);
}
}

127
kirby/src/Cms/Layout.php Normal file
View file

@ -0,0 +1,127 @@
<?php
namespace Kirby\Cms;
/**
* Represents a single Layout with
* multiple columns
* @since 3.5.0
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Layout extends Item
{
use HasMethods;
public const ITEMS_CLASS = '\Kirby\Cms\Layouts';
/**
* @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 = [])
{
// layout methods
if ($this->hasMethod($method) === true) {
return $this->callMethod($method, $args);
}
return $this->attrs()->get($method);
}
/**
* Creates a new Layout object
*
* @param array $params
*/
public function __construct(array $params = [])
{
parent::__construct($params);
$this->columns = LayoutColumns::factory($params['columns'] ?? [], [
'parent' => $this->parent
]);
// create the attrs object
$this->attrs = new Content($params['attrs'] ?? [], $this->parent);
}
/**
* Returns the attrs object
*
* @return \Kirby\Cms\Content
*/
public function attrs()
{
return $this->attrs;
}
/**
* Returns the columns in this layout
*
* @return \Kirby\Cms\LayoutColumns
*/
public function columns()
{
return $this->columns;
}
/**
* Checks if the layout is empty
* @since 3.5.2
*
* @return bool
*/
public function isEmpty(): bool
{
return $this
->columns()
->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
{
return $this->isEmpty() === false;
}
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{
return [
'attrs' => $this->attrs()->toArray(),
'columns' => $this->columns()->toArray(),
'id' => $this->id(),
];
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Str;
/**
* Represents a single layout column with
* multiple blocks
* @since 3.5.0
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class LayoutColumn extends Item
{
use HasMethods;
public const ITEMS_CLASS = '\Kirby\Cms\LayoutColumns';
/**
* @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'] ?? [], [
'parent' => $this->parent
]);
$this->width = $params['width'] ?? '1/1';
}
/**
* Magic getter function
*
* @param string $method
* @param mixed $args
* @return mixed
*/
public function __call(string $method, $args)
{
// layout column methods
if ($this->hasMethod($method) === true) {
return $this->callMethod($method, $args);
}
}
/**
* Returns the blocks collection
*
* @param bool $includeHidden Sets whether to include hidden blocks
* @return \Kirby\Cms\Blocks
*/
public function blocks(bool $includeHidden = false)
{
if ($includeHidden === false) {
return $this->blocks->filter('isHidden', false);
}
return $this->blocks;
}
/**
* Checks if the column is empty
* @since 3.5.2
*
* @return bool
*/
public function isEmpty(): bool
{
return $this
->blocks()
->filter('isHidden', false)
->count() === 0;
}
/**
* Checks if the column is not empty
* @since 3.5.2
*
* @return bool
*/
public function isNotEmpty(): bool
{
return $this->isEmpty() === false;
}
/**
* Returns the number of columns this column spans
*
* @param int $columns
* @return int
*/
public function span(int $columns = 12): int
{
$fraction = Str::split($this->width, '/');
$a = $fraction[0] ?? 1;
$b = $fraction[1] ?? 1;
return $columns * $a / $b;
}
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{
return [
'blocks' => $this->blocks(true)->toArray(),
'id' => $this->id(),
'width' => $this->width(),
];
}
/**
* Returns the width of the column
*
* @return string
*/
public function width(): string
{
return $this->width;
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace Kirby\Cms;
/**
* A collection of layout columns
* @since 3.5.0
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class LayoutColumns extends Items
{
public const ITEM_CLASS = '\Kirby\Cms\LayoutColumn';
}

102
kirby/src/Cms/Layouts.php Normal file
View file

@ -0,0 +1,102 @@
<?php
namespace Kirby\Cms;
use Kirby\Data\Data;
use Throwable;
/**
* A collection of layouts
* @since 3.5.0
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Layouts extends 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 (array_key_exists('content', $first) === true || array_key_exists('type', $first) === true) {
$items = [
[
'id' => uuid(),
'columns' => [
[
'width' => '1/1',
'blocks' => $items
]
]
]
];
}
return parent::factory($items, $params);
}
/**
* Checks if a given block type exists in the layouts collection
* @since 3.6.0
*
* @param string $type
* @return bool
*/
public function hasBlockType(string $type): bool
{
return $this->toBlocks()->hasType($type);
}
/**
* Parse layouts data
*
* @param array|string $input
* @return array
*/
public static function parse($input): array
{
if (empty($input) === false && is_array($input) === false) {
try {
$input = Data::decode($input, 'json');
} catch (Throwable $e) {
return [];
}
}
if (empty($input) === true) {
return [];
}
return $input;
}
/**
* Converts layouts to blocks
* @since 3.6.0
*
* @param bool $includeHidden Sets whether to include hidden blocks
* @return \Kirby\Cms\Blocks
*/
public function toBlocks(bool $includeHidden = false)
{
$blocks = [];
if ($this->isNotEmpty() === true) {
foreach ($this->data() as $layout) {
foreach ($layout->columns() as $column) {
foreach ($column->blocks($includeHidden) as $block) {
$blocks[] = $block->toArray();
}
}
}
}
return Blocks::factory($blocks);
}
}

250
kirby/src/Cms/Loader.php Normal file
View file

@ -0,0 +1,250 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Data\Data;
use Kirby\Filesystem\F;
/**
* The Loader class is an internal loader for
* core parts, like areas, components, sections, etc.
*
* It's exposed in the `$kirby->load()` and the
* `$kirby->core()->load()` methods.
*
* With `$kirby->load()` you get access to core parts
* that might be overwritten by plugins.
*
* With `$kirby->core()->load()` you get access to
* untouched core parts. This is useful if you want to
* reuse or fall back to core features in your plugins.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Loader
{
/**
* @var \Kirby\Cms\App
*/
protected $kirby;
/**
* @var bool
*/
protected $withPlugins;
/**
* @param \Kirby\Cms\App $kirby
* @param bool $withPlugins
*/
public function __construct(App $kirby, bool $withPlugins = true)
{
$this->kirby = $kirby;
$this->withPlugins = $withPlugins;
}
/**
* Loads the area definition
*
* @param string $name
* @return array|null
*/
public function area(string $name): ?array
{
return $this->areas()[$name] ?? null;
}
/**
* 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
foreach ($this->kirby->core()->areas() as $id => $area) {
$area = $this->resolveArea($area);
if (isset($extensions[$id]) === true) {
foreach ($extensions[$id] as $areaExtension) {
$extension = $this->resolveArea($areaExtension);
$area = array_replace_recursive($area, $extension);
}
unset($extensions[$id]);
}
$areas[$id] = $area;
}
// add additional areas from plugins
foreach ($extensions as $id => $areaExtensions) {
foreach ($areaExtensions as $areaExtension) {
$areas[$id] = $this->resolve($areaExtension);
}
}
return $areas;
}
/**
* Loads a core component closure
*
* @param string $name
* @return \Closure|null
*/
public function component(string $name): ?Closure
{
return $this->extension('components', $name);
}
/**
* Loads all core component closures
*
* @return array
*/
public function components(): array
{
return $this->extensions('components');
}
/**
* Loads a particular extension
*
* @param string $type
* @param string $name
* @return 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
{
return $this->withPlugins === false ? $this->kirby->core()->$type() : $this->kirby->extensions($type);
}
/**
* The resolver takes a string, array or closure.
*
* 1.) a string is supposed to be a path to an existing file.
* The file will either be included when it's a PHP file and
* the array contents will be read. Or it will be parsed with
* the Data class to read yml or json data into an array
*
* 2.) arrays are untouched and returned
*
* 3.) closures will be called and the Kirby instance will be
* passed as first argument
*
* @param mixed $item
* @return mixed
*/
public function resolve($item)
{
if (is_string($item) === true) {
if (F::extension($item) !== 'php') {
$item = Data::read($item);
} else {
$item = require $item;
}
}
if (is_callable($item)) {
$item = $item($this->kirby);
}
return $item;
}
/**
* Calls `static::resolve()` on all items
* in the given array
*
* @param array $items
* @return array
*/
public function resolveAll(array $items): array
{
$result = [];
foreach ($items as $key => $value) {
$result[$key] = $this->resolve($value);
}
return $result;
}
/**
* Areas need a bit of special treatment
* when they are being loaded
*
* @param string|array|Closure $area
* @return array
*/
public function resolveArea($area): array
{
$area = $this->resolve($area);
$dropdowns = $area['dropdowns'] ?? [];
// convert closure dropdowns to an array definition
// otherwise they cannot be merged properly later
foreach ($dropdowns as $key => $dropdown) {
if (is_a($dropdown, 'Closure') === true) {
$area['dropdowns'][$key] = [
'options' => $dropdown
];
}
}
return $area;
}
/**
* Loads a particular section definition
*
* @param string $name
* @return 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
{
return $this->resolveAll($this->extensions('sections'));
}
/**
* Returns the status flag, which shows
* if plugins are loaded as well.
*
* @return bool
*/
public function withPlugins(): bool
{
return $this->withPlugins;
}
}

172
kirby/src/Cms/Media.php Normal file
View file

@ -0,0 +1,172 @@
<?php
namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Str;
use Throwable;
/**
* Handles all tasks to get the Media API
* up and running and link files correctly
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Media
{
/**
* Tries to find a file by model and filename
* and to copy it to the media folder.
*
* @param \Kirby\Cms\Model|null $model
* @param string $hash
* @param string $filename
* @return \Kirby\Cms\Response|false
*/
public static function link(Model $model = null, string $hash, string $filename)
{
if ($model === null) {
return false;
}
// fix issues with spaces in filenames
$filename = urldecode($filename);
// try to find a file by model and filename
// this should work for all original files
if ($file = $model->file($filename)) {
// check if the request contained an outdated media hash
if ($file->mediaHash() !== $hash) {
// if at least the token was correct, redirect
if (Str::startsWith($hash, $file->mediaToken() . '-') === true) {
return Response::redirect($file->mediaUrl(), 307);
} else {
// don't leak the correct token, render the error page
return false;
}
}
// send the file to the browser
return Response::file($file->publish()->mediaRoot());
}
// try to generate a thumb for the file
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
{
// never publish risky files (e.g. HTML, PHP or Apache config files)
FileRules::validFile($file, false);
$src = $file->root();
$version = dirname($dest);
$directory = dirname($version);
// unpublish all files except stuff in the version folder
Media::unpublish($directory, $file, $version);
// copy/overwrite the file to the dest folder
return F::copy($src, $dest, true);
}
/**
* Tries to find a job file for the
* given filename and then calls the thumb
* component to create a thumbnail accordingly
*
* @param \Kirby\Cms\Model|string $model
* @param string $hash
* @param string $filename
* @return \Kirby\Cms\Response|false
*/
public static function thumb($model, string $hash, string $filename)
{
$kirby = App::instance();
// 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);
if (empty($options) === true) {
return false;
}
if (is_string($model) === true) {
$source = $kirby->root('index') . '/' . $model . '/' . $options['filename'];
} else {
$source = $model->file($options['filename'])->root();
}
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) {
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 $ignore = null): bool
{
if (is_dir($directory) === false) {
return true;
}
// get both old and new versions (pre and post Kirby 3.4.0)
$versions = array_merge(
glob($directory . '/' . crc32($file->filename()) . '-*', GLOB_ONLYDIR),
glob($directory . '/' . $file->mediaToken() . '-*', GLOB_ONLYDIR)
);
// delete all versions of the file
foreach ($versions as $version) {
if ($version === $ignore) {
continue;
}
Dir::remove($version);
}
return true;
}
}

117
kirby/src/Cms/Model.php Normal file
View file

@ -0,0 +1,117 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Properties;
/**
* Foundation for Page, Site, File and User models.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
abstract class Model
{
use Properties;
/**
* Each model must define a CLASS_ALIAS
* which will be used in template queries.
* The CLASS_ALIAS is a short human-readable
* version of the class name. I.e. page.
*/
public const CLASS_ALIAS = null;
/**
* The parent Kirby instance
*
* @var \Kirby\Cms\App
*/
public static $kirby;
/**
* The parent site instance
*
* @var \Kirby\Cms\Site
*/
protected $site;
/**
* Makes it possible to convert the entire model
* to a string. Mostly useful for debugging
*
* @return string
*/
public function __toString(): string
{
return $this->id();
}
/**
* Each model must return a unique id
*
* @return string|int
*/
public function id()
{
return null;
}
/**
* Returns the parent Kirby instance
*
* @return \Kirby\Cms\App
*/
public function kirby()
{
return static::$kirby ??= App::instance();
}
/**
* Returns the parent Site instance
*
* @return \Kirby\Cms\Site
*/
public function site()
{
return $this->site ??= $this->kirby()->site();
}
/**
* Setter for the parent Kirby object
*
* @param \Kirby\Cms\App|null $kirby
* @return $this
*/
protected function setKirby(App $kirby = null)
{
static::$kirby = $kirby;
return $this;
}
/**
* Setter for the parent site object
*
* @internal
* @param \Kirby\Cms\Site|null $site
* @return $this
*/
public function setSite(Site $site = null)
{
$this->site = $site;
return $this;
}
/**
* Convert the model to a simple array
*
* @return array
*/
public function toArray(): array
{
return $this->propertiesToArray();
}
}

View file

@ -0,0 +1,116 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\A;
/**
* ModelPermissions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
abstract class ModelPermissions
{
protected $category;
protected $model;
protected $options;
protected $permissions;
protected $user;
/**
* @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();
$this->user = $model->kirby()->user() ?? User::nobody();
$this->permissions = $this->user->role()->permissions();
}
/**
* Improved `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* @param string $action
* @return bool
*/
public function can(string $action): bool
{
$role = $this->user->role()->id();
if ($role === 'nobody') {
return false;
}
// check for a custom overall can method
if (method_exists($this, 'can' . $action) === true && $this->{'can' . $action}() === false) {
return false;
}
// evaluate the blueprint options block
if (isset($this->options[$action]) === true) {
$options = $this->options[$action];
if ($options === false) {
return false;
}
if ($options === true) {
return true;
}
if (is_array($options) === true && A::isAssociative($options) === true) {
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 = [];
foreach ($this->options as $key => $value) {
$array[$key] = $this->can($key);
}
return $array;
}
}

View file

@ -0,0 +1,699 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Form\Form;
use Kirby\Toolkit\Str;
use Throwable;
/**
* ModelWithContent
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
abstract class ModelWithContent extends Model
{
/**
* The content
*
* @var \Kirby\Cms\Content
*/
public $content;
/**
* @var \Kirby\Cms\Translations
*/
public $translations;
/**
* Returns the blueprint of the model
*
* @return \Kirby\Cms\Blueprint
*/
abstract public function blueprint();
/**
* Returns an array with all blueprints that are available
*
* @param string|null $inSection
* @return array
*/
public function blueprints(string $inSection = null): array
{
$blueprints = [];
$blueprint = $this->blueprint();
$sections = $inSection !== null ? [$blueprint->section($inSection)] : $blueprint->sections();
foreach ($sections as $section) {
if ($section === null) {
continue;
}
foreach ((array)$section->blueprints() as $blueprint) {
$blueprints[$blueprint['name']] = $blueprint;
}
}
return array_values($blueprints);
}
/**
* Executes any given model action
*
* @param string $action
* @param array $arguments
* @param \Closure $callback
* @return mixed
*/
abstract protected function commit(string $action, array $arguments, Closure $callback);
/**
* Returns the content
*
* @param string|null $languageCode
* @return \Kirby\Cms\Content
* @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist
*/
public function content(string $languageCode = null)
{
// single language support
if ($this->kirby()->multilang() === false) {
if (is_a($this->content, 'Kirby\Cms\Content') === true) {
return $this->content;
}
return $this->setContent($this->readContent())->content;
// multi language support
} else {
// only fetch from cache for the default language
if ($languageCode === null && is_a($this->content, 'Kirby\Cms\Content') === true) {
return $this->content;
}
// get the translation by code
if ($translation = $this->translation($languageCode)) {
$content = new Content($translation->content(), $this);
} else {
throw new InvalidArgumentException('Invalid language: ' . $languageCode);
}
// only store the content for the current language
if ($languageCode === null) {
$this->content = $content;
}
return $content;
}
}
/**
* Returns the absolute path to the content file
*
* @internal
* @param string|null $languageCode
* @param bool $force
* @return string
* @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist
*/
public function contentFile(string $languageCode = null, bool $force = false): string
{
$extension = $this->contentFileExtension();
$directory = $this->contentFileDirectory();
$filename = $this->contentFileName();
// overwrite the language code
if ($force === true) {
if (empty($languageCode) === false) {
return $directory . '/' . $filename . '.' . $languageCode . '.' . $extension;
} else {
return $directory . '/' . $filename . '.' . $extension;
}
}
// add and validate the language code in multi language mode
if ($this->kirby()->multilang() === true) {
if ($language = $this->kirby()->languageCode($languageCode)) {
return $directory . '/' . $filename . '.' . $language . '.' . $extension;
} else {
throw new InvalidArgumentException('Invalid language: ' . $languageCode);
}
} else {
return $directory . '/' . $filename . '.' . $extension;
}
}
/**
* Returns an array with all content files
*
* @return array
*/
public function contentFiles(): array
{
if ($this->kirby()->multilang() === true) {
$files = [];
foreach ($this->kirby()->languages()->codes() as $code) {
$files[] = $this->contentFile($code);
}
return $files;
} else {
return [
$this->contentFile()
];
}
}
/**
* Prepares the content that should be written
* to the text file
*
* @internal
* @param array $data
* @param string|null $languageCode
* @return array
*/
public function contentFileData(array $data, string $languageCode = null): array
{
return $data;
}
/**
* Returns the absolute path to the
* folder in which the content file is
* located
*
* @internal
* @return string|null
*/
public function contentFileDirectory(): ?string
{
return $this->root();
}
/**
* Returns the extension of the content file
*
* @internal
* @return string
*/
public function contentFileExtension(): string
{
return $this->kirby()->contentExtension();
}
/**
* Needs to be declared by the final model
*
* @internal
* @return string
*/
abstract public function contentFileName(): string;
/**
* Decrement a given field value
*
* @param string $field
* @param int $by
* @param int $min
* @return static
*/
public function decrement(string $field, int $by = 1, int $min = 0)
{
$value = (int)$this->content()->get($field)->value() - $by;
if ($value < $min) {
$value = $min;
}
return $this->update([$field => $value]);
}
/**
* Returns all content validation errors
*
* @return array
*/
public function errors(): array
{
$errors = [];
foreach ($this->blueprint()->sections() as $section) {
$errors = array_merge($errors, $section->errors());
}
return $errors;
}
/**
* Increment a given field value
*
* @param string $field
* @param int $by
* @param int|null $max
* @return static
*/
public function increment(string $field, int $by = 1, int $max = null)
{
$value = (int)$this->content()->get($field)->value() + $by;
if ($max && $value > $max) {
$value = $max;
}
return $this->update([$field => $value]);
}
/**
* Checks if the model is locked for the current user
*
* @return bool
*/
public function isLocked(): bool
{
$lock = $this->lock();
return $lock && $lock->isLocked() === true;
}
/**
* Checks if the data has any errors
*
* @return bool
*/
public function isValid(): bool
{
return Form::for($this)->hasErrors() === false;
}
/**
* Returns the lock object for this model
*
* Only if a content directory exists,
* virtual pages will need to overwrite this method
*
* @return \Kirby\Cms\ContentLock|null
*/
public function lock()
{
$dir = $this->contentFileDirectory();
if (
$this->kirby()->option('content.locking', true) &&
is_string($dir) === true &&
file_exists($dir) === true
) {
return new ContentLock($this);
}
}
/**
* Returns the panel info of the model
* @since 3.6.0
*
* @return \Kirby\Panel\Model
*/
abstract public function panel();
/**
* Must return the permissions object for the model
*
* @return \Kirby\Cms\ModelPermissions
*/
abstract public function permissions();
/**
* Creates a string query, starting from the model
*
* @internal
* @param string|null $query
* @param string|null $expect
* @return mixed
*/
public function query(string $query = null, string $expect = null)
{
if ($query === null) {
return null;
}
try {
$result = Str::query($query, [
'kirby' => $this->kirby(),
'site' => is_a($this, 'Kirby\Cms\Site') ? $this : $this->site(),
static::CLASS_ALIAS => $this
]);
} catch (Throwable $e) {
return null;
}
if ($expect !== null && is_a($result, $expect) !== true) {
return null;
}
return $result;
}
/**
* Read the content from the content file
*
* @internal
* @param string|null $languageCode
* @return array
*/
public function readContent(string $languageCode = null): array
{
try {
return Data::read($this->contentFile($languageCode));
} catch (Throwable $e) {
return [];
}
}
/**
* Returns the absolute path to the model
*
* @return string|null
*/
abstract public function root(): ?string;
/**
* Stores the content on disk
*
* @internal
* @param array|null $data
* @param string|null $languageCode
* @param bool $overwrite
* @return static
*/
public function save(array $data = null, string $languageCode = null, bool $overwrite = false)
{
if ($this->kirby()->multilang() === true) {
return $this->saveTranslation($data, $languageCode, $overwrite);
} else {
return $this->saveContent($data, $overwrite);
}
}
/**
* Save the single language content
*
* @param array|null $data
* @param bool $overwrite
* @return static
*/
protected function saveContent(array $data = null, bool $overwrite = false)
{
// create a clone to avoid modifying the original
$clone = $this->clone();
// merge the new data with the existing content
$clone->content()->update($data, $overwrite);
// send the full content array to the writer
$clone->writeContent($clone->content()->toArray());
return $clone;
}
/**
* Save a translation
*
* @param array|null $data
* @param string|null $languageCode
* @param bool $overwrite
* @return static
* @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist
*/
protected function saveTranslation(array $data = null, string $languageCode = null, bool $overwrite = false)
{
// create a clone to not touch the original
$clone = $this->clone();
// fetch the matching translation and update all the strings
$translation = $clone->translation($languageCode);
if ($translation === null) {
throw new InvalidArgumentException('Invalid language: ' . $languageCode);
}
// get the content to store
$content = $translation->update($data, $overwrite)->content();
$kirby = $this->kirby();
$languageCode = $kirby->languageCode($languageCode);
// remove all untranslatable fields
if ($languageCode !== $kirby->defaultLanguage()->code()) {
foreach ($this->blueprint()->fields() as $field) {
if (($field['translate'] ?? true) === false) {
$content[$field['name']] = null;
}
}
// merge the translation with the new data
$translation->update($content, true);
}
// send the full translation array to the writer
$clone->writeContent($translation->content(), $languageCode);
// reset the content object
$clone->content = null;
// return the updated model
return $clone;
}
/**
* Sets the Content object
*
* @param array|null $content
* @return $this
*/
protected function setContent(array $content = null)
{
if ($content !== null) {
$content = new Content($content, $this);
}
$this->content = $content;
return $this;
}
/**
* Create the translations collection from an array
*
* @param array|null $translations
* @return $this
*/
protected function setTranslations(array $translations = null)
{
if ($translations !== null) {
$this->translations = new Collection();
foreach ($translations as $props) {
$props['parent'] = $this;
$translation = new ContentTranslation($props);
$this->translations->data[$translation->code()] = $translation;
}
}
return $this;
}
/**
* String template builder with automatic HTML escaping
* @since 3.6.0
*
* @param string|null $template Template string or `null` to use the model ID
* @param array $data
* @param string $fallback Fallback for tokens in the template that cannot be replaced
* @return string
*/
public function toSafeString(string $template = null, array $data = [], string $fallback = ''): string
{
return $this->toString($template, $data, $fallback, 'safeTemplate');
}
/**
* String template builder
*
* @param string|null $template Template string or `null` to use the model ID
* @param array $data
* @param string $fallback Fallback for tokens in the template that cannot be replaced
* @param string $handler For internal use
* @return string
*/
public function toString(string $template = null, array $data = [], string $fallback = '', string $handler = 'template'): string
{
if ($template === null) {
return $this->id() ?? '';
}
if ($handler !== 'template' && $handler !== 'safeTemplate') {
throw new InvalidArgumentException('Invalid toString handler'); // @codeCoverageIgnore
}
$result = Str::$handler($template, array_replace([
'kirby' => $this->kirby(),
'site' => is_a($this, 'Kirby\Cms\Site') ? $this : $this->site(),
static::CLASS_ALIAS => $this
], $data), ['fallback' => $fallback]);
return $result;
}
/**
* Returns a single translation by language code
* If no code is specified the current translation is returned
*
* @param string|null $languageCode
* @return \Kirby\Cms\ContentTranslation|null
*/
public function translation(string $languageCode = null)
{
return $this->translations()->find($languageCode ?? $this->kirby()->language()->code());
}
/**
* Returns the translations collection
*
* @return \Kirby\Cms\Collection
*/
public function translations()
{
if ($this->translations !== null) {
return $this->translations;
}
$this->translations = new Collection();
foreach ($this->kirby()->languages() as $language) {
$translation = new ContentTranslation([
'parent' => $this,
'code' => $language->code(),
]);
$this->translations->data[$translation->code()] = $translation;
}
return $this->translations;
}
/**
* Updates the model data
*
* @param array|null $input
* @param string|null $languageCode
* @param bool $validate
* @return static
* @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values
*/
public function update(array $input = null, string $languageCode = null, bool $validate = false)
{
$form = Form::for($this, [
'ignoreDisabled' => $validate === false,
'input' => $input,
'language' => $languageCode,
]);
// validate the input
if ($validate === true) {
if ($form->isInvalid() === true) {
throw new InvalidArgumentException([
'fallback' => 'Invalid form with errors',
'details' => $form->errors()
]);
}
}
$arguments = [static::CLASS_ALIAS => $this, 'values' => $form->data(), 'strings' => $form->strings(), 'languageCode' => $languageCode];
return $this->commit('update', $arguments, function ($model, $values, $strings, $languageCode) {
// save updated values
$model = $model->save($strings, $languageCode, true);
// update model in siblings collection
$model->siblings()->add($model);
return $model;
});
}
/**
* Low level data writer method
* to store the given data on disk or anywhere else
*
* @internal
* @param array $data
* @param string|null $languageCode
* @return bool
*/
public function writeContent(array $data, string $languageCode = null): bool
{
return Data::write(
$this->contentFile($languageCode),
$this->contentFileData($data, $languageCode)
);
}
/**
* Deprecated!
*/
/**
* Returns the panel icon definition
*
* @deprecated 3.6.0 Use `->panel()->image()` instead
* @todo Add `deprecated()` helper warning in 3.7.0
* @todo Remove in 3.8.0
*
* @internal
* @param array|null $params
* @return array|null
* @codeCoverageIgnore
*/
public function panelIcon(array $params = null): ?array
{
return $this->panel()->image($params);
}
/**
* @deprecated 3.6.0 Use `->panel()->image()` instead
* @todo Add `deprecated()` helper warning in 3.7.0
* @todo Remove in 3.8.0
*
* @internal
* @param string|array|false|null $settings
* @return array|null
* @codeCoverageIgnore
*/
public function panelImage($settings = null): ?array
{
return $this->panel()->image($settings);
}
/**
* Returns an array of all actions
* that can be performed in the Panel
* This also checks for the lock status
*
* @deprecated 3.6.0 Use `->panel()->options()` instead
* @todo Add `deprecated()` helper warning in 3.7.0
* @todo Remove in 3.8.0
*
* @param array $unlock An array of options that will be force-unlocked
* @return array
* @codeCoverageIgnore
*/
public function panelOptions(array $unlock = []): array
{
return $this->panel()->options($unlock);
}
}

48
kirby/src/Cms/Nest.php Normal file
View file

@ -0,0 +1,48 @@
<?php
namespace Kirby\Cms;
/**
* The Nest class converts any array type
* into a Kirby style collection/object. This
* can be used make any type of array compatible
* with Kirby queries.
*
* REFACTOR: move this to the toolkit
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Nest
{
/**
* @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);
}
$result = [];
foreach ($data as $key => $value) {
if (is_array($value) === true) {
$result[$key] = static::create($value, $parent);
} elseif (is_scalar($value) === true) {
$result[$key] = new Field($parent, $key, $value);
}
}
if (is_int(key($data))) {
return new NestCollection($result);
} else {
return new NestObject($result);
}
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Toolkit\Collection as BaseCollection;
/**
* NestCollection
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
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
{
return parent::toArray($map ?? fn ($object) => $object->toArray());
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Obj;
/**
* NestObject
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class NestObject extends Obj
{
/**
* Converts the object to an array
*
* @return array
*/
public function toArray(): array
{
$result = [];
foreach ((array)$this as $key => $value) {
if (is_a($value, 'Kirby\Cms\Field') === true) {
$result[$key] = $value->value();
continue;
}
if (is_object($value) === true && method_exists($value, 'toArray')) {
$result[$key] = $value->toArray();
continue;
}
$result[$key] = $value;
}
return $result;
}
}

1554
kirby/src/Cms/Page.php Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,878 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\DuplicateException;
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\Str;
/**
* PageActions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait PageActions
{
/**
* Changes the sorting number.
* The sorting number must already be correct
* when the method is called.
* This only affects this page,
* siblings will not be resorted.
*
* @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 $num = null)
{
if ($this->isDraft() === true) {
throw new LogicException('Drafts cannot change their sorting number');
}
// don't run the action if everything stayed the same
if ($this->num() === $num) {
return $this;
}
return $this->commit('changeNum', ['page' => $this, 'num' => $num], function ($oldPage, $num) {
$newPage = $oldPage->clone([
'num' => $num,
'dirname' => null,
'root' => null
]);
// actually move the page on disk
if ($oldPage->exists() === true) {
if (Dir::move($oldPage->root(), $newPage->root()) === true) {
// Updates the root path of the old page with the root path
// of the moved new page to use fly actions on old page in loop
$oldPage->setRoot($newPage->root());
} else {
throw new LogicException('The page directory cannot be moved');
}
}
// overwrite the child in the parent page
$newPage
->parentModel()
->children()
->set($newPage->id(), $newPage);
return $newPage;
});
}
/**
* 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 $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 ($language = $this->kirby()->language($languageCode)) {
if ($language->isDefault() === false) {
return $this->changeSlugForLanguage($slug, $languageCode);
}
}
// if the slug stays exactly the same,
// nothing needs to be done.
if ($slug === $this->slug()) {
return $this;
}
$arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => null];
return $this->commit('changeSlug', $arguments, function ($oldPage, $slug) {
$newPage = $oldPage->clone([
'slug' => $slug,
'dirname' => null,
'root' => null
]);
if ($oldPage->exists() === true) {
// remove the lock of the old page
if ($lock = $oldPage->lock()) {
$lock->remove();
}
// actually move stuff on disk
if (Dir::move($oldPage->root(), $newPage->root()) !== true) {
throw new LogicException('The page directory cannot be moved');
}
// remove from the siblings
$oldPage->parentModel()->children()->remove($oldPage);
Dir::remove($oldPage->mediaRoot());
}
// overwrite the new page in the parent collection
if ($newPage->isDraft() === true) {
$newPage->parentModel()->drafts()->set($newPage->id(), $newPage);
} else {
$newPage->parentModel()->children()->set($newPage->id(), $newPage);
}
return $newPage;
});
}
/**
* 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 $languageCode = null)
{
$language = $this->kirby()->language($languageCode);
if (!$language) {
throw new NotFoundException('The language: "' . $languageCode . '" does not exist');
}
if ($language->isDefault() === true) {
throw new InvalidArgumentException('Use the changeSlug method to change the slug for the default language');
}
$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()) {
$slug = null;
}
return $page->save(['slug' => $slug], $languageCode);
});
}
/**
* Change the status of the current page
* to either draft, listed or unlisted.
* If changing to `listed`, you can pass a position for the
* page in the siblings collection. Siblings will be resorted.
*
* @param string $status "draft", "listed" or "unlisted"
* @param int|null $position Optional sorting number
* @return static
* @throws \Kirby\Exception\InvalidArgumentException If an invalid status is being passed
*/
public function changeStatus(string $status, int $position = null)
{
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);
}
}
/**
* @return static
*/
protected function changeStatusToDraft()
{
$arguments = ['page' => $this, 'status' => 'draft', 'position' => null];
$page = $this->commit(
'changeStatus',
$arguments,
fn ($page) => $page->unpublish()
);
return $page;
}
/**
* @param int|null $position
* @return $this|static
*/
protected function changeStatusToListed(int $position = null)
{
// create a sorting number for the page
$num = $this->createNum($position);
// don't sort if not necessary
if ($this->status() === 'listed' && $num === $this->num()) {
return $this;
}
$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);
}
return $page;
}
/**
* @return $this|static
*/
protected function changeStatusToUnlisted()
{
if ($this->status() === 'unlisted') {
return $this;
}
$arguments = ['page' => $this, 'status' => 'unlisted', 'position' => null];
$page = $this->commit('changeStatus', $arguments, function ($page) {
return $page->publish()->changeNum(null);
});
$this->resortSiblingsAfterUnlisting();
return $page;
}
/**
* Change the position of the page in its siblings
* collection. Siblings will be resorted. If the page
* status isn't yet `listed`, it will be changed to it.
*
* @param int|null $position
* @return $this|static
*/
public function changeSort(int $position = null)
{
return $this->changeStatus('listed', $position);
}
/**
* 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)
{
if ($template === $this->intendedTemplate()->name()) {
return $this;
}
return $this->commit('changeTemplate', ['page' => $this, 'template' => $template], function ($oldPage, $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
if ($page->isDraft() === true) {
$page->parentModel()->drafts()->set($page->id(), $page);
} else {
$page->parentModel()->children()->set($page->id(), $page);
}
return $page;
});
}
/**
* Change the page title
*
* @param string $title
* @param string|null $languageCode
* @return static
*/
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);
// flush the parent cache to get children and drafts right
if ($page->isDraft() === true) {
$page->parentModel()->drafts()->set($page->id(), $page);
} else {
$page->parentModel()->children()->set($page->id(), $page);
}
return $page;
});
}
/**
* Commits a page action, by following these steps
*
* 1. checks the action rules
* 2. sends the before hook
* 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)
{
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
$this->rules()->$action(...$argumentValues);
$kirby->trigger('page.' . $action . ':before', $arguments);
$result = $callback(...$argumentValues);
if ($action === 'create') {
$argumentsAfter = ['page' => $result];
} elseif ($action === 'duplicate') {
$argumentsAfter = ['duplicatePage' => $result, 'originalPage' => $old];
} elseif ($action === 'delete') {
$argumentsAfter = ['status' => $result, 'page' => $old];
} else {
$argumentsAfter = ['newPage' => $result, 'oldPage' => $old];
}
$kirby->trigger('page.' . $action . ':after', $argumentsAfter);
$kirby->cache('pages')->flush();
return $result;
}
/**
* 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 = [])
{
$slug = $options['slug'] ?? $this->slug();
$isDraft = $options['isDraft'] ?? $this->isDraft();
$parent = $options['parent'] ?? null;
$parentModel = $options['parent'] ?? $this->site();
$num = $options['num'] ?? null;
$children = $options['children'] ?? false;
$files = $options['files'] ?? false;
// clean up the slug
$slug = Str::slug($slug);
if ($parentModel->findPageOrDraft($slug)) {
throw new DuplicateException([
'key' => 'page.duplicate',
'data' => [
'slug' => $slug
]
]);
}
$tmp = new static([
'isDraft' => $isDraft,
'num' => $num,
'parent' => $parent,
'slug' => $slug,
]);
$ignore = [
$this->kirby()->locks()->file($this)
];
// don't copy files
if ($files === false) {
foreach ($this->files() as $file) {
$ignore[] = $file->root();
// append all content files
array_push($ignore, ...$file->contentFiles());
}
}
Dir::copy($this->root(), $tmp->root(), $children, $ignore);
$copy = $parentModel->clone()->findPageOrDraft($slug);
// 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
if ($isDraft === true) {
$parentModel->drafts()->append($copy->id(), $copy);
} else {
$parentModel->children()->append($copy->id(), $copy);
}
return $copy;
}
/**
* Creates and stores a new page
*
* @param array $props
* @return static
*/
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);
// create a temporary page object
$page = Page::factory($props);
// create a form for the page
$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) {
// 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
if ($page->isDraft() === true) {
$page->parentModel()->drafts()->append($page->id(), $page);
} else {
$page->parentModel()->children()->append($page->id(), $page);
}
return $page;
});
// publish the new page if a number is given
if (isset($props['num']) === true) {
$page = $page->changeStatus('listed', $props['num']);
}
return $page;
}
/**
* Creates a child of the current page
*
* @param array $props
* @return static
*/
public function createChild(array $props)
{
$props = array_merge($props, [
'url' => null,
'num' => null,
'parent' => $this,
'site' => $this->site(),
]);
$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
{
$mode = $this->blueprint()->num();
switch ($mode) {
case 'zero':
return 0;
case 'date':
case 'datetime':
// the $format needs to produce only digits,
// so it can be converted to integer below
$format = $mode === 'date' ? 'Ymd' : 'YmdHi';
$lang = $this->kirby()->defaultLanguage() ?? null;
$field = $this->content($lang)->get('date');
$date = $field->isEmpty() ? 'now' : $field;
return (int)date($format, strtotime($date));
case 'default':
$max = $this
->parentModel()
->children()
->listed()
->merge($this)
->count();
// default positioning at the end
if ($num === null) {
$num = $max;
}
// avoid zeros or negative numbers
if ($num < 1) {
return 1;
}
// avoid higher numbers than possible
if ($num > $max) {
return $max;
}
return $num;
default:
// get instance with default language
$app = $this->kirby()->clone([], false);
$app->setCurrentLanguage();
$template = Str::template($mode, [
'kirby' => $app,
'page' => $app->page($this->id()),
'site' => $app->site(),
], ['fallback' => '']);
return (int)$template;
}
}
/**
* 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) {
// delete all files individually
foreach ($page->files() as $file) {
$file->delete();
}
// delete all children individually
foreach ($page->children() as $child) {
$child->delete(true);
}
// actually remove the page from disc
if ($page->exists() === true) {
// delete all public media files
Dir::remove($page->mediaRoot());
// delete the content folder for this page
Dir::remove($page->root());
// if the page is a draft and the _drafts folder
// is now empty. clean it up.
if ($page->isDraft() === true) {
$draftsDir = dirname($page->root());
if (Dir::isEmpty($draftsDir) === true) {
Dir::remove($draftsDir);
}
}
}
if ($page->isDraft() === true) {
$page->parentModel()->drafts()->remove($page);
} else {
$page->parentModel()->children()->remove($page);
$page->resortSiblingsAfterUnlisting();
}
return true;
});
}
/**
* 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 $slug = null, array $options = [])
{
// create the slug for the duplicate
$slug = Str::slug($slug ?? $this->slug() . '-' . Str::slug(t('page.duplicate.appendix')));
$arguments = [
'originalPage' => $this,
'input' => $slug,
'options' => $options
];
return $this->commit('duplicate', $arguments, function ($page, $slug, $options) {
$page = $this->copy([
'parent' => $this->parent(),
'slug' => $slug,
'isDraft' => true,
'files' => $options['files'] ?? false,
'children' => $options['children'] ?? false,
]);
if (isset($options['title']) === true) {
$page = $page->changeTitle($options['title']);
}
return $page;
});
}
/**
* @return $this|static
* @throws \Kirby\Exception\LogicException If the folder cannot be moved
*/
public function publish()
{
if ($this->isDraft() === false) {
return $this;
}
$page = $this->clone([
'isDraft' => false,
'root' => null
]);
// actually do it on disk
if ($this->exists() === true) {
if (Dir::move($this->root(), $page->root()) !== true) {
throw new LogicException('The draft folder cannot be moved');
}
// Get the draft folder and check if there are any other drafts
// left. Otherwise delete it.
$draftDir = dirname($this->root());
if (Dir::isEmpty($draftDir) === true) {
Dir::remove($draftDir);
}
}
// remove the page from the parent drafts and add it to children
$page->parentModel()->drafts()->remove($page);
$page->parentModel()->children()->append($page->id(), $page);
return $page;
}
/**
* Clean internal caches
* @return $this
*/
public function purge()
{
$this->blueprint = null;
$this->children = 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
{
// get all siblings including the current page
$siblings = $this
->parentModel()
->children()
->listed()
->append($this)
->filter(fn ($page) => $page->blueprint()->num() === 'default');
// get a non-associative array of ids
$keys = $siblings->keys();
$index = array_search($this->id(), $keys);
// if the page is not included in the siblings something went wrong
if ($index === false) {
throw new LogicException('The page is not included in the sorting index');
}
if ($position > count($keys)) {
$position = count($keys);
}
// move the current page number in the array of keys
// subtract 1 from the num and the position, because of the
// zero-based array keys
$sorted = A::move($keys, $index, $position - 1);
foreach ($sorted as $key => $id) {
if ($id === $this->id()) {
continue;
} elseif ($sibling = $siblings->get($id)) {
$sibling->changeNum($key + 1);
}
}
$parent = $this->parentModel();
$parent->children = $parent->children()->sort('num', 'asc');
return true;
}
/**
* @return bool
*/
public function resortSiblingsAfterUnlisting(): bool
{
$index = 0;
$parent = $this->parentModel();
$siblings = $parent
->children()
->listed()
->not($this)
->filter(fn ($page) => $page->blueprint()->num() === 'default');
if ($siblings->count() > 0) {
foreach ($siblings as $sibling) {
$index++;
$sibling->changeNum($index);
}
$parent->children = $siblings->sort('num', 'asc');
}
return true;
}
/**
* Convert a page from listed or
* unlisted to draft.
*
* @return $this|static
* @throws \Kirby\Exception\LogicException If the folder cannot be moved
*/
public function unpublish()
{
if ($this->isDraft() === true) {
return $this;
}
$page = $this->clone([
'isDraft' => true,
'num' => null,
'dirname' => null,
'root' => null
]);
// actually do it on disk
if ($this->exists() === true) {
if (Dir::move($this->root(), $page->root()) !== true) {
throw new LogicException('The page folder cannot be moved to drafts');
}
}
// remove the page from the parent children and add it to drafts
$page->parentModel()->children()->remove($page);
$page->parentModel()->drafts()->append($page->id(), $page);
$page->resortSiblingsAfterUnlisting();
return $page;
}
/**
* Updates the page data
*
* @param array|null $input
* @param string|null $languageCode
* @param bool $validate
* @return static
*/
public function update(array $input = null, string $languageCode = null, bool $validate = false)
{
if ($this->isDraft() === true) {
$validate = false;
}
$page = parent::update($input, $languageCode, $validate);
// if num is created from page content, update num on content update
if ($page->isListed() === true && in_array($page->blueprint()->num(), ['zero', 'default']) === false) {
$page = $page->changeNum($page->createNum());
}
return $page;
}
}

View file

@ -0,0 +1,209 @@
<?php
namespace Kirby\Cms;
/**
* PageBlueprint
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class PageBlueprint extends Blueprint
{
/**
* Creates a new page blueprint object
* with the given props
*
* @param array $props
*/
public function __construct(array $props)
{
parent::__construct($props);
// normalize all available page options
$this->props['options'] = $this->normalizeOptions(
$props['options'] ?? true,
// defaults
[
'changeSlug' => null,
'changeStatus' => null,
'changeTemplate' => null,
'changeTitle' => null,
'create' => null,
'delete' => null,
'duplicate' => null,
'read' => null,
'preview' => null,
'sort' => null,
'update' => null,
],
// aliases (from v2)
[
'status' => 'changeStatus',
'template' => 'changeTemplate',
'title' => 'changeTitle',
'url' => 'changeSlug',
]
);
// normalize the ordering number
$this->props['num'] = $this->normalizeNum($props['num'] ?? 'default');
// normalize the available status array
$this->props['status'] = $this->normalizeStatus($props['status'] ?? null);
}
/**
* Returns the page numbering mode
*
* @return string
*/
public function num(): string
{
return $this->props['num'];
}
/**
* Normalizes the ordering number
*
* @param mixed $num
* @return string
*/
protected function normalizeNum($num): string
{
$aliases = [
'0' => 'zero',
'sort' => 'default',
];
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
{
$defaults = [
'draft' => [
'label' => $this->i18n('page.status.draft'),
'text' => $this->i18n('page.status.draft.description'),
],
'unlisted' => [
'label' => $this->i18n('page.status.unlisted'),
'text' => $this->i18n('page.status.unlisted.description'),
],
'listed' => [
'label' => $this->i18n('page.status.listed'),
'text' => $this->i18n('page.status.listed.description'),
]
];
// use the defaults, when the status is not defined
if (empty($status) === true) {
$status = $defaults;
}
// extend the status definition
$status = $this->extend($status);
// clean up and translate each status
foreach ($status as $key => $options) {
// skip invalid status definitions
if (in_array($key, ['draft', 'listed', 'unlisted']) === false || $options === false) {
unset($status[$key]);
continue;
}
if ($options === true) {
$status[$key] = $defaults[$key];
continue;
}
// convert everything to a simple array
if (is_array($options) === false) {
$status[$key] = [
'label' => $options,
'text' => null
];
}
// always make sure to have a proper label
if (empty($status[$key]['label']) === true) {
$status[$key]['label'] = $defaults[$key]['label'];
}
// also make sure to have the text field set
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']);
$status[$key]['text'] = $this->i18n($status[$key]['text'], $status[$key]['text']);
}
// the draft status is required
if (isset($status['draft']) === false) {
$status = ['draft' => $defaults['draft']] + $status;
}
// remove the draft status for the home and error pages
if ($this->model->isHomeOrErrorPage() === true) {
unset($status['draft']);
}
return $status;
}
/**
* Returns the options object
* that handles page options and permissions
*
* @return array
*/
public function options(): array
{
return $this->props['options'];
}
/**
* Returns the preview settings
* The preview setting controls the "Open"
* button in the panel and redirects it to a
* different URL if necessary.
*
* @return string|bool
*/
public function preview()
{
$preview = $this->props['options']['preview'] ?? true;
if (is_string($preview) === true) {
return $this->model->toString($preview);
}
return $preview;
}
/**
* Returns the status array
*
* @return array
*/
public function status(): array
{
return $this->props['status'];
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace Kirby\Cms;
/**
* PagePermissions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class PagePermissions extends ModelPermissions
{
/**
* @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->isHomeOrErrorPage() === true) {
return false;
}
if (count($this->model->blueprints()) <= 1) {
return false;
}
return true;
}
/**
* @return bool
*/
protected function canDelete(): bool
{
return $this->model->isHomeOrErrorPage() !== true;
}
/**
* @return bool
*/
protected function canSort(): bool
{
if ($this->model->isErrorPage() === true) {
return false;
}
if ($this->model->isListed() !== true) {
return false;
}
if ($this->model->blueprint()->num() !== 'default') {
return false;
}
return true;
}
}

View file

@ -0,0 +1,265 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
/**
* The PagePicker class helps to
* fetch the right pages and the parent
* model for the API calls for the
* page picker component in the panel.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class PagePicker extends Picker
{
/**
* @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
{
return array_merge(parent::defaults(), [
// Page ID of the selected parent. Used to navigate
'parent' => null,
// enable/disable subpage navigation
'subpages' => true,
]);
}
/**
* Returns the parent model object that
* is currently selected in the page picker.
* It normally starts at the site, but can
* also be any subpage. When a query is given
* and subpage navigation is deactivated,
* there will be no model available at all.
*
* @return \Kirby\Cms\Page|\Kirby\Cms\Site|null
*/
public function model()
{
// no subpages navigation = no model
if ($this->options['subpages'] === false) {
return null;
}
// the model for queries is a bit more tricky to find
if (empty($this->options['query']) === false) {
return $this->modelForQuery();
}
return $this->parent();
}
/**
* Returns a model object for the given
* query, depending on the parent and subpages
* options.
*
* @return \Kirby\Cms\Page|\Kirby\Cms\Site|null
*/
public function modelForQuery()
{
if ($this->options['subpages'] === true && empty($this->options['parent']) === false) {
return $this->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($model = null): ?array
{
if ($model === null) {
return null;
}
// the selected model is the site. there's nothing above
if (is_a($model, 'Kirby\Cms\Site') === true) {
return [
'id' => null,
'parent' => null,
'title' => $model->title()->value()
];
}
// the top-most page has been reached
// the missing id indicates that there's nothing above
if ($model->id() === $this->start()->id()) {
return [
'id' => null,
'parent' => null,
'title' => $model->title()->value()
];
}
// the model is a regular page
return [
'id' => $model->id(),
'parent' => $model->parentModel()->id(),
'title' => $model->title()->value()
];
}
/**
* Search all pages for the picker
*
* @return \Kirby\Cms\Pages|null
*/
public function items()
{
// cache
if ($this->items !== null) {
return $this->items;
}
// no query? simple parent-based search for pages
if (empty($this->options['query']) === true) {
$items = $this->itemsForParent();
// when subpage navigation is enabled, a parent
// might be passed in addition to the query.
// The parent then takes priority.
} elseif ($this->options['subpages'] === true && empty($this->options['parent']) === false) {
$items = $this->itemsForParent();
// search by query
} else {
$items = $this->itemsForQuery();
}
// filter protected pages
$items = $items->filter('isReadable', true);
// search
$items = $this->search($items);
// paginate the result
return $this->items = $this->paginate($items);
}
/**
* Search for pages by parent
*
* @return \Kirby\Cms\Pages
*/
public function itemsForParent()
{
return $this->parent()->children();
}
/**
* Search for pages by query string
*
* @return \Kirby\Cms\Pages
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function itemsForQuery()
{
// cache
if ($this->itemsForQuery !== null) {
return $this->itemsForQuery;
}
$model = $this->options['model'];
$items = $model->query($this->options['query']);
// help mitigate some typical query usage issues
// by converting site and page objects to proper
// pages by returning their children
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;
}
/**
* Returns the parent model.
* 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()
{
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()
{
if (empty($this->options['query']) === false) {
if ($items = $this->itemsForQuery()) {
return $items->parent();
}
return $this->site;
}
return $this->site;
}
/**
* Returns an associative array
* with all information for the picker.
* This will be passed directly to the API.
*
* @return array
*/
public function toArray(): array
{
$array = parent::toArray();
$array['model'] = $this->modelToArray($this->model());
return $array;
}
}

439
kirby/src/Cms/PageRules.php Normal file
View file

@ -0,0 +1,439 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\Str;
/**
* Validators for all page actions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class PageRules
{
/**
* Validates if the sorting number of the page can be changed
*
* @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
{
if ($num !== null && $num < 0) {
throw new InvalidArgumentException(['key' => 'page.num.invalid']);
}
return true;
}
/**
* 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
*/
public static function changeSlug(Page $page, string $slug): bool
{
if ($page->permissions()->changeSlug() !== true) {
throw new PermissionException([
'key' => 'page.changeSlug.permission',
'data' => [
'slug' => $page->slug()
]
]);
}
self::validateSlugLength($slug);
$siblings = $page->parentModel()->children();
$drafts = $page->parentModel()->drafts();
if ($duplicate = $siblings->find($slug)) {
if ($duplicate->is($page) === false) {
throw new DuplicateException([
'key' => 'page.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;
}
/**
* 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
{
if (isset($page->blueprint()->status()[$status]) === false) {
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)
{
if ($page->permissions()->changeStatus() !== true) {
throw new PermissionException([
'key' => 'page.changeStatus.permission',
'data' => [
'slug' => $page->slug()
]
]);
}
if ($page->isHomeOrErrorPage() === true) {
throw new PermissionException([
'key' => 'page.changeStatus.toDraft.invalid',
'data' => [
'slug' => $page->slug()
]
]);
}
return true;
}
/**
* 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)
{
// no need to check for status changing permissions,
// instead we need to check for sorting permissions
if ($page->isListed() === true) {
if ($page->isSortable() !== true) {
throw new PermissionException([
'key' => 'page.sort.permission',
'data' => [
'slug' => $page->slug()
]
]);
}
return true;
}
static::publish($page);
if ($position !== null && $position < 0) {
throw new InvalidArgumentException(['key' => 'page.num.invalid']);
}
return true;
}
/**
* 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)
{
static::publish($page);
return true;
}
/**
* 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
*/
public static function changeTemplate(Page $page, string $template): bool
{
if ($page->permissions()->changeTemplate() !== true) {
throw new PermissionException([
'key' => 'page.changeTemplate.permission',
'data' => [
'slug' => $page->slug()
]
]);
}
if (count($page->blueprints()) <= 1) {
throw new LogicException([
'key' => 'page.changeTemplate.invalid',
'data' => ['slug' => $page->slug()]
]);
}
return true;
}
/**
* 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
*/
public static function changeTitle(Page $page, string $title): bool
{
if ($page->permissions()->changeTitle() !== true) {
throw new PermissionException([
'key' => 'page.changeTitle.permission',
'data' => [
'slug' => $page->slug()
]
]);
}
if (Str::length($title) === 0) {
throw new InvalidArgumentException([
'key' => 'page.changeTitle.empty',
]);
}
return true;
}
/**
* 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
*/
public static function create(Page $page): bool
{
if ($page->permissions()->create() !== true) {
throw new PermissionException([
'key' => 'page.create.permission',
'data' => [
'slug' => $page->slug()
]
]);
}
self::validateSlugLength($page->slug());
if ($page->exists() === true) {
throw new DuplicateException([
'key' => 'page.draft.duplicate',
'data' => [
'slug' => $page->slug()
]
]);
}
$siblings = $page->parentModel()->children();
$drafts = $page->parentModel()->drafts();
$slug = $page->slug();
if ($siblings->find($slug)) {
throw new DuplicateException([
'key' => 'page.duplicate',
'data' => ['slug' => $slug]
]);
}
if ($drafts->find($slug)) {
throw new DuplicateException([
'key' => 'page.draft.duplicate',
'data' => ['slug' => $slug]
]);
}
return true;
}
/**
* 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
*/
public static function delete(Page $page, bool $force = false): bool
{
if ($page->permissions()->delete() !== true) {
throw new PermissionException([
'key' => 'page.delete.permission',
'data' => [
'slug' => $page->slug()
]
]);
}
if (($page->hasChildren() === true || $page->hasDrafts() === true) && $force === false) {
throw new LogicException(['key' => 'page.delete.hasChildren']);
}
return true;
}
/**
* 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
{
if ($page->permissions()->duplicate() !== true) {
throw new PermissionException([
'key' => 'page.duplicate.permission',
'data' => [
'slug' => $page->slug()
]
]);
}
self::validateSlugLength($slug);
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
{
if ($page->permissions()->changeStatus() !== true) {
throw new PermissionException([
'key' => 'page.changeStatus.permission',
'data' => [
'slug' => $page->slug()
]
]);
}
if ($page->isDraft() === true && empty($page->errors()) === false) {
throw new PermissionException([
'key' => 'page.changeStatus.incomplete',
'details' => $page->errors()
]);
}
return true;
}
/**
* 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
{
if ($page->permissions()->update() !== true) {
throw new PermissionException([
'key' => 'page.update.permission',
'data' => [
'slug' => $page->slug()
]
]);
}
return true;
}
/**
* 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
*/
protected static function validateSlugLength(string $slug): void
{
$slugLength = Str::length($slug);
if ($slugLength === 0) {
throw new InvalidArgumentException([
'key' => 'page.slug.invalid',
]);
}
if ($slugsMaxlength = App::instance()->option('slugs.maxlength', 255)) {
$maxlength = (int)$slugsMaxlength;
if ($slugLength > $maxlength) {
throw new InvalidArgumentException([
'key' => 'page.slug.maxlength',
'data' => [
'length' => $maxlength
]
]);
}
}
}
}

View file

@ -0,0 +1,140 @@
<?php
namespace Kirby\Cms;
/**
* PageSiblings
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait PageSiblings
{
/**
* Checks if there's a next listed
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasNextListed($collection = null): bool
{
return $this->nextListed($collection) !== null;
}
/**
* Checks if there's a next unlisted
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasNextUnlisted($collection = null): bool
{
return $this->nextUnlisted($collection) !== null;
}
/**
* Checks if there's a previous listed
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasPrevListed($collection = null): bool
{
return $this->prevListed($collection) !== null;
}
/**
* Checks if there's a previous unlisted
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return bool
*/
public function hasPrevUnlisted($collection = null): bool
{
return $this->prevUnlisted($collection) !== null;
}
/**
* Returns the next listed page if it exists
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Page|null
*/
public function nextListed($collection = null)
{
return $this->nextAll($collection)->listed()->first();
}
/**
* Returns the next unlisted page if it exists
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Page|null
*/
public function nextUnlisted($collection = null)
{
return $this->nextAll($collection)->unlisted()->first();
}
/**
* Returns the previous listed page
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Page|null
*/
public function prevListed($collection = null)
{
return $this->prevAll($collection)->listed()->last();
}
/**
* Returns the previous unlisted page
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Page|null
*/
public function prevUnlisted($collection = null)
{
return $this->prevAll($collection)->unlisted()->last();
}
/**
* Private siblings collector
*
* @return \Kirby\Cms\Collection
*/
protected function siblingsCollection()
{
if ($this->isDraft() === true) {
return $this->parentModel()->drafts();
} else {
return $this->parentModel()->children();
}
}
/**
* Returns siblings with the same template
*
* @param bool $self
* @return \Kirby\Cms\Pages
*/
public function templateSiblings(bool $self = true)
{
return $this->siblings($self)->filter('intendedTemplate', $this->intendedTemplate()->name());
}
}

520
kirby/src/Cms/Pages.php Normal file
View file

@ -0,0 +1,520 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
/**
* The `$pages` object refers to a
* collection of pages. The pages in this
* collection can have the same or different
* parents, they can actually exist as
* subfolders in the content folder or be
* virtual pages created from a database,
* an Excel sheet, any API or any other
* source.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Pages extends Collection
{
/**
* Cache for the index only listed and unlisted pages
*
* @var \Kirby\Cms\Pages|null
*/
protected $index = null;
/**
* Cache for the index all statuses also including drafts
*
* @var \Kirby\Cms\Pages|null
*/
protected $indexWithDrafts = null;
/**
* All registered pages methods
*
* @var array
*/
public static $methods = [];
/**
* Adds a single page or
* an entire second collection to the
* current collection
*
* @param \Kirby\Cms\Pages|\Kirby\Cms\Page|string $object
* @return $this
* @throws \Kirby\Exception\InvalidArgumentException When no `Page` or `Pages` object or an ID of an existing page is passed
*/
public function add($object)
{
// add a pages collection
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 = page($object)) {
$this->__set($page->id(), $page);
// add a page object
} elseif (is_a($object, 'Kirby\Cms\Page') === true) {
$this->__set($object->id(), $object);
// give a useful error message on invalid input;
// silently ignore "empty" values for compatibility with existing setups
} elseif (in_array($object, [null, false, true], true) !== true) {
throw new InvalidArgumentException('You must pass a Pages or Page object or an ID of an existing page to the Pages collection');
}
return $this;
}
/**
* Returns all audio files of all children
*
* @return \Kirby\Cms\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()
{
$children = new Pages([], $this->parent);
foreach ($this->data as $page) {
foreach ($page->children() as $childKey => $child) {
$children->data[$childKey] = $child;
}
}
return $children;
}
/**
* Returns all code files of all children
*
* @return \Kirby\Cms\Files
*/
public function code()
{
return $this->files()->filter('type', 'code');
}
/**
* Returns all documents of all children
*
* @return \Kirby\Cms\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()
{
$drafts = new Pages([], $this->parent);
foreach ($this->data as $page) {
foreach ($page->drafts() as $draftKey => $draft) {
$drafts->data[$draftKey] = $draft;
}
}
return $drafts;
}
/**
* Creates a pages collection from an array of props
*
* @param array $pages
* @param \Kirby\Cms\Model|null $model
* @param bool $draft
* @return 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 (is_a($model, 'Kirby\Cms\Page') === true) {
$parent = $model;
$site = $model->site();
} else {
$parent = null;
$site = $model;
}
foreach ($pages as $props) {
$props['kirby'] = $kirby;
$props['parent'] = $parent;
$props['site'] = $site;
$props['isDraft'] = $draft;
$page = Page::factory($props);
$children->data[$page->id()] = $page;
}
return $children;
}
/**
* Returns all files of all children
*
* @return \Kirby\Cms\Files
*/
public function files()
{
$files = new Files([], $this->parent);
foreach ($this->data as $page) {
foreach ($page->files() as $fileKey => $file) {
$files->data[$fileKey] = $file;
}
}
return $files;
}
/**
* Finds a page in the collection by id.
* This works recursively for children and
* children of children, etc.
*
* @param string|null $id
* @return mixed
*/
public function findById(string $id = null)
{
if ($id === null) {
return null;
}
// remove trailing or leading slashes
$id = trim($id, '/');
// strip extensions from the id
if (strpos($id, '.') !== false) {
$info = pathinfo($id);
if ($info['dirname'] !== '.') {
$id = $info['dirname'] . '/' . $info['filename'];
} else {
$id = $info['filename'];
}
}
// try the obvious way
if ($page = $this->get($id)) {
return $page;
}
$start = is_a($this->parent, 'Kirby\Cms\Page') === true ? $this->parent->id() : '';
$page = $this->findByIdRecursive($id, $start, App::instance()->multilang());
return $page;
}
/**
* Finds a child or child of a child recursively.
*
* @param string $id
* @param string|null $startAt
* @param bool $multiLang
* @return mixed
*/
public function findByIdRecursive(string $id, string $startAt = null, bool $multiLang = false)
{
$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;
}
/**
* Uses the specialized find by id method
*
* @param string|null $key
* @return mixed
*/
public function findByKey(string $key = null)
{
return $this->findById($key);
}
/**
* Alias for Pages::findById
*
* @param string $id
* @return \Kirby\Cms\Page|null
*/
public function findByUri(string $id)
{
return $this->findById($id);
}
/**
* Finds the currently open page
*
* @return \Kirby\Cms\Page|null
*/
public function findOpen()
{
return $this->findBy('isOpen', true);
}
/**
* Custom getter that is able to find
* extension pages
*
* @param string $key
* @param mixed $default
* @return \Kirby\Cms\Page|null
*/
public function get($key, $default = null)
{
if ($key === null) {
return null;
}
if ($item = parent::get($key)) {
return $item;
}
return App::instance()->extension('pages', $key);
}
/**
* Returns all images of all children
*
* @return \Kirby\Cms\Files
*/
public function images()
{
return $this->files()->filter('type', 'image');
}
/**
* Create a recursive flat index of all
* pages and subpages, etc.
*
* @param bool $drafts
* @return \Kirby\Cms\Pages
*/
public function index(bool $drafts = false)
{
// get object property by cache mode
$index = $drafts === true ? $this->indexWithDrafts : $this->index;
if (is_a($index, 'Kirby\Cms\Pages') === true) {
return $index;
}
$index = new Pages([], $this->parent);
foreach ($this->data as $pageKey => $page) {
$index->data[$pageKey] = $page;
$pageIndex = $page->index($drafts);
if ($pageIndex) {
foreach ($pageIndex as $childKey => $child) {
$index->data[$childKey] = $child;
}
}
}
if ($drafts === true) {
return $this->indexWithDrafts = $index;
}
return $this->index = $index;
}
/**
* Returns all listed pages in the collection
*
* @return \Kirby\Cms\Pages
*/
public function listed()
{
return $this->filter('isListed', '==', true);
}
/**
* Returns all unlisted pages in the collection
*
* @return \Kirby\Cms\Pages
*/
public function unlisted()
{
return $this->filter('isUnlisted', '==', true);
}
/**
* Include all given items in the collection
*
* @param mixed ...$args
* @return $this|static
*/
public function merge(...$args)
{
// merge multiple arguments at once
if (count($args) > 1) {
$collection = clone $this;
foreach ($args as $arg) {
$collection = $collection->merge($arg);
}
return $collection;
}
// merge all parent drafts
if ($args[0] === 'drafts') {
if ($parent = $this->parent()) {
return $this->merge($parent->drafts());
}
return $this;
}
// merge an entire collection
if (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 (is_a($args[0], 'Kirby\Cms\Page') === true) {
$collection = clone $this;
return $collection->set($args[0]->id(), $args[0]);
}
// merge an array
if (is_array($args[0]) === true) {
$collection = clone $this;
foreach ($args[0] as $arg) {
$collection = $collection->merge($arg);
}
return $collection;
}
if (is_string($args[0]) === true) {
return $this->merge(App::instance()->site()->find($args[0]));
}
return $this;
}
/**
* Filter all pages by excluding the given template
* @since 3.3.0
*
* @param string|array $templates
* @return \Kirby\Cms\Pages
*/
public function notTemplate($templates)
{
if (empty($templates) === true) {
return $this;
}
if (is_array($templates) === false) {
$templates = [$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
*
* @return \Kirby\Cms\Pages
*/
public function published()
{
return $this->filter('isDraft', '==', false);
}
/**
* Filter all pages by the given template
*
* @param string|array $templates
* @return \Kirby\Cms\Pages
*/
public function template($templates)
{
if (empty($templates) === true) {
return $this;
}
if (is_array($templates) === false) {
$templates = [$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()
{
return $this->files()->filter('type', 'video');
}
}

View file

@ -0,0 +1,179 @@
<?php
namespace Kirby\Cms;
use Kirby\Http\Uri;
use Kirby\Toolkit\Pagination as BasePagination;
/**
* The `$pagination` object divides
* a collection of pages, files etc.
* into discrete pages consisting of
* the number of defined items. The
* pagination object can then be used
* to navigate between these pages,
* create a navigation etc.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Pagination extends BasePagination
{
/**
* Pagination method (param, query, none)
*
* @var string
*/
protected $method;
/**
* The base URL
*
* @var string
*/
protected $url;
/**
* Variable name for query strings
*
* @var string
*/
protected $variable;
/**
* Creates the pagination object. As a new
* property you can now pass the base Url.
* That Url must be the Url of the first
* page of the collection without additional
* pagination information/query parameters in it.
*
* ```php
* $pagination = new Pagination([
* 'page' => 1,
* 'limit' => 10,
* 'total' => 120,
* 'method' => 'query',
* 'variable' => 'p',
* 'url' => new Uri('https://getkirby.com/blog')
* ]);
* ```
*
* @param array $params
*/
public function __construct(array $params = [])
{
$kirby = App::instance();
$config = $kirby->option('pagination', []);
$request = $kirby->request();
$params['limit'] ??= $config['limit'] ?? 20;
$params['method'] ??= $config['method'] ?? 'param';
$params['variable'] ??= $config['variable'] ?? 'page';
if (empty($params['url']) === true) {
$params['url'] = new Uri($kirby->url('current'), [
'params' => $request->params(),
'query' => $request->query()->toArray(),
]);
}
if ($params['method'] === 'query') {
$params['page'] ??= $params['url']->query()->get($params['variable']);
} elseif ($params['method'] === 'param') {
$params['page'] ??= $params['url']->params()->get($params['variable']);
}
parent::__construct($params);
$this->method = $params['method'];
$this->url = $params['url'];
$this->variable = $params['variable'];
}
/**
* Returns the Url for the first page
*
* @return string
*/
public function firstPageUrl(): string
{
return $this->pageUrl(1);
}
/**
* Returns the Url for the last page
*
* @return string
*/
public function lastPageUrl(): string
{
return $this->pageUrl($this->lastPage());
}
/**
* Returns the Url for the next page.
* Returns null if there's no next page.
*
* @return string|null
*/
public function nextPageUrl(): ?string
{
if ($page = $this->nextPage()) {
return $this->pageUrl($page);
}
return null;
}
/**
* Returns the URL of the current page.
* If the `$page` variable is set, the URL
* for that page will be returned.
*
* @param int|null $page
* @return string|null
*/
public function pageUrl(int $page = null): ?string
{
if ($page === null) {
return $this->pageUrl($this->page());
}
$url = clone $this->url;
$variable = $this->variable;
if ($this->hasPage($page) === false) {
return null;
}
$pageValue = $page === 1 ? null : $page;
if ($this->method === 'query') {
$url->query->$variable = $pageValue;
} elseif ($this->method === 'param') {
$url->params->$variable = $pageValue;
} else {
return null;
}
return $url->toString();
}
/**
* Returns the Url for the previous page.
* Returns null if there's no previous page.
*
* @return string|null
*/
public function prevPageUrl(): ?string
{
if ($page = $this->prevPage()) {
return $this->pageUrl($page);
}
return null;
}
}

View file

@ -0,0 +1,238 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
/**
* Handles permission definition in each user
* blueprint and wraps a couple useful methods
* around it to check for available permissions.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Permissions
{
/**
* @var array
*/
public static $extendedActions = [];
/**
* @var array
*/
protected $actions = [
'access' => [
'account' => true,
'languages' => true,
'panel' => true,
'site' => true,
'system' => true,
'users' => true,
],
'files' => [
'changeName' => true,
'create' => true,
'delete' => true,
'read' => true,
'replace' => true,
'update' => true
],
'languages' => [
'create' => true,
'delete' => true
],
'pages' => [
'changeSlug' => true,
'changeStatus' => true,
'changeTemplate' => true,
'changeTitle' => true,
'create' => true,
'delete' => true,
'duplicate' => true,
'preview' => true,
'read' => true,
'sort' => true,
'update' => true
],
'site' => [
'changeTitle' => true,
'update' => true
],
'users' => [
'changeEmail' => true,
'changeLanguage' => true,
'changeName' => true,
'changePassword' => true,
'changeRole' => true,
'create' => true,
'delete' => true,
'update' => true
],
'user' => [
'changeEmail' => true,
'changeLanguage' => true,
'changeName' => true,
'changePassword' => true,
'changeRole' => true,
'delete' => true,
'update' => true
]
];
/**
* Permissions constructor
*
* @param array $settings
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function __construct($settings = [])
{
// dynamically register the extended actions
foreach (static::$extendedActions as $key => $actions) {
if (isset($this->actions[$key]) === true) {
throw new InvalidArgumentException('The action ' . $key . ' is already a core action');
}
$this->actions[$key] = $actions;
}
if (is_array($settings) === true) {
return $this->setCategories($settings);
}
if (is_bool($settings) === true) {
return $this->setAll($settings);
}
}
/**
* @param string|null $category
* @param string|null $action
* @return bool
*/
public function for(string $category = null, string $action = null): bool
{
if ($action === null) {
if ($this->hasCategory($category) === false) {
return false;
}
return $this->actions[$category];
}
if ($this->hasAction($category, $action) === false) {
return false;
}
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;
}
/**
* @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)
{
// deprecated fallback for the settings/system view
// TODO: remove in 3.7
if ($category === 'access' && $action === 'settings') {
$action = 'system';
}
// wildcard to overwrite the entire category
if ($action === '*') {
return $this->setCategory($category, $setting);
}
$this->actions[$category][$action] = $setting;
return $this;
}
/**
* @param bool $setting
* @return $this
*/
protected function setAll(bool $setting)
{
foreach ($this->actions as $categoryName => $actions) {
$this->setCategory($categoryName, $setting);
}
return $this;
}
/**
* @param array $settings
* @return $this
*/
protected function setCategories(array $settings)
{
foreach ($settings as $categoryName => $categoryActions) {
if (is_bool($categoryActions) === true) {
$this->setCategory($categoryName, $categoryActions);
}
if (is_array($categoryActions) === true) {
foreach ($categoryActions as $actionName => $actionSetting) {
$this->setAction($categoryName, $actionName, $actionSetting);
}
}
}
return $this;
}
/**
* @param string $category
* @param bool $setting
* @return $this
* @throws \Kirby\Exception\InvalidArgumentException
*/
protected function setCategory(string $category, bool $setting)
{
if ($this->hasCategory($category) === false) {
throw new InvalidArgumentException('Invalid permissions category');
}
foreach ($this->actions[$category] as $actionName => $actionSetting) {
$this->actions[$category][$actionName] = $setting;
}
return $this;
}
/**
* @return array
*/
public function toArray(): array
{
return $this->actions;
}
}

179
kirby/src/Cms/Picker.php Normal file
View file

@ -0,0 +1,179 @@
<?php
namespace Kirby\Cms;
/**
* The Picker abstract is the foundation
* for the UserPicker, PagePicker and FilePicker
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
abstract class Picker
{
/**
* @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 = [])
{
$this->options = array_merge($this->defaults(), $params);
$this->kirby = $this->options['model']->kirby();
$this->site = $this->kirby->site();
}
/**
* Return the array of default values
*
* @return array
*/
protected function defaults(): array
{
// default params
return [
// image settings (ratio, cover, etc.)
'image' => [],
// query template for the info field
'info' => false,
// listing style: list, cards, cardlets
'layout' =>'list',
// number of users displayed per pagination page
'limit' => 20,
// optional mapping function for the result array
'map' => null,
// the reference model
'model' => site(),
// current page when paginating
'page' => 1,
// a query string to fetch specific items
'query' => null,
// search query
'search' => null,
// query template for the text field
'text' => null
];
}
/**
* Fetches all items for the picker
*
* @return \Kirby\Cms\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($items = null): array
{
if ($items === null) {
return [];
}
$result = [];
foreach ($items as $index => $item) {
if (empty($this->options['map']) === false) {
$result[] = $this->options['map']($item);
} else {
$result[] = $item->panel()->pickerData([
'image' => $this->options['image'],
'info' => $this->options['info'],
'layout' => $this->options['layout'],
'model' => $this->options['model'],
'text' => $this->options['text'],
]);
}
}
return $result;
}
/**
* Apply pagination to the collection
* of items according to the options.
*
* @param \Kirby\Cms\Collection $items
* @return \Kirby\Cms\Collection
*/
public function paginate(Collection $items)
{
return $items->paginate([
'limit' => $this->options['limit'],
'page' => $this->options['page']
]);
}
/**
* Return the most relevant pagination
* info as array
*
* @param \Kirby\Cms\Pagination $pagination
* @return array
*/
public function paginationToArray(Pagination $pagination): array
{
return [
'limit' => $pagination->limit(),
'page' => $pagination->page(),
'total' => $pagination->total()
];
}
/**
* Search through the collection of items
* if not deactivate in the options
*
* @param \Kirby\Cms\Collection $items
* @return \Kirby\Cms\Collection
*/
public function search(Collection $items)
{
if (empty($this->options['search']) === false) {
return $items->search($this->options['search']);
}
return $items;
}
/**
* Returns an associative array
* with all information for the picker.
* This will be passed directly to the API.
*
* @return array
*/
public function toArray(): array
{
$items = $this->items();
return [
'data' => $this->itemsToArray($items),
'pagination' => $this->paginationToArray($items->pagination()),
];
}
}

221
kirby/src/Cms/Plugin.php Normal file
View file

@ -0,0 +1,221 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\V;
/**
* Represents a Plugin and handles parsing of
* the composer.json. It also creates the prefix
* and media url for the plugin.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Plugin extends Model
{
protected $extends;
protected $info;
protected $name;
protected $root;
/**
* @param string $key
* @param array|null $arguments
* @return mixed|null
*/
public function __call(string $key, array $arguments = null)
{
return $this->info()[$key] ?? null;
}
/**
* Plugin constructor
*
* @param string $name
* @param array $extends
*/
public function __construct(string $name, array $extends = [])
{
$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;
unset($this->extends['root'], $this->extends['info']);
}
/**
* Returns the array with author information
* from the composer file
*
* @return array
*/
public function authors(): array
{
return $this->info()['authors'] ?? [];
}
/**
* Returns a comma-separated list with all author names
*
* @return string
*/
public function authorsNames(): string
{
$names = [];
foreach ($this->authors() as $author) {
$names[] = $author['name'] ?? null;
}
return implode(', ', array_filter($names));
}
/**
* @return array
*/
public function extends(): array
{
return $this->extends;
}
/**
* Returns the unique id for the plugin
*
* @return string
*/
public function id(): string
{
return $this->name();
}
/**
* @return array
*/
public function info(): array
{
if (is_array($this->info) === true) {
return $this->info;
}
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
{
$homepage = $this->info['homepage'] ?? null;
$docs = $this->info['support']['docs'] ?? null;
$source = $this->info['support']['source'] ?? null;
$link = $homepage ?? $docs ?? $source;
return V::url($link) ? $link : null;
}
/**
* @return string
*/
public function manifest(): string
{
return $this->root() . '/composer.json';
}
/**
* @return string
*/
public function mediaRoot(): string
{
return App::instance()->root('media') . '/plugins/' . $this->name();
}
/**
* @return string
*/
public function mediaUrl(): string
{
return App::instance()->url('media') . '/plugins/' . $this->name();
}
/**
* @return string
*/
public function name(): string
{
return $this->name;
}
/**
* @param string $key
* @return mixed
*/
public function option(string $key)
{
return $this->kirby()->option($this->prefix() . '.' . $key);
}
/**
* @return string
*/
public function prefix(): string
{
return str_replace('/', '.', $this->name());
}
/**
* @return string
*/
public function root(): string
{
return $this->root;
}
/**
* @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
{
return [
'authors' => $this->authors(),
'description' => $this->description(),
'name' => $this->name(),
'license' => $this->license(),
'link' => $this->link(),
'root' => $this->root(),
'version' => $this->version()
];
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace Kirby\Cms;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Http\Response;
/**
* Plugin assets are automatically copied/linked
* to the media folder, to make them publicly
* available. This class handles the magic around that.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class PluginAssets
{
/**
* Clean old/deprecated assets on every resolve
*
* @param string $pluginName
* @return void
*/
public static function clean(string $pluginName): void
{
if ($plugin = App::instance()->plugin($pluginName)) {
$root = $plugin->root() . '/assets';
$media = $plugin->mediaRoot();
$assets = Dir::index($media, true);
foreach ($assets as $asset) {
$original = $root . '/' . $asset;
if (file_exists($original) === false) {
$assetRoot = $media . '/' . $asset;
if (is_file($assetRoot) === true) {
F::remove($assetRoot);
} else {
Dir::remove($assetRoot);
}
}
}
}
}
/**
* Create a symlink for a plugin asset and
* return the public URL
*
* @param string $pluginName
* @param string $filename
* @return \Kirby\Cms\Response|null
*/
public static function resolve(string $pluginName, string $filename)
{
if ($plugin = App::instance()->plugin($pluginName)) {
$source = $plugin->root() . '/assets/' . $filename;
if (F::exists($source, $plugin->root()) === true) {
// do some spring cleaning for older files
static::clean($pluginName);
$target = $plugin->mediaRoot() . '/' . $filename;
// create a symlink if possible
F::link($source, $target, 'symlink');
// return the file response
return Response::file($source);
}
}
return null;
}
}

25
kirby/src/Cms/R.php Normal file
View file

@ -0,0 +1,25 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Facade;
/**
* Shortcut to the request object
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class R extends Facade
{
/**
* @return \Kirby\Http\Request
*/
public static function instance()
{
return App::instance()->request();
}
}

313
kirby/src/Cms/Responder.php Normal file
View file

@ -0,0 +1,313 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Filesystem\Mime;
use Kirby\Toolkit\Str;
/**
* Global response configuration
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Responder
{
/**
* Timestamp when the response expires
* in Kirby's cache
*
* @var int|null
*/
protected $expires = null;
/**
* HTTP status code
*
* @var int
*/
protected $code = null;
/**
* Response body
*
* @var string
*/
protected $body = null;
/**
* Flag that defines whether the current
* response can be cached by Kirby's cache
*
* @var string
*/
protected $cache = true;
/**
* HTTP headers
*
* @var array
*/
protected $headers = [];
/**
* Content type
*
* @var string
*/
protected $type = null;
/**
* Creates and sends the response
*
* @return string
*/
public function __toString(): string
{
return (string)$this->send();
}
/**
* Setter and getter for the response body
*
* @param string|null $body
* @return string|$this
*/
public function body(string $body = null)
{
if ($body === null) {
return $this->body;
}
$this->body = $body;
return $this;
}
/**
* Setter and getter for the flag that defines
* whether the current response can be cached
* by Kirby's cache
* @since 3.5.5
*
* @param bool|null $cache
* @return bool|$this
*/
public function cache(?bool $cache = null)
{
if ($cache === null) {
return $this->cache;
}
$this->cache = $cache;
return $this;
}
/**
* Setter and getter for the cache expiry
* timestamp for Kirby's cache
* @since 3.5.5
*
* @param int|string|null $expires Timestamp, number of minutes or time string to parse
* @param bool $override If `true`, the already defined timestamp will be overridden
* @return int|null|$this
*/
public function expires($expires = null, bool $override = false)
{
// getter
if ($expires === null && $override === false) {
return $this->expires;
}
// explicit un-setter
if ($expires === null) {
$this->expires = null;
return $this;
}
// normalize the value to an integer timestamp
if (is_int($expires) === true && $expires < 1000000000) {
// number of minutes
$expires = time() + ($expires * 60);
} elseif (is_int($expires) !== true) {
// time string
$parsedExpires = strtotime($expires);
if (is_int($parsedExpires) !== true) {
throw new InvalidArgumentException('Invalid time string "' . $expires . '"');
}
$expires = $parsedExpires;
}
// by default only ever *reduce* the cache expiry time
if (
$override === true ||
$this->expires === null ||
$expires < $this->expires
) {
$this->expires = $expires;
}
return $this;
}
/**
* Setter and getter for the status code
*
* @param int|null $code
* @return int|$this
*/
public function code(int $code = null)
{
if ($code === null) {
return $this->code;
}
$this->code = $code;
return $this;
}
/**
* Construct response from an array
*
* @param array $response
*/
public function fromArray(array $response): void
{
$this->body($response['body'] ?? null);
$this->expires($response['expires'] ?? null);
$this->code($response['code'] ?? null);
$this->headers($response['headers'] ?? null);
$this->type($response['type'] ?? null);
}
/**
* Setter and getter for a single header
*
* @param string $key
* @param string|false|null $value
* @param bool $lazy If `true`, an existing header value is not overridden
* @return string|$this
*/
public function header(string $key, $value = null, bool $lazy = false)
{
if ($value === null) {
return $this->headers[$key] ?? null;
}
if ($value === false) {
unset($this->headers[$key]);
return $this;
}
if ($lazy === true && isset($this->headers[$key]) === true) {
return $this;
}
$this->headers[$key] = $value;
return $this;
}
/**
* Setter and getter for all headers
*
* @param array|null $headers
* @return array|$this
*/
public function headers(array $headers = null)
{
if ($headers === null) {
return $this->headers;
}
$this->headers = $headers;
return $this;
}
/**
* Shortcut to configure a json response
*
* @param array|null $json
* @return string|$this
*/
public function json(array $json = null)
{
if ($json !== null) {
$this->body(json_encode($json));
}
return $this->type('application/json');
}
/**
* Shortcut to create a redirect response
*
* @param string|null $location
* @param int|null $code
* @return $this
*/
public function redirect(?string $location = null, ?int $code = null)
{
$location = Url::to($location ?? '/');
$location = Url::unIdn($location);
return $this
->header('Location', (string)$location)
->code($code ?? 302);
}
/**
* Creates and returns the response object from the config
*
* @param string|null $body
* @return \Kirby\Cms\Response
*/
public function send(string $body = null)
{
if ($body !== null) {
$this->body($body);
}
return new Response($this->toArray());
}
/**
* Converts the response configuration
* to an array
*
* @return array
*/
public function toArray(): array
{
return [
'body' => $this->body,
'code' => $this->code,
'headers' => $this->headers,
'type' => $this->type,
];
}
/**
* Setter and getter for the content type
*
* @param string|null $type
* @return string|$this
*/
public function type(string $type = null)
{
if ($type === null) {
return $this->type;
}
if (Str::contains($type, '/') === false) {
$type = Mime::fromExtension($type);
}
$this->type = $type;
return $this;
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Kirby\Cms;
/**
* Custom response object with an optimized
* redirect method to build correct Urls
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Response extends \Kirby\Http\Response
{
/**
* Adjusted redirect creation which
* parses locations with the Url::to method
* first.
*
* @param string $location
* @param int $code
* @return static
*/
public static function redirect(string $location = '/', int $code = 302)
{
return parent::redirect(Url::to($location), $code);
}
}

232
kirby/src/Cms/Role.php Normal file
View file

@ -0,0 +1,232 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Data;
use Kirby\Filesystem\F;
use Kirby\Toolkit\I18n;
/**
* Represents a User role with attached
* permissions. Roles are defined by user blueprints.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Role extends Model
{
protected $description;
protected $name;
protected $permissions;
protected $title;
public function __construct(array $props)
{
$this->setProperties($props);
}
/**
* Improved `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* @return string
*/
public function __toString(): string
{
return $this->name();
}
/**
* @param array $inject
* @return static
*/
public static function admin(array $inject = [])
{
try {
return static::load('admin');
} catch (Exception $e) {
return static::factory(static::defaults()['admin'], $inject);
}
}
/**
* @return array
*/
protected static function defaults(): array
{
return [
'admin' => [
'name' => 'admin',
'description' => I18n::translate('role.admin.description'),
'title' => I18n::translate('role.admin.title'),
'permissions' => true,
],
'nobody' => [
'name' => 'nobody',
'description' => I18n::translate('role.nobody.description'),
'title' => I18n::translate('role.nobody.title'),
'permissions' => false,
]
];
}
/**
* @return mixed
*/
public function description()
{
return $this->description;
}
/**
* @param array $props
* @param array $inject
* @return static
*/
public static function factory(array $props, array $inject = [])
{
return new static($props + $inject);
}
/**
* @return string
*/
public function id(): string
{
return $this->name();
}
/**
* @return bool
*/
public function isAdmin(): bool
{
return $this->name() === 'admin';
}
/**
* @return bool
*/
public function isNobody(): bool
{
return $this->name() === 'nobody';
}
/**
* @param string $file
* @param array $inject
* @return static
*/
public static function load(string $file, array $inject = [])
{
$data = Data::read($file);
$data['name'] = F::name($file);
return static::factory($data, $inject);
}
/**
* @return string
*/
public function name(): string
{
return $this->name;
}
/**
* @param array $inject
* @return static
*/
public static function nobody(array $inject = [])
{
try {
return static::load('nobody');
} catch (Exception $e) {
return static::factory(static::defaults()['nobody'], $inject);
}
}
/**
* @return \Kirby\Cms\Permissions
*/
public function permissions()
{
return $this->permissions;
}
/**
* @param mixed $description
* @return $this
*/
protected function setDescription($description = null)
{
$this->description = I18n::translate($description, $description);
return $this;
}
/**
* @param string $name
* @return $this
*/
protected function setName(string $name)
{
$this->name = $name;
return $this;
}
/**
* @param mixed $permissions
* @return $this
*/
protected function setPermissions($permissions = null)
{
$this->permissions = new Permissions($permissions);
return $this;
}
/**
* @param mixed $title
* @return $this
*/
protected function setTitle($title = null)
{
$this->title = I18n::translate($title, $title);
return $this;
}
/**
* @return string
*/
public function title(): string
{
return $this->title ??= ucfirst($this->name());
}
/**
* Converts the most important role
* properties to an array
*
* @return array
*/
public function toArray(): array
{
return [
'description' => $this->description(),
'id' => $this->id(),
'name' => $this->name(),
'permissions' => $this->permissions()->toArray(),
'title' => $this->title(),
];
}
}

139
kirby/src/Cms/Roles.php Normal file
View file

@ -0,0 +1,139 @@
<?php
namespace Kirby\Cms;
/**
* Extension of the Collection class that
* introduces `Roles::factory()` to convert an
* array of role definitions into a proper
* collection with Role objects. It also has
* a `Roles::load()` method that handles loading
* role definitions from disk.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Roles extends Collection
{
/**
* Returns a filtered list of all
* roles that can be created by the
* current user
*
* @return $this|static
* @throws \Exception
*/
public function canBeChanged()
{
if (App::instance()->user()) {
return $this->filter(function ($role) {
$newUser = new User([
'email' => 'test@getkirby.com',
'role' => $role->id()
]);
return $newUser->permissions()->can('changeRole');
});
}
return $this;
}
/**
* Returns a filtered list of all
* roles that can be created by the
* current user
*
* @return $this|static
* @throws \Exception
*/
public function canBeCreated()
{
if (App::instance()->user()) {
return $this->filter(function ($role) {
$newUser = new User([
'email' => 'test@getkirby.com',
'role' => $role->id()
]);
return $newUser->permissions()->can('create');
});
}
return $this;
}
/**
* @param array $roles
* @param array $inject
* @return static
*/
public static function factory(array $roles, array $inject = [])
{
$collection = new static();
// read all user blueprints
foreach ($roles as $props) {
$role = Role::factory($props, $inject);
$collection->set($role->id(), $role);
}
// always include the admin role
if ($collection->find('admin') === null) {
$collection->set('admin', Role::admin());
}
// return the collection sorted by name
return $collection->sort('name', 'asc');
}
/**
* @param string|null $root
* @param array $inject
* @return static
*/
public static function load(string $root = null, array $inject = [])
{
$roles = new static();
// load roles from plugins
foreach (App::instance()->extensions('blueprints') as $blueprintName => $blueprint) {
if (substr($blueprintName, 0, 6) !== 'users/') {
continue;
}
if (is_array($blueprint) === true) {
$role = Role::factory($blueprint, $inject);
} else {
$role = Role::load($blueprint, $inject);
}
$roles->set($role->id(), $role);
}
// load roles from directory
if ($root !== null) {
foreach (glob($root . '/*.yml') as $file) {
$filename = basename($file);
if ($filename === 'default.yml') {
continue;
}
$role = Role::load($file, $inject);
$roles->set($role->id(), $role);
}
}
// always include the admin role
if ($roles->find('admin') === null) {
$roles->set('admin', Role::admin($inject));
}
// return the collection sorted by name
return $roles->sort('name', 'asc');
}
}

25
kirby/src/Cms/S.php Normal file
View file

@ -0,0 +1,25 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Facade;
/**
* Shortcut to the session object
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class S extends Facade
{
/**
* @return \Kirby\Session\Session
*/
public static function instance()
{
return App::instance()->session();
}
}

62
kirby/src/Cms/Search.php Normal file
View file

@ -0,0 +1,62 @@
<?php
namespace Kirby\Cms;
/**
* The Search class extracts the
* search logic from collections, to
* provide a more globally usable interface
* for any searches.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Search
{
/**
* @param string|null $query
* @param array $params
* @return \Kirby\Cms\Files
*/
public static function files(string $query = null, $params = [])
{
return App::instance()->site()->index()->files()->search($query, $params);
}
/**
* Native search method to search for anything within the collection
*
* @param \Kirby\Cms\Collection $collection
* @param string|null $query
* @param mixed $params
* @return \Kirby\Cms\Collection|bool
*/
public static function collection(Collection $collection, string $query = null, $params = [])
{
$kirby = App::instance();
return ($kirby->component('search'))($kirby, $collection, $query, $params);
}
/**
* @param string|null $query
* @param array $params
* @return \Kirby\Cms\Pages
*/
public static function pages(string $query = null, $params = [])
{
return App::instance()->site()->index()->search($query, $params);
}
/**
* @param string|null $query
* @param array $params
* @return \Kirby\Cms\Users
*/
public static function users(string $query = null, $params = [])
{
return App::instance()->users()->search($query, $params);
}
}

107
kirby/src/Cms/Section.php Normal file
View file

@ -0,0 +1,107 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Component;
/**
* Section
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Section extends Component
{
/**
* Registry for all component mixins
*
* @var array
*/
public static $mixins = [];
/**
* Registry for all component types
*
* @var array
*/
public static $types = [];
/**
* Section constructor.
*
* @param string $type
* @param array $attrs
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function __construct(string $type, array $attrs = [])
{
if (isset($attrs['model']) === false) {
throw new InvalidArgumentException('Undefined section model');
}
if (is_a($attrs['model'], 'Kirby\Cms\Model') === false) {
throw new InvalidArgumentException('Invalid section model');
}
// use the type as fallback for the name
$attrs['name'] ??= $type;
$attrs['type'] = $type;
parent::__construct($type, $attrs);
}
public function errors(): array
{
if (array_key_exists('errors', $this->methods) === true) {
return $this->methods['errors']->call($this);
}
return $this->errors ?? [];
}
/**
* @return \Kirby\Cms\App
*/
public function kirby()
{
return $this->model()->kirby();
}
/**
* @return \Kirby\Cms\Model
*/
public function model()
{
return $this->model;
}
/**
* @return array
*/
public function toArray(): array
{
$array = parent::toArray();
unset($array['model']);
return $array;
}
/**
* @return array
*/
public function toResponse(): array
{
return array_merge([
'status' => 'ok',
'code' => 200,
'name' => $this->name,
'type' => $this->type
], $this->toArray());
}
}

699
kirby/src/Cms/Site.php Normal file
View file

@ -0,0 +1,699 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\Dir;
use Kirby\Panel\Site as Panel;
use Kirby\Toolkit\A;
/**
* The `$site` object is the root element
* for any site with pages. It represents
* the main content folder with its
* `site.txt`.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Site extends ModelWithContent
{
use SiteActions;
use HasChildren;
use HasFiles;
use HasMethods;
public const CLASS_ALIAS = 'site';
/**
* The SiteBlueprint object
*
* @var \Kirby\Cms\SiteBlueprint
*/
protected $blueprint;
/**
* The error page object
*
* @var \Kirby\Cms\Page
*/
protected $errorPage;
/**
* The id of the error page, which is
* fetched in the errorPage method
*
* @var string
*/
protected $errorPageId = 'error';
/**
* The home page object
*
* @var \Kirby\Cms\Page
*/
protected $homePage;
/**
* The id of the home page, which is
* fetched in the errorPage method
*
* @var string
*/
protected $homePageId = 'home';
/**
* Cache for the inventory array
*
* @var array
*/
protected $inventory;
/**
* The current page object
*
* @var \Kirby\Cms\Page
*/
protected $page;
/**
* The absolute path to the site directory
*
* @var string
*/
protected $root;
/**
* The page url
*
* @var string
*/
protected $url;
/**
* Modified getter to also return fields
* from the content
*
* @param string $method
* @param array $arguments
* @return mixed
*/
public function __call(string $method, array $arguments = [])
{
// public property access
if (isset($this->$method) === true) {
return $this->$method;
}
// site methods
if ($this->hasMethod($method)) {
return $this->callMethod($method, $arguments);
}
// return site content otherwise
return $this->content()->get($method);
}
/**
* Creates a new Site object
*
* @param array $props
*/
public function __construct(array $props = [])
{
$this->setProperties($props);
}
/**
* Improved `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
return array_merge($this->toArray(), [
'content' => $this->content(),
'children' => $this->children(),
'files' => $this->files(),
]);
}
/**
* Makes it possible to convert the site model
* to a string. Mostly useful for debugging.
*
* @return string
*/
public function __toString(): string
{
return $this->url();
}
/**
* Returns the url to the api endpoint
*
* @internal
* @param bool $relative
* @return string
*/
public function apiUrl(bool $relative = false): string
{
if ($relative === true) {
return 'site';
} else {
return $this->kirby()->url('api') . '/site';
}
}
/**
* Returns the blueprint object
*
* @return \Kirby\Cms\SiteBlueprint
*/
public function blueprint()
{
if (is_a($this->blueprint, 'Kirby\Cms\SiteBlueprint') === true) {
return $this->blueprint;
}
return $this->blueprint = SiteBlueprint::factory('site', null, $this);
}
/**
* Builds a breadcrumb collection
*
* @return \Kirby\Cms\Pages
*/
public function breadcrumb()
{
// get all parents and flip the order
$crumb = $this->page()->parents()->flip();
// add the home page
$crumb->prepend($this->homePage()->id(), $this->homePage());
// add the active page
$crumb->append($this->page()->id(), $this->page());
return $crumb;
}
/**
* Prepares the content for the write method
*
* @internal
* @param array $data
* @param string|null $languageCode
* @return array
*/
public function contentFileData(array $data, ?string $languageCode = null): array
{
return A::prepend($data, [
'title' => $data['title'] ?? null,
]);
}
/**
* Filename for the content file
*
* @internal
* @return string
*/
public function contentFileName(): string
{
return 'site';
}
/**
* Returns the error page object
*
* @return \Kirby\Cms\Page|null
*/
public function errorPage()
{
if (is_a($this->errorPage, 'Kirby\Cms\Page') === true) {
return $this->errorPage;
}
if ($error = $this->find($this->errorPageId())) {
return $this->errorPage = $error;
}
return null;
}
/**
* Returns the global error page id
*
* @internal
* @return string
*/
public function errorPageId(): string
{
return $this->errorPageId ?? 'error';
}
/**
* Checks if the site exists on disk
*
* @return bool
*/
public function exists(): bool
{
return is_dir($this->root()) === true;
}
/**
* Returns the home page object
*
* @return \Kirby\Cms\Page|null
*/
public function homePage()
{
if (is_a($this->homePage, 'Kirby\Cms\Page') === true) {
return $this->homePage;
}
if ($home = $this->find($this->homePageId())) {
return $this->homePage = $home;
}
return null;
}
/**
* Returns the global home page id
*
* @internal
* @return string
*/
public function homePageId(): string
{
return $this->homePageId ?? 'home';
}
/**
* Creates an inventory of all files
* and children in the site directory
*
* @internal
* @return array
*/
public function inventory(): array
{
if ($this->inventory !== null) {
return $this->inventory;
}
$kirby = $this->kirby();
return $this->inventory = Dir::inventory(
$this->root(),
$kirby->contentExtension(),
$kirby->contentIgnore(),
$kirby->multilang()
);
}
/**
* Compares the current object with the given site object
*
* @param mixed $site
* @return bool
*/
public function is($site): bool
{
if (is_a($site, 'Kirby\Cms\Site') === false) {
return false;
}
return $this === $site;
}
/**
* Returns the root to the media folder for the site
*
* @internal
* @return string
*/
public function mediaRoot(): string
{
return $this->kirby()->root('media') . '/site';
}
/**
* The site's base url for any files
*
* @internal
* @return string
*/
public function mediaUrl(): string
{
return $this->kirby()->url('media') . '/site';
}
/**
* Gets the last modification date of all pages
* in the content folder.
*
* @param string|null $format
* @param string|null $handler
* @return int|string
*/
public function modified(?string $format = null, ?string $handler = null)
{
return Dir::modified(
$this->root(),
$format,
$handler ?? $this->kirby()->option('date.handler', 'date')
);
}
/**
* Returns the current page if `$path`
* is not specified. Otherwise it will try
* to find a page by the given path.
*
* If no current page is set with the page
* prop, the home page will be returned if
* it can be found. (see `Site::homePage()`)
*
* @param string|null $path omit for current page,
* otherwise e.g. `notes/across-the-ocean`
* @return \Kirby\Cms\Page|null
*/
public function page(?string $path = null)
{
if ($path !== null) {
return $this->find($path);
}
if (is_a($this->page, 'Kirby\Cms\Page') === true) {
return $this->page;
}
try {
return $this->page = $this->homePage();
} catch (LogicException $e) {
return $this->page = null;
}
}
/**
* Alias for `Site::children()`
*
* @return \Kirby\Cms\Pages
*/
public function pages()
{
return $this->children();
}
/**
* Returns the panel info object
*
* @return \Kirby\Panel\Site
*/
public function panel()
{
return new Panel($this);
}
/**
* Returns the permissions object for this site
*
* @return \Kirby\Cms\SitePermissions
*/
public function permissions()
{
return new SitePermissions($this);
}
/**
* Preview Url
*
* @internal
* @return string|null
*/
public function previewUrl(): ?string
{
$preview = $this->blueprint()->preview();
if ($preview === false) {
return null;
}
if ($preview === true) {
$url = $this->url();
} else {
$url = $preview;
}
return $url;
}
/**
* Returns the absolute path to the content directory
*
* @return string
*/
public function root(): string
{
return $this->root ??= $this->kirby()->root('content');
}
/**
* Returns the SiteRules class instance
* which is being used in various methods
* to check for valid actions and input.
*
* @return \Kirby\Cms\SiteRules
*/
protected function rules()
{
return new SiteRules();
}
/**
* Search all pages in the site
*
* @param string|null $query
* @param array $params
* @return \Kirby\Cms\Pages
*/
public function search(?string $query = null, $params = [])
{
return $this->index()->search($query, $params);
}
/**
* Sets the Blueprint object
*
* @param array|null $blueprint
* @return $this
*/
protected function setBlueprint(?array $blueprint = null)
{
if ($blueprint !== null) {
$blueprint['model'] = $this;
$this->blueprint = new SiteBlueprint($blueprint);
}
return $this;
}
/**
* Sets the id of the error page, which
* is used in the errorPage method
* to get the default error page if nothing
* else is set.
*
* @param string $id
* @return $this
*/
protected function setErrorPageId(string $id = 'error')
{
$this->errorPageId = $id;
return $this;
}
/**
* Sets the id of the home page, which
* is used in the homePage method
* to get the default home page if nothing
* else is set.
*
* @param string $id
* @return $this
*/
protected function setHomePageId(string $id = 'home')
{
$this->homePageId = $id;
return $this;
}
/**
* Sets the current page object
*
* @internal
* @param \Kirby\Cms\Page|null $page
* @return $this
*/
public function setPage(?Page $page = null)
{
$this->page = $page;
return $this;
}
/**
* Sets the Url
*
* @param string|null $url
* @return $this
*/
protected function setUrl(?string $url = null)
{
$this->url = $url;
return $this;
}
/**
* Converts the most important site
* properties to an array
*
* @return array
*/
public function toArray(): array
{
return [
'children' => $this->children()->keys(),
'content' => $this->content()->toArray(),
'errorPage' => $this->errorPage() ? $this->errorPage()->id() : false,
'files' => $this->files()->keys(),
'homePage' => $this->homePage() ? $this->homePage()->id() : false,
'page' => $this->page() ? $this->page()->id() : false,
'title' => $this->title()->value(),
'url' => $this->url(),
];
}
/**
* Returns the Url
*
* @param string|null $language
* @return string
*/
public function url(?string $language = null): string
{
if ($language !== null || $this->kirby()->multilang() === true) {
return $this->urlForLanguage($language);
}
return $this->url ?? $this->kirby()->url();
}
/**
* Returns the translated url
*
* @internal
* @param string|null $languageCode
* @param array|null $options
* @return string
*/
public function urlForLanguage(?string $languageCode = null, ?array $options = null): string
{
if ($language = $this->kirby()->language($languageCode)) {
return $language->url();
}
return $this->kirby()->url();
}
/**
* Sets the current page by
* id or page object and
* returns the current page
*
* @internal
* @param string|\Kirby\Cms\Page $page
* @param string|null $languageCode
* @return \Kirby\Cms\Page
*/
public function visit($page, ?string $languageCode = null)
{
if ($languageCode !== null) {
$this->kirby()->setCurrentTranslation($languageCode);
$this->kirby()->setCurrentLanguage($languageCode);
}
// convert ids to a Page object
if (is_string($page)) {
$page = $this->find($page);
}
// handle invalid pages
if (is_a($page, 'Kirby\Cms\Page') === false) {
throw new InvalidArgumentException('Invalid page object');
}
// set the current active page
$this->setPage($page);
// return the page
return $page;
}
/**
* Checks if any content of the site has been
* modified after the given unix timestamp
* This is mainly used to auto-update the cache
*
* @param mixed $time
* @return bool
*/
public function wasModifiedAfter($time): bool
{
return Dir::wasModifiedAfter($this->root(), $time);
}
/**
* Deprecated!
*/
/**
* Returns the full path without leading slash
*
* @todo Add `deprecated()` helper warning in 3.7.0
* @todo Remove in 3.8.0
*
* @internal
* @return string
* @codeCoverageIgnore
*/
public function panelPath(): string
{
return $this->panel()->path();
}
/**
* Returns the url to the editing view
* in the panel
*
* @todo Add `deprecated()` helper warning in 3.7.0
* @todo Remove in 3.8.0
*
* @internal
* @param bool $relative
* @return string
* @codeCoverageIgnore
*/
public function panelUrl(bool $relative = false): string
{
return $this->panel()->url($relative);
}
}

View file

@ -0,0 +1,101 @@
<?php
namespace Kirby\Cms;
use Closure;
/**
* SiteActions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait SiteActions
{
/**
* Commits a site action, by following these steps
*
* 1. checks the action rules
* 2. sends the before hook
* 3. commits the store action
* 4. sends the after hook
* 5. returns the result
*
* @param string $action
* @param mixed ...$arguments
* @param Closure $callback
* @return mixed
*/
protected function commit(string $action, array $arguments, Closure $callback)
{
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
$this->rules()->$action(...$argumentValues);
$kirby->trigger('site.' . $action . ':before', $arguments);
$result = $callback(...$argumentValues);
$kirby->trigger('site.' . $action . ':after', ['newSite' => $result, 'oldSite' => $old]);
$kirby->cache('pages')->flush();
return $result;
}
/**
* Change the site title
*
* @param string $title
* @param string|null $languageCode
* @return static
*/
public function changeTitle(string $title, string $languageCode = null)
{
$site = $this;
$title = trim($title);
$arguments = compact('site', 'title', 'languageCode');
return $this->commit('changeTitle', $arguments, function ($site, $title, $languageCode) {
return $site->save(['title' => $title], $languageCode);
});
}
/**
* Creates a main page
*
* @param array $props
* @return \Kirby\Cms\Page
*/
public function createChild(array $props)
{
$props = array_merge($props, [
'url' => null,
'num' => null,
'parent' => null,
'site' => $this,
]);
return Page::create($props);
}
/**
* Clean internal caches
*
* @return $this
*/
public function purge()
{
$this->blueprint = null;
$this->children = null;
$this->content = null;
$this->files = null;
$this->inventory = null;
$this->translations = null;
return $this;
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace Kirby\Cms;
/**
* Extension of the basic blueprint class
* to handle the blueprint for the site.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class SiteBlueprint extends Blueprint
{
/**
* Creates a new page blueprint object
* with the given props
*
* @param array $props
*/
public function __construct(array $props)
{
parent::__construct($props);
// normalize all available page options
$this->props['options'] = $this->normalizeOptions(
$props['options'] ?? true,
// defaults
[
'changeTitle' => null,
'update' => null,
],
// aliases
[
'title' => 'changeTitle',
]
);
}
/**
* Returns the preview settings
* 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()
{
$preview = $this->props['options']['preview'] ?? true;
if (is_string($preview) === true) {
return $this->model->toString($preview);
}
return $preview;
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace Kirby\Cms;
/**
* SitePermissions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class SitePermissions extends ModelPermissions
{
protected $category = 'site';
}

View file

@ -0,0 +1,58 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\Str;
/**
* Validators for all site actions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class SiteRules
{
/**
* Validates if the site title can be changed
*
* @param \Kirby\Cms\Site $site
* @param string $title
* @return bool
* @throws \Kirby\Exception\InvalidArgumentException If the title is empty
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the title
*/
public static function changeTitle(Site $site, string $title): bool
{
if ($site->permissions()->changeTitle() !== true) {
throw new PermissionException(['key' => 'site.changeTitle.permission']);
}
if (Str::length($title) === 0) {
throw new InvalidArgumentException(['key' => 'site.changeTitle.empty']);
}
return true;
}
/**
* Validates if the site can be updated
*
* @param \Kirby\Cms\Site $site
* @param array $content
* @return bool
* @throws \Kirby\Exception\PermissionException If the user is not allowed to update the site
*/
public static function update(Site $site, array $content = []): bool
{
if ($site->permissions()->update() !== true) {
throw new PermissionException(['key' => 'site.update.permission']);
}
return true;
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
/**
* The Structure class wraps
* array data into a nicely chainable
* collection with objects and Kirby-style
* content with fields. The Structure class
* is the heart and soul of our yaml conversion
* method for pages.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Structure extends Collection
{
/**
* Creates a new Collection with the given objects
*
* @param array $objects Kirby\Cms\StructureObject` objects or props arrays
* @param object|null $parent
*/
public function __construct($objects = [], $parent = null)
{
$this->parent = $parent;
$this->set($objects);
}
/**
* The internal setter for collection items.
* This makes sure that nothing unexpected ends
* up in the collection. You can pass arrays or
* StructureObjects
*
* @param string $id
* @param array|StructureObject $props
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function __set(string $id, $props)
{
if (is_a($props, 'Kirby\Cms\StructureObject') === true) {
$object = $props;
} else {
if (is_array($props) === false) {
throw new InvalidArgumentException('Invalid structure data');
}
$object = new StructureObject([
'content' => $props,
'id' => $props['id'] ?? $id,
'parent' => $this->parent,
'structure' => $this
]);
}
return parent::__set($object->id(), $object);
}
}

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