first version

This commit is contained in:
Bastian Allgeier
2019-01-13 23:17:34 +01:00
commit 01277f79f2
595 changed files with 82913 additions and 0 deletions

428
kirby/src/Api/Api.php Executable file
View File

@@ -0,0 +1,428 @@
<?php
namespace Kirby\Api;
use Closure;
use Exception;
use Throwable;
use Kirby\Exception\NotFoundException;
use Kirby\Http\Router;
use Kirby\Http\Response;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Properties;
/**
* 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
*/
class Api
{
use Properties;
protected $authentication;
protected $debug = false;
protected $collections = [];
protected $data = [];
protected $models = [];
protected $routes = [];
protected $requestData = [];
protected $requestMethod;
public function __call($method, $args)
{
return $this->data($method, ...$args);
}
public function __construct(array $props)
{
$this->setProperties($props);
}
public function authenticate()
{
if ($auth = $this->authentication()) {
return $auth->call($this);
}
return true;
}
public function authentication()
{
return $this->authentication;
}
public function call(string $path = null, string $method = 'GET', array $requestData = [])
{
$path = rtrim($path, '/');
$this->setRequestMethod($method);
$this->setRequestData($requestData);
$router = new Router($this->routes());
$result = $router->find($path, $method);
$auth = $result->attributes()['auth'] ?? true;
if ($auth !== false) {
$this->authenticate();
}
$output = $result->action()->call($this, ...$result->arguments());
if (is_object($output) === true) {
return $this->resolve($output)->toResponse();
}
return $output;
}
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]);
}
public function collections(): array
{
return $this->collections;
}
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];
}
public function hasData($key): bool
{
return isset($this->data[$key]) === true;
}
public function model(string $name, $object = null)
{
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]);
}
public function models(): array
{
return $this->models;
}
public function requestData($type = null, $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;
}
public function requestBody(string $key = null, $default = null)
{
return $this->requestData('body', $key, $default);
}
public function requestFiles(string $key = null, $default = null)
{
return $this->requestData('files', $key, $default);
}
public function requestHeaders(string $key = null, $default = null)
{
return $this->requestData('headers', $key, $default);
}
public function requestMethod(): string
{
return $this->requestMethod;
}
public function requestQuery(string $key = null, $default = null)
{
return $this->requestData('query', $key, $default);
}
public function resolve($object)
{
if (is_a($object, 'Kirby\Api\Model') === true || is_a($object, 'Kirby\Api\Collection') === true) {
return $object;
}
$className = strtolower(get_class($object));
$lastDash = strrpos($className, '\\');
if ($lastDash !== false) {
$className = substr($className, $lastDash + 1);
}
if (isset($this->models[$className]) === true) {
return $this->model($className, $object);
}
if (isset($this->collections[$className]) === true) {
return $this->collection($className, $object);
}
// now models deeply by checking for the actual type
foreach ($this->models as $modelClass => $model) {
if (is_a($object, $model['type']) === true) {
return $this->model($modelClass, $object);
}
}
throw new NotFoundException(sprintf('The object "%s" cannot be resolved', $className));
}
public function routes(): array
{
return $this->routes;
}
protected function setAuthentication(Closure $authentication = null)
{
$this->authentication = $authentication;
return $this;
}
protected function setCollections(array $collections = null)
{
if ($collections !== null) {
$this->collections = array_change_key_case($collections);
}
return $this;
}
protected function setData(array $data = null)
{
$this->data = $data ?? [];
return $this;
}
protected function setDebug(bool $debug = false)
{
$this->debug = $debug;
return $this;
}
protected function setModels(array $models = null)
{
if ($models !== null) {
$this->models = array_change_key_case($models);
}
return $this;
}
protected function setRequestData(array $requestData = null)
{
$defaults = [
'query' => [],
'body' => [],
'files' => []
];
$this->requestData = array_merge($defaults, (array)$requestData);
return $this;
}
protected function setRequestMethod(string $requestMethod = null)
{
$this->requestMethod = $requestMethod;
return $this;
}
protected function setRoutes(array $routes = null)
{
$this->routes = $routes ?? [];
return $this;
}
public function render(string $path, $method = 'GET', array $requestData = [])
{
try {
$result = $this->call($path, $method, $requestData);
} catch (Throwable $e) {
if (is_a($e, 'Kirby\Exception\Exception') === true) {
$result = ['status' => 'error'] + $e->toArray();
} else {
$result = [
'status' => 'error',
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => ltrim($e->getFile(), $_SERVER['DOCUMENT_ROOT'] ?? null),
'line' => $e->getLine(),
'code' => empty($e->getCode()) === false ? $e->getCode() : 500
];
}
}
if ($result === null) {
$result = [
'status' => 'error',
'message' => 'not found',
'code' => 404,
];
}
if ($result === true) {
$result = [
'status' => 'ok',
];
}
if ($result === false) {
$result = [
'status' => 'error',
'message' => 'bad request',
'code' => 400,
];
}
if (is_array($result) === false) {
return $result;
}
// pretty print json data
$pretty = (bool)($requestData['query']['pretty'] ?? false) === true;
// remove critical info from the result set if
// debug mode is switched off
if ($this->debug !== true) {
unset(
$result['file'],
$result['exception'],
$result['line']
);
}
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);
}
public function upload(Closure $callback, $single = false): array
{
$trials = 0;
$uploads = [];
$errors = [];
$files = $this->requestFiles();
if (empty($files) === true) {
throw new Exception('No uploaded files');
}
foreach ($files as $upload) {
if (isset($upload['tmp_name']) === false && is_array($upload)) {
continue;
}
$trials++;
try {
if ($upload['error'] !== 0) {
throw new Exception('Upload error');
}
// 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 (move_uploaded_file($upload['tmp_name'], $source) === false) {
throw new Exception('The uploaded file could not be moved');
}
$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
];
}
}

124
kirby/src/Api/Collection.php Executable file
View File

@@ -0,0 +1,124 @@
<?php
namespace Kirby\Api;
use Closure;
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.
*/
class Collection
{
protected $api;
protected $data;
protected $model;
protected $select;
protected $view;
public function __construct(Api $api, $data = null, array $schema)
{
$this->api = $api;
$this->data = $data;
$this->model = $schema['model'];
$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');
}
}
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;
}
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;
}
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'
];
}
public function view(string $view)
{
$this->view = $view;
return $this;
}
}

188
kirby/src/Api/Model.php Executable file
View File

@@ -0,0 +1,188 @@
<?php
namespace Kirby\Api;
use Closure;
use Exception;
use Kirby\Toolkit\Str;
/**
* The API Model class can be wrapped around any
* kind of object. Each model defines a set of properties that
* are availabel 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.
*
*/
class Model
{
protected $api;
protected $data;
protected $fields;
protected $select;
protected $views;
public function __construct(Api $api, $data = null, 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']));
}
}
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;
}
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;
}
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;
}
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'
];
}
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]);
}
}

77
kirby/src/Cache/ApcuCache.php Executable file
View File

@@ -0,0 +1,77 @@
<?php
namespace Kirby\Cache;
/**
* APCu Cache Driver
*
* @package Kirby Cache
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
* @license MIT
*/
class ApcuCache extends Cache
{
/**
* Write an item to the cache for a given number of minutes.
*
* <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 void
*/
public function set(string $key, $value, int $minutes = 0)
{
return apcu_store($key, $this->value($value, $minutes)->toJson(), $this->expiration($minutes));
}
/**
* Retrieve an item from the cache.
*
* @param string $key
* @return mixed
*/
public function retrieve(string $key)
{
return Value::fromJson(apcu_fetch($key));
}
/**
* Checks if the current key exists in cache
*
* @param string $key
* @return boolean
*/
public function exists(string $key): bool
{
return apcu_exists($key);
}
/**
* Remove an item from the cache
*
* @param string $key
* @return boolean
*/
public function remove(string $key): bool
{
return apcu_delete($key);
}
/**
* Flush the entire cache directory
*
* @return boolean
*/
public function flush(): bool
{
return apcu_clear_cache();
}
}

237
kirby/src/Cache/Cache.php Executable file
View File

@@ -0,0 +1,237 @@
<?php
namespace Kirby\Cache;
/**
* Cache foundation
* This class doesn't do anything
* and is perfect as foundation for
* other cache drivers and to be used
* when the cache is disabled
*
* @package Kirby Cache
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
* @license MIT
*/
class Cache
{
/**
* stores all options for the driver
* @var array
*/
protected $options = [];
/**
* Set all parameters which are needed to connect to the cache storage
*
* @param array $options
*/
public function __construct(array $options = [])
{
$this->options = $options;
}
/**
* Write an item to the cache for a given number of minutes.
*
* <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 void
*/
public function set(string $key, $value, int $minutes = 0)
{
return null;
}
/**
* Private method to retrieve the cache value
* This needs to be defined by the driver
*
* @param string $key
* @return mixed
*/
public function retrieve(string $key)
{
return null;
}
/**
* Get 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 (time() >= $value->expires()) {
$this->remove($key);
return $default;
}
// get the pure value
$cache = $value->value();
// return the cache value or the default
return $cache ?? $default;
}
/**
* Calculates the expiration timestamp
*
* @param int $minutes
* @return int
*/
protected function expiration(int $minutes = 0): int
{
// keep forever if minutes are not defined
if ($minutes === 0) {
$minutes = 2628000;
}
// calculate the time
return time() + ($minutes * 60);
}
/**
* Checks when an item in the cache expires
*
* @param string $key
* @return mixed
*/
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 boolean
*/
public function expired(string $key): bool
{
return $this->expires($key) <= time();
}
/**
* Checks when the cache has been created
*
* @param string $key
* @return mixed
*/
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 mixed
*/
public function modified(string $key)
{
return static::created($key);
}
/**
* Returns Value object
*
* @param mixed $value The value, which should be cached
* @param int $minutes The number of minutes before expiration
* @return Value
*/
protected function value($value, int $minutes): Value
{
return new Value($value, $minutes);
}
/**
* Determine if an item exists in the cache.
*
* @param string $key
* @return boolean
*/
public function exists(string $key): bool
{
return !$this->expired($key);
}
/**
* Remove an item from the cache
*
* @param string $key
* @return boolean
*/
public function remove(string $key): bool
{
return true;
}
/**
* Flush the entire cache
*
* @return boolean
*/
public function flush(): bool
{
return true;
}
/**
* Returns all passed cache options
*
* @return array
*/
public function options(): array
{
return $this->options;
}
}

126
kirby/src/Cache/FileCache.php Executable file
View File

@@ -0,0 +1,126 @@
<?php
namespace Kirby\Cache;
use Exception;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
/**
* File System Cache Driver
*
* @package Kirby Cache
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
* @license MIT
*/
class FileCache extends Cache
{
/**
* Set all parameters which are needed for the file cache
* see defaults for available parameters
*
* @param array $params
*/
public function __construct(array $params)
{
$defaults = [
'root' => null,
'extension' => null
];
parent::__construct(array_merge($defaults, $params));
// try to create the directory
Dir::make($this->options['root'], true);
// check for a valid cache directory
if (is_dir($this->options['root']) === false) {
throw new Exception('The cache directory does not exist');
}
}
/**
* Returns the full path to a file for a given key
*
* @param string $key
* @return string
*/
protected function file(string $key): string
{
$extension = isset($this->options['extension']) ? '.' . $this->options['extension'] : '';
return $this->options['root'] . '/' . $key . $extension;
}
/**
* Write an item to the cache for a given number of minutes.
*
* <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
*/
public function set(string $key, $value, int $minutes = 0)
{
return F::write($this->file($key), $this->value($value, $minutes)->toJson());
}
/**
* Retrieve an item from the cache.
*
* @param string $key
* @return mixed
*/
public function retrieve(string $key)
{
return Value::fromJson(F::read($this->file($key)));
}
/**
* Checks when the cache has been created
*
* @param string $key
* @return int
*/
public function created(string $key): int
{
// 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)) : 0;
}
/**
* Remove an item from the cache
*
* @param string $key
* @return boolean
*/
public function remove(string $key): bool
{
return F::remove($this->file($key));
}
/**
* Flush the entire cache directory
*
* @return boolean
*/
public function flush(): bool
{
if (Dir::remove($this->options['root']) === true && Dir::make($this->options['root']) === true) {
return true;
}
return false;
}
}

137
kirby/src/Cache/MemCached.php Executable file
View File

@@ -0,0 +1,137 @@
<?php
namespace Kirby\Cache;
/**
* Memcached Driver
*
* @package Kirby Cache
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
* @license MIT
*/
class MemCached extends Cache
{
/**
* store for the memache connection
* @var Memcached
*/
protected $connection;
/**
* Set all parameters which are needed for the memcache client
* see defaults for available parameters
*
* @param array $params
*/
public function __construct(array $params = [])
{
$defaults = [
'host' => 'localhost',
'port' => 11211,
'prefix' => null,
];
parent::__construct(array_merge($defaults, $params));
$this->connection = new \Memcached();
$this->connection->addServer($this->options['host'], $this->options['port']);
}
/**
* Write an item to the cache for a given number of minutes.
*
* <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 void
*/
public function set(string $key, $value, int $minutes = 0)
{
return $this->connection->set($this->key($key), $this->value($value, $minutes)->toJson(), $this->expiration($minutes));
}
/**
* Returns the full keyname
* including the prefix (if set)
*
* @param string $key
* @return string
*/
public function key(string $key): string
{
return $this->options['prefix'] . $key;
}
/**
* Retrieve the CacheValue object from the cache.
*
* @param string $key
* @return object CacheValue
*/
public function retrieve(string $key)
{
return Value::fromJson($this->connection->get($this->key($key)));
}
/**
* Remove an item from the cache
*
* @param string $key
* @return boolean
*/
public function remove(string $key): bool
{
return $this->connection->delete($this->key($key));
}
/**
* Checks when an item in the cache expires
*
* @param string $key
* @return int
*/
public function expires(string $key): int
{
return parent::expires($this->key($key));
}
/**
* Checks if an item in the cache is expired
*
* @param string $key
* @return boolean
*/
public function expired(string $key): bool
{
return parent::expired($this->key($key));
}
/**
* Checks when the cache has been created
*
* @param string $key
* @return int
*/
public function created(string $key): int
{
return parent::created($this->key($key));
}
/**
* Flush the entire cache directory
*
* @return boolean
*/
public function flush(): bool
{
return $this->connection->flush();
}
}

139
kirby/src/Cache/Value.php Executable file
View File

@@ -0,0 +1,139 @@
<?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 http://getkirby.com
* @copyright Bastian Allgeier
* @license MIT
*/
class Value
{
/**
* the cached value
* @var mixed
*/
protected $value;
/**
* the expiration timestamp
* @var int
*/
protected $expires;
/**
* the creation timestamp
* @var int
*/
protected $created;
/**
* Constructor
*
* @param mixed $value
* @param int $minutes the number of minutes until the value expires
* @param int $created the unix timestamp when the value has been created
*/
public function __construct($value, int $minutes = 0, $created = null)
{
// keep forever if minutes are not defined
if ($minutes === 0) {
$minutes = 2628000;
}
$this->value = $value;
$this->minutes = $minutes;
$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
*
* @return int
*/
public function expires(): int
{
return $this->created + ($this->minutes * 60);
}
/**
* Creates a value object from an array
*
* @param array $array
* @return array
*/
public static function fromArray(array $array): self
{
return new static($array['value'] ?? null, $array['minutes'] ?? 0, $array['created'] ?? null);
}
/**
* Creates a value object from a json string
*
* @param string $json
* @return array
*/
public static function fromJson($json): self
{
try {
$array = json_decode($json, true) ?? [];
} catch (Throwable $e) {
$array = [];
}
return static::fromArray($array);
}
/**
* Convert the object to a json string
*
* @return string
*/
public function toJson(): string
{
return json_encode($this->toArray());
}
/**
* Convert the object to an array
*
* @return array
*/
public function toArray(): array
{
return [
'created' => $this->created,
'minutes' => $this->minutes,
'value' => $this->value,
];
}
/**
* Returns the value
*
* @return mixed
*/
public function value()
{
return $this->value;
}
}

176
kirby/src/Cms/Api.php Executable file
View File

@@ -0,0 +1,176 @@
<?php
namespace Kirby\Cms;
use Kirby\Api\Api as BaseApi;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Toolkit\Str;
class Api extends BaseApi
{
protected $kirby;
public function call(string $path = null, string $method = 'GET', array $requestData = [])
{
$this->setRequestMethod($method);
$this->setRequestData($requestData);
if ($languageCode = $this->requestHeaders('x-language')) {
$this->kirby->setCurrentLanguage($languageCode);
}
if ($user = $this->kirby->user()) {
$this->kirby->setCurrentTranslation($user->language());
}
return parent::call($path, $method, $requestData);
}
public function fieldApi($model, string $name, string $path = null)
{
$form = Form::for($model);
$fieldNames = Str::split($name, '+');
$index = 0;
$count = count($fieldNames);
$field = null;
foreach ($fieldNames as $fieldName) {
$index++;
if ($field = $form->fields()->get($fieldName)) {
if ($count !== $index) {
$form = $field->form();
}
} else {
throw new NotFoundException('The field "' . $fieldName . '" could not be found');
}
}
if ($field === null) {
throw new NotFoundException('The field "' . $fieldNames . '" could not be found');
}
$fieldApi = $this->clone([
'routes' => $field->api(),
'data' => array_merge($this->data(), ['field' => $field])
]);
return $fieldApi->call($path, $this->requestMethod(), $this->requestData());
}
public function file(string $path = null, string $filename)
{
$filename = urldecode($filename);
if ($file = $this->parent($path)->file($filename)) {
return $file;
}
throw new NotFoundException([
'key' => 'file.notFound',
'data' => [
'filename' => $filename
]
]);
}
public function parent(string $path)
{
$modelType = $path === 'site' ? 'site' : dirname($path);
$modelTypes = ['site' => 'site', 'users' => 'user', 'pages' => 'page'];
$modelName = $modelTypes[$modelType] ?? null;
if ($modelName === null) {
throw new InvalidArgumentException('Invalid file model type');
}
if ($modelName === 'site') {
$modelId = null;
} else {
$modelId = basename($path);
if ($modelName === 'page') {
$modelId = str_replace('+', '/', $modelId);
}
}
if ($model = $this->kirby()->$modelName($modelId)) {
return $model;
}
throw new NotFoundException([
'key' => $modelName . '.undefined'
]);
}
public function kirby()
{
return $this->kirby;
}
public function language()
{
return $this->requestHeaders('x-language');
}
public function page(string $id)
{
$id = str_replace('+', '/', $id);
$page = $this->kirby->page($id);
if ($page && $page->isReadable()) {
return $page;
}
throw new NotFoundException([
'key' => 'page.notFound',
'data' => [
'slug' => $id
]
]);
}
public function session(array $options = [])
{
return $this->kirby->session(array_merge([
'detect' => true
], $options));
}
protected function setKirby(App $kirby)
{
$this->kirby = $kirby;
return $this;
}
public function site()
{
return $this->kirby->site();
}
public function user(string $id = null)
{
// get the authenticated user
if ($id === null) {
return $this->kirby->auth()->user();
}
// get a specific user by id
if ($user = $this->kirby->users()->find($id)) {
return $user;
}
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $id
]
]);
}
public function users()
{
return $this->kirby->users();
}
}

1177
kirby/src/Cms/App.php Executable file

File diff suppressed because it is too large Load Diff

112
kirby/src/Cms/AppCaches.php Executable file
View File

@@ -0,0 +1,112 @@
<?php
namespace Kirby\Cms;
use Kirby\Cache\Cache;
use Kirby\Exception\InvalidArgumentException;
trait AppCaches
{
protected $caches = [];
/**
* Returns a cache instance by key
*
* @param string $key
* @return 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) {
return $this->caches[$key] = new Cache;
}
$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
return $this->caches[$key] = new $className($options);
}
/**
* Returns the cache options by key
*
* @param string $key
* @return array
*/
protected function cacheOptions(string $key): array
{
$options = $this->option($cacheKey = $this->cacheOptionsKey($key), false);
if ($options === false) {
return [
'active' => false
];
}
$defaults = [
'active' => true,
'type' => 'file',
'extension' => 'cache',
'root' => $this->root('cache') . '/' . str_replace('.', '/', $key)
];
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 ($plugin = $this->plugin($pluginName)) {
return empty($cacheName) === true ? $pluginPrefix . '.cache' : $pluginPrefix . '.cache.' . $cacheName;
}
return $prefixedKey;
}
}

112
kirby/src/Cms/AppErrors.php Executable file
View File

@@ -0,0 +1,112 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\Exception;
use Kirby\Http\Response;
use Whoops\Run as Whoops;
use Whoops\Handler\Handler;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Handler\PlainTextHandler;
use Whoops\Handler\CallbackHandler;
trait AppErrors
{
protected function handleCliErrors()
{
$whoops = new Whoops;
$whoops->pushHandler(new PlainTextHandler);
$whoops->register();
}
protected function handleErrors()
{
$request = $this->request();
// TODO: implement acceptance
if ($request->ajax()) {
return $this->handleJsonErrors();
}
if ($request->cli()) {
return $this->handleCliErrors();
}
return $this->handleHtmlErrors();
}
protected function handleHtmlErrors()
{
$whoops = new Whoops;
if ($this->option('debug') === true) {
if ($this->option('whoops', true) === true) {
$handler = new PrettyPageHandler;
$handler->setPageTitle('Kirby CMS Debugger');
if ($editor = $this->option('editor')) {
$handler->setEditor($editor);
}
$whoops->pushHandler($handler);
$whoops->register();
}
} else {
$handler = new CallbackHandler(function ($exception, $inspector, $run) {
$fatal = $this->option('fatal');
if (is_a($fatal, 'Closure') === true) {
echo $fatal($this);
} else {
include static::$root . '/views/fatal.php';
}
return Handler::QUIT;
});
$whoops->pushHandler($handler);
$whoops->register();
}
}
protected function handleJsonErrors()
{
$whoops = new Whoops;
$handler = new CallbackHandler(function ($exception, $inspector, $run) {
if (is_a($exception, 'Kirby\Exception\Exception') === true) {
$httpCode = $exception->getHttpCode();
$code = $exception->getCode();
$details = $exception->getDetails();
} else {
$httpCode = 500;
$code = $exception->getCode();
$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'] ?? null),
'line' => $exception->getLine(),
], $httpCode);
} else {
echo Response::json([
'status' => 'error',
'code' => $code,
'details' => $details,
'message' => 'An unexpected error occurred! Enable debug mode for more info: https://getkirby.com/docs/reference/options/debug',
], $httpCode);
}
return Handler::QUIT;
});
$whoops->pushHandler($handler);
$whoops->register();
}
}

507
kirby/src/Cms/AppPlugins.php Executable file
View File

@@ -0,0 +1,507 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Form\Field as FormField;
use Kirby\Text\KirbyTag;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Collection;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
use Kirby\Toolkit\V;
trait AppPlugins
{
/**
* A list of all registered plugins
*
* @var array
*/
protected static $plugins = [];
/**
* The extension registry
*
* @var array
*/
protected $extensions = [
'api' => [],
'blueprints' => [],
'cacheTypes' => [],
'collections' => [],
'components' => [],
'controllers' => [],
'collectionFilters' => [],
'fieldMethods' => [],
'fileMethods' => [],
'filesMethods' => [],
'fields' => [],
'hooks' => [],
'options' => [],
'pages' => [],
'pageMethods' => [],
'pageModels' => [],
'pagesMethods' => [],
'routes' => [],
'sections' => [],
'siteMethods' => [],
'snippets' => [],
'tags' => [],
'templates' => [],
'translations' => [],
'validators' => []
];
/**
* Flag when plugins have been loaded
* to not load them again
*
* @var bool
*/
protected $pluginsAreLoaded = false;
/**
* Register all given extensions
*
* @param array $extensions
* @param Plugin $plugin 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;
}
protected function extendApi($api): array
{
if (is_array($api) === true) {
return $this->extensions['api'] = A::merge($this->extensions['api'], $api, A::MERGE_APPEND);
} else {
return $this->extensions['api'];
}
}
protected function extendBlueprints(array $blueprints): array
{
return $this->extensions['blueprints'] = array_merge($this->extensions['blueprints'], $blueprints);
}
protected function extendCacheTypes(array $cacheTypes): array
{
return $this->extensions['cacheTypes'] = array_merge($this->extensions['cacheTypes'], $cacheTypes);
}
protected function extendCollectionFilters(array $filters): array
{
return $this->extensions['collectionFilters'] = Collection::$filters = array_merge(Collection::$filters, $filters);
}
protected function extendCollections(array $collections): array
{
return $this->extensions['collections'] = array_merge($this->extensions['collections'], $collections);
}
protected function extendComponents(array $components): array
{
return $this->extensions['components'] = array_merge($this->extensions['components'], $components);
}
protected function extendControllers(array $controllers): array
{
return $this->extensions['controllers'] = array_merge($this->extensions['controllers'], $controllers);
}
protected function extendFileMethods(array $methods): array
{
return $this->extensions['fileMethods'] = File::$methods = array_merge(File::$methods, $methods);
}
protected function extendFilesMethods(array $methods): array
{
return $this->extensions['filesMethods'] = Files::$methods = array_merge(Files::$methods, $methods);
}
protected function extendFieldMethods(array $methods): array
{
return $this->extensions['fieldMethods'] = Field::$methods = array_merge(Field::$methods, $methods);
}
protected function extendFields(array $fields): array
{
return $this->extensions['fields'] = FormField::$types = array_merge(FormField::$types, $fields);
}
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'];
}
protected function extendMarkdown(Closure $markdown): array
{
return $this->extensions['markdown'] = $markdown;
}
protected function extendOptions(array $options, Plugin $plugin = null): array
{
if ($plugin !== null) {
$prefixed = [];
foreach ($options as $key => $value) {
$prefixed[$plugin->prefix() . '.' . $key] = $value;
}
$options = $prefixed;
}
return $this->extensions['options'] = $this->options = A::merge($options, $this->options, A::MERGE_REPLACE);
}
protected function extendPageMethods(array $methods): array
{
return $this->extensions['pageMethods'] = Page::$methods = array_merge(Page::$methods, $methods);
}
protected function extendPagesMethods(array $methods): array
{
return $this->extensions['pagesMethods'] = Pages::$methods = array_merge(Pages::$methods, $methods);
}
protected function extendPageModels(array $models): array
{
return $this->extensions['pageModels'] = Page::$models = array_merge(Page::$models, $models);
}
protected function extendPages(array $pages): array
{
return $this->extensions['pages'] = array_merge($this->extensions['pages'], $pages);
}
/**
* Registers additional routes
*
* @param array|Closure $routes
* @return array
*/
protected function extendRoutes($routes): array
{
if (is_a($routes, Closure::class) === true) {
$routes = $routes($this);
}
return $this->extensions['routes'] = array_merge($this->extensions['routes'], $routes);
}
protected function extendSections(array $sections): array
{
return $this->extensions['sections'] = Section::$types = array_merge(Section::$types, $sections);
}
protected function extendSiteMethods(array $methods): array
{
return $this->extensions['siteMethods'] = Site::$methods = array_merge(Site::$methods, $methods);
}
protected function extendSmartypants(Closure $smartypants): array
{
return $this->extensions['smartypants'] = $smartypants;
}
protected function extendSnippets(array $snippets): array
{
return $this->extensions['snippets'] = array_merge($this->extensions['snippets'], $snippets);
}
protected function extendTags(array $tags): array
{
return $this->extensions['tags'] = KirbyTag::$types = array_merge(KirbyTag::$types, $tags);
}
protected function extendTemplates(array $templates): array
{
return $this->extensions['templates'] = array_merge($this->extensions['templates'], $templates);
}
protected function extendTranslations(array $translations): array
{
return $this->extensions['translations'] = array_replace_recursive($this->extensions['translations'], $translations);
}
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
*
* @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
*
* @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
include_once $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 array
*/
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
*
* @param array $plugins
* @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
*
* @return void
*/
protected function extensionsFromProps(array $props)
{
$this->extend($props);
}
/**
* Apply all default extensions
*
* @return void
*/
protected function extensionsFromSystem()
{
// Form Field Mixins
FormField::$mixins['options'] = include static::$root . '/config/fields/mixins/options.php';
// Tag Aliases
KirbyTag::$aliases = [
'youtube' => 'video',
'vimeo' => 'video'
];
// Field method aliases
Field::$aliases = [
'bool' => 'toBool',
'esc' => 'escape',
'excerpt' => 'toExcerpt',
'float' => 'toFloat',
'h' => 'html',
'int' => 'toInt',
'kt' => 'kirbytext',
'link' => 'toLink',
'md' => 'markdown',
'sp' => 'smartypants',
'v' => 'isValid',
'x' => 'xml'
];
// default cache types
$this->extendCacheTypes([
'apcu' => 'Kirby\Cache\ApcuCache',
'file' => 'Kirby\Cache\FileCache',
'memcached' => 'Kirby\Cache\MemCache',
]);
$this->extendComponents(include static::$root . '/config/components.php');
$this->extendBlueprints(include static::$root . '/config/blueprints.php');
$this->extendFields(include static::$root . '/config/fields.php');
$this->extendFieldMethods((include static::$root . '/config/methods.php')($this));
$this->extendTags(include static::$root . '/config/tags.php');
// blueprint presets
PageBlueprint::$presets['pages'] = include static::$root . '/config/presets/pages.php';
PageBlueprint::$presets['page'] = include static::$root . '/config/presets/page.php';
PageBlueprint::$presets['files'] = include static::$root . '/config/presets/files.php';
// section mixins
Section::$mixins['headline'] = include static::$root . '/config/sections/mixins/headline.php';
Section::$mixins['layout'] = include static::$root . '/config/sections/mixins/layout.php';
Section::$mixins['max'] = include static::$root . '/config/sections/mixins/max.php';
Section::$mixins['min'] = include static::$root . '/config/sections/mixins/min.php';
Section::$mixins['pagination'] = include static::$root . '/config/sections/mixins/pagination.php';
Section::$mixins['parent'] = include static::$root . '/config/sections/mixins/parent.php';
// section types
Section::$types['info'] = include static::$root . '/config/sections/info.php';
Section::$types['pages'] = include static::$root . '/config/sections/pages.php';
Section::$types['files'] = include static::$root . '/config/sections/files.php';
Section::$types['fields'] = include static::$root . '/config/sections/fields.php';
}
/**
* 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 Plugin|null
*/
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.
*
* @param array $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');
$kirby = $this;
$loaded = [];
foreach (Dir::read($root) as $dirname) {
if (in_array(substr($dirname, 0, 1), ['.', '_']) === true) {
continue;
}
if (is_dir($root . '/' . $dirname) === false) {
continue;
}
$dir = $root . '/' . $dirname;
$entry = $dir . '/index.php';
if (file_exists($entry) === false) {
continue;
}
include_once $entry;
$loaded[] = $dir;
}
return $loaded;
}
}

127
kirby/src/Cms/AppTranslations.php Executable file
View File

@@ -0,0 +1,127 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\I18n;
trait AppTranslations
{
protected $translations;
/**
* Setup internationalization
*
* @return void
*/
protected function i18n()
{
I18n::$load = function ($locale) {
$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;
};
I18n::$locale = function () {
if ($this->multilang() === true) {
return $this->defaultLanguage()->code();
} else {
return 'en';
}
};
I18n::$fallback = function () {
if ($this->multilang() === true) {
return $this->defaultLanguage()->code();
} else {
return 'en';
}
};
I18n::$translations = [];
}
/**
* Load and set the current language if it exists
* Otherwise fall back to the default language
*
* @param string $languageCode
* @return Language|null
*/
public function setCurrentLanguage(string $languageCode = null)
{
if ($languageCode === null) {
return $this->language = null;
}
if ($language = $this->language($languageCode)) {
$this->language = $language;
} else {
$this->language = $this->defaultLanguage();
}
if ($this->language) {
setlocale(LC_ALL, $this->language->locale());
}
return $this->language;
}
/**
* Set the current translation
*
* @param string $translationCode
* @return void
*/
public function setCurrentTranslation(string $translationCode = null)
{
I18n::$locale = $translationCode ?? 'en';
}
/**
* Load a specific translation by locale
*
* @param string|null $locale
* @return Translation|null
*/
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] ?? [];
// load from disk instead
return Translation::load($locale, $this->root('translations') . '/' . $locale . '.json', $inject);
}
/**
* Returns all available translations
*
* @return Translations
*/
public function translations()
{
if (is_a($this->translations, 'Kirby\Cms\Translations') === true) {
return $this->translations;
}
return Translations::load($this->root('translations'), $this->extensions['translations'] ?? []);
}
}

99
kirby/src/Cms/AppUsers.php Executable file
View File

@@ -0,0 +1,99 @@
<?php
namespace Kirby\Cms;
trait AppUsers
{
/**
* Cache for the auth auth layer
*
* @var Auth
*/
protected $auth;
/**
* Returns the Authentication layer class
*
* @return Auth
*/
public function auth()
{
return $this->auth = $this->auth ?? new Auth($this);
}
/**
* Become any existing user
*
* @param string|null $who
* @return self
*/
public function impersonate(string $who = null)
{
return $this->auth()->impersonate($who);
}
/**
* Set the currently active user id
*
* @param User|string $user
* @return self
*/
protected function setUser($user = null): self
{
$this->user = $user;
return $this;
}
/**
* Create your own set of app users
*
* @param array $users
* @return self
*/
protected function setUsers(array $users = null): self
{
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 $id
* @param \Kirby\Session\Session|array $session Session options or session object for getting the current user
* @return User|null
*/
public function user(string $id = null, $session = null)
{
if ($id !== null) {
return $this->users()->find($id);
}
if (is_string($this->user) === true) {
return $this->auth()->impersonate($this->user);
} else {
return $this->auth()->user();
}
}
/**
* Returns all users
*
* @return Users
*/
public function users(): Users
{
if (is_a($this->users, 'Kirby\Cms\Users') === true) {
return $this->users;
}
return $this->users = Users::load($this->root('accounts'), ['kirby' => $this]);
}
}

11
kirby/src/Cms/Asset.php Executable file
View File

@@ -0,0 +1,11 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Properties;
class Asset
{
use FileFoundation;
use Properties;
}

333
kirby/src/Cms/Auth.php Executable file
View File

@@ -0,0 +1,333 @@
<?php
namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Exception\PermissionException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Http\Request\Auth\BasicAuth;
use Kirby\Session\Session;
use Throwable;
/**
* Authentication layer
*/
class Auth
{
protected $impersonate;
protected $kirby;
protected $user;
/**
* @param App $kirby
*/
public function __construct(App $kirby)
{
$this->kirby = $kirby;
}
/**
* 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->kirby->option('api.csrf', csrf());
// compare both tokens
if (hash_equals((string)$fromSession, (string)$fromHeader) !== true) {
return false;
}
return $fromSession;
}
/**
* Returns the logged in user by checking
* for a basic authentication header with
* valid credentials
*
* @param BasicAuth|null $auth
* @return User|null
*/
public function currentUserFromBasicAuth(BasicAuth $auth = null)
{
if ($this->kirby->option('api.basicAuth', false) !== true) {
throw new PermissionException('Basic authentication is not activated');
}
$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
if ($request->ssl() === false) {
throw new PermissionException('Basic authentication is only allowed over HTTPS');
}
if ($user = $this->kirby->users()->find($auth->username())) {
if ($user->validatePassword($auth->password()) === true) {
return $user;
}
}
return null;
}
/**
* Returns the logged in user by checking
* the current session and finding a valid
* valid user id in there
*
* @param Session|null $session
* @return User|null
*/
public function currentUserFromSession($session = null)
{
// use passed session options or session object if set
if (is_array($session)) {
$session = $this->kirby->session($session);
}
// try session in header or cookie
if (is_a($session, 'Kirby\Session\Session') === false) {
$session = $this->kirby->session(['detect' => true]);
}
$id = $session->data()->get('user.id');
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;
}
/**
* Become any existing user
*
* @param string|null $who
* @return User|null
*/
public function impersonate(string $who = null)
{
switch ($who) {
case null:
return $this->impersonate = null;
case 'kirby':
return $this->impersonate = new User([
'email' => 'kirby@getkirby.com',
'id' => 'kirby',
'role' => 'admin',
]);
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
{
return hash('sha256', $this->kirby->visitor()->ip());
}
/**
* Check if logins are blocked for the current ip
*
* @return boolean
*/
public function isBlocked(): bool
{
$ip = $this->ipHash();
$log = $this->log();
$trials = $this->kirby->option('auth.trials', 10);
$timeout = $this->kirby->option('auth.timeout', 3600);
if ($entry = ($log[$ip] ?? null)) {
if ($entry['trials'] > $trials) {
if ($entry['time'] > (time() - $timeout)) {
return true;
}
}
}
return false;
}
/**
* Login a user by email and password
*
* @param string $email
* @param string $password
* @param boolean $long
* @return User|false
*/
public function login(string $email, string $password, bool $long = false)
{
// check for blocked ips
if ($this->isBlocked() === true) {
throw new PermissionException('Rate limit exceeded', 403);
}
// stop impersonating
$this->impersonate = null;
// session options
$options = [
'createMode' => 'cookie',
'long' => $long === true
];
// validate the user and log in to the session
if ($user = $this->kirby->users()->find($email)) {
if ($user->login($password, $options) === true) {
return $this->user = $user;
}
}
// log invalid login trial
$this->track();
// sleep for a random amount of milliseconds
// to make automated attacks harder
usleep(random_int(1000, 2000000));
return false;
}
/**
* 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 {
return Data::read($this->logfile(), 'json');
} catch (Throwable $e) {
return [];
}
}
/**
* Logout the current user
*
* @return boolean
*/
public function logout(): bool
{
// stop impersonating
$this->impersonate = null;
// logout the current user if it exists
if ($user = $this->user()) {
$user->logout();
}
$this->user = null;
return true;
}
/**
* Tracks a login
*
* @return boolean
*/
public function track(): bool
{
$ip = $this->ipHash();
$log = $this->log();
$time = time();
if (isset($log[$ip]) === true) {
$log[$ip] = [
'time' => $time,
'trials' => ($log[$ip]['trials'] ?? 0) + 1
];
} else {
$log[$ip] = [
'time' => $time,
'trials' => 1
];
}
return Data::write($this->logfile(), $log, 'json');
}
/**
* Returns the current authentication type
*
* @return string
*/
public function type(): string
{
$basicAuth = $this->kirby->option('api.basicAuth', false);
$auth = $this->kirby->request()->auth();
if ($basicAuth === true && $auth && $auth->type() === 'basic') {
return 'basic';
} elseif ($this->impersonate !== null) {
return 'impersonate';
} else {
return 'session';
}
}
/**
* Validates the currently logged in user
*
* @param array|Session|null $session
* @return User|null
*/
public function user($session = null): ?User
{
if ($this->impersonate !== null) {
return $this->impersonate;
}
try {
if ($this->type() === 'basic') {
return $this->user = $this->currentUserFromBasicAuth();
} else {
return $this->user = $this->currentUserFromSession($session);
}
} catch (Throwable $e) {
return $this->user = null;
}
}
}

759
kirby/src/Cms/Blueprint.php Executable file
View File

@@ -0,0 +1,759 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Data\Data;
use Kirby\Form\Field;
use Kirby\Toolkit\F;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Obj;
use Throwable;
/**
* The Blueprint class normalizes an array from a
* blueprint file and converts sections, columns, fields
* etc. into a correct tab layout.
*/
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 $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
*/
public function __construct(array $props)
{
if (empty($props['model']) === true) {
throw new InvalidArgumentException('A blueprint model is required');
}
$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'] = $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;
}
$mixin = static::find($extends);
if ($mixin === null) {
$props = $props;
} elseif (is_array($mixin) === true) {
$props = array_replace_recursive($mixin, $props);
} else {
try {
$props = array_replace_recursive(Data::read($mixin), $props);
} catch (Exception $e) {
$props = $props;
}
}
// remove the extends flag
unset($props['extends']);
return $props;
}
/**
* Create a new blueprint for a model
*
* @param string $name
* @param string $fallback
* @param Model $model
* @return self
*/
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 string|array
*/
public static function find(string $name)
{
$kirby = App::instance();
$root = $kirby->root('blueprints');
$file = $root . '/' . $name . '.yml';
if (F::exists($file, $root) === true) {
return $file;
}
if ($blueprint = $kirby->extension('blueprints', $name)) {
return $blueprint;
}
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
* @param string $fallback
* @param Model $model
* @return array
*/
public static function load(string $name)
{
if (isset(static::$loaded[$name]) === true) {
return static::$loaded[$name];
}
$props = static::find($name);
$normalize = function ($props) use ($name) {
// inject the filename as name if no name is set
$props['name'] = $props['name'] ?? $name;
// normalize the title
$title = $props['title'] ?? ucfirst($props['name']);
// translate the title
$props['title'] = I18n::translate($title, $title);
return $props;
};
if (is_array($props) === true) {
return $normalize($props);
}
$file = $props;
$props = Data::read($file);
return static::$loaded[$name] = $normalize($props);
}
/**
* Returns the parent model
*
* @return 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) {
$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;
}
public static function helpList(array $items)
{
$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
*/
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' => $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 = [];
}
// 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(function () {
return false;
}, $defaults);
}
// extend options if possible
$options = $this->extend($options);
foreach ($options as $key => $value) {
$alias = $aliases[$key] ?? null;
if ($alias !== null) {
$options[$alias] = $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) {
// inject all section extensions
$sectionProps = $this->extend($sectionProps);
$sections[$sectionName] = $sectionProps = array_merge($sectionProps, [
'name' => $sectionName,
'type' => $type = $sectionProps['type'] ?? null
]);
if (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) {
// 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)),
'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;
}
return static::$presets[$props['preset']]($props);
}
/**
* Returns a single section by name
*
* @param string $name
* @return Section|null
*/
public function section(string $name): ?Section
{
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 array_map(function ($section) {
return $this->section($section['name']);
}, $this->sections);
}
/**
* Returns a single tab by name
*
* @param string $name
* @return array|null
*/
public function tab(string $name): ?array
{
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;
}
}

271
kirby/src/Cms/Collection.php Executable file
View File

@@ -0,0 +1,271 @@
<?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 http://getkirby.com
* @copyright Bastian Allgeier
*/
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)) {
return $this->callMethod($key, $arguments);
}
}
/**
* Creates a new Collection with the given objects
*
* @param array $objects
* @param object $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[$object->id()] = $object;
}
/**
* Adds a single object or
* an entire second collection to the
* current collection
*
* @param mixed $item
*/
public function add($object)
{
if (is_a($object, static::class) === true) {
$this->data = array_merge($this->data, $object->data);
} else {
$this->__set($object->id(), $object);
}
return $this;
}
/**
* Appends an element to the data array
*
* @param mixed $key
* @param mixed $item
* @return Collection
*/
public function append(...$args)
{
if (count($args) === 1) {
if (is_object($args[0]) === true) {
$this->data[$args[0]->id()] = $args[0];
} else {
$this->data[] = $args[0];
}
} elseif (count($args) === 2) {
$this->set($args[0], $args[1]);
}
return $this;
}
/**
* Groups the items by a given field
*
* @param string $field
* @param bool $i (ignore upper/lowercase for group names)
* @return Collection A collection with an item for each group and a Collection for each group
*/
public function groupBy(string $field, bool $i = 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;
}
/**
* Checks if the given object or id
* is in the collection
*
* @param string|object
* @return boolean
*/
public function has($id): bool
{
if (is_object($id) === true) {
$id = $id->id();
}
return parent::has($id);
}
/**
* Correct position detection for objects.
* The method will automatically detect objects
* or ids and then search accordingly.
*
* @param string|object $object
* @return int
*/
public function indexOf($object): int
{
if (is_string($object) === true) {
return array_search($object, $this->keys());
}
return array_search($object->id(), $this->keys());
}
/**
* Returns a Collection without the given element(s)
*
* @param args any number of keys, passed as individual arguments
* @return Collection
*/
public function not(...$keys)
{
$collection = $this->clone();
foreach ($keys as $key) {
if (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
*
* @return Collection a sliced set of data
*/
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 Model
*/
public function parent()
{
return $this->parent;
}
/**
* 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 $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 $map
* @return array
*/
public function toArray(Closure $map = null): array
{
return parent::toArray($map ?? function ($object) {
return $object->toArray();
});
}
}

121
kirby/src/Cms/Collections.php Executable file
View File

@@ -0,0 +1,121 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\NotFoundException;
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 http://getkirby.com
* @copyright Bastian Allgeier
*/
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 Collection|null
*/
public function __call(string $name, array $arguments = [])
{
return $this->get($name, ...$arguments);
}
/**
* Creates a new Collections set
*
* @param array $collections
*/
public function __construct(array $collections = [])
{
$this->collections = $collections;
}
/**
* Loads a collection by name if registered
*
* @param string $name
* @param array $data
* @return Collection|null
*/
public function get(string $name, array $data = [])
{
if (isset($this->cache[$name]) === true) {
return $this->cache[$name];
}
if (isset($this->collections[$name]) === false) {
return null;
}
$controller = new Controller($this->collections[$name]);
return $this->cache[$name] = $controller->call(null, $data);
}
/**
* Checks if a collection exists
*
* @param string $name
* @return boolean
*/
public function has(string $name): bool
{
return isset($this->collections[$name]) === true;
}
/**
* Loads collections from php files in a
* given directory.
*
* @param string $root
* @return self
*/
public static function load(App $app): self
{
$collections = $app->extensions('collections');
$root = $app->root('collections');
foreach (glob($root . '/*.php') as $file) {
$collection = require $file;
if (is_a($collection, 'Closure')) {
$name = pathinfo($file, PATHINFO_FILENAME);
$collections[$name] = $collection;
}
}
return new static($collections);
}
}

222
kirby/src/Cms/Content.php Executable file
View File

@@ -0,0 +1,222 @@
<?php
namespace Kirby\Cms;
use Closure;
/**
* The Content class handles all fields
* for content from pages, the site and users
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
*/
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 Page|File|User|Site
*/
protected $parent;
/**
* Magic getter for content fields
*
* @param string $name
* @param array $arguments
* @return Field
*/
public function __call(string $name, array $arguments = []): Field
{
return $this->get($name);
}
/**
* Creates a new Content object
*
* @param array $data
* @param object $parent
*/
public function __construct($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();
}
/**
* 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 $key
* @return 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 boolean
*/
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 self
*/
public function not(...$keys): self
{
$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 Site|Page|File|User
*/
public function parent()
{
return $this->parent;
}
/**
* Set the parent model
*
* @param Model $parent
* @return self
*/
public function setParent(Model $parent): self
{
$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 $content
* @param bool $overwrite
* @return self
*/
public function update(array $content = null, bool $overwrite = false): self
{
$this->data = $overwrite === true ? (array)$content : array_merge($this->data, (array)$content);
return $this;
}
}

View File

@@ -0,0 +1,230 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Data;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Properties;
/**
* Each page, file or site can have multiple
* translated versions of their content,
* represented by this class
*/
class ContentTranslation
{
use Properties;
/**
* @var string
*/
protected $code;
/**
* @var array
*/
protected $content;
/**
* @var string
*/
protected $contentFile;
/**
* @var Page|Site|File
*/
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();
$content = $this->content ?? $parent->readContent($this->code());
// merge with the default content
if ($this->isDefault() === false && $defaultLanguage = $parent->kirby()->defaultLanguage()) {
$default = $parent->translation($defaultLanguage->code())->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 boolean
*/
public function exists(): bool
{
return file_exists($this->contentFile()) === true;
}
/**
* Returns the translation code as id
*
* @return void
*/
public function id()
{
return $this->code();
}
/**
* Checks if the this is the default translation
* of the model
*
* @return boolean
*/
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 Page|File|Site
*/
public function parent()
{
return $this->parent;
}
/**
* @param string $code
* @return self
*/
protected function setCode(string $code): self
{
$this->code = $code;
return $this;
}
/**
* @param array $content
* @return self
*/
protected function setContent(array $content = null): self
{
$this->content = $content;
return $this;
}
/**
* @param Model $parent
* @return self
*/
protected function setParent(Model $parent): self
{
$this->parent = $parent;
return $this;
}
/**
* @param string $slug
* @return self
*/
protected function setSlug(string $slug = null): self
{
$this->slug = $slug;
return $this;
}
/**
* Returns the custom translation slug
*
* @return string|null
*/
public function slug(): ?string
{
return $this->slug = $this->slug ?? ($this->content()['slug'] ?? null);
}
/**
* Merge the old and new data
*
* @param array|null $data
* @param bool $overwrite
* @return self
*/
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 imporant translation
* props to an array
*
* @return array
*/
public function toArray(): array
{
return [
'code' => $this->code(),
'content' => $this->content(),
'exists' => $this->exists(),
'slug' => $this->slug(),
];
}
}

177
kirby/src/Cms/Dir.php Executable file
View File

@@ -0,0 +1,177 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Toolkit\F;
/**
* Extension of the Toolkit Dir class with a new
* Dir::inventory method, that handles scanning directories
* and converts the results into our children, files and
* other page stuff.
*/
class Dir extends \Kirby\Toolkit\Dir
{
public static $numSeparator = '_';
/**
* Scans the directory and analyzes files,
* content, meta info and children. This is used
* in Page, Site and User objects to fetch all
* relevant information.
*
* @param string $dir
* @param string $contentExtension
* @param array $contentIgnore
* @param boolean $multilang
* @return array
*/
public static function inventory(string $dir, string $contentExtension = 'txt', array $contentIgnore = null, bool $multilang = false): array
{
$dir = realpath($dir);
$inventory = [
'children' => [],
'files' => [],
'template' => 'default',
];
if ($dir === false) {
return $inventory;
}
$items = Dir::read($dir, $contentIgnore);
// a temporary store for all content files
$content = [];
// sort all items naturally to avoid sorting issues later
natsort($items);
foreach ($items as $item) {
// ignore all items with a leading dot
if (in_array(substr($item, 0, 1), ['.', '_']) === true) {
continue;
}
$root = $dir . '/' . $item;
if (is_dir($root) === true) {
// extract the slug and num of the directory
if (preg_match('/^([0-9]+)' . static::$numSeparator . '(.*)$/', $item, $match)) {
$num = $match[1];
$slug = $match[2];
} else {
$num = null;
$slug = $item;
}
$inventory['children'][] = [
'dirname' => $item,
'model' => null,
'num' => $num,
'root' => $root,
'slug' => $slug,
];
} else {
$extension = pathinfo($item, PATHINFO_EXTENSION);
switch ($extension) {
case 'htm':
case 'html':
case 'php':
// don't track those files
break;
case $contentExtension:
$content[] = pathinfo($item, PATHINFO_FILENAME);
break;
default:
$inventory['files'][$item] = [
'filename' => $item,
'extension' => $extension,
'root' => $root,
];
}
}
}
// remove the language codes from all content filenames
if ($multilang === true) {
foreach ($content as $key => $filename) {
$content[$key] = pathinfo($filename, PATHINFO_FILENAME);
}
$content = array_unique($content);
}
$inventory = static::inventoryContent($dir, $inventory, $content);
$inventory = static::inventoryModels($inventory, $contentExtension, $multilang);
return $inventory;
}
/**
* Take all content files,
* remove those who are meta files and
* detect the main content file
*
* @param array $inventory
* @param array $content
* @return array
*/
protected static function inventoryContent(string $dir, array $inventory, array $content): array
{
// filter meta files from the content file
if (empty($content) === true) {
$inventory['template'] = 'default';
return $inventory;
}
foreach ($content as $contentName) {
// could be a meta file. i.e. cover.jpg
if (isset($inventory['files'][$contentName]) === true) {
continue;
}
// it's most likely the template
$inventory['template'] = $contentName;
}
return $inventory;
}
/**
* Go through all inventory children
* and inject a model for each
*
* @param array $inventory
* @param string $contentExtension
* @param bool $multilang
* @return array
*/
protected static function inventoryModels(array $inventory, string $contentExtension, bool $multilang = false): array
{
// inject models
if (empty($inventory['children']) === false && empty(Page::$models) === false) {
if ($multilang === true) {
$contentExtension = App::instance()->defaultLanguage()->code() . '.' . $contentExtension;
}
foreach ($inventory['children'] as $key => $child) {
foreach (Page::$models as $modelName => $modelClass) {
if (file_exists($child['root'] . '/' . $modelName . '.' . $contentExtension) === true) {
$inventory['children'][$key]['model'] = $modelName;
break;
}
}
}
}
return $inventory;
}
}

140
kirby/src/Cms/Email.php Executable file
View File

@@ -0,0 +1,140 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\NotFoundException;
use Kirby\Toolkit\Tpl;
/**
* Wrapper around our PHPMailer package, which
* handles all the magic connections between Kirby
* and sending emails, like email templates, file
* attachments, etc.
*/
class Email
{
protected $options;
protected $preset;
protected $props;
protected static $transform = [
'from' => 'user',
'replyTo' => 'user',
'to' => 'user',
'cc' => 'user',
'bcc' => 'user',
'attachments' => 'file'
];
public function __construct($preset = [], array $props = [])
{
$this->options = $options = App::instance()->option('email');
// load presets from options
$this->preset = $this->preset($preset);
$this->props = array_merge($this->preset, $props);
// add transport settings
$this->props['transport'] = $this->options['transport'] ?? [];
// transform model objects to values
foreach (static::$transform as $prop => $model) {
$this->transformProp($prop, $model);
}
// load template for body text
$this->template();
}
protected function preset($preset)
{
// only passed props, not preset name
if (is_string($preset) !== true) {
return $preset;
}
// preset does not exist
if (isset($this->options['presets'][$preset]) === false) {
throw new NotFoundException([
'key' => 'email.preset.notFound',
'data' => ['name' => $preset]
]);
}
return $this->options['presets'][$preset];
}
protected function template()
{
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() && $text->exists()) {
$this->props['body'] = [
'html' => $html->render($data),
'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');
}
}
}
protected function getTemplate(string $name, string $type = null)
{
return App::instance()->template('emails/' . $name, $type, 'text');
}
public function toArray(): array
{
return $this->props;
}
protected function transformFile($file)
{
return $this->transformModel($file, 'Kirby\Cms\File', 'root');
}
protected function transformModel($value, $class, $content)
{
// value is already a string
if (is_string($value) === true) {
return $value;
}
// value is a model object, get value through content method
if (is_a($value, $class) === true) {
return $value->$content();
}
// value is an array or collection, call transform on each item
if (is_array($value) === true || is_a($value, 'Kirby\Cms\Collection') === true) {
$models = [];
foreach ($value as $model) {
$models[] = $this->transformModel($model, $class, $content);
}
return $models;
}
}
protected function transformProp($prop, $model)
{
if (isset($this->props[$prop]) === true) {
$this->props[$prop] = $this->{'transform' . ucfirst($model)}($this->props[$prop]);
}
}
protected function transformUser($user)
{
return $this->transformModel($user, 'Kirby\Cms\User', 'email');
}
}

246
kirby/src/Cms/Field.php Executable file
View File

@@ -0,0 +1,246 @@
<?php
namespace Kirby\Cms;
use Closure;
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 http://getkirby.com
* @copyright Bastian Allgeier
*/
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 Site|Page|File|User
*/
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 = [])
{
if (isset(static::$methods[$method]) === true) {
return static::$methods[$method](clone $this, ...$arguments);
}
if (isset(static::$aliases[$method]) === true) {
$method = 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 $parent
* @param string $key
* @param mixed $value
*/
public function __construct($parent = null, string $key, $value)
{
$this->key = $key;
$this->value = $value;
$this->parent = $parent;
}
/**
* Simplifies the var_dump result
*
* @see Field::toArray
* @return void
*/
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 boolean
*/
public function exists(): bool
{
return $this->parent->content()->has($this->key);
}
/**
* Checks if the field content is empty
*
* @return boolean
*/
public function isEmpty(): bool
{
return empty($this->value) === true;
}
/**
* Checks if the field content is not empty
*
* @return boolean
*/
public function isNotEmpty(): bool
{
return empty($this->value) === false;
}
/**
* Returns the name of the field
*
* @return string
*/
public function key(): string
{
return $this->key;
}
/**
* Provides a fallback if the field value is empty
*
* @param mixed $fallback
* @return self
*/
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 Page|File|Site|User
*/
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
*
* @param string|Closure $value
* @return mixed If a new value is passed, the modified
* field will be returned. Otherwise it
* will return the field value.
*/
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;
}
}

867
kirby/src/Cms/File.php Executable file
View File

@@ -0,0 +1,867 @@
<?php
namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Image\Image;
use Kirby\Toolkit\A;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
use Throwable;
/**
* The File class is a wrapper around
* the Kirby\Image\Image class, which
* is used to handle all file methods.
* In addition the File class handles
* File meta data via Kirby\Cms\Content.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
*/
class File extends ModelWithContent
{
use FileActions;
use FileFoundation;
use HasMethods;
use HasSiblings;
/**
* The parent asset object
* This is used to do actual file
* method calls, like size, mime, etc.
*
* @var Image
*/
protected $asset;
/**
* Cache for the initialized blueprint object
*
* @var FileBlueprint
*/
protected $blueprint;
/**
* @var string
*/
protected $id;
/**
* @var string
*/
protected $filename;
/**
* All registered file methods
*
* @var array
*/
public static $methods = [];
/**
* The parent object
*
* @var 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, $arguments);
}
/**
* Creates a new File object
*
* @param array $props
*/
public function __construct(array $props)
{
// 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
*
* @param bool $relative
* @return string
*/
public function apiUrl(bool $relative = false): string
{
return $this->parent()->apiUrl($relative) . '/files/' . $this->filename();
}
/**
* Returns the Asset object
*
* @return Image
*/
public function asset(): Image
{
return $this->asset = $this->asset ?? new Image($this->root());
}
/**
* Returns the FileBlueprint object for the file
*
* @return FileBlueprint
*/
public function blueprint(): FileBlueprint
{
if (is_a($this->blueprint, 'Kirby\Cms\FileBlueprint') === true) {
return $this->blueprint;
}
return $this->blueprint = FileBlueprint::factory('files/' . $this->template(), 'files/default', $this);
}
/**
* Blurs the image by the given amount of pixels
*
* @param boolean $pixels
* @return self
*/
public function blur($pixels = true)
{
return $this->thumb(['blur' => $pixels]);
}
/**
* Converts the image to black and white
*
* @return self
*/
public function bw()
{
return $this->thumb(['grayscale' => true]);
}
/**
* Store the template in addition to the
* other content.
*
* @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
*
* @return string
*/
public function contentFileDirectory(): string
{
return dirname($this->root());
}
/**
* Filename for the content file
*
* @return string
*/
public function contentFileName(): string
{
return $this->filename();
}
/**
* Crops the image by the given width and height
*
* @param integer $width
* @param integer $height
* @param string|array $options
* @return self
*/
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
]);
}
/**
* Provides a kirbytag or markdown
* tag for the file, which will be
* used in the panel, when the file
* gets dragged onto a textarea
*
* @return string
*/
public function dragText($type = 'kirbytext'): string
{
switch ($type) {
case 'kirbytext':
if ($this->type() === 'image') {
return '(image: ' . $this->filename() . ')';
} else {
return '(file: ' . $this->filename() . ')';
}
// no break
case 'markdown':
if ($this->type() === 'image') {
return '![' . $this->alt() . '](./' . $this->filename() . ')';
} else {
return '[' . $this->filename() . '](./' . $this->filename() . ')';
}
}
}
/**
* Checks if the file exists on disk
*
* @return boolean
*/
public function exists(): bool
{
return is_file($this->root()) === true;
}
/**
* Returns the filename with extension
*
* @return string
*/
public function filename(): string
{
return $this->filename;
}
/**
* Returns the parent Files collection
*
* @return Files
*/
public function files(): Files
{
return $this->siblingsCollection();
}
/**
* Converts the file to html
*
* @param array $attr
* @return string
*/
public function html(array $attr = []): string
{
if ($this->type() === 'image') {
return Html::img($this->url(), array_merge(['alt' => $this->alt()], $attr));
} else {
return Html::a($this->url(), $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 File $file
* @return bool
*/
public function is(File $file): bool
{
return $this->id() === $file->id();
}
/**
* Create a unique media hash
*
* @return string
*/
public function mediaHash(): string
{
return crc32($this->filename()) . '-' . $this->modified();
}
/**
* Returns the absolute path to the file in the public media folder
*
* @return string
*/
public function mediaRoot(): string
{
return $this->parent()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->filename();
}
/**
* Returns the absolute Url to the file in the public media folder
*
* @return string
*/
public function mediaUrl(): string
{
return $this->parent()->mediaUrl() . '/' . $this->mediaHash() . '/' . $this->filename();
}
/**
* Alias for the old way of fetching File
* content. Nowadays `File::content()` should
* be used instead.
*
* @return Content
*/
public function meta(): Content
{
return $this->content();
}
/**
* Returns the parent model.
* This is normally the parent page
* or the site object.
*
* @return Site|Page
*/
public function model()
{
return $this->parent();
}
/**
* Get the file's last modification time.
*
* @param string $format
* @param string|null $handler date or strftime
* @return mixed
*/
public function modified(string $format = null, string $handler = null)
{
return F::modified($this->root(), $format, $handler ?? $this->kirby()->option('date.handler', 'date'));
}
/**
* Returns the parent Page object
*
* @return Page
*/
public function page()
{
return is_a($this->parent(), 'Kirby\Cms\Page') === true ? $this->parent() : null;
}
/**
* Panel icon definition
*
* @param array $params
* @return array
*/
public function panelIcon(array $params = null): array
{
$colorBlue = '#81a2be';
$colorPurple = '#b294bb';
$colorOrange = '#de935f';
$colorGreen = '#a7bd68';
$colorAqua = '#8abeb7';
$colorYellow = '#f0c674';
$colorRed = '#d16464';
$colorWhite = '#c5c9c6';
$types = [
'image' => ['color' => $colorOrange, 'type' => 'file-image'],
'video' => ['color' => $colorYellow, 'type' => 'file-video'],
'document' => ['color' => $colorRed, 'type' => 'file-document'],
'audio' => ['color' => $colorAqua, 'type' => 'file-audio'],
'code' => ['color' => $colorBlue, 'type' => 'file-code'],
'archive' => ['color' => $colorWhite, 'type' => 'file-zip'],
];
$extensions = [
'indd' => ['color' => $colorPurple],
'xls' => ['color' => $colorGreen, 'type' => 'file-spreadsheet'],
'xlsx' => ['color' => $colorGreen, 'type' => 'file-spreadsheet'],
'csv' => ['color' => $colorGreen, 'type' => 'file-spreadsheet'],
'docx' => ['color' => $colorBlue, 'type' => 'file-word'],
'doc' => ['color' => $colorBlue, 'type' => 'file-word'],
'rtf' => ['color' => $colorBlue, 'type' => 'file-word'],
'mdown' => ['type' => 'file-text'],
'md' => ['type' => 'file-text']
];
$definition = array_merge($types[$this->type()] ?? [], $extensions[$this->extension()] ?? []);
$settings = [
'type' => $definition['type'] ?? 'file',
'back' => 'pattern',
'color' => $definition['color'] ?? $colorWhite,
'ratio' => $params['ratio'] ?? null,
];
return $settings;
}
/**
* Panel image definition
*
* @param string|array|false $settings
* @param array $thumbSettings
* @return array
*/
public function panelImage($settings = null, array $thumbSettings = null): ?array
{
$defaults = [
'ratio' => '3/2',
'back' => 'pattern',
'cover' => false
];
// switch the image off
if ($settings === false) {
return null;
}
if (is_string($settings) === true) {
$settings = [
'query' => $settings
];
}
$image = $this->query($settings['query'] ?? null, 'Kirby\Cms\File');
if ($image === null && $this->isViewable() === true) {
$image = $this;
}
if ($image) {
$settings['url'] = $image->thumb($thumbSettings)->url(true);
unset($settings['query']);
}
return array_merge($defaults, (array)$settings);
}
/**
* Returns the full path without leading slash
*
* @return string
*/
public function panelPath(): string
{
return 'files/' . $this->filename();
}
/**
* Returns the url to the editing view
* in the panel
*
* @param bool $relative
* @return string
*/
public function panelUrl(bool $relative = false): string
{
return $this->parent()->panelUrl($relative) . '/' . $this->panelPath();
}
/**
* Returns the parent Model object
*
* @return Model
*/
public function parent()
{
return $this->parent = $this->parent ?? $this->kirby()->site();
}
/**
* Returns the parent id if a parent exists
*
* @return string|null
*/
public function parentId(): ?string
{
if ($parent = $this->parent()) {
return $parent->id();
}
return null;
}
/**
* Returns a collection of all parent pages
*
* @return Pages
*/
public function parents(): Pages
{
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 FilePermissions
*/
public function permissions()
{
return new FilePermissions($this);
}
/**
* Sets the JPEG compression quality
*
* @param integer $quality
* @return self
*/
public function quality(int $quality)
{
return $this->thumb(['quality' => $quality]);
}
/**
* Creates a string query, starting from the model
*
* @param string|null $query
* @param string|null $expect
* @return mixed
*/
public function query(string $query = null, string $expect = null)
{
if ($query === null) {
return null;
}
$result = Str::query($query, [
'kirby' => $this->kirby(),
'site' => $this->site(),
'file' => $this
]);
if ($expect !== null && is_a($result, $expect) !== true) {
return null;
}
return $result;
}
/**
* Resizes the file with the given width and height
* while keeping the aspect ratio.
*
* @param integer $width
* @param integer $height
* @param integer $quality
* @return self
*/
public function resize(int $width = null, int $height = null, int $quality = null)
{
return $this->thumb([
'width' => $width,
'height' => $height,
'quality' => $quality
]);
}
/**
* Returns the absolute root to the file
*
* @return string|null
*/
public function root(): ?string
{
return $this->root = $this->root ?? $this->parent()->root() . '/' . $this->filename();
}
/**
* Returns the FileRules class to
* validate any important action.
*
* @return FileRules
*/
protected function rules()
{
return new FileRules();
}
/**
* Sets the Blueprint object
*
* @param array|null $blueprint
* @return self
*/
protected function setBlueprint(array $blueprint = null): self
{
if ($blueprint !== null) {
$blueprint['model'] = $this;
$this->blueprint = new FileBlueprint($blueprint);
}
return $this;
}
/**
* Sets the filename
*
* @param string $filename
* @return self
*/
protected function setFilename(string $filename): self
{
$this->filename = $filename;
return $this;
}
/**
* Sets the parent model object
*
* @param Model $parent
* @return self
*/
protected function setParent(Model $parent = null): self
{
$this->parent = $parent;
return $this;
}
/**
* Always set the root to null, to invoke
* auto root detection
*
* @param string|null $root
* @return self
*/
protected function setRoot(string $root = null)
{
$this->root = null;
return $this;
}
/**
* @param string $template
* @return self
*/
protected function setTemplate(string $template = null): self
{
$this->template = $template;
return $this;
}
/**
* Sets the url
*
* @param string $url
* @return self
*/
protected function setUrl(string $url = null): self
{
$this->url = $url;
return $this;
}
/**
* Returns the parent Files collection
*
* @return Files
*/
protected function siblingsCollection()
{
return $this->parent()->files();
}
/**
* Returns the parent Site object
*
* @return Site
*/
public function site(): 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->template ?? $this->content()->get('template')->value();
}
/**
* Returns siblings with the same template
*
* @param bool $self
* @return self
*/
public function templateSiblings(bool $self = true)
{
return $this->siblings($self)->filterBy('template', $this->template());
}
/**
* 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 $options
* @return FileVersion|File
*/
public function thumb(array $options = null)
{
if (empty($options) === true) {
return $this;
}
$result = $this->kirby()->component('file::version')($this->kirby(), $this, $options);
if (is_a($result, FileVersion::class) === false && is_a($result, File::class) === false) {
throw new InvalidArgumentException('The file::version component must return a File or FileVersion object');
}
return $result;
}
/**
* 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());
}
/**
* String template builder
*
* @param string|null $template
* @return string
*/
public function toString(string $template = null): string
{
if ($template === null) {
return $this->id();
}
return Str::template($template, [
'file' => $this,
'site' => $this->site(),
'kirby' => $this->kirby()
]);
}
/**
* Returns the Url
*
* @return string
*/
public function url(): string
{
return $this->url ?? $this->url = $this->kirby()->component('file::url')($this->kirby(), $this, []);
}
}

256
kirby/src/Cms/FileActions.php Executable file
View File

@@ -0,0 +1,256 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Image\Image;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
trait FileActions
{
/**
* Renames the file without touching the extension
* The store is used to actually execute this.
*
* @param string $name
* @param bool $sanitize
* @return self
*/
public function changeName(string $name, bool $sanitize = true): self
{
if ($sanitize === true) {
$name = F::safeName($name);
}
// don't rename if not necessary
if ($name === $this->name()) {
return $this;
}
return $this->commit('changeName', [$this, $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 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());
}
return $newFile;
});
}
/**
* Changes the file's sorting number in the meta file
*
* @param integer $sort
* @return self
*/
public function changeSort(int $sort)
{
return $this->commit('changeSort', [$this, $sort], function ($file, $sort) {
return $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();
$this->rules()->$action(...$arguments);
$kirby->trigger('file.' . $action . ':before', ...$arguments);
$result = $callback(...$arguments);
$kirby->trigger('file.' . $action . ':after', $result, $old);
$kirby->cache('pages')->flush();
return $result;
}
/**
* 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 self
*/
public static function create(array $props): self
{
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']));
// create the basic file and a test upload object
$file = new static($props);
$upload = new Image($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', [$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', [$this], function ($file) {
$file->unpublish();
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());
return true;
});
}
/**
* Move the file to the public media folder
* if it's not already there.
*
* @return self
*/
public function publish(): self
{
Media::publish($this->root(), $this->mediaRoot());
return $this;
}
/**
* Alias for changeName
*
* @param string $name
* @param bool $sanitize
* @return self
*/
public function rename(string $name, bool $sanitize = true)
{
return $this->changeName($name, $sanitize);
}
/**
* 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 self
*/
public function replace(string $source): self
{
return $this->commit('replace', [$this, new Image($source)], 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 self
*/
public function unpublish(): self
{
Media::unpublish($this->parent()->mediaRoot(), $this->filename());
return $this;
}
}

65
kirby/src/Cms/FileBlueprint.php Executable file
View File

@@ -0,0 +1,65 @@
<?php
namespace Kirby\Cms;
/**
* Extension of the basic blueprint class
* to handle all blueprints for files.
*/
class FileBlueprint extends Blueprint
{
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,
'replace' => null,
'update' => null,
]
);
// normalize the accept settings
$this->props['accept'] = $this->normalizeAccept($this->props['accept'] ?? []);
}
public function accept(): array
{
return $this->props['accept'];
}
protected function normalizeAccept($accept = null)
{
if (is_string($accept) === true) {
$accept = [
'mime' => $accept
];
}
// accept anything
if (empty($accept) === true) {
return [];
}
$accept = array_change_key_case($accept);
$defaults = [
'mime' => null,
'maxheight' => null,
'maxsize' => null,
'maxwidth' => null,
'minheight' => null,
'minsize' => null,
'minwidth' => null,
'orientation' => null
];
return array_merge($defaults, $accept);
}
}

230
kirby/src/Cms/FileFoundation.php Executable file
View File

@@ -0,0 +1,230 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\BadMethodCallException;
use Kirby\Image\Image;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Properties;
/**
* Foundation for all file objects
*/
trait FileFoundation
{
protected $asset;
protected $root;
protected $url;
/**
* Magic caller for asset methods
*
* @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);
}
throw new BadMethodCallException('The method: "' . $method . '" does not exist');
}
/**
* Constructor sets all file properties
*
* @param array $props
*/
public function __construct(array $props)
{
$this->setProperties($props);
}
/**
* Converts the file object to a string
* In case of an image, it will create an image tag
* Otherwise it will return the url
*
* @return string
*/
public function __toString(): string
{
if ($this->type() === 'image') {
return $this->html();
}
return $this->url();
}
/**
* Returns the Asset object
*^
* @return Image
*/
public function asset(): Image
{
return $this->asset = $this->asset ?? new Image($this->root());
}
/**
* Checks if the file exists on disk
*
* @return boolean
*/
public function exists(): bool
{
return file_exists($this->root()) === true;
}
/**
* Returns the file extension
*
* @return string
*/
public function extension(): string
{
return F::extension($this->root());
}
/**
* Converts the file to html
*
* @param array $attr
* @return string
*/
public function html(array $attr = []): string
{
if ($this->type() === 'image') {
return Html::img($this->url(), array_merge(['alt' => $this->alt()], $attr));
} else {
return Html::a($this->url(), $attr);
}
}
/**
* Checks if the file is a resizable image
*
* @return boolean
*/
public function isResizable(): bool
{
$resizable = [
'jpg',
'jpeg',
'gif',
'png',
'webp'
];
return in_array($this->extension(), $resizable) === true;
}
/**
* Checks if a preview can be displayed for the file
* in the panel or in the frontend
*
* @return boolean
*/
public function isViewable(): bool
{
$viewable = [
'jpg',
'jpeg',
'gif',
'png',
'svg',
'webp'
];
return in_array($this->extension(), $viewable) === true;
}
/**
* Returns the paren app instance
*
* @return App
*/
public function kirby(): App
{
return App::instance();
}
/**
* Returns the absolute path to the file root
*
* @return string|null
*/
public function root(): ?string
{
return $this->root;
}
/**
* Setter for the root
*
* @param string $root
* @return self
*/
protected function setRoot(string $root = null)
{
$this->root = $root;
return $this;
}
/**
* Setter for the file url
*
* @param string $url
* @return self
*/
protected function setUrl(string $url)
{
$this->url = $url;
return $this;
}
/**
* Convert the object to an array
*
* @return array
*/
public function toArray(): array
{
$array = array_merge($this->asset()->toArray(), [
'isResizable' => $this->isResizable(),
'url' => $this->url(),
]);
ksort($array);
return $array;
}
/**
* Returns the file type
*
* @return string|null
*/
public function type()
{
return F::type($this->root());
}
/**
* Returns the absolute url for the file
*
* @return string
*/
public function url(): string
{
return $this->url;
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace Kirby\Cms;
class FilePermissions extends ModelPermissions
{
protected $category = 'files';
}

192
kirby/src/Cms/FileRules.php Executable file
View File

@@ -0,0 +1,192 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\PermissionException;
use Kirby\Image\Image;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\V;
/**
* Validators for all file actions
*/
class FileRules
{
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()]
]);
}
$parent = $file->parent();
$duplicate = $parent->files()->not($file)->findBy('name', $name);
if ($duplicate) {
throw new DuplicateException([
'key' => 'file.duplicate',
'data' => ['filename' => $duplicate->filename()]
]);
}
return true;
}
public static function changeSort(File $file, int $sort): bool
{
return true;
}
public static function create(File $file, Image $upload): bool
{
if ($file->exists() === true) {
throw new LogicException('The file exists and cannot be overwritten');
}
if ($file->permissions()->create() !== true) {
throw new PermissionException('The file cannot be created');
}
static::validExtension($file, $file->extension());
static::validMime($file, $upload->mime());
static::validFilename($file, $file->filename());
$upload->match($file->blueprint()->accept());
return true;
}
public static function delete(File $file): bool
{
if ($file->permissions()->delete() !== true) {
throw new LogicException('The file cannot be deleted');
}
return true;
}
public static function replace(File $file, Image $upload): bool
{
if ($file->permissions()->replace() !== true) {
throw new LogicException('The file cannot be replaced');
}
static::validMime($file, $upload->mime());
if ((string)$upload->mime() !== (string)$file->mime()) {
throw new InvalidArgumentException([
'key' => 'file.mime.differs',
'data' => ['mime' => $file->mime()]
]);
}
$upload->match($file->blueprint()->accept());
return true;
}
public static function update(File $file, array $content = []): bool
{
if ($file->permissions()->update() !== true) {
throw new LogicException('The file cannot be updated');
}
return true;
}
public static function validExtension(File $file, string $extension): bool
{
// make it easier to compare the extension
$extension = strtolower($extension);
if (empty($extension)) {
throw new InvalidArgumentException([
'key' => 'file.extension.missing',
'data' => ['filename' => $file->filename()]
]);
}
if (V::in($extension, ['php', 'html', 'htm', 'exe', App::instance()->contentExtension()])) {
throw new InvalidArgumentException([
'key' => 'file.extension.forbidden',
'data' => ['extension' => $extension]
]);
}
if (Str::contains($extension, 'php')) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
'data' => ['type' => 'PHP']
]);
}
return true;
}
public static function validFilename(File $file, string $filename)
{
// 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;
}
public static function validMime(File $file, string $mime = null)
{
// 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;
}
}

81
kirby/src/Cms/FileVersion.php Executable file
View File

@@ -0,0 +1,81 @@
<?php
namespace Kirby\Cms;
class FileVersion extends Asset
{
protected $modifications;
protected $original;
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
return $this->original()->content()->get($method, $arguments);
}
public function id(): string
{
return dirname($this->original()->id()) . '/' . $this->filename();
}
public function kirby(): App
{
return $this->original()->kirby();
}
public function modifications(): array
{
return $this->modifications ?? [];
}
public function original(): File
{
return $this->original;
}
public function save()
{
$this->kirby()->thumb($this->original()->root(), $this->root(), $this->modifications());
return $this;
}
protected function setModifications(array $modifications = null)
{
$this->modifications = $modifications;
}
protected function setOriginal(File $original)
{
$this->original = $original;
}
/**
* Convert the object to an array
*
* @return array
*/
public function toArray(): array
{
$array = array_merge(parent::toArray(), [
'modifications' => $this->modifications(),
]);
ksort($array);
return $array;
}
}

303
kirby/src/Cms/Filename.php Executable file
View File

@@ -0,0 +1,303 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Str;
/**
* The Filename class handles complex
* mapping of file attributes (i.e for thumbnails)
* into human readable filenames.
*
* ```php
* $filename = new Filename('some-file.jpg', '{{ name }}-{{ attributes }}.{{ extension }}', [
* 'crop' => 'top left',
* 'width' => 300,
* 'height' => 200
* 'quality' => 80
* ]);
*
* echo $filename->toString();
* // result: some-file-300x200-crop-top-left-q80.jpg
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
*/
class Filename
{
/**
* List of all applicable attributes
*
* @var array
*/
protected $attributes;
/**
* The sanitized file extension
*
* @var string
*/
protected $extension;
/**
* The source original filename
*
* @var string
*/
protected $filename;
/**
* The sanitized file name
*
* @var string
*/
protected $name;
/**
* The template for the final name
*
* @var string
*/
protected $template;
/**
* Creates a new Filename object
*
* @param string $filename
* @param string $template
* @param array $attributes
*/
public function __construct(string $filename, string $template, array $attributes = [])
{
$this->filename = $filename;
$this->template = $template;
$this->attributes = $attributes;
$this->extension = $this->sanitizeExtension(pathinfo($filename, PATHINFO_EXTENSION));
$this->name = $this->sanitizeName(pathinfo($filename, PATHINFO_FILENAME));
}
/**
* Converts the entire object to a string
*
* @return string
*/
public function __toString(): string
{
return $this->toString();
}
/**
* Converts all processed attributes
* to an array. The array keys are already
* the shortened versions for the filename
*
* @return array
*/
public function attributesToArray(): array
{
$array = [
'dimensions' => implode('x', $this->dimensions()),
'crop' => $this->crop(),
'blur' => $this->blur(),
'bw' => $this->grayscale(),
'q' => $this->quality(),
];
$array = array_filter($array, function ($item) {
return $item !== null && $item !== false && $item !== '';
});
return $array;
}
/**
* Converts all processed attributes
* to a string, that can be used in the
* new filename
*
* @param string $prefix The prefix will be used in the filename creation
* @return string
*/
public function attributesToString(string $prefix = null): string
{
$array = $this->attributesToArray();
$result = [];
foreach ($array as $key => $value) {
if ($value === true) {
$value = '';
}
switch ($key) {
case 'dimensions':
$result[] = $value;
break;
case 'crop':
$result[] = ($value === 'center') ? null : $key . '-' . $value;
break;
default:
$result[] = $key . $value;
}
}
$result = array_filter($result);
$attributes = implode('-', $result);
if (empty($attributes) === true) {
return '';
}
return $prefix . $attributes;
}
/**
* Normalizes the blur option value
*
* @return false|int
*/
public function blur()
{
$value = $this->attributes['blur'] ?? false;
if ($value === false) {
return false;
}
return intval($value);
}
/**
* Normalizes the crop option value
*
* @return false|string
*/
public function crop()
{
// get the crop value
$crop = $this->attributes['crop'] ?? false;
if ($crop === false) {
return false;
}
return Str::slug($crop);
}
/**
* Returns a normalized array
* with width and height values
* if available
*
* @return array
*/
public function dimensions()
{
if (empty($this->attributes['width']) === true && empty($this->attributes['height']) === true) {
return [];
}
return [
'width' => $this->attributes['width'] ?? null,
'height' => $this->attributes['height'] ?? null
];
}
/**
* Returns the sanitized extension
*
* @return string
*/
public function extension(): string
{
return $this->extension;
}
/**
* Normalizes the grayscale option value
* and also the available ways to write
* the option. You can use `grayscale`,
* `greyscale` or simply `bw`. The function
* will always return `grayscale`
*
* @return bool
*/
public function grayscale(): bool
{
// normalize options
$value = $this->attributes['grayscale'] ?? $this->attributes['greyscale'] ?? $this->attributes['bw'] ?? false;
// turn anything into boolean
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
/**
* Returns the filename without extension
*
* @return string
*/
public function name(): string
{
return $this->name;
}
/**
* Normalizes the quality option value
*
* @return false|int
*/
public function quality()
{
$value = $this->attributes['quality'] ?? false;
if ($value === false || $value === true) {
return false;
}
return intval($value);
}
/**
* Sanitizes the file extension.
* The extension will be converted
* to lowercase and `jpeg` will be
* replaced with `jpg`
*
* @param string $extension
* @return string
*/
protected function sanitizeExtension(string $extension): string
{
$extension = strtolower($extension);
$extension = str_replace('jpeg', 'jpg', $extension);
return $extension;
}
/**
* Sanitizes the name with Kirby's
* Str::slug function
*
* @param string $name
* @return string
*/
protected function sanitizeName(string $name): string
{
return Str::slug($name);
}
/**
* Returns the converted filename as string
*
* @return string
*/
public function toString(): string
{
return Str::template($this->template, [
'name' => $this->name(),
'attributes' => $this->attributesToString('-'),
'extension' => $this->extension()
]);
}
}

132
kirby/src/Cms/Files.php Executable file
View File

@@ -0,0 +1,132 @@
<?php
namespace Kirby\Cms;
/**
* An extended version of the Collection
* class, that has custom find methods and
* a Files::factory method to convert an array
* into a Files collection.
*/
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 mixed $item
* @return Files
*/
public function add($object)
{
// add a page collection
if (is_a($object, static::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, File::class) === true) {
$this->__set($object->id(), $object);
}
return $this;
}
/**
* Sort all given files by the
* order in the array
*
* @param array $files
* @return self
*/
public function changeSort(array $files)
{
$index = 0;
foreach ($files as $filename) {
if ($file = $this->get($filename)) {
$index++;
$file->changeSort($index);
}
}
return $this;
}
/**
* Creates a files collection from an array of props
*
* @param array $files
* @param Model $parent
* @param array $inject
* @return Files
*/
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 = new File($props);
$collection->data[$file->id()] = $file;
}
return $collection;
}
/**
* Tries to find a file by id/filename
*
* @param string $id
* @return File|null
*/
public function findById($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 File|null
*/
public function findByKey($key)
{
return $this->findById($key);
}
/**
* Filter all files by the given template
*
* @param null|string|array $template
* @return self
*/
public function template($template): self
{
if (empty($template) === true) {
return $this;
}
return $this->filterBy('template', is_array($template) ? 'in' : '==', $template);
}
}

67
kirby/src/Cms/Form.php Executable file
View File

@@ -0,0 +1,67 @@
<?php
namespace Kirby\Cms;
use Kirby\Form\Form as BaseForm;
/**
* Extension of `Kirby\Form\Form` that introduces
* a Form::for method that creates a proper form
* definition for any Cms Model.
*/
class Form extends BaseForm
{
protected $errors;
protected $fields;
protected $values = [];
public function __construct(array $props)
{
$kirby = App::instance();
if ($kirby->multilang() === true) {
$fields = $props['fields'] ?? [];
$isDefaultLanguage = $kirby->language()->isDefault();
foreach ($fields as $fieldName => $fieldProps) {
// switch untranslatable fields to readonly
if (($fieldProps['translate'] ?? true) === false && $isDefaultLanguage === false) {
$fields[$fieldName]['unset'] = true;
$fields[$fieldName]['disabled'] = true;
}
}
$props['fields'] = $fields;
}
parent::__construct($props);
}
public static function for(Model $model, array $props = [])
{
// get the original model data
$original = $model->content()->toArray();
// set a few defaults
$props['values'] = array_merge($original, $props['values'] ?? []);
$props['fields'] = $props['fields'] ?? [];
$props['model'] = $model;
// search for the blueprint
if (method_exists($model, 'blueprint') === true && $blueprint = $model->blueprint()) {
$props['fields'] = $blueprint->fields();
}
$ignoreDisabled = $props['ignoreDisabled'] ?? false;
// REFACTOR: this could be more elegant
if ($ignoreDisabled === true) {
$props['fields'] = array_map(function ($field) {
$field['disabled'] = false;
return $field;
}, $props['fields']);
}
return new static($props);
}
}

252
kirby/src/Cms/HasChildren.php Executable file
View File

@@ -0,0 +1,252 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Str;
trait HasChildren
{
/**
* The Pages collection
*
* @var Pages
*/
public $children;
/**
* The list of available drafts
*
* @var Pages
*/
public $drafts;
/**
* Returns the Pages collection
*
* @return 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 children and drafts at the same time
*
* @return Pages
*/
public function childrenAndDrafts()
{
return $this->children()->merge($this->drafts());
}
/**
* Return a list of ids for the model's
* toArray method
*
* @return array
*/
protected function convertChildrenToArray(): array
{
return $this->children()->keys();
}
/**
* Searches for a child draft by id
*
* @param string $path
* @return 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;
}
/**
* Return all drafts for the site
*
* @return Pages
*/
public function drafts(): Pages
{
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 children by id
*
* @param string ...$arguments
* @return Pages
*/
public function find(...$arguments)
{
return $this->children()->find(...$arguments);
}
/**
* Finds a single page or draft
*
* @return Page|null
*/
public function findPageOrDraft(string $path)
{
return $this->children()->find($path) ?? $this->drafts()->find($path);
}
/**
* Returns a collection of all children of children
*
* @return Pages
*/
public function grandChildren(): Pages
{
return $this->children()->children();
}
/**
* Checks if the model has any children
*
* @return boolean
*/
public function hasChildren(): bool
{
return $this->children()->count() > 0;
}
/**
* Checks if the model has any drafts
*
* @return boolean
*/
public function hasDrafts(): bool
{
return $this->drafts()->count() > 0;
}
/**
* Deprecated! Use Page::hasUnlistedChildren
*
* @return boolean
*/
public function hasInvisibleChildren(): bool
{
return $this->children()->invisible()->count() > 0;
}
/**
* Checks if the page has any listed children
*
* @return boolean
*/
public function hasListedChildren(): bool
{
return $this->children()->listed()->count() > 0;
}
/**
* Checks if the page has any unlisted children
*
* @return boolean
*/
public function hasUnlistedChildren(): bool
{
return $this->children()->unlisted()->count() > 0;
}
/**
* Deprecated! Use Page::hasListedChildren
*
* @return boolean
*/
public function hasVisibleChildren(): bool
{
return $this->children()->listed()->count() > 0;
}
/**
* Creates a flat child index
*
* @param bool $drafts
* @return Pages
*/
public function index(bool $drafts = false): Pages
{
if ($drafts === true) {
return $this->childrenAndDrafts()->index($drafts);
} else {
return $this->children()->index();
}
}
/**
* Sets the Children collection
*
* @param array|null $children
* @return self
*/
protected function setChildren(array $children = null)
{
if ($children !== null) {
$this->children = Pages::factory($children, $this);
}
return $this;
}
/**
* Sets the Drafts collection
*
* @param array|null $drafts
* @return self
*/
protected function setDrafts(array $drafts = null)
{
if ($drafts !== null) {
$this->drafts = Pages::factory($drafts, $this, true);
}
return $this;
}
}

220
kirby/src/Cms/HasFiles.php Executable file
View File

@@ -0,0 +1,220 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Str;
trait HasFiles
{
/**
* The Files collection
*
* @var Files
*/
protected $files;
/**
* Filters the Files collection by type audio
*
* @return Files
*/
public function audio(): Files
{
return $this->files()->filterBy('type', '==', 'audio');
}
/**
* Filters the Files collection by type code
*
* @return Files
*/
public function code(): Files
{
return $this->files()->filterBy('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 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 Files
*/
public function documents(): Files
{
return $this->files()->filterBy('type', '==', 'document');
}
/**
* Returns a specific file by filename or the first one
*
* @param string $filename
* @param string $in
* @return File
*/
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 Files
*/
public function files(): 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 $filename
* @return File
*/
public function image(string $filename = null)
{
return $this->file($filename, 'images');
}
/**
* Filters the Files collection by type image
*
* @return Files
*/
public function images(): Files
{
return $this->files()->filterBy('type', '==', 'image');
}
/**
* Sets the Files collection
*
* @param Files|null $files
* @return self
*/
protected function setFiles(array $files = null): self
{
if ($files !== null) {
$this->files = Files::factory($files, $this);
}
return $this;
}
/**
* Filters the Files collection by type videos
*
* @return Files
*/
public function videos(): Files
{
return $this->files()->filterBy('type', '==', 'video');
}
}

38
kirby/src/Cms/HasMethods.php Executable file
View File

@@ -0,0 +1,38 @@
<?php
namespace Kirby\Cms;
trait HasMethods
{
/**
* All registered methods
*
* @var array
*/
public static $methods = [];
/**
* Calls a registered method class with the
* passed arguments
*
* @param string $method
* @param array $args
* @return mixed
*/
public function callMethod(string $method, array $args = [])
{
return static::$methods[$method]->call($this, ...$args);
}
/**
* Checks if the object has a registered method
*
* @param string $method
* @return boolean
*/
public function hasMethod(string $method): bool
{
return isset(static::$methods[$method]) === true;
}
}

133
kirby/src/Cms/HasSiblings.php Executable file
View File

@@ -0,0 +1,133 @@
<?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 http://getkirby.com
* @copyright Bastian Allgeier
*/
trait HasSiblings
{
/**
* Returns the position / index in the collection
*
* @return int
*/
public function indexOf(): int
{
return $this->siblingsCollection()->indexOf($this);
}
/**
* Returns the next item in the collection if available
*
* @return Model|null
*/
public function next()
{
return $this->siblingsCollection()->nth($this->indexOf() + 1);
}
/**
* Returns the end of the collection starting after the current item
*
* @return Collection
*/
public function nextAll()
{
return $this->siblingsCollection()->slice($this->indexOf() + 1);
}
/**
* Returns the previous item in the collection if available
*
* @return Model|null
*/
public function prev()
{
return $this->siblingsCollection()->nth($this->indexOf() - 1);
}
/**
* Returns the beginning of the collection before the current item
*
* @return Collection
*/
public function prevAll()
{
return $this->siblingsCollection()->slice(0, $this->indexOf());
}
/**
* Returns all sibling elements
*
* @param bool $self
* @return 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
*
* @return bool
*/
public function hasNext(): bool
{
return $this->next() !== null;
}
/**
* Checks if there's a previous item in the collection
*
* @return bool
*/
public function hasPrev(): bool
{
return $this->prev() !== null;
}
/**
* Checks if the item is the first in the collection
*
* @return bool
*/
public function isFirst(): bool
{
return $this->siblingsCollection()->first()->is($this);
}
/**
* Checks if the item is the last in the collection
*
* @return bool
*/
public function isLast(): bool
{
return $this->siblingsCollection()->last()->is($this);
}
/**
* Checks if the item is at a certain position
*
* @return bool
*/
public function isNth(int $n): bool
{
return $this->indexOf() === $n;
}
}

24
kirby/src/Cms/Html.php Executable file
View File

@@ -0,0 +1,24 @@
<?php
namespace Kirby\Cms;
/**
* Custom extension of the Toolkit Html builder class
* that overwrites the Html::a method to include Cms
* Url handling.
*/
class Html extends \Kirby\Toolkit\Html
{
/**
* Generates an a tag with an absolute Url
*
* @param string $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 a(string $href = null, $text = null, array $attr = []): string
{
return parent::a(Url::to($href), $text, $attr);
}
}

91
kirby/src/Cms/Ingredients.php Executable file
View File

@@ -0,0 +1,91 @@
<?php
namespace Kirby\Cms;
use Closure;
/**
* The Ingredients class is the foundation for
* $kirby->urls() and $kirby->roots() objects.
* Those are configured in `kirby/config/urls.php`
* and `kirby/config/roots.php`
*/
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 $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
*
* @param array $ingredients
* @return self
*/
public static function bake(array $ingredients): self
{
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;
}
}

55
kirby/src/Cms/KirbyTag.php Executable file
View File

@@ -0,0 +1,55 @@
<?php
namespace Kirby\Cms;
/**
* Extended KirbyTag class to provide
* common helpers for tag objects
*/
class KirbyTag extends \Kirby\Text\KirbyTag
{
/**
* Finds a file for the given path.
* The method first searches the file
* in the current parent, if it's a page.
* Afterwards it uses Kirby's global file finder.
*
* @param string $path
* @return File|null
*/
public function file(string $path): ?File
{
$parent = $this->parent();
if (method_exists($parent, 'file') === true && $file = $parent->file($path)) {
return $file;
}
if (is_a($parent, File::class) === true && $file = $parent->page()->file($path)) {
return $file;
}
return $this->kirby()->file($path, null, true);
}
/**
* Returns the current Kirby instance
*
* @return App
*/
public function kirby(): App
{
return $this->data['kirby'] ?? App::instance();
}
/**
* Returns the parent model
*
* @return Page|Site|File|User
*/
public function parent()
{
return $this->data['parent'];
}
}

55
kirby/src/Cms/KirbyTags.php Executable file
View File

@@ -0,0 +1,55 @@
<?php
namespace Kirby\Cms;
use Exception;
/**
* Extension of `Kirby\Text\KirbyTags` that introduces
* `kirbytags:before` and `kirbytags:after` hooks
*/
class KirbyTags extends \Kirby\Text\KirbyTags
{
/**
* The KirbyTag rendering class
*
* @var string
*/
protected static $tagClass = KirbyTag::class;
/**
* @param string $text
* @param array $data
* @param array $options
* @param array $hooks
* @return string
*/
public static function parse(string $text = null, array $data = [], array $options = [], array $hooks = []): string
{
$text = static::hooks($hooks['kirbytags:before'] ?? [], $text, $data, $options);
$text = parent::parse($text, $data, $options);
$text = static::hooks($hooks['kirbytags:after'] ?? [], $text, $data, $options);
return $text;
}
/**
* Runs the given hooks and returns the
* modified text
*
* @param array $hooks
* @param string $text
* @param array $data
* @param array $options
* @return string
*/
protected static function hooks(array $hooks, string $text = null, array $data, array $options)
{
foreach ($hooks as $hook) {
$text = $hook->call($data['kirby'], $text, $data, $options);
}
return $text;
}
}

485
kirby/src/Cms/Language.php Executable file
View File

@@ -0,0 +1,485 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
/**
* Represents a content language
* in a multi-language setup
*/
class Language extends Model
{
/**
* @var string
*/
protected $code;
/**
* @var bool
*/
protected $default;
/**
* @var string
*/
protected $direction;
/**
* @var string
*/
protected $locale;
/**
* @var string
*/
protected $name;
/**
* @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',
'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 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 boolean
*/
protected static function converter(string $from, string $to): bool
{
$kirby = App::instance();
$site = $kirby->site();
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
*
* @param array $props
* @return self
*/
public static function create(array $props): self
{
$props['code'] = Str::slug($props['code'] ?? null);
$kirby = App::instance();
$languages = $kirby->languages();
$site = $kirby->site();
// make the first language the default language
if ($languages->count() === 0) {
$props['default'] = true;
}
$language = new static($props);
if ($language->exists() === true) {
throw new DuplicateException('The language already exists');
}
$language->save();
if ($languages->count() === 0) {
static::converter('', $language->code());
}
return $language;
}
/**
* Delete the current language and
* all its translation files
*
* @return boolean
*/
public function delete(): bool
{
if ($this->exists() === false) {
return true;
}
$kirby = App::instance();
$languages = $kirby->languages();
$site = $kirby->site();
$code = $this->code();
if (F::remove($this->root()) !== true) {
throw new Exception('The language could not be deleted');
}
if ($languages->count() === 1) {
return $this->converter($code, '');
} else {
return $this->deleteContentFiles($code);
}
}
/**
* When the language is deleted, all content files with
* the language code must be removed as well.
*
* @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 boolean
*/
public function exists(): bool
{
return file_exists($this->root());
}
/**
* Checks if this is the default language
* for the site.
*
* @return boolean
*/
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;
}
/**
* Returns the PHP locale setting string
*
* @return string
*/
public function locale(): string
{
return $this->locale;
}
/**
* Returns the human-readable name
* of the language
*
* @return string
*/
public function name(): string
{
return $this->name;
}
/**
* Returns the routing pattern for the language
*
* @return string
*/
public function pattern(): string
{
return $this->url;
}
/**
* Returns the absolute path to the language file
*
* @return string
*/
public function root(): string
{
return App::instance()->root('languages') . '/' . $this->code() . '.php';
}
/**
* Saves the language settings in the languages folder
*
* @return self
*/
public function save(): self
{
$data = $this->toArray();
unset($data['url']);
$export = '<?php' . PHP_EOL . PHP_EOL . 'return ' . var_export($data, true) . ';';
F::write($this->root(), $export);
return $this;
}
/**
* @param string $code
* @return self
*/
protected function setCode(string $code): self
{
$this->code = $code;
return $this;
}
/**
* @param boolean $default
* @return self
*/
protected function setDefault(bool $default = false): self
{
$this->default = $default;
return $this;
}
/**
* @param string $direction
* @return self
*/
protected function setDirection(string $direction = 'ltr'): self
{
$this->direction = $direction === 'rtl' ? 'rtl' : 'ltr';
return $this;
}
/**
* @param string $locale
* @return self
*/
protected function setLocale(string $locale = null): self
{
$this->locale = $locale ?? $this->code;
return $this;
}
/**
* @param string $name
* @return self
*/
protected function setName(string $name = null): self
{
$this->name = $name ?? $this->code;
return $this;
}
/**
* @param array $translations
* @return self
*/
protected function setTranslations(array $translations = null): self
{
$this->translations = $translations ?? [];
return $this;
}
/**
* @param string $url
* @return self
*/
protected function setUrl(string $url = null): self
{
$this->url = $url !== null ? trim($url, '/') : $this->code;
return $this;
}
/**
* 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(),
'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
{
return Url::to($this->url);
}
/**
* Update language properties and save them
*
* @param array $props
* @return self
*/
public function update(array $props = null): self
{
$props['slug'] = Str::slug($props['slug'] ?? null);
$kirby = App::instance();
$updated = $this->clone($props);
// 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');
}
return $updated->save();
}
}

83
kirby/src/Cms/Languages.php Executable file
View File

@@ -0,0 +1,83 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\F;
/**
* A collection of all defined site languages
*/
class Languages extends Collection
{
/**
* Returns all language codes as array
*
* @return array
*/
public function codes(): array
{
return $this->keys();
}
/**
* Creates a new language with the given props
*
* @param array $props
* @return Language
*/
public function create(array $props): Language
{
return Language::create($props);
}
/**
* Returns the default language
*
* @return Language|null
*/
public function default(): ?Language
{
if ($language = $this->findBy('isDefault', true)) {
return $language;
} else {
return $this->first();
}
}
/**
* Deprecated version of static::default();
*
* @return Language|null
*/
public function findDefault(): ?Language
{
return $this->default();
}
/**
* Convert all defined languages to a collection
*
* @return self
*/
public static function load(): self
{
$languages = new static;
$files = glob(App::instance()->root('languages') . '/*.php');
foreach ($files as $file) {
$props = include_once $file;
if (is_array($props) === true) {
// inject the language code from the filename if it does not exist
$props['code'] = $props['code'] ?? F::name($file);
$language = new Language($props);
$languages->data[$language->code()] = $language;
}
}
return $languages;
}
}

136
kirby/src/Cms/Media.php Executable file
View File

@@ -0,0 +1,136 @@
<?php
namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
use Throwable;
/**
* Handles all tasks to get the Media API
* up and running and link files correctly
*/
class Media
{
/**
* Tries to find a job file for the
* given filename and then calls the thumb
* component to create a thumbnail accordingly
*
* @param Model $model
* @param string $hash
* @param string $filename
* @return Response|false
*/
public static function thumb(Model $model, string $hash, string $filename)
{
try {
$kirby = $model->kirby();
$url = $model->mediaUrl() . '/' . $hash . '/' . $filename;
$root = $model->mediaRoot() . '/' . $hash;
$thumb = $root . '/' . $filename;
$job = $root . '/.jobs/' . $filename . '.json';
$options = Data::read($job);
$file = $model->file($options['filename']);
if (!$file || empty($options) === true) {
return false;
}
$kirby->thumb($file->root(), $thumb, $options);
F::remove($job);
return Response::file($thumb);
} catch (Throwable $e) {
return false;
}
}
/**
* Tries to find a file by model and filename
* and to copy it to the media folder.
*
* @param Model $model
* @param string $hash
* @param string $filename
* @return 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)) {
// the media hash is outdated. redirect to the correct url
if ($file->mediaHash() !== $hash) {
return Response::redirect($file->mediaUrl(), 307);
}
// 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 string $src
* @param string $dest
* @return boolean
*/
public static function publish(string $src, string $dest): bool
{
$filename = basename($src);
$version = dirname($dest);
$directory = dirname($version);
// unpublish all files except stuff in the version folder
Media::unpublish($directory, $filename, $version);
// copy/overwrite the file to the dest folder
return F::copy($src, $dest, true);
}
/**
* Deletes all versions of the given filename
* within the parent directory
*
* @param string $directory
* @param string $filename
* @param string $ignore
* @return bool
*/
public static function unpublish(string $directory, string $filename, string $ignore = null): bool
{
if (is_dir($directory) === false) {
return true;
}
$versions = glob($directory . '/' . crc32($filename) . '*', GLOB_ONLYDIR);
// delete all versions of the file
foreach ($versions as $version) {
if ($version === $ignore) {
continue;
}
Dir::remove($version);
}
return true;
}
}

105
kirby/src/Cms/Model.php Executable file
View File

@@ -0,0 +1,105 @@
<?php
namespace Kirby\Cms;
use stdClass;
use ReflectionMethod;
use Kirby\Toolkit\Properties;
use Kirby\Toolkit\Str;
/**
* Foundation for Page, Site, File and User models.
*/
abstract class Model
{
use Properties;
/**
* The parent Kirby instance
*
* @var App
*/
public static $kirby;
/**
* The parent Site instance
*
* @var Site
*/
protected $site;
/**
* Makes it possible to convert the entire model
* to a string. Mostly useful for debugging
*
* @return string
*/
public function __toString()
{
return $this->id();
}
/**
* Each model must return a unique id
*
* @return string|int
*/
public function id()
{
return null;
}
/**
* Returns the parent Kirby instance
*
* @return App|null
*/
public function kirby(): App
{
return static::$kirby = static::$kirby ?? App::instance();
}
/**
* Returns the parent Site instance
*
* @return Site|null
*/
public function site()
{
return $this->site = $this->site ?? $this->kirby()->site();
}
/**
* Setter for the parent Kirby object
*
* @param Kirby|null $kirby
* @return self
*/
protected function setKirby(App $kirby = null)
{
static::$kirby = $kirby;
return $this;
}
/**
* Setter for the parent Site object
*
* @param Site|null $site
* @return self
*/
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,68 @@
<?php
namespace Kirby\Cms;
abstract class ModelPermissions
{
protected $category;
protected $model;
protected $options;
protected $permissions;
protected $user;
public function __call(string $method, array $arguments = [])
{
return $this->can($method);
}
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();
}
public function can(string $action): bool
{
if ($this->user->role()->id() === 'nobody') {
return false;
}
if (method_exists($this, 'can' . $action) === true && $this->{'can' . $action}() === false) {
return false;
}
if (isset($this->options[$action]) === true && $this->options[$action] === false) {
return false;
}
return $this->permissions->for($this->category, $action);
}
public function cannot(string $action): bool
{
return $this->can($action) === false;
}
public function toArray(): array
{
$array = [];
foreach ($this->options as $key => $value) {
$array[$key] = $this->can($key);
}
return $array;
}
}

View File

@@ -0,0 +1,442 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
use Throwable;
abstract class ModelWithContent extends Model
{
/**
* The content
*
* @var Content
*/
public $content;
/**
* @var Translations
*/
public $translations;
/**
* Returns the blueprint of the model
*
* @return Blueprint
*/
abstract public function blueprint();
/**
* 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 $languageCode
* @return Content
*/
public function content(string $languageCode = null): Content
{
// 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
*
* @param string|null $languageCode
* @param bool $force
* @return string
*/
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;
}
}
/**
* Prepares the content that should be written
* to the text file
*
* @param array $data
* @param string $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
*
* @return string|null
*/
public function contentFileDirectory(): ?string
{
return $this->root();
}
/**
* Returns the extension of the content file
*
* @return string
*/
public function contentFileExtension(): string
{
return $this->kirby()->contentExtension();
}
/**
* Needs to be declared by the final model
*
* @return string
*/
abstract public function contentFileName(): string;
/**
* Decrement a given field value
*
* @param string $field
* @param integer $by
* @param integer $min
* @return self
*/
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) {
if (method_exists($section, 'errors') === true || isset($section->errors)) {
$errors = array_merge($errors, $section->errors());
}
}
return $errors;
}
/**
* Increment a given field value
*
* @param string $field
* @param integer $by
* @param integer $max
* @return self
*/
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 data has any errors
*
* @return boolean
*/
public function isValid(): bool
{
return Form::for($this)->hasErrors() === false;
}
/**
* Read the content from the content file
*
* @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
*/
abstract public function root(): ?string;
/**
* Stores the content on disk
*
* @param string $languageCode
* @param array $data
* @param bool $overwrite
* @return self
*/
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 self
*/
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 self
*/
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);
}
// merge the translation with the new data
$translation->update($data, $overwrite);
// 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 Content|null $content
* @return self
*/
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 $translations
* @return self
*/
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;
}
/**
* Returns a single translation by language code
* If no code is specified the current translation is returned
*
* @param string $languageCode
* @return Translation|null
*/
public function translation(string $languageCode = null)
{
return $this->translations()->find($languageCode ?? $this->kirby()->language()->code());
}
/**
* Returns the translations collection
*
* @return 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 $input
* @param string $language
* @param boolean $validate
* @return self
*/
public function update(array $input = null, string $languageCode = null, bool $validate = false)
{
$form = Form::for($this, [
'input' => $input,
'ignoreDisabled' => $validate === false,
]);
// validate the input
if ($validate === true) {
if ($form->isInvalid() === true) {
throw new InvalidArgumentException([
'fallback' => 'Invalid form with errors',
'details' => $form->errors()
]);
}
}
return $this->commit('update', [$this, $form->data(), $form->strings(), $languageCode], function ($model, $values, $strings, $languageCode) {
return $model->save($strings, $languageCode, true);
});
}
/**
* Low level data writer method
* to store the given data on disk or anywhere else
*
* @param array $data
* @param string $languageCode
* @return boolean
*/
public function writeContent(array $data, string $languageCode = null): bool
{
return Data::write(
$this->contentFile($languageCode),
$this->contentFileData($data, $languageCode)
);
}
}

39
kirby/src/Cms/Nest.php Executable file
View File

@@ -0,0 +1,39 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Obj;
/**
* 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
*/
class Nest
{
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_string($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,25 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Toolkit\Collection as BaseCollection;
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 $map
* @return array
*/
public function toArray(Closure $map = null): array
{
return parent::toArray($map ?? function ($object) {
return $object->toArray();
});
}
}

35
kirby/src/Cms/NestObject.php Executable file
View File

@@ -0,0 +1,35 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Obj;
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;
}
}

1510
kirby/src/Cms/Page.php Executable file

File diff suppressed because it is too large Load Diff

730
kirby/src/Cms/PageActions.php Executable file
View File

@@ -0,0 +1,730 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
trait PageActions
{
/**
* Changes the sorting number
* The sorting number must already be correct
* when the method is called
*
* @param int $num
* @return self
*/
public function changeNum(int $num = null): self
{
if ($this->isDraft() === true) {
throw new LogicException('Drafts cannot change their sorting number');
}
return $this->commit('changeNum', [$this, $num], function ($oldPage, $num) {
$newPage = $oldPage->clone([
'num' => $num,
'dirname' => null,
'root' => null
]);
// actually move the page on disk
if ($oldPage->exists() === true) {
Dir::move($oldPage->root(), $newPage->root());
}
// 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 $language
* @return self
*/
public function changeSlug(string $slug, string $languageCode = null): self
{
// 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;
}
return $this->commit('changeSlug', [$this, $slug, $languageCode = null], function ($oldPage, $slug) {
$newPage = $oldPage->clone([
'slug' => $slug,
'dirname' => null,
'root' => null
]);
// actually move stuff on disk
if ($oldPage->exists() === true) {
if (Dir::move($oldPage->root(), $newPage->root()) !== true) {
throw new LogicException('The page directory cannot be moved');
}
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 $language
* @return self
*/
protected function changeSlugForLanguage(string $slug, string $languageCode = null): self
{
$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');
}
return $this->commit('changeSlug', [$this, $slug, $languageCode], 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
*
* @param string $status "draft", "listed" or "unlisted"
* @param integer $position Optional sorting number
* @return Page
*/
public function changeStatus(string $status, int $position = null): self
{
switch ($status) {
case 'draft':
return $this->changeStatusToDraft();
case 'listed':
return $this->changeStatusToListed($position);
case 'unlisted':
return $this->changeStatusToUnlisted();
default:
throw new Exception('Invalid status: ' . $status);
}
}
protected function changeStatusToDraft(): self
{
$page = $this->commit('changeStatus', [$this, 'draft'], function ($page) {
return $page->unpublish();
});
return $page;
}
protected function changeStatusToListed(int $position = null): self
{
// create a sorting number for the page
$num = $this->createNum($position);
// don't sort if not necessary
if ($this->status() === 'listed' && $num === $this->num()) {
return $this;
}
$page = $this->commit('changeStatus', [$this, 'listed', $num], function ($page, $status, $position) {
return $page->publish()->changeNum($position);
});
if ($this->blueprint()->num() === 'default') {
$page->resortSiblingsAfterListing($num);
}
return $page;
}
protected function changeStatusToUnlisted(): self
{
if ($this->status() === 'unlisted') {
return $this;
}
$page = $this->commit('changeStatus', [$this, 'unlisted'], function ($page) {
return $page->publish()->changeNum(null);
});
$this->resortSiblingsAfterUnlisting();
return $page;
}
/**
* Changes the page template
*
* @param string $template
* @return self
*/
public function changeTemplate(string $template): self
{
if ($template === $this->template()->name()) {
return $this;
}
// prepare data to transfer between blueprints
$oldBlueprint = 'pages/' . $this->template();
$newBlueprint = 'pages/' . $template;
return $this->commit('changeTemplate', [$this, $template], function ($oldPage, $template) use ($oldBlueprint, $newBlueprint) {
if ($this->kirby()->multilang() === true) {
$newPage = $this->clone([
'template' => $template
]);
foreach ($this->kirby()->languages()->codes() as $code) {
if (F::remove($oldPage->contentFile($code)) !== true) {
throw new LogicException('The old text file could not be removed');
}
// convert the content to the new blueprint
$content = $oldPage->transferData($oldPage->content($code), $oldBlueprint, $newBlueprint)['data'];
// save the language file
$newPage->save($content, $code);
}
// return a fresh copy of the object
return $newPage->clone();
} else {
$newPage = $this->clone([
'content' => $this->transferData($this->content(), $oldBlueprint, $newBlueprint)['data'],
'template' => $template
]);
if (F::remove($oldPage->contentFile()) !== true) {
throw new LogicException('The old text file could not be removed');
}
return $newPage->save();
}
});
}
/**
* Change the page title
*
* @param string $title
* @param string|null $languageCode
* @return self
*/
public function changeTitle(string $title, string $languageCode = null): self
{
return $this->commit('changeTitle', [$this, $title, $languageCode], function ($page, $title, $languageCode) {
return $page->save(['title' => $title], $languageCode);
});
}
/**
* 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 mixed ...$arguments
* @return mixed
*/
protected function commit(string $action, array $arguments, Closure $callback)
{
$old = $this->hardcopy();
$this->rules()->$action(...$arguments);
$this->kirby()->trigger('page.' . $action . ':before', ...$arguments);
$result = $callback(...$arguments);
$this->kirby()->trigger('page.' . $action . ':after', $result, $old);
$this->kirby()->cache('pages')->flush();
return $result;
}
/**
* Creates and stores a new page
*
* @param array $props
* @return self
*/
public static function create(array $props): self
{
// 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, $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 self
*/
public function createChild(array $props): self
{
$props = array_merge($props, [
'url' => null,
'num' => null,
'parent' => $this,
'site' => $this->site(),
]);
return static::create($props);
}
/**
* Create the sorting number for the page
* depending on the blueprint settings
*
* @param integer $num
* @return integer
*/
public function createNum(int $num = null): int
{
$mode = $this->blueprint()->num();
switch ($mode) {
case 'zero':
return 0;
case 'date':
case 'datetime':
$format = 'date' ? 'Ymd' : 'YmdHi';
$date = $this->content()->get('date')->value();
$time = empty($date) === true ? time() : strtotime($date);
return date($format, $time);
break;
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:
$template = Str::template($mode, [
'kirby' => $this->kirby(),
'page' => $this,
'site' => $this->site(),
]);
return intval($template);
}
}
/**
* Deletes the page
*
* @param bool $force
* @return bool
*/
public function delete(bool $force = false): bool
{
return $this->commit('delete', [$this, $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;
});
}
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
*/
public function purge(): self
{
$this->children = null;
$this->blueprint = null;
$this->drafts = null;
$this->files = null;
$this->content = null;
$this->inventory = null;
return $this;
}
protected function resortSiblingsAfterListing(int $position = null): bool
{
// get all siblings including the current page
$siblings = $this
->parentModel()
->children()
->listed()
->append($this)
->filter(function ($page) {
return $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;
} else {
if ($sibling = $siblings->get($id)) {
$sibling->changeNum($key + 1);
}
}
}
$parent = $this->parentModel();
$parent->children = $parent->children()->sortBy('num', 'asc');
return true;
}
public function resortSiblingsAfterUnlisting(): bool
{
$index = 0;
$parent = $this->parentModel();
$siblings = $parent
->children()
->listed()
->not($this)
->filter(function ($page) {
return $page->blueprint()->num() === 'default';
});
if ($siblings->count() > 0) {
foreach ($siblings as $sibling) {
$index++;
$sibling->changeNum($index);
}
$parent->children = $siblings->sortBy('num', 'desc');
}
return true;
}
public function sort($position = null)
{
return $this->changeStatus('listed', $position);
}
/**
* Transfers data from old to new blueprint and tracks changes
*
* @param Content $content
* @param string $old Old blueprint
* @param string $new New blueprint
* @return array
*/
protected function transferData(Content $content, string $old, string $new): array
{
// Prepare data
$data = [];
$old = Blueprint::factory($old, 'pages/default', $this);
$new = Blueprint::factory($new, 'pages/default', $this);
$oldForm = new Form(['fields' => $old->fields(), 'model' => $this]);
$newForm = new Form(['fields' => $new->fields(), 'model' => $this]);
$oldFields = $oldForm->fields();
$newFields = $newForm->fields();
// Tracking changes
$added = [];
$replaced = [];
$removed = [];
// Ensure to keep title
$data['title'] = $content->get('title')->value();
// Go through all fields of new template
foreach ($newFields as $newField) {
$name = $newField->name();
$oldField = $oldFields->get($name);
// Field name matches with old template
if ($oldField !== null) {
// Same field type, add and keep value
if ($oldField->type() === $newField->type()) {
$data[$name] = $content->get($name)->value();
// Different field type, add with empty value
} else {
$data[$name] = null;
$replaced[$name] = $oldFields->get($name)->label() ?? $name;
}
// Field does not exist in old template,
// add with empty or preserved value
} else {
$preserved = $content->get($name);
$data[$name] = $preserved ? $preserved->value(): null;
$added[$name] = $newField->label() ?? $name;
}
}
// Go through all values to preserve them
foreach ($content->fields() as $field) {
$name = $field->key();
$newField = $newFields->get($name);
if ($newField === null) {
$data[$name] = $field->value();
$removed[$name] = $field->name();
}
}
return [
'data' => $data,
'added' => $added,
'replaced' => $replaced,
'removed' => $removed
];
}
/**
* Convert a page from listed or
* unlisted to draft.
*
* @return self
*/
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 $input
* @param string $language
* @param boolean $validate
* @return self
*/
public function update(array $input = null, string $language = null, bool $validate = false)
{
if ($this->isDraft() === true) {
$validate = false;
}
$page = parent::update($input, $language, $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;
}
}

196
kirby/src/Cms/PageBlueprint.php Executable file
View File

@@ -0,0 +1,196 @@
<?php
namespace Kirby\Cms;
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,
'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',
'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;
}
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 controlls the "Open"
* button in the panel and redirects it to a
* different URL if necessary.
*
* @return string|boolean
*/
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,53 @@
<?php
namespace Kirby\Cms;
class PagePermissions extends ModelPermissions
{
protected $category = 'pages';
protected function canChangeSlug(): bool
{
return $this->model->isHomeOrErrorPage() !== true;
}
protected function canChangeStatus(): bool
{
return $this->model->isErrorPage() !== true;
}
protected function canChangeTemplate(): bool
{
if ($this->model->isHomeOrErrorPage() === true) {
return false;
}
if (count($this->model->blueprints()) <= 1) {
return false;
}
return true;
}
protected function canDelete(): bool
{
return $this->model->isHomeOrErrorPage() !== true;
}
protected function canSort(): bool
{
if ($this->model->isErrorPage() === true) {
return false;
}
if ($this->model->isListed() !== true) {
return false;
}
if ($this->model->blueprint()->num() !== 'default') {
return false;
}
return true;
}
}

241
kirby/src/Cms/PageRules.php Executable file
View File

@@ -0,0 +1,241 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\Str;
/**
* Validators for all page actions
*/
class PageRules
{
public static function changeNum(Page $page, int $num = null): bool
{
if ($num !== null && $num < 0) {
throw new InvalidArgumentException(['key' => 'page.num.invalid']);
}
return true;
}
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()]
]);
}
$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;
}
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']);
}
}
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;
}
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;
}
if ($page->permissions()->changeStatus() !== true) {
throw new PermissionException([
'key' => 'page.changeStatus.permission',
'data' => ['slug' => $page->slug()]
]);
}
if ($position !== null && $position < 0) {
throw new InvalidArgumentException(['key' => 'page.num.invalid']);
}
if ($page->isDraft() === true && empty($page->errors()) === false) {
throw new PermissionException([
'key' => 'page.changeStatus.incomplete',
'details' => $page->errors()
]);
}
return true;
}
public static function changeStatusToUnlisted(Page $page)
{
if ($page->permissions()->changeStatus() !== true) {
throw new PermissionException([
'key' => 'page.changeStatus.permission',
'data' => ['slug' => $page->slug()]
]);
}
return true;
}
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;
}
public static function changeTitle(Page $page, string $title): bool
{
if (Str::length($title) === 0) {
throw new InvalidArgumentException([
'key' => 'page.changeTitle.empty',
]);
}
if ($page->permissions()->changeTitle() !== true) {
throw new PermissionException([
'key' => 'page.changeTitle.permission',
'data' => ['slug' => $page->slug()]
]);
}
return true;
}
public static function create(Page $page): bool
{
if ($page->exists() === true) {
throw new DuplicateException([
'key' => 'page.draft.duplicate',
'data' => ['slug' => $page->slug()]
]);
}
if ($page->permissions()->create() !== true) {
throw new PermissionException(['key' => 'page.create.permission']);
}
$siblings = $page->parentModel()->children();
$drafts = $page->parentModel()->drafts();
$slug = $page->slug();
if ($duplicate = $siblings->find($slug)) {
throw new DuplicateException([
'key' => 'page.duplicate',
'data' => ['slug' => $slug]
]);
}
if ($duplicate = $drafts->find($slug)) {
throw new DuplicateException([
'key' => 'page.draft.duplicate',
'data' => ['slug' => $slug]
]);
}
return true;
}
public static function delete(Page $page, bool $force = false): bool
{
if ($page->permissions()->delete() !== true) {
throw new PermissionException(['key' => 'page.delete.permission']);
}
if (($page->hasChildren() === true || $page->hasDrafts() === true) && $force === false) {
throw new LogicException(['key' => 'page.delete.hasChildren']);
}
return true;
}
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;
}
}

188
kirby/src/Cms/PageSiblings.php Executable file
View File

@@ -0,0 +1,188 @@
<?php
namespace Kirby\Cms;
trait PageSiblings
{
/**
* @deprecated Use Page::hasNextUnlisted instead
* @return boolean
*/
public function hasNextInvisible(): bool
{
return $this->hasNextUnlisted();
}
/**
* Checks if there's a next listed
* page in the siblings collection
*
* @return bool
*/
public function hasNextListed(): bool
{
return $this->nextListed() !== null;
}
/**
* @deprecated Use Page::hasNextListed instead
* @return boolean
*/
public function hasNextVisible(): bool
{
return $this->hasNextListed();
}
/**
* Checks if there's a next unlisted
* page in the siblings collection
*
* @return bool
*/
public function hasNextUnlisted(): bool
{
return $this->nextUnlisted() !== null;
}
/**
* @deprecated Use Page::hasPrevUnlisted instead
* @return boolean
*/
public function hasPrevInvisible(): bool
{
return $this->hasPrevUnlisted();
}
/**
* Checks if there's a previous listed
* page in the siblings collection
*
* @return bool
*/
public function hasPrevListed(): bool
{
return $this->prevListed() !== null;
}
/**
* Checks if there's a previous unlisted
* page in the siblings collection
*
* @return bool
*/
public function hasPrevUnlisted(): bool
{
return $this->prevUnlisted() !== null;
}
/**
* @deprecated Use Page::hasPrevListed instead
* @return boolean
*/
public function hasPrevVisible(): bool
{
return $this->hasPrevListed();
}
/**
* @deprecated Use Page::nextUnlisted() instead
* @return self|null
*/
public function nextInvisible()
{
return $this->nextUnlisted();
}
/**
* Returns the next listed page if it exists
*
* @return self|null
*/
public function nextListed()
{
return $this->nextAll()->listed()->first();
}
/**
* Returns the next unlisted page if it exists
*
* @return self|null
*/
public function nextUnlisted()
{
return $this->nextAll()->unlisted()->first();
}
/**
* @deprecated Use Page::prevListed() instead
* @return self|null
*/
public function nextVisible()
{
return $this->nextListed();
}
/**
* @deprecated Use Page::prevUnlisted() instead
* @return self|null
*/
public function prevInvisible()
{
return $this->prevUnlisted();
}
/**
* Returns the previous listed page
*
* @return self|null
*/
public function prevListed()
{
return $this->prevAll()->listed()->last();
}
/**
* Returns the previous unlisted page
*
* @return self|null
*/
public function prevUnlisted()
{
return $this->prevAll()->unlisted()->first();
}
/**
* @deprecated Use Page::prevListed() instead
* @return self|null
*/
public function prevVisible()
{
return $this->prevListed();
}
/**
* Private siblings collector
*
* @return 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 self
*/
public function templateSiblings(bool $self = true)
{
return $this->siblings($self)->filterBy('intendedTemplate', $this->intendedTemplate()->name());
}
}

482
kirby/src/Cms/Pages.php Executable file
View File

@@ -0,0 +1,482 @@
<?php
namespace Kirby\Cms;
/**
* The Pages collection contains
* any number and mixture of page objects
* They don't necessarily have to belong
* to the same parent unless it is passed
* as second argument in the constructor.
*
* Pages collection can be constructed very
* easily:
*
* ```php
* $collection = new Pages([
* new Page(['id' => 'project-a']),
* new Page(['id' => 'project-b']),
* new Page(['id' => 'project-c']),
* ]);
* ```
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
*/
class Pages extends Collection
{
/**
* Cache for the index
*
* @var null|Pages
*/
protected $index = null;
/**
* All registered pages methods
*
* @var array
*/
public static $methods = [];
/**
* Adds a single page or
* an entire second collection to the
* current collection
*
* @param mixed $item
* @return Pages
*/
public function add($object)
{
// add a page collection
if (is_a($object, static::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, Page::class) === true) {
$this->__set($object->id(), $object);
}
return $this;
}
/**
* Returns all audio files of all children
*
* @return Files
*/
public function audio(): Files
{
return $this->files()->filterBy("type", "audio");
}
/**
* Returns all children for each page in the array
*
* @return Pages
*/
public function children(): Pages
{
$children = new Pages([], $this->parent);
foreach ($this->data as $pageKey => $page) {
foreach ($page->children() as $childKey => $child) {
$children->data[$childKey] = $child;
}
}
return $children;
}
/**
* Returns all code files of all children
*
* @return Files
*/
public function code(): Files
{
return $this->files()->filterBy("type", "code");
}
/**
* Returns all documents of all children
*
* @return Files
*/
public function documents(): Files
{
return $this->files()->filterBy("type", "document");
}
/**
* Fetch all drafts for all pages in the collection
*
* @return Pages
*/
public function drafts()
{
$drafts = new Pages([], $this->parent);
foreach ($this->data as $pageKey => $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 Model $parent
* @param array $inject
* @param bool $draft
* @return Pages
*/
public static function factory(array $pages, Model $model = null, bool $draft = false)
{
$model = $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 Files
*/
public function files(): Files
{
$files = new Files([], $this->parent);
foreach ($this->data as $pageKey => $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 $id
* @return mixed
*/
public function findById($id)
{
$id = trim($id, '/');
$page = $this->get($id);
$multiLang = App::instance()->multilang();
if ($multiLang === true) {
$page = $this->findBy('slug', $id);
}
if (!$page) {
$start = is_a($this->parent, 'Kirby\Cms\Page') === true ? $this->parent->id() : '';
$page = $this->findByIdRecursive($id, $start, $multiLang);
}
return $page;
}
/**
* Finds a child or child of a child recursively.
*
* @param string $id
* @param string $startAt
* @return mixed
*/
public function findByIdRecursive($id, $startAt = null, bool $multiLang = false)
{
$path = explode('/', $id);
$collection = $this;
$item = null;
$query = $startAt;
foreach ($path as $key) {
$query = ltrim($query . '/' . $key, '/');
$item = $collection->get($query) ?? null;
if ($item === null && $multiLang === true) {
$item = $collection->findBy('slug', $key);
}
if ($item === null) {
return null;
}
$collection = $item->children();
}
return $item;
}
/**
* Uses the specialized find by id method
*
* @param string $key
* @return mixed
*/
public function findByKey($key)
{
return $this->findById($key);
}
/**
* Alias for Pages::findById
*
* @param string $id
* @return Page|null
*/
public function findByUri(string $id)
{
return $this->findById($id);
}
/**
* Finds the currently open page
*
* @return Page|null
*/
public function findOpen()
{
return $this->findBy('isOpen', true);
}
/**
* Custom getter that is able to find
* extension pages
*
* @param string $key
* @return 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 Files
*/
public function images(): Files
{
return $this->files()->filterBy("type", "image");
}
/**
* Create a recursive flat index of all
* pages and subpages, etc.
*
* @param bool $drafts
* @return Pages
*/
public function index(bool $drafts = false): Pages
{
if (is_a($this->index, 'Kirby\Cms\Pages') === true) {
return $this->index;
}
$this->index = new Pages([], $this->parent);
foreach ($this->data as $pageKey => $page) {
$this->index->data[$pageKey] = $page;
foreach ($page->index($drafts) as $childKey => $child) {
$this->index->data[$childKey] = $child;
}
}
return $this->index;
}
/**
* Deprecated alias for Pages::unlisted()
*
* @return self
*/
public function invisible(): self
{
return $this->filterBy('isUnlisted', '==', true);
}
/**
* Returns all listed pages in the collection
*
* @return self
*/
public function listed(): self
{
return $this->filterBy('isListed', '==', true);
}
/**
* Returns all unlisted pages in the collection
*
* @return self
*/
public function unlisted(): self
{
return $this->filterBy('isUnlisted', '==', true);
}
/**
* Include all given items in the collection
*
* @return self
*/
public function merge(...$args): self
{
// 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], static::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;
}
/**
* 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 self
*/
public function published(): self
{
return $this->filterBy('isDraft', '==', false);
}
/**
* Filter all pages by the given template
*
* @param null|string|array $template
* @return self
*/
public function template($templates): self
{
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 Files
*/
public function videos(): Files
{
return $this->files()->filterBy("type", "video");
}
/**
* Deprecated alias for Pages::listed()
*
* @return self
*/
public function visible(): self
{
return $this->filterBy('isListed', '==', true);
}
}

172
kirby/src/Cms/Pagination.php Executable file
View File

@@ -0,0 +1,172 @@
<?php
namespace Kirby\Cms;
use Kirby\Http\Uri;
use Kirby\Toolkit\Pagination as BasePagination;
/**
* The extended Pagination class handles
* URLs in addition to the pagination features
* from Kirby\Toolkit\Pagination
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
*/
class Pagination extends BasePagination
{
/**
* Pagination method (param or query)
*
* @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'] = $params['limit'] ?? $config['limit'] ?? 20;
$params['method'] = $params['method'] ?? $config['method'] ?? 'param';
$params['variable'] = $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['page'] ?? $params['url']->query()->get($params['variable'], 1);
} else {
$params['page'] = $params['page'] ?? $params['url']->params()->get($params['variable'], 1);
}
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
*/
public function nextPageUrl()
{
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.
*
* @return string|null
*/
public function pageUrl(int $page = null)
{
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;
} else {
$url->params->$variable = $pageValue;
}
return $url->toString();
}
/**
* Returns the Url for the previous page.
* Returns null if there's no previous page.
*
* @return string
*/
public function prevPageUrl()
{
if ($page = $this->prevPage()) {
return $this->pageUrl($page);
}
return null;
}
}

94
kirby/src/Cms/Panel.php Executable file
View File

@@ -0,0 +1,94 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Http\Response;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
use Kirby\Toolkit\View;
use Throwable;
/**
* The Panel class is only responsible to create
* a working panel view with all the right URLs
* and other panel options. The view template is
* located in `kirby/views/panel.php`
*/
class Panel
{
/**
* Links all dist files in the media folder
* and returns the link to the requested asset
*
* @param App $kirby
* @return bool
*/
public static function link(App $kirby): bool
{
$mediaRoot = $kirby->root('media') . '/panel';
$panelRoot = $kirby->root('panel') . '/dist';
$versionHash = md5($kirby->version());
$versionRoot = $mediaRoot . '/' . $versionHash;
// check if the version already exists
if (is_dir($versionRoot) === true) {
return false;
}
// delete the panel folder and all previous versions
Dir::remove($mediaRoot);
// recreate the panel folder
Dir::make($mediaRoot, true);
// create a symlink to the dist folder
if (Dir::copy($kirby->root('panel') . '/dist', $versionRoot) !== true) {
throw new Exception('Panel assets could not be linked');
}
return true;
}
/**
* Renders the main panel view
*
* @param App $kirby
* @return Response
*/
public static function render(App $kirby): Response
{
try {
if (static::link($kirby) === true) {
usleep(1);
go($kirby->request()->url());
}
} catch (Throwable $e) {
die('The panel assets cannot be installed properly. Please check permissions of your media folder.');
}
$view = new View($kirby->root('kirby') . '/views/panel.php', [
'kirby' => $kirby,
'config' => $kirby->option('panel'),
'assetUrl' => $kirby->url('media') . '/panel/' . md5($kirby->version()),
'pluginCss' => $kirby->url('media') . '/plugins/index.css',
'pluginJs' => $kirby->url('media') . '/plugins/index.js',
'icons' => F::read($kirby->root('panel') . '/dist/img/icons.svg'),
'panelUrl' => $url = $kirby->url('panel'),
'options' => [
'url' => $url,
'site' => $kirby->url('index'),
'api' => $kirby->url('api'),
'csrf' => $kirby->option('api')['csrf'] ?? csrf(),
'translation' => 'en',
'debug' => true,
'search' => [
'limit' => $kirby->option('panel')['search']['limit'] ?? 10
]
]
]);
return new Response($view->render());
}
}

161
kirby/src/Cms/Permissions.php Executable file
View File

@@ -0,0 +1,161 @@
<?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.
*/
class Permissions
{
protected $actions = [
'access' => [
'panel' => true,
'users' => true,
'site' => true
],
'files' => [
'changeName' => true,
'create' => true,
'delete' => true,
'replace' => true,
'update' => true
],
'languages' => [
'create' => true,
'delete' => true
],
'pages' => [
'changeSlug' => true,
'changeStatus' => true,
'changeTemplate' => true,
'changeTitle' => true,
'create' => true,
'delete' => 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
]
];
public function __construct($settings = [])
{
if (is_bool($settings) === true) {
return $this->setAll($settings);
}
if (is_array($settings) === true) {
return $this->setCategories($settings);
}
}
public function for(string $category = null, string $action = null)
{
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];
}
protected function hasAction(string $category, string $action)
{
return $this->hasCategory($category) === true && array_key_exists($action, $this->actions[$category]) === true;
}
protected function hasCategory(string $category)
{
return array_key_exists($category, $this->actions) === true;
}
protected function setAction(string $category, string $action, $setting)
{
// wildcard to overwrite the entire category
if ($action === '*') {
return $this->setCategory($category, $setting);
}
$this->actions[$category][$action] = $setting;
return $this;
}
protected function setAll(bool $setting)
{
foreach ($this->actions as $categoryName => $actions) {
$this->setCategory($categoryName, $setting);
}
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;
}
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;
}
public function toArray(): array
{
return $this->actions;
}
}

106
kirby/src/Cms/Plugin.php Executable file
View File

@@ -0,0 +1,106 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\F;
/**
* Represents a Plugin and handles parsing of
* the composer.json. It also creates the prefix
* and media url for the plugin.
*/
class Plugin extends Model
{
protected $extends;
protected $info;
protected $name;
protected $root;
public function __call(string $key, array $arguments = null)
{
return $this->info()[$key] ?? null;
}
public function __construct(string $name, array $extends = [])
{
$this->setName($name);
$this->extends = $extends;
$this->root = $extends['root'] ?? dirname(debug_backtrace()[0]['file']);
unset($this->extends['root']);
}
public function extends(): array
{
return $this->extends;
}
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;
}
public function manifest(): string
{
return $this->root() . '/composer.json';
}
public function mediaRoot(): string
{
return App::instance()->root('media') . '/plugins/' . $this->name();
}
public function mediaUrl(): string
{
return App::instance()->url('media') . '/plugins/' . $this->name();
}
public function name(): string
{
return $this->name;
}
public function option(string $key)
{
return $this->kirby()->option($this->prefix() . '.' . $key);
}
public function prefix(): string
{
return str_replace('/', '.', $this->name());
}
public function root(): string
{
return $this->root;
}
protected function setName(string $name)
{
if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) == false) {
throw new InvalidArgumentException('The plugin name must follow the format "a-z0-9-/a-z0-9-"');
}
$this->name = $name;
return $this;
}
public function toArray(): array
{
return $this->propertiesToArray();
}
}

115
kirby/src/Cms/PluginAssets.php Executable file
View File

@@ -0,0 +1,115 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
/**
* Plugin assets are automatically copied/linked
* to the media folder, to make them publicly
* available. This class handles the magic around that.
*/
class PluginAssets
{
/**
* Concatenate all plugin js and css files into
* a single file and copy them to /media/plugins/index.css or /media/plugins/index.js
*
* @param string $extension
* @return string
*/
public static function index(string $extension): string
{
$kirby = App::instance();
$cache = $kirby->root('media') . '/plugins/.index.' . $extension;
$build = false;
$modified = [0];
$assets = [];
foreach ($kirby->plugins() as $plugin) {
$file = $plugin->root() . '/index.' . $extension;
if (file_exists($file) === true) {
$assets[] = $file;
$modified[] = F::modified($file);
}
}
if (empty($assets)) {
return false;
}
if (file_exists($cache) === false || filemtime($cache) < max($modified)) {
$dist = [];
foreach ($assets as $asset) {
$dist[] = file_get_contents($asset);
}
$dist = implode(PHP_EOL, $dist);
F::write($cache, $dist);
} else {
$dist = file_get_contents($cache);
}
return $dist;
}
/**
* Clean old/deprecated assets on every resolve
*
* @param string $pluginName
* @return void
*/
public static function clean(string $pluginName)
{
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 string
*/
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;
$url = $plugin->mediaUrl() . '/' . $filename;
F::link($source, $target, 'symlink');
return $url;
}
}
return null;
}
}

20
kirby/src/Cms/R.php Executable file
View File

@@ -0,0 +1,20 @@
<?php
namespace Kirby\Cms;
use Kirby\Http\Request;
use Kirby\Toolkit\Facade;
/**
* Shortcut to the request object
*/
class R extends Facade
{
/**
* @return Request
*/
protected static function instance()
{
return App::instance()->request();
}
}

218
kirby/src/Cms/Responder.php Executable file
View File

@@ -0,0 +1,218 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Mime;
use Kirby\Toolkit\Str;
/**
* Global response configuration
*/
class Responder
{
/**
* HTTP status code
*
* @var integer
*/
protected $code = null;
/**
* Response body
*
* @var string
*/
protected $body = null;
/**
* 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 $this->send();
}
/**
* Setter and getter for the response body
*
* @param string $body
* @return string|self
*/
public function body(string $body = null)
{
if ($body === null) {
return $this->body;
}
$this->body = $body;
return $this;
}
/**
* Setter and getter for the status code
*
* @param integer $code
* @return integer|self
*/
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
* @return self
*/
public function fromArray(array $response)
{
$this->body($response['body'] ?? 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
* @return string|self
*/
public function header(string $key, $value = null)
{
if ($value === null) {
return $this->headers[$key] ?? null;
}
if ($value === false) {
unset($this->headers[$key]);
return $this;
}
$this->headers[$key] = $value;
return $this;
}
/**
* Setter and getter for all headers
*
* @param array $headers
* @return array|self
*/
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 $json
* @return self
*/
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 integer|null $code
* @return self
*/
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 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 $type
* @return string|self
*/
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;
}
}

29
kirby/src/Cms/Response.php Executable file
View File

@@ -0,0 +1,29 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Http\Response as BaseResponse;
/**
* Custom response object with an optimized
* redirect method to build correct Urls
*/
class Response extends BaseResponse
{
/**
* Adjusted redirect creation which
* parses locations with the Url::to method
* first.
*
* @param string $location
* @param int $code
* @return self
*/
public static function redirect(?string $location = null, ?int $code = null)
{
return parent::redirect(Url::to($location ?? '/'), $code);
}
}

167
kirby/src/Cms/Role.php Executable file
View File

@@ -0,0 +1,167 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Data;
use Kirby\Exception\NotFoundException;
use Kirby\Toolkit\F;
/**
* Represents a User role with attached
* permissions. Roles are defined by user blueprints.
*/
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();
}
public function __toString(): string
{
return $this->name();
}
public static function admin(array $inject = [])
{
try {
return static::load('admin');
} catch (Exception $e) {
return static::factory(static::defaults()['admin'], $inject);
}
}
protected static function defaults()
{
return [
'admin' => [
'description' => 'The admin has all rights',
'name' => 'admin',
'title' => 'Admin',
'permissions' => true,
],
'nobody' => [
'description' => 'This is a fallback role without any permissions',
'name' => 'nobody',
'title' => 'Nobody',
'permissions' => false,
]
];
}
public function description()
{
return $this->description;
}
public static function factory(array $props, array $inject = []): self
{
return new static($props + $inject);
}
public function id(): string
{
return $this->name();
}
public function isAdmin(): bool
{
return $this->name() === 'admin';
}
public function isNobody(): bool
{
return $this->name() === 'nobody';
}
public static function load(string $file, array $inject = []): self
{
$name = F::name($file);
$data = Data::read($file);
$data['name'] = $name;
return static::factory($data, $inject);
}
public function name(): string
{
return $this->name;
}
public static function nobody(array $inject = [])
{
try {
return static::load('nobody');
} catch (Exception $e) {
return static::factory(static::defaults()['nobody'], $inject);
}
}
public function permissions(): Permissions
{
return $this->permissions;
}
protected function setDescription(string $description = null): self
{
$this->description = $description;
return $this;
}
protected function setName(string $name): self
{
$this->name = $name;
return $this;
}
protected function setPermissions($permissions = null): self
{
$this->permissions = new Permissions($permissions);
return $this;
}
protected function setTitle($title = null): self
{
$this->title = $title;
return $this;
}
public function title(): string
{
return $this->title = $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(),
];
}
}

76
kirby/src/Cms/Roles.php Executable file
View File

@@ -0,0 +1,76 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
/**
* 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.
*/
class Roles extends Collection
{
public static function factory(array $roles, array $inject = []): self
{
$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->sortBy('name', 'asc');
}
public static function load(string $root = null, array $inject = []): self
{
$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 (Dir::read($root) as $filename) {
if ($filename === 'default.yml') {
continue;
}
$role = Role::load($root . '/' . $filename, $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->sortBy('name', 'asc');
}
}

20
kirby/src/Cms/S.php Executable file
View File

@@ -0,0 +1,20 @@
<?php
namespace Kirby\Cms;
use Kirby\Session\Session;
use Kirby\Toolkit\Facade;
/**
* Shortcut to the session object
*/
class S extends Facade
{
/**
* @return Session
*/
protected static function instance()
{
return App::instance()->session();
}
}

111
kirby/src/Cms/Search.php Executable file
View File

@@ -0,0 +1,111 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Str;
/**
* The Search class extracts the
* search logic from collections, to
* provide a more globally usable interface
* for any searches.
*/
class Search
{
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
*/
public static function collection(Collection $collection, string $query = null, $params = [])
{
if (empty(trim($query)) === true) {
return $collection->limit(0);
}
if (is_string($params) === true) {
$params = ['fields' => Str::split($params, '|')];
}
$defaults = [
'fields' => [],
'minlength' => 2,
'score' => [],
'words' => false,
];
$options = array_merge($defaults, $params);
$collection = clone $collection;
$searchwords = preg_replace('/(\s)/u', ',', $query);
$searchwords = Str::split($searchwords, ',', $options['minlength']);
$lowerQuery = strtolower($query);
if (empty($options['stopwords']) === false) {
$searchwords = array_diff($searchwords, $options['stopwords']);
}
$searchwords = array_map(function ($value) use ($options) {
return $options['words'] ? '\b' . preg_quote($value) . '\b' : preg_quote($value);
}, $searchwords);
$preg = '!(' . implode('|', $searchwords) . ')!i';
$results = $collection->filter(function ($item) use ($query, $preg, $options, $lowerQuery) {
$data = $item->content()->toArray();
$keys = array_keys($data);
$keys[] = 'id';
if (empty($options['fields']) === false) {
$keys = array_intersect($keys, $options['fields']);
}
$item->searchHits = 0;
$item->searchScore = 0;
foreach ($keys as $key) {
$score = $options['score'][$key] ?? 1;
$value = $key === 'id' ? $item->id() : $data[$key];
$lowerValue = strtolower($value);
// check for exact matches
if ($lowerQuery == $lowerValue) {
$item->searchScore += 16 * $score;
$item->searchHits += 1;
// check for exact beginning matches
} elseif (Str::startsWith($lowerValue, $lowerQuery) === true) {
$item->searchScore += 8 * $score;
$item->searchHits += 1;
// check for exact query matches
} elseif ($matches = preg_match_all('!' . preg_quote($query) . '!i', $value, $r)) {
$item->searchScore += 2 * $score;
$item->searchHits += $matches;
}
// check for any match
if ($matches = preg_match_all($preg, $value, $r)) {
$item->searchHits += $matches;
$item->searchScore += $matches * $score;
}
}
return $item->searchHits > 0 ? true : false;
});
return $results->sortBy('searchScore', 'desc');
}
public static function pages(string $query = null, $params = [])
{
return App::instance()->site()->index()->search($query, $params);
}
public static function users(string $query = null, $params = [])
{
return App::instance()->users()->search($query, $params);
}
}

67
kirby/src/Cms/Section.php Executable file
View File

@@ -0,0 +1,67 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Component;
class Section extends Component
{
/**
* Registry for all component mixins
*
* @var array
*/
public static $mixins = [];
/**
* Registry for all component types
*
* @var array
*/
public static $types = [];
public function __construct(string $type, array $attrs = [])
{
if (isset($attrs['model']) === false) {
throw new InvalidArgumentException('Undefined section model');
}
// use the type as fallback for the name
$attrs['name'] = $attrs['name'] ?? $type;
$attrs['type'] = $type;
parent::__construct($type, $attrs);
}
public function kirby()
{
return $this->model->kirby();
}
public function model()
{
return $this->model;
}
public function toArray(): array
{
$array = parent::toArray();
unset($array['model']);
return $array;
}
public function toResponse(): array
{
return array_merge([
'status' => 'ok',
'code' => 200,
'name' => $this->name,
'type' => $this->type
], $this->toArray());
}
}

675
kirby/src/Cms/Site.php Executable file
View File

@@ -0,0 +1,675 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* The Site class 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 http://getkirby.com
* @copyright Bastian Allgeier
*/
class Site extends ModelWithContent
{
use SiteActions;
use HasChildren;
use HasFiles;
use HasMethods;
/**
* The SiteBlueprint object
*
* @var SiteBlueprint
*/
protected $blueprint;
/**
* The error page object
*
* @var 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 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 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, $arguments);
}
/**
* 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(),
]);
}
/**
* Returns the url to the api endpoint
*
* @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 SiteBlueprint
*/
public function blueprint(): SiteBlueprint
{
if (is_a($this->blueprint, 'Kirby\Cms\SiteBlueprint') === true) {
return $this->blueprint;
}
return $this->blueprint = SiteBlueprint::factory('site', null, $this);
}
/**
* Returns an array with all blueprints that are available
* as subpages of the site
*
* @params string $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);
}
/**
* Builds a breadcrumb collection
*
* @return 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
*
* @return array
*/
public function contentFileData(array $data, string $languageCode = null): array
{
return A::prepend($data, [
'title' => $data['title'] ?? null,
]);
}
/**
* Filename for the content file
*
* @return string
*/
public function contentFileName(): string
{
return 'site';
}
/**
* Returns the error page object
*
* @return Page
*/
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
*
* @return string
*/
public function errorPageId(): string
{
return $this->errorPageId ?? 'error';
}
/**
* Checks if the site exists on disk
*
* @return boolean
*/
public function exists(): bool
{
return is_dir($this->root()) === true;
}
/**
* Returns the home page object
*
* @return Page
*/
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
*
* @return string
*/
public function homePageId(): string
{
return $this->homePageId ?? 'home';
}
/**
* Creates an inventory of all files
* and children in the site directory
*
* @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()
);
}
/**
* Returns the root to the media folder for the site
*
* @return string
*/
public function mediaRoot(): string
{
return $this->kirby()->root('media') . '/site';
}
/**
* The site's base url for any files
*
* @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 mixed
*/
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 $path
* @return 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 Pages
*/
public function pages(): Pages
{
return $this->children();
}
/**
* Returns the full path without leading slash
*
* @return string
*/
public function panelPath(): string
{
return 'site';
}
/**
* Returns the url to the editing view
* in the panel
*
* @param bool $relative
* @return string
*/
public function panelUrl(bool $relative = false): string
{
if ($relative === true) {
return '/' . $this->panelPath();
} else {
return $this->kirby()->url('panel') . '/' . $this->panelPath();
}
}
/**
* Returns the permissions object for this site
*
* @return SitePermissions
*/
public function permissions()
{
return new SitePermissions($this);
}
/**
* Creates a string query, starting from the model
*
* @param string|null $query
* @param string|null $expect
* @return mixed
*/
public function query(string $query = null, string $expect = null)
{
if ($query === null) {
return null;
}
$result = Str::query($query, [
'kirby' => $this->kirby(),
'site' => $this,
]);
if ($expect !== null && is_a($result, $expect) !== true) {
return null;
}
return $result;
}
/**
* Returns the absolute path to the content directory
*
* @return string
*/
public function root(): string
{
return $this->root = $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 SiteRules
*/
protected function rules()
{
return new SiteRules();
}
/**
* Search all pages in the site
*
* @param string $query
* @param array $params
* @return Pages
*/
public function search(string $query = null, $params = [])
{
return $this->index()->search($query, $params);
}
/**
* Sets the Blueprint object
*
* @param array|null $blueprint
* @return self
*/
protected function setBlueprint(array $blueprint = null): self
{
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 self
*/
protected function setErrorPageId(string $id = 'error'): self
{
$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 self
*/
protected function setHomePageId(string $id = 'home'): self
{
$this->homePageId = $id;
return $this;
}
/**
* Sets the current page object
*
* @param Page|null $page
* @return self
*/
public function setPage(Page $page = null): self
{
$this->page = $page;
return $this;
}
/**
* Sets the Url
*
* @param string $url
* @return void
*/
protected function setUrl($url = null): self
{
$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(),
];
}
/**
* String template builder
*
* @param string|null $template
* @return string
*/
public function toString(string $template = null): string
{
if ($template === null) {
return $this->url();
}
return Str::template($template, [
'site' => $this,
'kirby' => $this->kirby()
]);
}
/**
* Returns the Url
*
* @param string|null $language
* @return string
*/
public function url($language = null): string
{
if ($language !== null || $this->kirby()->multilang() === true) {
return $this->urlForLanguage($language);
}
return $this->url ?? $this->kirby()->url();
}
/**
* Returns the translated url
*
* @params string $languageCode
* @params array $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
*
* @param string|Page $page
* @param string|null $languageCode
* @return Page
*/
public function visit($page, string $languageCode = null): Page
{
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
*
* @return bool
*/
public function wasModifiedAfter($time): bool
{
return Dir::wasModifiedAfter($this->root(), $time);
}
}

85
kirby/src/Cms/SiteActions.php Executable file
View File

@@ -0,0 +1,85 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
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
* @return mixed
*/
protected function commit(string $action, array $arguments, Closure $callback)
{
$old = $this->hardcopy();
$kirby = $this->kirby();
$this->rules()->$action(...$arguments);
$kirby->trigger('site.' . $action . ':before', ...$arguments);
$result = $callback(...$arguments);
$kirby->trigger('site.' . $action . ':after', $result, $old);
$kirby->cache('pages')->flush();
return $result;
}
/**
* Change the site title
*
* @param string $title
* @param string|null $languageCode
* @return self
*/
public function changeTitle(string $title, string $languageCode = null): self
{
return $this->commit('changeTitle', [$this, $title, $languageCode], function ($site, $title, $languageCode) {
return $site->save(['title' => $title], $languageCode);
});
}
/**
* Creates a main page
*
* @param array $props
* @return 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
*/
public function purge(): self
{
$this->children = null;
$this->blueprint = null;
$this->files = null;
$this->content = null;
$this->inventory = null;
return $this;
}
}

29
kirby/src/Cms/SiteBlueprint.php Executable file
View File

@@ -0,0 +1,29 @@
<?php
namespace Kirby\Cms;
/**
* Extension of the basic blueprint class
* to handle the blueprint for the site.
*/
class SiteBlueprint extends Blueprint
{
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',
]
);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace Kirby\Cms;
class SitePermissions extends ModelPermissions
{
protected $category = 'site';
}

30
kirby/src/Cms/SiteRules.php Executable file
View File

@@ -0,0 +1,30 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Exception\PermissionException;
/**
* Validators for all site actions
*/
class SiteRules
{
public static function changeTitle(Site $site, string $title): bool
{
if ($site->permissions()->changeTitle() !== true) {
throw new PermissionException(['key' => 'site.changeTitle.permission']);
}
return true;
}
public static function update(Site $site, array $content = []): bool
{
if ($site->permissions()->update() !== true) {
throw new PermissionException(['key' => 'site.update.permission']);
}
return true;
}
}

59
kirby/src/Cms/Structure.php Executable file
View File

@@ -0,0 +1,59 @@
<?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 http://getkirby.com
* @copyright Bastian Allgeier
*/
class Structure extends Collection
{
/**
* Creates a new Collection with the given objects
*
* @param array $objects
* @param object $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 $object
*/
public function __set(string $id, $props)
{
if (is_a($props, StructureObject::class) === true) {
$object = $props;
} else {
$object = new StructureObject([
'content' => $props,
'id' => $props['id'] ?? $id,
'parent' => $this->parent,
'structure' => $this
]);
}
return parent::__set($object->id(), $object);
}
}

206
kirby/src/Cms/StructureObject.php Executable file
View File

@@ -0,0 +1,206 @@
<?php
namespace Kirby\Cms;
/**
* The StructureObject reprents each item
* in a Structure collection. StructureObjects
* behave pretty much the same as Pages or Users
* and have a Content object to access their fields.
* All fields in a StructureObject are therefor also
* wrapped in a Field object and can be accessed in
* the same way as Page fields. They also use the same
* Field methods.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
*/
class StructureObject extends Model
{
use HasSiblings;
/**
* The content
*
* @var Content
*/
protected $content;
/**
* @var string
*/
protected $id;
/**
* @var Page|Site|File|User
*/
protected $parent;
/**
* The parent Structure collection
*
* @var Structure
*/
protected $structure;
/**
* Modified getter to also return fields
* from the object's 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;
}
return $this->content()->get($method, $arguments);
}
/**
* Creates a new StructureObject with the given props
*
* @param array $props
*/
public function __construct(array $props)
{
$this->setProperties($props);
}
/**
* Returns the content
*
* @return Content
*/
public function content(): Content
{
if (is_a($this->content, 'Kirby\Cms\Content') === true) {
return $this->content;
}
if (is_array($this->content) !== true) {
$this->content = [];
}
return $this->content = new Content($this->content, $this->parent());
}
/**
* Returns a formatted date field from the content
*
* @param string $format
* @param string $field
* @return Field
*/
public function date(string $format = null, $field = 'date')
{
return $this->content()->get($field)->toDate($format);
}
/**
* Returns the required id
*
* @return string
*/
public function id(): string
{
return $this->id;
}
/**
* Returns the parent Model object
*
* @return Page|Site|File|User
*/
public function parent()
{
return $this->parent;
}
/**
* Sets the Content object with the given parent
*
* @param array|null $content
* @return self
*/
protected function setContent(array $content = null): self
{
$this->content = $content;
return $this;
}
/**
* Sets the id of the object.
* The id is required. The structure
* class will use the index, if no id is
* specified.
*
* @param string $id
* @return self
*/
protected function setId(string $id): self
{
$this->id = $id;
return $this;
}
/**
* Sets the parent Model. This can either be a
* Page, Site, File or User object
*
* @param Page|Site|File|User|null $parent
* @return self
*/
protected function setParent(Model $parent = null): self
{
$this->parent = $parent;
return $this;
}
/**
* Sets the parent Structure collection
*
* @param Structure $structure
* @return self
*/
protected function setStructure(Structure $structure = null): self
{
$this->structure = $structure;
return $this;
}
/**
* Returns the parent Structure collection as siblings
*
* @return Structure
*/
protected function siblingsCollection()
{
return $this->structure;
}
/**
* Converts all fields in the object to a
* plain associative array. The id is
* injected into the array afterwards
* to make sure it's always present and
* not overloaded in the content.
*
* @return array
*/
public function toArray(): array
{
$array = $this->content()->toArray();
$array['id'] = $this->id();
ksort($array);
return $array;
}
}

446
kirby/src/Cms/System.php Executable file
View File

@@ -0,0 +1,446 @@
<?php
namespace Kirby\Cms;
use Throwable;
use Kirby\Data\Json;
use Kirby\Exception\Exception;
use Kirby\Exception\PermissionException;
use Kirby\Http\Remote;
use Kirby\Http\Uri;
use Kirby\Http\Url;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
/**
* The System class gathers all information
* about the server, PHP and other environment
* parameters and checks for a valid setup.
*
* This is mostly used by the panel installer
* to check if the panel can be installed at all.
*/
class System
{
/**
* @var App
*/
protected $app;
/**
* @param App $app
*/
public function __construct(App $app)
{
$this->app = $app;
// try to create all folders that could be missing
$this->init();
}
/**
* Improved var_dump output
*
* @return array
*/
public function __debuginfo(): array
{
return $this->toArray();
}
/**
* Get an status array of all checks
*
* @return array
*/
public function status(): array
{
return [
'accounts' => $this->accounts(),
'content' => $this->content(),
'curl' => $this->curl(),
'sessions' => $this->sessions(),
'mbstring' => $this->mbstring(),
'media' => $this->media(),
'php' => $this->php(),
'server' => $this->server(),
];
}
/**
* Check for a writable accounts folder
*
* @return boolean
*/
public function accounts(): bool
{
return is_writable($this->app->root('accounts'));
}
/**
* Check for a writable content folder
*
* @return boolean
*/
public function content(): bool
{
return is_writable($this->app->root('content'));
}
/**
* Check for an existing curl extension
*
* @return boolean
*/
public function curl(): bool
{
return extension_loaded('curl');
}
/**
* Create the most important folders
* if they don't exist yet
*
* @return void
*/
public function init()
{
/* /site/accounts */
try {
Dir::make($this->app->root('accounts'));
} catch (Throwable $e) {
throw new PermissionException('The accounts directory could not be created');
}
/* /content */
try {
Dir::make($this->app->root('content'));
} catch (Throwable $e) {
throw new PermissionException('The content directory could not be created');
}
try {
Dir::make($this->app->root('media'));
} catch (Throwable $e) {
throw new PermissionException('The media directory could not be created');
}
}
/**
* Check if the panel is installable.
* On a public server the panel.install
* option must be explicitly set to true
* to get the installer up and running.
*
* @return boolean
*/
public function isInstallable(): bool
{
return $this->isLocal() === true || $this->app->option('panel.install', false) === true;
}
/**
* Check if Kirby is already installed
*
* @return boolean
*/
public function isInstalled(): bool
{
return $this->app->users()->count() > 0;
}
/**
* Check if this is a local installation
*
* @return boolean
*/
public function isLocal(): bool
{
$server = $this->app->server();
$host = $server->host();
if ($host === 'localhost') {
return true;
}
if (in_array($server->address(), ['::1', '127.0.0.1', '0.0.0.0']) === true) {
return true;
}
if (Str::endsWith($host, '.dev') === true) {
return true;
}
if (Str::endsWith($host, '.local') === true) {
return true;
}
if (Str::endsWith($host, '.test') === true) {
return true;
}
return false;
}
/**
* Check if all tests pass
*
* @return boolean
*/
public function isOk(): bool
{
if ($this->isInstallable() === false) {
return false;
}
return in_array(false, array_values($this->status()), true) === false;
}
/**
* Returns the app's index URL for
* licensing purposes without scheme
*
* @return string
*/
protected function licenseUrl(): string
{
$url = $this->app->url('index');
if (Url::isAbsolute($url)) {
$uri = Url::toObject($url);
} else {
// index URL was configured without host, use the current host
$uri = Uri::current([
'path' => $url,
'query' => null
]);
}
return $uri->setScheme(null)->setSlash(false)->toString();
}
/**
* Normalizes the app's index URL for
* licensing purposes
*
* @param string|null $url Input URL, by default the app's index URL
* @return string Normalized URL
*/
protected function licenseUrlNormalized(string $url = null): string
{
if ($url === null) {
$url = $this->licenseUrl();
}
// remove common "testing" subdomains as well as www.
// to ensure that installations of the same site have
// the same license URL; only for installations at /,
// subdirectory installations are difficult to normalize
if (Str::contains($url, '/') === false) {
if (Str::startsWith($url, 'www.')) {
return substr($url, 4);
}
if (Str::startsWith($url, 'dev.')) {
return substr($url, 4);
}
if (Str::startsWith($url, 'test.')) {
return substr($url, 5);
}
if (Str::startsWith($url, 'staging.')) {
return substr($url, 8);
}
}
return $url;
}
/**
* Loads the license file and returns
* the license information if available
*
* @return string|false
*/
public function license()
{
try {
$license = Json::read($this->app->root('config') . '/.license');
} catch (Throwable $e) {
return false;
}
// check for all required fields for the validation
if (isset(
$license['license'],
$license['order'],
$license['date'],
$license['email'],
$license['domain'],
$license['signature']
) !== true) {
return false;
}
// build the license verification data
$data = [
'license' => $license['license'],
'order' => $license['order'],
'email' => hash('sha256', $license['email'] . 'kwAHMLyLPBnHEskzH9pPbJsBxQhKXZnX'),
'domain' => $license['domain'],
'date' => $license['date']
];
// get the public key
$pubKey = F::read($this->app->root('kirby') . '/kirby.pub');
// verify the license signature
if (openssl_verify(json_encode($data), hex2bin($license['signature']), $pubKey, 'RSA-SHA256') !== 1) {
return false;
}
// verify the URL
if ($this->licenseUrlNormalized() !== $this->licenseUrlNormalized($license['domain'])) {
return false;
}
return $license['license'];
}
/**
* Check for an existing mbstring extension
*
* @return boolean
*/
public function mbString(): bool
{
return extension_loaded('mbstring');
}
/**
* Check for a writable media folder
*
* @return boolean
*/
public function media(): bool
{
return is_writable($this->app->root('media'));
}
/**
* Check for a valid PHP version
*
* @return boolean
*/
public function php(): bool
{
return version_compare(phpversion(), '7.1.0', '>');
}
/**
* Validates the license key
* and adds it to the .license file in the config
* folder if possible.
*
* @param string $license
* @param string $email
* @return boolean
*/
public function register(string $license, string $email): bool
{
$response = Remote::get('https://licenses.getkirby.com/register', [
'data' => [
'license' => $license,
'email' => $email,
'domain' => $this->licenseUrl()
]
]);
if ($response->code() !== 200) {
throw new Exception($response->content());
}
// decode the response
$json = Json::decode($response->content());
// replace the email with the plaintext version
$json['email'] = $email;
// where to store the license file
$file = $this->app->root('config') . '/.license';
// save the license information
Json::write($file, $json);
if ($this->license() === false) {
throw new Exception('The license could not be verified');
}
return true;
}
/**
* Check for a valid server environment
*
* @return boolean
*/
public function server(): bool
{
$servers = [
'apache',
'caddy',
'litespeed',
'nginx',
'php'
];
$software = $_SERVER['SERVER_SOFTWARE'] ?? null;
return preg_match('!(' . implode('|', $servers) . ')!i', $software) > 0;
}
/**
* Check for a writable sessions folder
*
* @return boolean
*/
public function sessions(): bool
{
return is_writable($this->app->root('sessions'));
}
/**
* Return the status as array
*
* @return array
*/
public function toArray(): array
{
return $this->status();
}
/**
* Upgrade to the new folder separator
*
* @param string $root
* @return void
*/
public static function upgradeContent(string $root)
{
$index = Dir::read($root);
foreach ($index as $dir) {
$oldRoot = $root . '/' . $dir;
$newRoot = preg_replace('!\/([0-9]+)\-!', '/$1_', $oldRoot);
if (is_dir($oldRoot) === true) {
Dir::move($oldRoot, $newRoot);
static::upgradeContent($newRoot);
}
}
}
}

198
kirby/src/Cms/Template.php Executable file
View File

@@ -0,0 +1,198 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Tpl;
use Kirby\Toolkit\View;
use Throwable;
/**
* Represents a Kirby template and takes care
* of loading the correct file.
*/
class Template
{
/**
* Global template data
*
* @var array
*/
public static $data = [];
/**
* The name of the template
*
* @var string
*/
protected $name;
/**
* Template type (html, json, etc.)
*
* @var string
*/
protected $type;
/**
* Default template type if no specific type is set
*
* @var string
*/
protected $defaultType;
/**
* Creates a new template object
*
* @param string $name
* @param string $type
* @param string $defaultType
*/
public function __construct(string $name, string $type = 'html', string $defaultType = 'html')
{
$this->name = strtolower($name);
$this->type = $type;
$this->defaultType = $defaultType;
}
/**
* Converts the object to a simple string
* This is used in template filters for example
*
* @return string
*/
public function __toString(): string
{
return $this->name;
}
/**
* Checks if the template exists
*
* @return boolean
*/
public function exists(): bool
{
return file_exists($this->file());
}
/**
* Returns the expected template file extension
*
* @return string
*/
public function extension(): string
{
return 'php';
}
/**
* Returns the default template type
*
* @return string
*/
public function defaultType(): string
{
return $this->defaultType;
}
/**
* Returns the place where templates are located
* in the site folder and and can be found in extensions
*
* @return string
*/
public function store(): string
{
return 'templates';
}
/**
* Detects the location of the template file
* if it exists.
*
* @return string|null
*/
public function file(): ?string
{
if ($this->hasDefaultType() === true) {
try {
// Try the default template in the default template directory.
return F::realpath($this->root() . '/' . $this->name() . '.' . $this->extension(), $this->root());
} catch (Exception $e) {
//
}
// Look for the default template provided by an extension.
$path = App::instance()->extension($this->store(), $this->name());
if ($path !== null) {
return $path;
}
}
$name = $this->name() . '.' . $this->type();
try {
// Try the template with type extension in the default template directory.
return F::realpath($this->root() . '/' . $name . '.' . $this->extension(), $this->root());
} catch (Exception $e) {
// Look for the template with type extension provided by an extension.
// This might be null if the template does not exist.
return App::instance()->extension($this->store(), $name);
}
}
/**
* Returns the template name
*
* @return string
*/
public function name(): string
{
return $this->name;
}
/**
* @param array $data
* @return string
*/
public function render(array $data = []): string
{
return Tpl::load($this->file(), $data);
}
/**
* Returns the root to the templates directory
*
* @return string
*/
public function root(): string
{
return App::instance()->root($this->store());
}
/**
* Returns the template type
*
* @return string
*/
public function type(): string
{
return $this->type;
}
/**
* Checks if the template uses the default type
*
* @return boolean
*/
public function hasDefaultType(): bool
{
$type = $this->type();
return $type === null || $type === $this->defaultType();
}
}

171
kirby/src/Cms/Translation.php Executable file
View File

@@ -0,0 +1,171 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Data;
/**
* Wrapper around Kirby's localization files,
* which are store in `kirby/translations`.
*/
class Translation
{
/**
* @var string
*/
protected $code;
/**
* @var array
*/
protected $data = [];
/**
* @param string $code
* @param array $data
*/
public function __construct(string $code, array $data)
{
$this->code = $code;
$this->data = $data;
}
/**
* Improved var_dump output
*
* @return array
*/
public function __debuginfo(): array
{
return $this->toArray();
}
/**
* Returns the translation author
*
* @return string
*/
public function author(): string
{
return $this->get('translation.author', 'Kirby');
}
/**
* Returns the official translation code
*
* @return string
*/
public function code(): string
{
return $this->code;
}
/**
* Returns an array with all
* translation strings
*
* @return array
*/
public function data(): array
{
return $this->data;
}
/**
* Returns the translation data and merges
* it with the data from the default translation
*
* @return array
*/
public function dataWithFallback(): array
{
if ($this->code === 'en') {
return $this->data;
}
// get the fallback array
$fallback = App::instance()->translation('en')->data();
return array_merge($fallback, $this->data);
}
/**
* Returns the writing direction
* (ltr or rtl)
*
* @return string
*/
public function direction(): string
{
return $this->get('translation.direction', 'ltr');
}
/**
* Returns a single translation
* string by key
*
* @param string $key
* @param string $default
* @return void
*/
public function get(string $key, string $default = null): ?string
{
return $this->data[$key] ?? $default;
}
/**
* Returns the translation id,
* which is also the code
*
* @return string
*/
public function id(): string
{
return $this->code;
}
/**
* Loads the translation from the
* json file in Kirby's translations folder
*
* @param string $code
* @param string $root
* @param array $inject
* @return self
*/
public static function load(string $code, string $root, array $inject = [])
{
try {
return new Translation($code, array_merge(Data::read($root), $inject));
} catch (Exception $e) {
return new Translation($code, []);
}
}
/**
* Returns the human-readable translation name.
*
* @return string
*/
public function name(): string
{
return $this->get('translation.name', $this->code);
}
/**
* Converts the most important
* properties to an array
*
* @return array
*/
public function toArray(): array
{
return [
'code' => $this->code(),
'data' => $this->data(),
'name' => $this->name(),
'author' => $this->author(),
];
}
}

55
kirby/src/Cms/Translations.php Executable file
View File

@@ -0,0 +1,55 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
/**
* A collection of all available Translations.
* Provides a factory method to convert an array
* to a collection of Translation objects and load
* method to load all translations from disk
*/
class Translations extends Collection
{
public function start(string $code)
{
F::move($this->parent->contentFile('', true), $this->parent->contentFile($code, true));
}
public function stop(string $code)
{
F::move($this->parent->contentFile($code, true), $this->parent->contentFile('', true));
}
public static function factory(array $translations)
{
$collection = new static;
foreach ($translations as $code => $props) {
$translation = new Translation($code, $props);
$collection->data[$translation->code()] = $translation;
}
return $collection;
}
public static function load(string $root, array $inject = [])
{
$collection = new static;
foreach (Dir::read($root) as $filename) {
if (F::extension($filename) !== 'json') {
continue;
}
$locale = F::name($filename);
$translation = Translation::load($locale, $root . '/' . $filename, $inject[$locale] ?? []);
$collection->data[$locale] = $translation;
}
return $collection;
}
}

45
kirby/src/Cms/Url.php Executable file
View File

@@ -0,0 +1,45 @@
<?php
namespace Kirby\Cms;
use Kirby\Http\Url as BaseUrl;
use Kirby\Toolkit\Str;
/**
* Extension of the Kirby\Http\Url class
* with a specific Url::home method that always
* creates the correct base Url and a template asset
* Url builder.
*/
class Url extends BaseUrl
{
public static $home = null;
/**
* Returns the Url to the homepage
*
* @return string
*/
public static function home(): string
{
return App::instance()->url();
}
/**
* Creates an absolute Url to a template asset if it exists. This is used in the `css()` and `js()` helpers
*
* @param string $assetPath
* @param string $extension
* @return string|null
*/
public static function toTemplateAsset(string $assetPath, string $extension)
{
$kirby = App::instance();
$page = $kirby->site()->page();
$path = $assetPath . '/' . $page->template() . '.' . $extension;
$file = $kirby->root('assets') . '/' . $path;
$url = $kirby->url('assets') . '/' . $path;
return file_exists($file) === true ? $url : null;
}
}

787
kirby/src/Cms/User.php Executable file
View File

@@ -0,0 +1,787 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Exception\PermissionException;
use Kirby\Session\Session;
use Kirby\Toolkit\A;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\V;
use Throwable;
/**
* The User class represents
* panel users as well as frontend users.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
*/
class User extends ModelWithContent
{
use HasFiles;
use HasSiblings;
use UserActions;
/**
* @var File
*/
protected $avatar;
/**
* @var UserBlueprint
*/
protected $blueprint;
/**
* @var array
*/
protected $credentials;
/**
* @var string
*/
protected $email;
/**
* @var string
*/
protected $hash;
/**
* @var string
*/
protected $id;
/**
* @var array|null
*/
protected $inventory;
/**
* @var string
*/
protected $language;
/**
* @var string
*/
protected $name;
/**
* @var string
*/
protected $password;
/**
* The user role
*
* @var string
*/
protected $role;
/**
* 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;
}
// return site content otherwise
return $this->content()->get($method, $arguments);
}
/**
* Creates a new User object
*
* @param array $props
*/
public function __construct(array $props)
{
$props['id'] = $props['id'] ?? $this->createId();
$this->setProperties($props);
}
/**
* Improved var_dump() output
*
* @return array
*/
public function __debuginfo(): array
{
return array_merge($this->toArray(), [
'avatar' => $this->avatar(),
'content' => $this->content(),
'role' => $this->role()
]);
}
/**
* Returns the url to the api endpoint
*
* @param bool $relative
* @return string
*/
public function apiUrl(bool $relative = false): string
{
if ($relative === true) {
return 'users/' . $this->id();
} else {
return $this->kirby()->url('api') . '/users/' . $this->id();
}
}
/**
* Returns the File object for the avatar or null
*
* @return File|null
*/
public function avatar()
{
return $this->files()->template('avatar')->first();
}
/**
* Returns the UserBlueprint object
*
* @return UserBlueprint
*/
public function blueprint()
{
if (is_a($this->blueprint, 'Kirby\Cms\Blueprint') === true) {
return $this->blueprint;
}
try {
return $this->blueprint = UserBlueprint::factory('users/' . $this->role(), 'users/default', $this);
} catch (Exception $e) {
return $this->blueprint = new UserBlueprint([
'model' => $this,
'name' => 'default',
'title' => 'Default',
]);
}
}
/**
* Prepares the content for the write method
*
* @param array $data
* @param string $languageCode Not used so far
* @return array
*/
public function contentFileData(array $data, string $languageCode = null): array
{
// remove stuff that has nothing to do in the text files
unset(
$data['email'],
$data['language'],
$data['name'],
$data['password'],
$data['role']
);
return $data;
}
/**
* Filename for the content file
*
* @return string
*/
public function contentFileName(): string
{
return 'user';
}
protected function credentials(): array
{
return $this->credentials = $this->credentials ?? $this->readCredentials();
}
/**
* Returns the user email address
*
* @return string
*/
public function email(): ?string
{
return $this->email = $this->email ?? $this->credentials()['email'] ?? null;
}
/**
* Checks if the user exists
*
* @return boolean
*/
public function exists(): bool
{
return is_file($this->contentFile('default')) === true;
}
/**
* Hashes user password
*
* @param string|null $password
* @return string|null
*/
public static function hashPassword($password)
{
if ($password !== null) {
$info = password_get_info($password);
if ($info['algo'] === 0) {
$password = password_hash($password, PASSWORD_DEFAULT);
}
}
return $password;
}
/**
* Returns the user id
*
* @return string
*/
public function id(): string
{
return $this->id;
}
/**
* Returns the inventory of files
* children and content files
*
* @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 user object
*
* @param User $user
* @return bool
*/
public function is(User $user): bool
{
return $this->id() === $user->id();
}
/**
* Checks if this user has the admin role
*
* @return boolean
*/
public function isAdmin(): bool
{
return $this->role()->id() === 'admin';
}
/**
* Checks if the current user is the virtual
* Kirby user
*
* @return boolean
*/
public function isKirby(): bool
{
return $this->email() === 'kirby@getkirby.com';
}
/**
* Checks if the current user is this user
*
* @return boolean
*/
public function isLoggedIn(): bool
{
return $this->is($this->kirby()->user());
}
/**
* Checks if the user is the last one
* with the admin role
*
* @return boolean
*/
public function isLastAdmin(): bool
{
return $this->role()->isAdmin() === true && $this->kirby()->users()->filterBy('role', 'admin')->count() <= 1;
}
/**
* Checks if the user is the last user
*
* @return boolean
*/
public function isLastUser(): bool
{
return $this->kirby()->users()->count() === 1;
}
/**
* Returns the user language
*
* @return string
*/
public function language(): string
{
return $this->language ?? $this->language = $this->credentials()['language'] ?? $this->kirby()->option('panel.language', 'en');
}
/**
* Logs the user in
*
* @param string $password
* @param Session|array $session Session options or session object to set the user in
* @return bool
*/
public function login(string $password, $session = null): bool
{
try {
$this->validatePassword($password);
} catch (Exception $e) {
throw new PermissionException(['key' => 'access.login']);
}
$this->loginPasswordless($session);
return true;
}
/**
* Logs the user in without checking the password
*
* @param Session|array $session Session options or session object to set the user in
* @return void
*/
public function loginPasswordless($session = null)
{
$session = $this->sessionFromOptions($session);
$session->regenerateToken(); // privilege change
$session->data()->set('user.id', $this->id());
}
/**
* Logs the user out
*
* @param Session|array $session Session options or session object to unset the user in
* @return void
*/
public function logout($session = null)
{
$session = $this->sessionFromOptions($session);
$session->data()->remove('user.id');
if ($session->data()->get() === []) {
// session is now empty, we might as well destroy it
$session->destroy();
} else {
// privilege change
$session->regenerateToken();
}
}
/**
* Returns the root to the media folder for the user
*
* @return string
*/
public function mediaRoot(): string
{
return $this->kirby()->root('media') . '/users/' . $this->id();
}
/**
* Returns the media url for the user object
*
* @return string
*/
public function mediaUrl(): string
{
return $this->kirby()->url('media') . '/users/' . $this->id();
}
/**
* Returns the last modification date of the user
*
* @param string $format
* @param string|null $handler
* @return int|string
*/
public function modified(string $format = 'U', string $handler = null)
{
$modifiedContent = F::modified($this->contentFile());
$modifiedIndex = F::modified($this->root() . '/index.php');
$modifiedTotal = max([$modifiedContent, $modifiedIndex]);
$handler = $handler ?? $this->kirby()->option('date.handler', 'date');
return $handler($format, $modifiedTotal);
}
/**
* Returns the user's name
*
* @return Field
*/
public function name()
{
if (is_string($this->name) === true) {
return new Field($this, 'name', $this->name);
}
if ($this->name !== null) {
return $this->name;
}
return $this->name = new Field($this, 'name', $this->credentials()['name'] ?? null);
}
/**
* Create a dummy nobody
*
* @return self
*/
public static function nobody(): self
{
return new static([
'email' => 'nobody@getkirby.com',
'role' => 'nobody'
]);
}
/**
* Returns the full path without leading slash
*
* @return string
*/
public function panelPath(): string
{
return 'users/' . $this->id();
}
/**
* Returns the url to the editing view
* in the panel
*
* @param bool $relative
* @return string
*/
public function panelUrl(bool $relative = false): string
{
if ($relative === true) {
return '/' . $this->panelPath();
} else {
return $this->kirby()->url('panel') . '/' . $this->panelPath();
}
}
/**
* Returns the encrypted user password
*
* @return string|null
*/
public function password(): ?string
{
if ($this->password !== null) {
return $this->password;
}
return $this->password = $this->readPassword();
}
/**
* @return UserPermissions
*/
public function permissions()
{
return new UserPermissions($this);
}
/**
* Creates a string query, starting from the model
*
* @param string|null $query
* @param string|null $expect
* @return mixed
*/
public function query(string $query = null, string $expect = null)
{
if ($query === null) {
return null;
}
$result = Str::query($query, [
'kirby' => $kirby = $this->kirby(),
'site' => $kirby->site(),
'user' => $this
]);
if ($expect !== null && is_a($result, $expect) !== true) {
return null;
}
return $result;
}
/**
* Returns the user role
*
* @return string
*/
public function role(): Role
{
if (is_a($this->role, 'Kirby\Cms\Role') === true) {
return $this->role;
}
$roleName = $this->role ?? $this->credentials()['role'] ?? 'visitor';
if ($role = $this->kirby()->roles()->find($roleName)) {
return $this->role = $role;
}
return $this->role = Role::nobody();
}
/**
* The absolute path to the user directory
*
* @return string
*/
public function root(): string
{
return $this->kirby()->root('accounts') . '/' . $this->id();
}
/**
* Returns the UserRules class to
* validate any important action.
*
* @return UserRules
*/
protected function rules()
{
return new UserRules();
}
/**
* Sets the Blueprint object
*
* @param array|null $blueprint
* @return self
*/
protected function setBlueprint(array $blueprint = null): self
{
if ($blueprint !== null) {
$blueprint['model'] = $this;
$this->blueprint = new UserBlueprint($blueprint);
}
return $this;
}
/**
* Sets the user email
*
* @param string $email
* @return self
*/
protected function setEmail(string $email = null): self
{
if ($email !== null) {
$this->email = strtolower(trim($email));
}
return $this;
}
/**
* Sets the user id
*
* @param string $id
* @return self
*/
protected function setId(string $id = null): self
{
$this->id = $id;
return $this;
}
/**
* Sets the user language
*
* @param string $language
* @return self
*/
protected function setLanguage(string $language = null): self
{
$this->language = $language !== null ? trim($language) : null;
return $this;
}
/**
* Sets the user name
*
* @param string $name
* @return self
*/
protected function setName(string $name = null): self
{
$this->name = $name !== null ? trim($name) : null;
return $this;
}
/**
* Sets and hashes a new user password
*
* @param string $password
* @return self
*/
protected function setPassword(string $password = null): self
{
$this->password = $password;
return $this;
}
/**
* Sets the user role
*
* @param string $role
* @return self
*/
protected function setRole(string $role = null): self
{
$this->role = $role !== null ? strtolower(trim($role)) : null;
return $this;
}
/**
* Converts session options into a session object
*
* @param Session|array $session Session options or session object to unset the user in
* @return Session
*/
protected function sessionFromOptions($session): Session
{
// use passed session options or session object if set
if (is_array($session) === true) {
$session = $this->kirby()->session($session);
} elseif (is_a($session, 'Kirby\Session\Session') === false) {
$session = $this->kirby()->session(['detect' => true]);
}
return $session;
}
/**
* Returns the parent Users collection
*
* @return Users
*/
protected function siblingsCollection()
{
return $this->kirby()->users();
}
/**
* Converts the most important user properties
* to an array
*
* @return array
*/
public function toArray(): array
{
return [
'avatar' => $this->avatar() ? $this->avatar()->toArray() : null,
'content' => $this->content()->toArray(),
'email' => $this->email(),
'id' => $this->id(),
'language' => $this->language(),
'role' => $this->role()->name(),
'username' => $this->username()
];
}
/**
* String template builder
*
* @param string|null $template
* @return string
*/
public function toString(string $template = null): string
{
if ($template === null) {
return $this->email();
}
return Str::template($template, [
'user' => $this,
'site' => $this->site(),
'kirby' => $this->kirby()
]);
}
/**
* Returns the username
* which is the given name or the email
* as a fallback
*
* @return string
*/
public function username(): ?string
{
return $this->name()->or($this->email())->value();
}
/**
* Compares the given password with the stored one
*
* @param string $password
* @return boolean
*/
public function validatePassword(string $password = null): bool
{
if (empty($this->password()) === true) {
throw new NotFoundException(['key' => 'user.password.undefined']);
}
if ($password === null) {
throw new InvalidArgumentException(['key' => 'user.password.invalid']);
}
if (password_verify($password, $this->password()) !== true) {
throw new InvalidArgumentException(['key' => 'user.password.invalid']);
}
return true;
}
}

295
kirby/src/Cms/UserActions.php Executable file
View File

@@ -0,0 +1,295 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Data\Data;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentLogicException;
use Kirby\Exception\LogicException;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\V;
trait UserActions
{
/**
* Changes the user email address
*
* @param string $email
* @return self
*/
public function changeEmail(string $email): self
{
return $this->commit('changeEmail', [$this, $email], function ($user, $email) {
$user = $user->clone([
'email' => $email
]);
$user->updateCredentials([
'email' => $email
]);
return $user;
});
}
/**
* Changes the user language
*
* @param string $language
* @return self
*/
public function changeLanguage(string $language): self
{
return $this->commit('changeLanguage', [$this, $language], function ($user, $language) {
$user = $user->clone([
'language' => $language,
]);
$user->updateCredentials([
'language' => $language
]);
return $user;
});
}
/**
* Changes the screen name of the user
*
* @param string $name
* @return self
*/
public function changeName(string $name): self
{
return $this->commit('changeName', [$this, $name], function ($user, $name) {
$user = $user->clone([
'name' => $name
]);
$user->updateCredentials([
'name' => $name
]);
return $user;
});
}
/**
* Changes the user password
*
* @param string $password
* @return self
*/
public function changePassword(string $password): self
{
return $this->commit('changePassword', [$this, $password], function ($user, $password) {
$user = $user->clone([
'password' => $password = $user->hashPassword($password)
]);
$user->writePassword($password);
return $user;
});
}
/**
* Changes the user role
*
* @param string $role
* @return self
*/
public function changeRole(string $role): self
{
return $this->commit('changeRole', [$this, $role], function ($user, $role) {
$user = $user->clone([
'role' => $role,
]);
$user->updateCredentials([
'role' => $role
]);
return $user;
});
}
/**
* Commits a user action, by following these steps
*
* 1. checks the action rules
* 2. sends the before hook
* 3. commits the 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)
{
if ($this->isKirby() === true) {
throw new PermissionException('The Kirby user cannot be changed');
}
$old = $this->hardcopy();
$this->rules()->$action(...$arguments);
$this->kirby()->trigger('user.' . $action . ':before', ...$arguments);
$result = $callback(...$arguments);
$this->kirby()->trigger('user.' . $action . ':after', $result, $old);
$this->kirby()->cache('pages')->flush();
return $result;
}
/**
* Creates a new User from the given props and returns a new User object
*
* @param array $input
* @return self
*/
public static function create(array $props = null): self
{
$data = $props;
if (isset($props['password']) === true) {
$data['password'] = static::hashPassword($props['password']);
}
$user = new static($data);
// create a form for the user
$form = Form::for($user, [
'values' => $props['content'] ?? []
]);
// inject the content
$user = $user->clone(['content' => $form->strings(true)]);
// run the hook
return $user->commit('create', [$user, $props], function ($user, $props) {
$user->writeCredentials([
'email' => $user->email(),
'language' => $user->language(),
'name' => $user->name()->value(),
'role' => $user->role()->id(),
]);
$user->writePassword($user->password());
// write the user data
return $user->save();
});
}
/**
* Returns a random user id
*
* @return string
*/
public function createId(): string
{
$length = 8;
$id = Str::random($length);
while ($this->kirby()->users()->has($id)) {
$length++;
$id = Str::random($length);
}
return $id;
}
/**
* Deletes the user
*
* @return bool
*/
public function delete(): bool
{
return $this->commit('delete', [$this], function ($user) {
if ($user->exists() === false) {
return true;
}
// delete all public assets for this user
Dir::remove($user->mediaRoot());
// delete the user directory
if (Dir::remove($user->root()) !== true) {
throw new LogicException('The user directory for "' . $user->email() . '" could not be deleted');
}
return true;
});
}
/**
* Read the account information from disk
*
* @return array
*/
protected function readCredentials(): array
{
if (file_exists($this->root() . '/index.php') === true) {
$credentials = require $this->root() . '/index.php';
return is_array($credentials) === false ? [] : $credentials;
} else {
return [];
}
}
/**
* Reads the user password from disk
*
* @return string|null
*/
protected function readPassword(): ?string
{
return F::read($this->root() . '/.htpasswd');
}
/**
* This always merges the existing credentials
* with the given input.
*
* @param array $credentials
* @return bool
*/
protected function updateCredentials(array $credentials): bool
{
return $this->writeCredentials(array_merge($this->credentials(), $credentials));
}
/**
* Writes the account information to disk
*
* @return boolean
*/
protected function writeCredentials(array $credentials): bool
{
$export = '<?php' . PHP_EOL . PHP_EOL . 'return ' . var_export($credentials, true) . ';';
return F::write($this->root() . '/index.php', $export);
}
/**
* Writes the password to disk
*
* @param string $password
* @return bool
*/
protected function writePassword(string $password = null): bool
{
return F::write($this->root() . '/.htpasswd', $password);
}
}

31
kirby/src/Cms/UserBlueprint.php Executable file
View File

@@ -0,0 +1,31 @@
<?php
namespace Kirby\Cms;
/**
* Extension of the basic blueprint class
* to handle all blueprints for users.
*/
class UserBlueprint extends Blueprint
{
public function __construct(array $props)
{
parent::__construct($props);
// normalize all available page options
$this->props['options'] = $this->normalizeOptions(
$props['options'] ?? true,
// defaults
[
'create' => null,
'changeEmail' => null,
'changeLanguage' => null,
'changeName' => null,
'changePassword' => null,
'changeRole' => null,
'delete' => null,
'update' => null,
]
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Kirby\Cms;
class UserPermissions extends ModelPermissions
{
protected $category = 'users';
public function __construct(Model $model)
{
parent::__construct($model);
// change the scope of the permissions, when the current user is this user
$this->category = $this->user && $this->user->is($model) ? 'user' : 'users';
}
protected function canChangeRole(): bool
{
return $this->model->isLastAdmin() !== true;
}
protected function canDelete(): bool
{
return $this->model->isLastAdmin() !== true;
}
}

206
kirby/src/Cms/UserRules.php Executable file
View File

@@ -0,0 +1,206 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\V;
/**
* Validators for all user actions
*/
class UserRules
{
public static function changeEmail(User $user, string $email): bool
{
if ($user->permissions()->changeEmail() !== true) {
throw new PermissionException([
'key' => 'user.changeEmail.permission',
'data' => ['name' => $user->username()]
]);
}
return static::validEmail($user, $email);
}
public static function changeLanguage(User $user, string $language): bool
{
if ($user->permissions()->changeLanguage() !== true) {
throw new PermissionException([
'key' => 'user.changeLanguage.permission',
'data' => ['name' => $user->username()]
]);
}
return static::validLanguage($user, $language);
}
public static function changeName(User $user, string $name): bool
{
if ($user->permissions()->changeName() !== true) {
throw new PermissionException([
'key' => 'user.changeName.permission',
'data' => ['name' => $user->username()]
]);
}
return true;
}
public static function changePassword(User $user, string $password): bool
{
if ($user->permissions()->changePassword() !== true) {
throw new PermissionException([
'key' => 'user.changePassword.permission',
'data' => ['name' => $user->username()]
]);
}
return static::validPassword($user, $password);
}
public static function changeRole(User $user, string $role): bool
{
static::validRole($user, $role);
if ($role !== 'admin' && $user->isLastAdmin() === true) {
throw new LogicException([
'key' => 'user.changeRole.lastAdmin',
'data' => ['name' => $user->username()]
]);
}
if ($user->permissions()->changeRole() !== true) {
throw new PermissionException([
'key' => 'user.changeRole.permission',
'data' => ['name' => $user->username()]
]);
}
return true;
}
public static function create(User $user, array $props = []): bool
{
static::validId($user, $user->id());
static::validEmail($user, $user->email(), true);
static::validLanguage($user, $user->language());
if (empty($props['password']) === false) {
static::validPassword($user, $props['password']);
}
if ($user->kirby()->users()->count() > 0) {
if ($user->permissions()->create() !== true) {
throw new PermissionException([
'key' => 'user.create.permission'
]);
}
}
return true;
}
public static function delete(User $user): bool
{
if ($user->isLastAdmin() === true) {
throw new LogicException(['key' => 'user.delete.lastAdmin']);
}
if ($user->isLastUser() === true) {
throw new LogicException([
'key' => 'user.delete.lastUser'
]);
}
if ($user->permissions()->delete() !== true) {
throw new PermissionException([
'key' => 'user.delete.permission',
'data' => ['name' => $user->username()]
]);
}
return true;
}
public static function update(User $user, array $values = [], array $strings = []): bool
{
if ($user->permissions()->update() !== true) {
throw new PermissionException([
'key' => 'user.update.permission',
'data' => ['name' => $user->username()]
]);
}
return true;
}
public static function validEmail(User $user, string $email, bool $strict = false): bool
{
if (V::email($email ?? null) === false) {
throw new InvalidArgumentException([
'key' => 'user.email.invalid',
]);
}
if ($strict === true) {
$duplicate = $user->kirby()->users()->find($email);
} else {
$duplicate = $user->kirby()->users()->not($user)->find($email);
}
if ($duplicate) {
throw new DuplicateException([
'key' => 'user.duplicate',
'data' => ['email' => $email]
]);
}
return true;
}
public static function validId(User $user, string $id): bool
{
if ($duplicate = $user->kirby()->users()->find($id)) {
throw new DuplicateException('A user with this id exists');
}
return true;
}
public static function validLanguage(User $user, string $language): bool
{
if (in_array($language, $user->kirby()->translations()->keys(), true) === false) {
throw new InvalidArgumentException([
'key' => 'user.language.invalid',
]);
}
return true;
}
public static function validPassword(User $user, string $password): bool
{
if (Str::length($password ?? null) < 8) {
throw new InvalidArgumentException([
'key' => 'user.password.invalid',
]);
}
return true;
}
public static function validRole(User $user, string $role): bool
{
if (is_a($user->kirby()->roles()->find($role), 'Kirby\Cms\Role') === true) {
return true;
}
throw new InvalidArgumentException([
'key' => 'user.role.invalid',
]);
}
}

108
kirby/src/Cms/Users.php Executable file
View File

@@ -0,0 +1,108 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\Str;
/**
* Extension of the Collection class that
* provides a Users::factory method to convert
* an array into a Users collection with User
* objects and a Users::load method to load
* user accounts from disk.
*/
class Users extends Collection
{
public function create(array $data)
{
return User::create($data);
}
/**
* Adds a single user or
* an entire second collection to the
* current collection
*
* @param mixed $item
* @return Users
*/
public function add($object)
{
// add a page collection
if (is_a($object, static::class) === true) {
$this->data = array_merge($this->data, $object->data);
// add a user by id
} elseif (is_string($object) === true && $user = App::instance()->user($object)) {
$this->__set($user->id(), $user);
// add a user object
} elseif (is_a($object, User::class) === true) {
$this->__set($object->id(), $object);
}
return $this;
}
/**
* Takes an array of user props and creates a nice and clean user collection from it
*
* @param array $users
* @param array $inject
* @return self
*/
public static function factory(array $users, array $inject = []): self
{
$collection = new static;
// read all user blueprints
foreach ($users as $props) {
$user = new User($props + $inject);
$collection->set($user->id(), $user);
}
return $collection;
}
/**
* Finds a user in the collection by id or email address
*
* @param string $key
* @return User|null
*/
public function findByKey($key)
{
if (Str::contains($key, '@') === true) {
return parent::findBy('email', $key);
}
return parent::findByKey($key);
}
/**
* Loads a user from disk by passing the absolute path (root)
*
* @param string $root
* @param array $inject
* @return self
*/
public static function load(string $root, array $inject = []): self
{
$users = new static;
foreach (Dir::read($root) as $userDirectory) {
if (is_dir($root . '/' . $userDirectory) === false) {
continue;
}
$user = new User([
'id' => $userDirectory,
] + $inject);
$users->set($user->id(), $user);
}
return $users;
}
}

19
kirby/src/Cms/Visitor.php Executable file
View File

@@ -0,0 +1,19 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Facade;
/**
* Shortcut to the visitor object
*/
class Visitor extends Facade
{
/**
* @return \Kirby\Http\Visitor
*/
protected static function instance()
{
return App::instance()->visitor();
}
}

124
kirby/src/Data/Data.php Executable file
View File

@@ -0,0 +1,124 @@
<?php
namespace Kirby\Data;
use Exception;
use Kirby\Toolkit\F;
/**
* Universal Data writer and reader class.
*
* The read and write methods automatically
* detect, which data handler to use in order
* to correctly encode and decode passed data.
*
* Data Handlers for the class can be
* extended and customized.
*
* @package Kirby
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright 2012 Bastian Allgeier
* @license MIT
*/
class Data
{
/**
* Handler Type Aliases
*
* @var array
*/
public static $aliases = [
'yml' => 'yaml',
'md' => 'txt',
'mdown' => 'txt'
];
/**
* All registered handlers
*
* @var array
*/
public static $handlers = [
'json' => 'Kirby\Data\Json',
'yaml' => 'Kirby\Data\Yaml',
'txt' => 'Kirby\Data\Txt'
];
/**
* Handler getter
*
* @param string $type
* @return Handler|null
*/
public static function handler(string $type)
{
// normalize the type
$type = strtolower($type);
$handler = static::$handlers[$type] ?? null;
if ($handler === null && isset(static::$aliases[$type]) === true) {
$handler = static::$handlers[static::$aliases[$type]] ?? null;
}
if ($handler === null) {
throw new Exception('Missing Handler for type: "' . $type . '"');
}
return new $handler;
}
/**
* Decode data with the specified handler
*
* @param string $data
* @param string $type
* @return array
*/
public static function decode(string $data = null, string $type): array
{
return static::handler($type)->decode($data);
}
/**
* Encode data with the specified handler
*
* @param array $data
* @param string $type
* @return string
*/
public static function encode(array $data = null, string $type): string
{
return static::handler($type)->encode($data);
}
/**
* Reads data from a file
* The data handler is automatically chosen by
* the extension if not specified.
*
* @param string $file
* @param string $type
* @return array
*/
public static function read(string $file, string $type = null): array
{
return static::handler($type ?? F::extension($file))->read($file);
}
/**
* Writes data to a file.
* The data handler is automatically chosen by
* the extension if not specified.
*
* @param string $file
* @param array $data
* @param string $type
* @return boolean
*/
public static function write(string $file = null, array $data = [], string $type = null): bool
{
return static::handler($type ?? F::extension($file))->write($file, $data);
}
}

67
kirby/src/Data/Handler.php Executable file
View File

@@ -0,0 +1,67 @@
<?php
namespace Kirby\Data;
use Exception;
use Kirby\Toolkit\F;
/**
* Base handler abstract,
* which needs to be extended to
* create valid data handlers
*
* @package Kirby Data
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
* @license MIT
*/
abstract class Handler
{
/**
* Parses an encoded string and returns a multi-dimensional array
*
* Needs to throw an Exception if the file can't be parsed.
*
* @param string $string
* @return array
*/
abstract public static function decode($string): array;
/**
* Converts an array to an encoded string
*
* @param array $data
* @return string
*/
abstract public static function encode(array $data): string;
/**
* Reads data from a file
*
* @param string $file
* @return array
*/
public static function read(string $file): array
{
if (file_exists($file) !== true) {
throw new Exception('The file "' . $file . '" does not exist');
}
return static::decode(F::read($file));
}
/**
* Writes data to a file.
* The data handler is automatically chosen by
* the extension if not specified.
*
* @param array $data
* @return boolean
*/
public static function write(string $file = null, array $data = []): bool
{
return F::write($file, static::encode($data));
}
}

45
kirby/src/Data/Json.php Executable file
View File

@@ -0,0 +1,45 @@
<?php
namespace Kirby\Data;
use Exception;
/**
* Simple Wrapper around json_encode and json_decode
*
* @package Kirby Data
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
* @license MIT
*/
class Json extends Handler
{
/**
* Converts an array to an encoded JSON string
*
* @param array $data
* @return string
*/
public static function encode(array $data): string
{
return json_encode($data);
}
/**
* Parses an encoded JSON string and returns a multi-dimensional array
*
* @param string $string
* @return array
*/
public static function decode($json): array
{
$result = json_decode($json, true);
if (is_array($result) === true) {
return $result;
} else {
throw new Exception('JSON string is invalid');
}
}
}

112
kirby/src/Data/Txt.php Executable file
View File

@@ -0,0 +1,112 @@
<?php
namespace Kirby\Data;
use Kirby\Toolkit\Str;
/**
* Kirby Txt Data Handler
*
* @package Kirby Data
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
* @license MIT
*/
class Txt extends Handler
{
/**
* Converts an array to an encoded Kirby txt string
*
* @param array $data
* @return string
*/
public static function encode(array $data): string
{
$result = [];
foreach ($data as $key => $value) {
if (empty($key) === true || $value === null) {
continue;
}
$key = Str::ucfirst(Str::slug($key));
$value = static::encodeValue($value);
$result[$key] = static::encodeResult($key, $value);
}
return implode("\n\n----\n\n", $result);
}
/**
* Helper for converting value
*
* @param array|string $value
* @return string
*/
protected static function encodeValue($value): string
{
// avoid problems with arrays
if (is_array($value) === true) {
$value = Yaml::encode($value);
}
// escape accidental dividers within a field
$value = preg_replace('!(\n|^)----(.*?\R*)!', '$1\\----$2', $value);
return $value;
}
/**
* Helper for converting key and value to result string
*
* @param string $key
* @param string $value
* @return string
*/
protected static function encodeResult(string $key, string $value): string
{
$result = $key . ': ';
// multi-line content
if (preg_match('!\R!', $value) === 1) {
$result .= "\n\n";
}
$result .= trim($value);
return $result;
}
/**
* Parses a Kirby txt string and returns a multi-dimensional array
*
* @param string $string
* @return array
*/
public static function decode($string): array
{
// remove BOM
$string = str_replace("\xEF\xBB\xBF", '', $string);
// explode all fields by the line separator
$fields = preg_split('!\n----\s*\n*!', $string);
// start the data array
$data = [];
// loop through all fields and add them to the content
foreach ($fields as $field) {
$pos = strpos($field, ':');
$key = str_replace(['-', ' '], '_', strtolower(trim(substr($field, 0, $pos))));
// Don't add fields with empty keys
if (empty($key) === true) {
continue;
}
$data[$key] = trim(substr($field, $pos + 1));
}
return $data;
}
}

56
kirby/src/Data/Yaml.php Executable file
View File

@@ -0,0 +1,56 @@
<?php
namespace Kirby\Data;
use Exception;
use Spyc;
/**
* Simple Wrapper around Symfony's Yaml Component
*
* @package Kirby Data
* @author Bastian Allgeier <bastian@getkirby.com>
* @link http://getkirby.com
* @copyright Bastian Allgeier
* @license MIT
*/
class Yaml extends Handler
{
/**
* Converts an array to an encoded YAML string
*
* @param array $data
* @return string
*/
public static function encode(array $data): string
{
// $data, $indent, $wordwrap, $no_opening_dashes
return Spyc::YAMLDump($data, false, false, true);
}
/**
* Parses an encoded YAML string and returns a multi-dimensional array
*
* @param string $string
* @return array
*/
public static function decode($yaml): array
{
if ($yaml === null) {
return [];
}
if (is_array($yaml) === true) {
return $yaml;
}
$result = Spyc::YAMLLoadString($yaml);
if (is_array($result)) {
return $result;
} else {
throw new Exception('YAML string is invalid');
}
}
}

621
kirby/src/Database/Database.php Executable file
View File

@@ -0,0 +1,621 @@
<?php
namespace Kirby\Database;
use Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use PDO;
use PDOStatement;
use Throwable;
/**
* A simple database class
*/
class Database
{
/**
* The number of affected rows for the last query
*
* @var int|null
*/
protected $affected;
/**
* Whitelist for column names
*
* @var array
*/
protected $columnWhitelist = [];
/**
* The established connection
*
* @var PDO|null
*/
protected $connection;
/**
* A global array of started connections
*
* @var array
*/
public static $connections = [];
/**
* Database name
*
* @var string
*/
protected $database;
/**
* @var string
*/
protected $dsn;
/**
* Set to true to throw exceptions on failed queries
*
* @var boolean
*/
protected $fail = false;
/**
* The connection id
*
* @var string
*/
protected $id;
/**
* The last error
*
* @var Exception|null
*/
protected $lastError;
/**
* The last insert id
*
* @var int|null
*/
protected $lastId;
/**
* The last query
*
* @var string
*/
protected $lastQuery;
/**
* The last result set
*
* @var mixed
*/
protected $lastResult;
/**
* Optional prefix for table names
*
* @var string
*/
protected $prefix;
/**
* The PDO query statement
*
* @var PDOStatement|null
*/
protected $statement;
/**
* Whitelists for table names
*
* @var array|null
*/
protected $tableWhitelist;
/**
* An array with all queries which are being made
*
* @var array
*/
protected $trace = [];
/**
* The database type (mysql, sqlite)
*
* @var string
*/
protected $type;
/**
* @var array
*/
public static $types = [];
/**
* Creates a new Database instance
*
* @param array $params
* @return void
*/
public function __construct(array $params = [])
{
$this->connect($params);
}
/**
* Returns one of the started instance
*
* @param string $id
* @return Database
*/
public static function instance(string $id = null): self
{
return $id === null ? A::last(static::$connections) : static::$connections[$id] ?? null;
}
/**
* Returns all started instances
*
* @return array
*/
public static function instances(): array
{
return static::$connections;
}
/**
* Connects to a database
*
* @param array|null $params This can either be a config key or an array of parameters for the connection
* @return Database
*/
public function connect(array $params = null)
{
$defaults = [
'database' => null,
'type' => 'mysql',
'prefix' => null,
'user' => null,
'password' => null,
'id' => uniqid()
];
$options = array_merge($defaults, $params);
// store the database information
$this->database = $options['database'];
$this->type = $options['type'];
$this->prefix = $options['prefix'];
$this->id = $options['id'];
if (isset(static::$types[$this->type]) === false) {
throw new InvalidArgumentException('Invalid database type: ' . $this->type);
}
// fetch the dsn and store it
$this->dsn = static::$types[$this->type]['dsn']($options);
// try to connect
$this->connection = new PDO($this->dsn, $options['user'], $options['password']);
$this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
// store the connection
static::$connections[$this->id] = $this;
// return the connection
return $this->connection;
}
/**
* Returns the currently active connection
*
* @return Database|null
*/
public function connection()
{
return $this->connection;
}
/**
* Sets the exception mode for the next query
*
* @param boolean $fail
* @return Database
*/
public function fail(bool $fail = true)
{
$this->fail = $fail;
return $this;
}
/**
* Returns the used database type
*
* @return string
*/
public function type(): string
{
return $this->type;
}
/**
* Returns the used table name prefix
*
* @return string|null
*/
public function prefix(): ?string
{
return $this->prefix;
}
/**
* Escapes a value to be used for a safe query
* NOTE: Prepared statements using bound parameters are more secure and solid
*
* @param string $value
* @return string
*/
public function escape(string $value): string
{
return substr($this->connection()->quote($value), 1, -1);
}
/**
* Adds a value to the db trace and also returns the entire trace if nothing is specified
*
* @param array $data
* @return array
*/
public function trace($data = null): array
{
// return the full trace
if ($data === null) {
return $this->trace;
}
// add a new entry to the trace
$this->trace[] = $data;
return $this->trace;
}
/**
* Returns the number of affected rows for the last query
*
* @return int|null
*/
public function affected(): ?int
{
return $this->affected;
}
/**
* Returns the last id if available
*
* @return int|null
*/
public function lastId(): ?int
{
return $this->lastId;
}
/**
* Returns the last query
*
* @return string|null
*/
public function lastQuery(): ?string
{
return $this->lastQuery;
}
/**
* Returns the last set of results
*
* @return mixed
*/
public function lastResult()
{
return $this->lastResult;
}
/**
* Returns the last db error
*
* @return Throwable
*/
public function lastError()
{
return $this->lastError;
}
/**
* Private method to execute database queries.
* This is used by the query() and execute() methods
*
* @param string $query
* @param array $bindings
* @return boolean
*/
protected function hit(string $query, array $bindings = []): bool
{
// try to prepare and execute the sql
try {
$this->statement = $this->connection->prepare($query);
$this->statement->execute($bindings);
$this->affected = $this->statement->rowCount();
$this->lastId = $this->connection->lastInsertId();
$this->lastError = null;
// store the final sql to add it to the trace later
$this->lastQuery = $this->statement->queryString;
} catch (Throwable $e) {
// store the error
$this->affected = 0;
$this->lastError = $e;
$this->lastId = null;
$this->lastQuery = $query;
// only throw the extension if failing is allowed
if ($this->fail === true) {
throw $e;
}
}
// add a new entry to the singleton trace array
$this->trace([
'query' => $this->lastQuery,
'bindings' => $bindings,
'error' => $this->lastError
]);
// reset some stuff
$this->fail = false;
// return true or false on success or failure
return $this->lastError === null;
}
/**
* Exectues a sql query, which is expected to return a set of results
*
* @param string $query
* @param array $bindings
* @param array $params
* @return mixed
*/
public function query(string $query, array $bindings = [], array $params = [])
{
$defaults = [
'flag' => null,
'method' => 'fetchAll',
'fetch' => 'Kirby\Toolkit\Obj',
'iterator' => 'Kirby\Toolkit\Collection',
];
$options = array_merge($defaults, $params);
if ($this->hit($query, $bindings) === false) {
return false;
}
// define the default flag for the fetch method
$flags = $options['fetch'] === 'array' ? PDO::FETCH_ASSOC : PDO::FETCH_CLASS|PDO::FETCH_PROPS_LATE;
// add optional flags
if (empty($options['flag']) === false) {
$flags |= $options['flag'];
}
// set the fetch mode
if ($options['fetch'] === 'array') {
$this->statement->setFetchMode($flags);
} else {
$this->statement->setFetchMode($flags, $options['fetch']);
}
// fetch that stuff
$results = $this->statement->{$options['method']}();
if ($options['iterator'] === 'array') {
return $this->lastResult = $results;
}
return $this->lastResult = new $options['iterator']($results);
}
/**
* Executes a sql query, which is expected to not return a set of results
*
* @param string $query
* @param array $bindings
* @return boolean
*/
public function execute(string $query, array $bindings = []): bool
{
return $this->lastResult = $this->hit($query, $bindings);
}
/**
* Returns the correct Sql generator instance
* for the type of database
*
* @return Sql
*/
public function sql()
{
$className = static::$types[$this->type]['sql'] ?? 'Sql';
return new $className($this);
}
/**
* Sets the current table, which should be queried
*
* @param string $table
* @return Query Returns a Query object, which can be used to build a full query for that table
*/
public function table(string $table)
{
return new Query($this, $this->prefix() . $table);
}
/**
* Checks if a table exists in the current database
*
* @param string $table
* @return boolean
*/
public function validateTable(string $table): bool
{
if ($this->tableWhitelist === null) {
// Get the table whitelist from the database
$sql = $this->sql()->tables($this->database);
$results = $this->query($sql['query'], $sql['bindings']);
if ($results) {
$this->tableWhitelist = $results->pluck('name');
} else {
return false;
}
}
return in_array($table, $this->tableWhitelist) === true;
}
/**
* Checks if a column exists in a specified table
*
* @param string $table
* @param string $column
* @return boolean
*/
public function validateColumn(string $table, string $column): bool
{
if (isset($this->columnWhitelist[$table]) === false) {
if ($this->validateTable($table) === false) {
$this->columnWhitelist[$table] = [];
return false;
}
// Get the column whitelist from the database
$sql = $this->sql()->columns($table);
$results = $this->query($sql['query'], $sql['bindings']);
if ($results) {
$this->columnWhitelist[$table] = $results->pluck('name');
} else {
return false;
}
}
return in_array($column, $this->columnWhitelist[$table]) === true;
}
/**
* Creates a new table
*
* @param string $table
* @param array $columns
* @return boolean
*/
public function createTable($table, $columns = []): bool
{
$sql = $this->sql()->createTable($table, $columns);
$queries = Str::split($sql['query'], ';');
foreach ($queries as $query) {
$query = trim($query);
if ($this->execute($query, $sql['bindings']) === false) {
return false;
}
}
return true;
}
/**
* Drops a table
*
* @param string $table
* @return boolean
*/
public function dropTable($table): bool
{
$sql = $this->sql()->dropTable($table);
return $this->execute($sql['query'], $sql['bindings']);
}
/**
* Magic way to start queries for tables by
* using a method named like the table.
* I.e. $db->users()->all()
*/
public function __call($method, $arguments = null)
{
return $this->table($method);
}
}
/**
* MySQL database connector
*/
Database::$types['mysql'] = [
'sql' => 'Kirby\Database\Sql\Mysql',
'dsn' => function (array $params) {
if (isset($params['host']) === false && isset($params['socket']) === false) {
throw new InvalidArgumentException('The mysql connection requires either a "host" or a "socket" parameter');
}
if (isset($params['database']) === false) {
throw new InvalidArgumentException('The mysql connection requires a "database" parameter');
}
$parts = [];
if (empty($params['host']) === false) {
$parts[] = 'host=' . $params['host'];
}
if (empty($params['port']) === false) {
$parts[] = 'port=' . $params['port'];
}
if (empty($params['socket']) === false) {
$parts[] = 'unix_socket=' . $params['socket'];
}
if (empty($params['database']) === false) {
$parts[] = 'dbname=' . $params['database'];
}
$parts[] = 'charset=' . ($params['charset'] ?? 'utf8');
return 'mysql:' . implode(';', $parts);
}
];
/**
* SQLite database connector
*/
Database::$types['sqlite'] = [
'sql' => 'Kirby\Database\Sql\Sqlite',
'dsn' => function (array $params) {
if (isset($params['database']) === false) {
throw new InvalidArgumentException('The sqlite connection requires a "database" parameter');
}
return 'sqlite:' . $params['database'];
}
];

262
kirby/src/Database/Db.php Executable file
View File

@@ -0,0 +1,262 @@
<?php
namespace Kirby\Database;
use InvalidArgumentException;
use Kirby\Toolkit\Config;
/**
* Database shortcuts
*/
class Db
{
const ERROR_UNKNOWN_METHOD = 0;
/**
* Query shortcuts
*
* @var array
*/
public static $queries = [];
/**
* The singleton Database object
*
* @var Database
*/
public static $connection = null;
/**
* (Re)connect the database
*
* @param array $params Pass [] to use the default params from the config
* @return Database
*/
public static function connect(array $params = null)
{
if ($params === null && static::$connection !== null) {
return static::$connection;
}
// try to connect with the default
// connection settings if no params are set
$params = $params ?? [
'type' => Config::get('db.type', 'mysql'),
'host' => Config::get('db.host', 'localhost'),
'user' => Config::get('db.user', 'root'),
'password' => Config::get('db.password', ''),
'database' => Config::get('db.database', ''),
'prefix' => Config::get('db.prefix', ''),
];
return static::$connection = new Database($params);
}
/**
* Returns the current database connection
*
* @return Database
*/
public static function connection()
{
return static::$connection;
}
/**
* Sets the current table, which should be queried
*
* @param string $table
* @return Query Returns a Query object, which can be used to build a full query for that table
*/
public static function table($table)
{
$db = static::connect();
return $db->table($table);
}
/**
* Executes a raw sql query which expects a set of results
*
* @param string $query
* @param array $bindings
* @param array $params
* @return mixed
*/
public static function query(string $query, array $bindings = [], array $params = [])
{
$db = static::connect();
return $db->query($query, $bindings, $params);
}
/**
* Executes a raw sql query which expects no set of results (i.e. update, insert, delete)
*
* @param string $query
* @param array $bindings
* @return mixed
*/
public static function execute(string $query, array $bindings = [])
{
$db = static::connect();
return $db->execute($query, $bindings);
}
/**
* Magic calls for other static db methods,
* which are redircted to the database class if available
*
* @param string $method
* @param mixed $arguments
* @return mixed
*/
public static function __callStatic($method, $arguments)
{
if (isset(static::$queries[$method])) {
return static::$queries[$method](...$arguments);
}
if (is_callable([static::$connection, $method]) === true) {
return call_user_func_array([static::$connection, $method], $arguments);
}
throw new InvalidArgumentException('Invalid static Db method: ' . $method, static::ERROR_UNKNOWN_METHOD);
}
}
/**
* Shortcut for select clauses
*
* @param string $table The name of the table, which should be queried
* @param mixed $columns Either a string with columns or an array of column names
* @param mixed $where The where clause. Can be a string or an array
* @param string $order
* @param int $offset
* @param int $limit
* @return mixed
*/
Db::$queries['select'] = function (string $table, $columns = '*', $where = null, string $order = null, int $offset = 0, int $limit = null) {
return Db::table($table)->select($columns)->where($where)->order($order)->offset($offset)->limit($limit)->all();
};
/**
* Shortcut for selecting a single row in a table
*
* @param string $table The name of the table, which should be queried
* @param mixed $columns Either a string with columns or an array of column names
* @param mixed $where The where clause. Can be a string or an array
* @param string $order
* @param int $offset
* @param int $limit
* @return mixed
*/
Db::$queries['first'] = Db::$queries['row'] = Db::$queries['one'] = function (string $table, $columns = '*', $where = null, string $order = null) {
return Db::table($table)->select($columns)->where($where)->order($order)->first();
};
/**
* Returns only values from a single column
*
* @param string $table The name of the table, which should be queried
* @param string $column The name of the column to select from
* @param mixed $where The where clause. Can be a string or an array
* @param string $order
* @param int $offset
* @param int $limit
* @return mixed
*/
Db::$queries['column'] = function (string $table, string $column, $where = null, string $order = null, int $offset = 0, int $limit = null) {
return Db::table($table)->where($where)->order($order)->offset($offset)->limit($limit)->column($column);
};
/**
* Shortcut for inserting a new row into a table
*
* @param string $table The name of the table, which should be queried
* @param array $values An array of values, which should be inserted
* @return boolean
*/
Db::$queries['insert'] = function (string $table, array $values) {
return Db::table($table)->insert($values);
};
/**
* Shortcut for updating a row in a table
*
* @param string $table The name of the table, which should be queried
* @param array $values An array of values, which should be inserted
* @param mixed $where An optional where clause
* @return boolean
*/
Db::$queries['update'] = function (string $table, array $values, $where = null) {
return Db::table($table)->where($where)->update($values);
};
/**
* Shortcut for deleting rows in a table
*
* @param string $table The name of the table, which should be queried
* @param mixed $where An optional where clause
* @return boolean
*/
Db::$queries['delete'] = function (string $table, $where = null) {
return Db::table($table)->where($where)->delete();
};
/**
* Shortcut for counting rows in a table
*
* @param string $table The name of the table, which should be queried
* @param mixed $where An optional where clause
* @return int
*/
Db::$queries['count'] = function (string $table, $where = null) {
return Db::table($table)->where($where)->count();
};
/**
* Shortcut for calculating the minimum value in a column
*
* @param string $table The name of the table, which should be queried
* @param string $column The name of the column of which the minimum should be calculated
* @param mixed $where An optional where clause
* @return mixed
*/
Db::$queries['min'] = function (string $table, string $column, $where = null) {
return Db::table($table)->where($where)->min($column);
};
/**
* Shortcut for calculating the maximum value in a column
*
* @param string $table The name of the table, which should be queried
* @param string $column The name of the column of which the maximum should be calculated
* @param mixed $where An optional where clause
* @return mixed
*/
Db::$queries['max'] = function (string $table, string $column, $where = null) {
return Db::table($table)->where($where)->max($column);
};
/**
* Shortcut for calculating the average value in a column
*
* @param string $table The name of the table, which should be queried
* @param string $column The name of the column of which the average should be calculated
* @param mixed $where An optional where clause
* @return mixed
*/
Db::$queries['avg'] = function (string $table, string $column, $where = null) {
return Db::table($table)->where($where)->avg($column);
};
/**
* Shortcut for calculating the sum of all values in a column
*
* @param string $table The name of the table, which should be queried
* @param string $column The name of the column of which the sum should be calculated
* @param mixed $where An optional where clause
* @return mixed
*/
Db::$queries['sum'] = function (string $table, string $column, $where = null) {
return Db::table($table)->where($where)->sum($column);
};

1055
kirby/src/Database/Query.php Executable file

File diff suppressed because it is too large Load Diff

929
kirby/src/Database/Sql.php Executable file
View File

@@ -0,0 +1,929 @@
<?php
namespace Kirby\Database;
use Closure;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* SQL Query builder
*/
class Sql
{
/**
* List of literals which should not be escaped in queries
*
* @var array
*/
public static $literals = ['NOW()', null];
/**
* The parent database connection
*
* @var Database
*/
public $database;
/**
* Constructor
*
* @param Database $database
*/
public function __construct($database)
{
$this->database = $database;
}
/**
* Returns a randomly generated binding name
*
* @param string $label String that contains lowercase letters and numbers to use as a readable identifier
* @param string $prefix
* @return string
*/
public function bindingName(string $label): string
{
// make sure that the binding name is valid to prevent injections
if (!preg_match('/^[a-z0-9_]+$/', $label)) {
$label = 'invalid';
}
return ':' . $label . '_' . Str::random(16);
}
/**
* Returns a list of columns for a specified table
* MySQL version
*
* @param string $table The table name
* @return array
*/
public function columns(string $table): array
{
$databaseBinding = $this->bindingName('database');
$tableBinding = $this->bindingName('table');
$query = 'SELECT COLUMN_NAME AS name FROM INFORMATION_SCHEMA.COLUMNS ';
$query .= 'WHERE TABLE_SCHEMA = ' . $databaseBinding . ' AND TABLE_NAME = ' . $tableBinding;
return [
'query' => $query,
'bindings' => [
$databaseBinding => $this->database->database,
$tableBinding => $table,
]
];
}
/**
* Optionl default value definition for the column
*
* @param array $column
* @return array
*/
public function columnDefault(array $column): array
{
if (isset($column['default']) === false) {
return [
'query' => null,
'bindings' => []
];
}
$binding = $this->bindingName($column['name'] . '_default');
return [
'query' => 'DEFAULT ' . $binding,
'bindings' => [
$binding = $column['default']
]
];
}
/**
* Returns a valid column name
*
* @param string $table
* @param string $column
* @param boolean $enforceQualified
* @return string|null
*/
public function columnName(string $table, string $column, bool $enforceQualified = false): ?string
{
list($table, $column) = $this->splitIdentifier($table, $column);
if ($this->validateColumn($table, $column) === true) {
return $this->combineIdentifier($table, $column, $enforceQualified !== true);
}
return null;
}
/**
* Abstracted column types to simplify table
* creation for multiple database drivers
*
* @return array
*/
public function columnTypes(): array
{
return [
'id' => '{{ name }} INT(11) UNSIGNED NOT NULL AUTO_INCREMENT',
'varchar' => '{{ name }} varchar(255) {{ null }} {{ default }}',
'text' => '{{ name }} TEXT',
'int' => '{{ name }} INT(11) UNSIGNED {{ null }} {{ default }}',
'timestamp' => '{{ name }} TIMESTAMP {{ null }} {{ default }}'
];
}
/**
* Optional key definition for the column.
*
* @param array $column
* @return array
*/
public function columnKey(array $column): array
{
return [
'query' => null,
'bindings' => []
];
}
/**
* Combines an identifier (table and column)
* Default version for MySQL
*
* @param $table string
* @param $column string
* @param $values boolean Whether the identifier is going to be used for a values clause
* Only relevant for SQLite
* @return string
*/
public function combineIdentifier(string $table, string $column, bool $values = false): string
{
return $this->quoteIdentifier($table) . '.' . $this->quoteIdentifier($column);
}
/**
* Creates the create syntax for a single column
*
* @param string $table
* @param array $column
* @return array
*/
public function createColumn(string $table, array $column): array
{
// column type
if (isset($column['type']) === false) {
throw new InvalidArgumentException('No column type given for column ' . $column);
}
// column name
if (isset($column['name']) === false) {
throw new InvalidArgumentException('No column name given');
}
if ($column['type'] === 'id') {
$column['key'] = 'PRIMARY';
}
if (!$template = ($this->columnTypes()[$column['type']] ?? null)) {
throw new InvalidArgumentException('Unsupported column type: ' . $column['type']);
}
// null
if (A::get($column, 'null') === false) {
$null = 'NOT NULL';
} else {
$null = 'NULL';
}
// indexes/keys
$key = false;
if (isset($column['key']) === true) {
$column['key'] = strtoupper($column['key']);
// backwards compatibility
if ($column['key'] === 'PRIMARY') {
$column['key'] = 'PRIMARY KEY';
}
if (in_array($column['key'], ['PRIMARY KEY', 'INDEX']) === true) {
$key = $column['key'];
}
}
// default value
$columnDefault = $this->columnDefault($column);
$columnKey = $this->columnKey($column);
$query = trim(Str::template($template, [
'name' => $this->quoteIdentifier($column['name']),
'null' => $null,
'key' => $columnKey['query'],
'default' => $columnDefault['query'],
]));
$bindings = array_merge($columnKey['bindings'], $columnDefault['bindings']);
return [
'query' => $query,
'bindings' => $bindings,
'key' => $key
];
}
/**
* Creates a table with a simple scheme array for columns
* Default version for MySQL
*
* @param string $table The table name
* @param array $columns
* @return array
*/
public function createTable(string $table, array $columns = []): array
{
$output = [];
$keys = [];
$bindings = [];
foreach ($columns as $name => $column) {
$sql = $this->createColumn($table, $column);
$output[] = $sql['query'];
if ($sql['key']) {
$keys[$column['name']] = $sql['key'];
}
$bindings = array_merge($bindings, $sql['bindings']);
}
// combine columns
$inner = implode(',' . PHP_EOL, $output);
// add keys
foreach ($keys as $name => $key) {
$inner .= ',' . PHP_EOL . $key . ' (' . $this->quoteIdentifier($name) . ')';
}
return [
'query' => 'CREATE TABLE ' . $this->quoteIdentifier($table) . ' (' . PHP_EOL . $inner . PHP_EOL . ')',
'bindings' => $bindings
];
}
/**
* Builds a delete clause
*
* @param array $params List of parameters for the delete clause. See defaults for more info.
* @return array
*/
public function delete(array $params = []): array
{
$defaults = [
'table' => '',
'where' => null,
'bindings' => []
];
$options = array_merge($defaults, $params);
$bindings = $options['bindings'];
$query = ['DELETE'];
// from
$this->extend($query, $bindings, $this->from($options['table']));
// where
$this->extend($query, $bindings, $this->where($options['where']));
return [
'query' => $this->query($query),
'bindings' => $bindings
];
}
/**
* Creates the sql for dropping a single table
*
* @param string $table
* @return array
*/
public function dropTable(string $table): array
{
return [
'query' => 'DROP TABLE ' . $this->tableName($table),
'bindings' => []
];
}
/**
* Extends a given query and bindings
* by reference
*
* @param array $query
* @param array $bindings
* @param array $input
* @return void
*/
public function extend(&$query, array &$bindings = [], $input)
{
if (empty($input['query']) === false) {
$query[] = $input['query'];
$bindings = array_merge($bindings, $input['bindings']);
}
}
/**
* Creates the from syntax
*
* @param string $table
* @return array
*/
public function from(string $table): array
{
return [
'query' => 'FROM ' . $this->tableName($table),
'bindings' => []
];
}
/**
* Creates the group by syntax
*
* @param string $group
* @return array
*/
public function group(string $group = null): array
{
if (empty($group) === true) {
return [
'query' => null,
'bindings' => []
];
}
return [
'query' => 'GROUP BY ' . $group,
'bindings' => []
];
}
/**
* Creates the having syntax
*
* @param string $having
* @return array
*/
public function having(string $having = null): array
{
if (empty($having) === true) {
return [
'query' => null,
'bindings' => []
];
}
return [
'query' => 'HAVING ' . $having,
'bindings' => []
];
}
/**
* Creates an insert query
*
* @param array $params
* @return array
*/
public function insert(array $params = []): array
{
$table = $params['table'] ?? null;
$values = $params['values'] ?? null;
$bindings = $params['bindings'];
$query = ['INSERT INTO ' . $this->tableName($table)];
// add the values
$this->extend($query, $bindings, $this->values($table, $values, ', ', false));
return [
'query' => $this->query($query),
'bindings' => $bindings
];
}
/**
* Creates a join query
*
* @param string $table
* @param string $type
* @param string $on
* @return array
*/
public function join(string $type, string $table, string $on): array
{
$types = [
'JOIN',
'INNER JOIN',
'OUTER JOIN',
'LEFT OUTER JOIN',
'LEFT JOIN',
'RIGHT OUTER JOIN',
'RIGHT JOIN',
'FULL OUTER JOIN',
'FULL JOIN',
'NATURAL JOIN',
'CROSS JOIN',
'SELF JOIN'
];
$type = strtoupper(trim($type));
// validate join type
if (in_array($type, $types) === false) {
throw new InvalidArgumentException('Invalid join type ' . $type);
}
return [
'query' => $type . ' ' . $this->tableName($table) . ' ON ' . $on,
'bindings' => [],
];
}
/**
* Create the syntax for multiple joins
*
* @params array $joins
* @return array
*/
public function joins(array $joins = null): array
{
$query = [];
$bindings = [];
foreach ((array)$joins as $join) {
$this->extend($query, $bindings, $this->join($join['type'] ?? 'JOIN', $join['table'] ?? null, $join['on'] ?? null));
}
return [
'query' => implode(' ', array_filter($query)),
'bindings' => [],
];
}
/**
* Creates a limit and offset query instruction
*
* @param integer $offset
* @param integer|null $limit
* @return array
*/
public function limit(int $offset = 0, int $limit = null): array
{
// no need to add it to the query
if ($offset === 0 && $limit === null) {
return [
'query' => null,
'bindings' => []
];
}
$limit = $limit ?? '18446744073709551615';
$offsetBinding = $this->bindingName('offset');
$limitBinding = $this->bindingName('limit');
return [
'query' => 'LIMIT ' . $offsetBinding . ', ' . $limitBinding,
'bindings' => [
$limitBinding => $limit,
$offsetBinding => $offset,
]
];
}
/**
* Creates the order by syntax
*
* @param string $order
* @return array
*/
public function order(string $order = null): array
{
if (empty($order) === true) {
return [
'query' => null,
'bindings' => []
];
}
return [
'query' => 'ORDER BY ' . $order,
'bindings' => []
];
}
/**
* Converts a query array into a final string
*
* @param array $query
* @param string $separator
* @return string
*/
public function query(array $query, string $separator = ' ')
{
return implode($separator, array_filter($query));
}
/**
* Quotes an identifier (table *or* column)
* Default version for MySQL
*
* @param $identifier string
* @return string
*/
public function quoteIdentifier(string $identifier): string
{
// * is special
if ($identifier === '*') {
return $identifier;
}
// replace every backtick with two backticks
$identifier = str_replace('`', '``', $identifier);
// wrap in backticks
return '`' . $identifier . '`';
}
/**
* Builds a select clause
*
* @param array $params List of parameters for the select clause. Check out the defaults for more info.
* @return array An array with the query and the bindings
*/
public function select(array $params = []): array
{
$defaults = [
'table' => '',
'columns' => '*',
'join' => null,
'distinct' => false,
'where' => null,
'group' => null,
'having' => null,
'order' => null,
'offset' => 0,
'limit' => null,
'bindings' => []
];
$options = array_merge($defaults, $params);
$bindings = $options['bindings'];
$query = ['SELECT'];
// select distinct values
if ($options['distinct'] === true) {
$query[] = 'DISTINCT';
}
// columns
$query[] = $this->selected($options['table'], $options['columns']);
// from
$this->extend($query, $bindings, $this->from($options['table']));
// joins
$this->extend($query, $bindings, $this->joins($options['join']));
// where
$this->extend($query, $bindings, $this->where($options['where']));
// group
$this->extend($query, $bindings, $this->group($options['group']));
// having
$this->extend($query, $bindings, $this->having($options['having']));
// order
$this->extend($query, $bindings, $this->order($options['order']));
// offset and limit
$this->extend($query, $bindings, $this->limit($options['offset'], $options['limit']));
return [
'query' => $this->query($query),
'bindings' => $bindings
];
}
/**
* Creates a columns definition from string or array
*
* @param string $table
* @param array|string|null $columns
* @return string
*/
public function selected($table, $columns = null): string
{
// all columns
if (empty($columns) === true) {
return '*';
}
// array of columns
if (is_array($columns) === true) {
// validate columns
$result = [];
foreach ($columns as $column) {
list($table, $columnPart) = $this->splitIdentifier($table, $column);
if ($this->validateColumn($table, $columnPart) === true) {
$result[] = $this->combineIdentifier($table, $columnPart);
}
}
return implode(', ', $result);
} else {
return $columns;
}
}
/**
* Splits a (qualified) identifier into table and column
*
* @param $table string Default table if the identifier is not qualified
* @param $identifier string
* @return array
*/
public function splitIdentifier($table, $identifier): array
{
// split by dot, but only outside of quotes
$parts = preg_split('/(?:`[^`]*`|"[^"]*")(*SKIP)(*F)|\./', $identifier);
switch (count($parts)) {
// non-qualified identifier
case 1:
return array($table, $this->unquoteIdentifier($parts[0]));
// qualified identifier
case 2:
return array($this->unquoteIdentifier($parts[0]), $this->unquoteIdentifier($parts[1]));
// every other number is an error
default:
throw new InvalidArgumentException('Invalid identifier ' . $identifier);
}
}
/**
* Returns a list of tables for a specified database
* MySQL version
*
* @return array
*/
public function tables(): array
{
$binding = $this->bindingName('database');
return [
'query' => 'SELECT TABLE_NAME AS name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ' . $binding,
'bindings' => [
$binding => $this->database->database
]
];
}
/**
* Validates and quotes a table name
*
* @param string $table
* @return string
*/
public function tableName(string $table): string
{
// validate table
if ($this->database->validateTable($table) === false) {
throw new InvalidArgumentException('Invalid table ' . $table);
}
return $this->quoteIdentifier($table);
}
/**
* Unquotes an identifier (table *or* column)
*
* @param $identifier string
* @return string
*/
public function unquoteIdentifier(string $identifier): string
{
// remove quotes around the identifier
if (in_array(Str::substr($identifier, 0, 1), ['"', '`']) === true) {
$identifier = Str::substr($identifier, 1);
}
if (in_array(Str::substr($identifier, -1), ['"', '`']) === true) {
$identifier = Str::substr($identifier, 0, -1);
}
// unescape duplicated quotes
return str_replace(['""', '``'], ['"', '`'], $identifier);
}
/**
* Builds an update clause
*
* @param array $params List of parameters for the update clause. See defaults for more info.
* @return array
*/
public function update(array $params = []): array
{
$defaults = [
'table' => null,
'values' => null,
'where' => null,
'bindings' => []
];
$options = array_merge($defaults, $params);
$bindings = $options['bindings'];
// start the query
$query = ['UPDATE ' . $this->tableName($options['table']) . ' SET'];
// add the values
$this->extend($query, $bindings, $this->values($options['table'], $options['values']));
// add the where clause
$this->extend($query, $bindings, $this->where($options['where']));
return [
'query' => $this->query($query),
'bindings' => $bindings
];
}
/**
* Validates a given column name in a table
*
* @param string $table
* @param string $column
* @return boolean
*/
public function validateColumn(string $table, string $column): bool
{
if ($this->database->validateColumn($table, $column) === false) {
throw new InvalidArgumentException('Invalid column ' . $column);
}
return true;
}
/**
* Builds a safe list of values for insert, select or update queries
*
* @param string $table Table name
* @param mixed $values A value string or array of values
* @param string $separator A separator which should be used to join values
* @param boolean $set If true builds a set list of values for update clauses
* @param boolean $enforceQualified Always use fully qualified column names
*/
public function values(string $table, $values, string $separator = ', ', bool $set = true, bool $enforceQualified = false): array
{
if (is_array($values) === false) {
return [
'query' => $values,
'bindings' => []
];
}
if ($set === true) {
return $this->valueSet($table, $values, $separator, $enforceQualified);
} else {
return $this->valueList($table, $values, $separator, $enforceQualified);
}
}
/**
* Creates a list of fields and values
*
* @param string $table
* @param string|array $values
* @param string $separator
* @param bool $enforceQualified
* @param array
*/
public function valueList(string $table, $values, string $separator = ',', bool $enforceQualified = false): array
{
$fields = [];
$query = [];
$bindings = [];
foreach ($values as $key => $value) {
$fields[] = $this->columnName($table, $key, $enforceQualified);
if (in_array($value, static::$literals, true) === true) {
$query[] = $value ?: 'null';
continue;
}
if (is_array($value) === true) {
$value = json_encode($value);
}
// add the binding
$bindings[$bindingName = $this->bindingName('value')] = $value;
// create the query
$query[] = $bindingName;
}
return [
'query' => '(' . implode($separator, $fields) . ') VALUES (' . implode($separator, $query) . ')',
'bindings' => $bindings
];
}
/**
* Creates a set of values
*
* @param string $table
* @param string|array $values
* @param string $separator
* @param bool $enforceQualified
* @param array
*/
public function valueSet(string $table, $values, string $separator = ',', bool $enforceQualified = false): array
{
$query = [];
$bindings = [];
foreach ($values as $column => $value) {
$key = $this->columnName($table, $column, $enforceQualified);
if (in_array($value, static::$literals, true) === true) {
$query[] = $key . ' = ' . ($value ?: 'null');
continue;
}
if (is_array($value) === true) {
$value = json_encode($value);
}
// add the binding
$bindings[$bindingName = $this->bindingName('value')] = $value;
// create the query
$query[] = $key . ' = ' . $bindingName;
}
return [
'query' => implode($separator, $query),
'bindings' => $bindings
];
}
/**
* @param string|array|null $where
* @param array $bindings
* @return array
*/
public function where($where, array $bindings = []): array
{
if (empty($where) === true) {
return [
'query' => null,
'bindings' => [],
];
}
if (is_string($where) === true) {
return [
'query' => 'WHERE ' . $where,
'bindings' => $bindings
];
}
$query = [];
foreach ($where as $key => $value) {
$binding = $this->bindingName('where_' . $key);
$bindings[$binding] = $value;
$query[] = $key . ' = ' . $binding;
}
return [
'query' => 'WHERE ' . implode(' AND ', $query),
'bindings' => $bindings
];
}
}

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