This commit is contained in:
Bastian Allgeier
2020-07-07 12:40:13 +02:00
parent 5f025ac2c2
commit f79d2e960c
176 changed files with 10532 additions and 5343 deletions

View File

@@ -293,6 +293,24 @@ class Api
return isset($this->data[$key]) === true;
}
/**
* Matches an object with an array item
* based on the `type` field
*
* @param array models or collections
* @param mixed $object
*
* @return string key of match
*/
protected function match(array $array, $object = null)
{
foreach ($array as $definition => $model) {
if (is_a($object, $model['type']) === true) {
return $definition;
}
}
}
/**
* Returns an API model instance by name
*
@@ -302,8 +320,15 @@ class Api
*
* @throws \Kirby\Exception\NotFoundException If no model for `$name` exists
*/
public function model(string $name, $object = null)
public function model(string $name = null, $object = null)
{
// Try to auto-match object with API models
if ($name === null) {
if ($model = $this->match($this->models, $object)) {
$name = $model;
}
}
if (isset($this->models[$name]) === false) {
throw new NotFoundException(sprintf('The model "%s" does not exist', $name));
}
@@ -420,29 +445,15 @@ class Api
return $object;
}
$className = strtolower(get_class($object));
$lastDash = strrpos($className, '\\');
if ($lastDash !== false) {
$className = substr($className, $lastDash + 1);
if ($model = $this->match($this->models, $object)) {
return $this->model($model, $object);
}
if (isset($this->models[$className]) === true) {
return $this->model($className, $object);
if ($collection = $this->match($this->collections, $object)) {
return $this->collection($collection, $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));
throw new NotFoundException(sprintf('The object "%s" cannot be resolved', get_class($object)));
}
/**

View File

@@ -29,7 +29,7 @@ class Collection
{
$this->api = $api;
$this->data = $data;
$this->model = $schema['model'];
$this->model = $schema['model'] ?? null;
$this->view = $schema['view'] ?? null;
if ($data === null) {
@@ -40,7 +40,10 @@ class Collection
$this->data = $schema['default']->call($this->api);
}
if (isset($schema['type']) === true && is_a($this->data, $schema['type']) === false) {
if (
isset($schema['type']) === true &&
is_a($this->data, $schema['type']) === false
) {
throw new Exception('Invalid collection type');
}
}

View File

@@ -48,7 +48,10 @@ class Model
$this->data = $schema['default']->call($this->api);
}
if (isset($schema['type']) === true && is_a($this->data, $schema['type']) === false) {
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']));
}
}
@@ -130,7 +133,10 @@ class Model
$value = $this->api->resolve($value);
}
if (is_a($value, 'Kirby\Api\Collection') === true || is_a($value, 'Kirby\Api\Model') === true) {
if (
is_a($value, 'Kirby\Api\Collection') === true ||
is_a($value, 'Kirby\Api\Model') === true
) {
$selection = $select[$key];
if ($subview = $selection['view']) {

View File

@@ -39,7 +39,8 @@ class Api extends BaseApi
$this->kirby->setCurrentLanguage($this->language());
if ($user = $this->kirby->user()) {
$allowImpersonation = $this->kirby()->option('api.allowImpersonation', false);
if ($user = $this->kirby->user(null, $allowImpersonation)) {
$this->kirby->setCurrentTranslation($user->language());
}
@@ -95,8 +96,9 @@ class Api extends BaseApi
public function file(string $path = null, string $filename)
{
$filename = urldecode($filename);
$file = $this->parent($path)->file($filename);
if ($file = $this->parent($path)->file($filename)) {
if ($file && $file->isReadable() === true) {
return $file;
}
@@ -136,7 +138,7 @@ class Api extends BaseApi
$model = $kirby->site();
break;
case 'account':
$model = $kirby->user();
$model = $kirby->user(null, $kirby->option('api.allowImpersonation', false));
break;
case 'page':
$id = str_replace(['+', ' '], '/', basename($path));
@@ -192,7 +194,7 @@ class Api extends BaseApi
$id = str_replace('+', '/', $id);
$page = $this->kirby->page($id);
if ($page && $page->isReadable()) {
if ($page && $page->isReadable() === true) {
return $page;
}
@@ -242,7 +244,7 @@ class Api extends BaseApi
{
// get the authenticated user
if ($id === null) {
return $this->kirby->auth()->user();
return $this->kirby->auth()->user(null, $this->kirby()->option('api.allowImpersonation', false));
}
// get a specific user by id

View File

@@ -6,6 +6,7 @@ use Kirby\Data\Data;
use Kirby\Email\PHPMailer as Emailer;
use Kirby\Exception\ErrorPageException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Http\Request;
use Kirby\Http\Router;
@@ -18,6 +19,7 @@ use Kirby\Toolkit\Controller;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Properties;
use Throwable;
/**
* The `$kirby` object is the app instance of
@@ -44,7 +46,6 @@ class App
use Properties;
protected static $instance;
protected static $root;
protected static $version;
public $data = [];
@@ -74,6 +75,14 @@ class App
protected $users;
protected $visitor;
/**
* List of options that shouldn't be converted
* to a tree structure by dot syntax
*
* @var array
*/
public static $nestIgnoreOptions = ['hooks'];
/**
* Creates a new App instance
*
@@ -81,9 +90,6 @@ class App
*/
public function __construct(array $props = [])
{
// the kirby folder directory
static::$root = dirname(__DIR__, 2);
// register all roots to be able to load stuff afterwards
$this->bakeRoots($props['roots'] ?? []);
@@ -91,6 +97,9 @@ class App
$this->optionsFromConfig();
$this->optionsFromProps($props['options'] ?? []);
// register the Whoops error handler
$this->handleErrors();
// set the path to make it available for the url bakery
$this->setPath($props['path'] ?? null);
@@ -118,20 +127,22 @@ class App
$this->extensionsFromSystem();
$this->extensionsFromProps($props);
$this->extensionsFromPlugins();
$this->extensionsFromOptions();
$this->extensionsFromFolders();
// bake the options for the first time
$this->bakeOptions();
// register the extensions from the normalized options
$this->extensionsFromOptions();
// trigger hook for use in plugins
$this->trigger('system.loadPlugins:after');
// handle those damn errors
$this->handleErrors();
// execute a ready callback from the config
$this->optionsFromReadyCallback();
// bake config
Config::$data = $this->options;
// bake the options again with those from the ready callback
$this->bakeOptions();
}
/**
@@ -164,7 +175,7 @@ class App
return $this->api;
}
$root = static::$root . '/config/api';
$root = $this->root('kirby') . '/config/api';
$extensions = $this->extensions['api'] ?? [];
$routes = (include $root . '/routes.php')($this);
@@ -182,37 +193,54 @@ class App
}
/**
* Applies a hook to the given value;
* the value that gets modified by the hooks
* is always the last argument
* Applies a hook to the given value
*
* @internal
* @param string $name Hook name
* @param mixed ...$args Arguments to pass to the hooks
* @param string $name Full event name
* @param array $args Associative array of named event arguments
* @param string $modify Key in $args that is modified by the hooks
* @param \Kirby\Cms\Event|null $originalEvent Event object (internal use)
* @return mixed Resulting value as modified by the hooks
*/
public function apply(string $name, ...$args)
public function apply(string $name, array $args, string $modify, ?Event $originalEvent = null)
{
// split up args into "passive" args and the value
$value = array_pop($args);
$event = $originalEvent ?? new Event($name, $args);
if ($functions = $this->extension('hooks', $name)) {
foreach ($functions as $function) {
// re-assemble args
$hookArgs = $args;
$hookArgs[] = $value;
// bind the App object to the hook
$newValue = $function->call($this, ...$hookArgs);
$newValue = $event->call($this, $function);
// update value if one was returned
if ($newValue !== null) {
$value = $newValue;
$event->updateArgument($modify, $newValue);
}
}
}
return $value;
// apply wildcard hooks if available
$nameWildcards = $event->nameWildcards();
if ($originalEvent === null && count($nameWildcards) > 0) {
foreach ($nameWildcards as $nameWildcard) {
// the $event object is passed by reference
// and will be modified down the chain
$this->apply($nameWildcard, $event->arguments(), $modify, $event);
}
}
return $event->argument($modify);
}
/**
* Normalizes and globally sets the configured options
*
* @return self
*/
protected function bakeOptions()
{
$this->options = A::nest($this->options, static::$nestIgnoreOptions);
Config::$data = $this->options;
return $this;
}
/**
@@ -223,7 +251,7 @@ class App
*/
protected function bakeRoots(array $roots = null)
{
$roots = array_merge(require static::$root . '/config/roots.php', (array)$roots);
$roots = array_merge(require dirname(__DIR__, 2) . '/config/roots.php', (array)$roots);
$this->roots = Ingredients::bake($roots);
return $this;
}
@@ -241,7 +269,7 @@ class App
$urls['index'] = $this->options['url'];
}
$urls = array_merge(require static::$root . '/config/urls.php', (array)$urls);
$urls = array_merge(require $this->root('kirby') . '/config/urls.php', (array)$urls);
$this->urls = Ingredients::bake($urls);
return $this;
}
@@ -285,11 +313,11 @@ class App
$router = $this->router();
$router::$beforeEach = function ($route, $path, $method) {
$this->trigger('route:before', $route, $path, $method);
$this->trigger('route:before', compact('route', 'path', 'method'));
};
$router::$afterEach = function ($route, $path, $method, $result) {
return $this->apply('route:after', $route, $path, $method, $result);
$router::$afterEach = function ($route, $path, $method, $result, $final) {
return $this->apply('route:after', compact('route', 'path', 'method', 'result', 'final'), 'result');
};
return $router->call($path ?? $this->path(), $method ?? $this->request()->method());
@@ -357,6 +385,31 @@ class App
return $this->options['content']['ignore'] ?? Dir::$ignore;
}
/**
* Generates a non-guessable token based on model
* data and a configured salt
*
* @param mixed $model Object to pass to the salt callback if configured
* @param string $value Model data to include in the generated token
* @return string
*/
public function contentToken($model, string $value): string
{
if (method_exists($model, 'root') === true) {
$default = $model->root();
} else {
$default = $this->root('content');
}
$salt = $this->option('content.salt', $default);
if (is_a($salt, 'Closure') === true) {
$salt = $salt($model);
}
return hash_hmac('sha1', $value, $salt);
}
/**
* Calls a page controller by name
* and with the given arguments
@@ -524,12 +577,17 @@ class App
* Returns the current App instance
*
* @param \Kirby\Cms\App $instance
* @return self
* @param bool $lazy If `true`, the instance is only returned if already existing
* @return self|null
*/
public static function instance(self $instance = null)
public static function instance(self $instance = null, bool $lazy = false)
{
if ($instance === null) {
return static::$instance ?? new static();
if ($lazy === true) {
return static::$instance;
} else {
return static::$instance ?? new static();
}
}
return static::$instance = $instance;
@@ -659,7 +717,7 @@ class App
$data['site'] = $data['site'] ?? $data['kirby']->site();
$data['parent'] = $data['parent'] ?? $data['site']->page();
return KirbyTags::parse($text, $data, $this->options, $this->extensions['hooks']);
return KirbyTags::parse($text, $data, $this->options, $this);
}
/**
@@ -673,7 +731,7 @@ class App
*/
public function kirbytext(string $text = null, array $data = [], bool $inline = false): string
{
$text = $this->apply('kirbytext:before', $text);
$text = $this->apply('kirbytext:before', compact('text'), 'text');
$text = $this->kirbytags($text, $data);
$text = $this->markdown($text, $inline);
@@ -681,7 +739,7 @@ class App
$text = $this->smartypants($text);
}
$text = $this->apply('kirbytext:after', $text);
$text = $this->apply('kirbytext:after', compact('text'), 'text');
return $text;
}
@@ -859,6 +917,27 @@ class App
// inject all last-minute options recursively
$this->options = array_replace_recursive($this->options, $options);
// update the system with changed options
if (
isset($options['debug']) === true ||
isset($options['whoops']) === true ||
isset($options['editor']) === true
) {
$this->handleErrors();
}
if (isset($options['debug']) === true) {
$this->api = null;
}
if (isset($options['home']) === true || isset($options['error']) === true) {
$this->site = null;
}
if (isset($options['slugs']) === true) {
$this->i18n();
}
}
return $this->options;
@@ -1091,7 +1170,7 @@ class App
}
$registry = $this->extensions('routes');
$system = (include static::$root . '/config/routes.php')($this);
$system = (include $this->root('kirby') . '/config/routes.php')($this);
$routes = array_merge($system['before'], $registry, $system['after']);
return $this->routes = $routes;
@@ -1312,12 +1391,15 @@ class App
* Trigger a hook by name
*
* @internal
* @param string $name
* @param mixed ...$arguments
* @param string $name Full event name
* @param array $args Associative array of named event arguments
* @param \Kirby\Cms\Event|null $originalEvent Event object (internal use)
* @return void
*/
public function trigger(string $name, ...$arguments)
public function trigger(string $name, array $args = [], ?Event $originalEvent = null)
{
$event = $originalEvent ?? new Event($name, $args);
if ($functions = $this->extension('hooks', $name)) {
static $level = 0;
static $triggered = [];
@@ -1332,7 +1414,7 @@ class App
$triggered[$name][] = $function;
// bind the App object to the hook
$function->call($this, ...$arguments);
$event->call($this, $function);
}
$level--;
@@ -1341,6 +1423,14 @@ class App
$triggered = [];
}
}
// trigger wildcard hooks if available
$nameWildcards = $event->nameWildcards();
if ($originalEvent === null && count($nameWildcards) > 0) {
foreach ($nameWildcards as $nameWildcard) {
$this->trigger($nameWildcard, $args, $event);
}
}
}
/**
@@ -1372,7 +1462,11 @@ class App
*/
public static function version(): ?string
{
return static::$version = static::$version ?? Data::read(static::$root . '/composer.json')['version'] ?? null;
try {
return static::$version = static::$version ?? Data::read(dirname(__DIR__, 2) . '/composer.json')['version'] ?? null;
} catch (Throwable $e) {
throw new LogicException('The Kirby version cannot be detected. The composer.json is probably missing or not readable.');
}
}
/**

View File

@@ -10,7 +10,7 @@ use Whoops\Handler\PrettyPageHandler;
use Whoops\Run as Whoops;
/**
* AppErrors
* PHP error handling using the Whoops library
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
@@ -20,14 +20,30 @@ use Whoops\Run as Whoops;
*/
trait AppErrors
{
/**
* Whoops instance cache
*
* @var \Whoops\Run
*/
protected $whoops;
/**
* Registers the PHP error handler for CLI usage
*
* @return void
*/
protected function handleCliErrors(): void
{
$whoops = new Whoops();
$whoops->pushHandler(new PlainTextHandler());
$whoops->register();
$this->setWhoopsHandler(new PlainTextHandler());
}
protected function handleErrors()
/**
* Registers the PHP error handler
* based on the environment
*
* @return void
*/
protected function handleErrors(): void
{
if ($this->request()->cli() === true) {
$this->handleCliErrors();
@@ -42,21 +58,25 @@ trait AppErrors
$this->handleHtmlErrors();
}
protected function handleHtmlErrors()
/**
* Registers the PHP error handler for HTML output
*
* @return void
*/
protected function handleHtmlErrors(): void
{
$whoops = new Whoops();
$handler = null;
if ($this->option('debug') === true) {
if ($this->option('whoops', true) === true) {
$handler = new PrettyPageHandler();
$handler->setPageTitle('Kirby CMS Debugger');
$handler->setResourcesPath(dirname(__DIR__, 2) . '/assets');
$handler->addCustomCss('whoops.css');
if ($editor = $this->option('editor')) {
$handler->setEditor($editor);
}
$whoops->pushHandler($handler);
$whoops->register();
}
} else {
$handler = new CallbackHandler(function ($exception, $inspector, $run) {
@@ -65,20 +85,27 @@ trait AppErrors
if (is_a($fatal, 'Closure') === true) {
echo $fatal($this);
} else {
include static::$root . '/views/fatal.php';
include $this->root('kirby') . '/views/fatal.php';
}
return Handler::QUIT;
});
}
$whoops->pushHandler($handler);
$whoops->register();
if ($handler !== null) {
$this->setWhoopsHandler($handler);
} else {
$this->unsetWhoopsHandler();
}
}
protected function handleJsonErrors()
/**
* Registers the PHP error handler for JSON output
*
* @return void
*/
protected function handleJsonErrors(): void
{
$whoops = new Whoops();
$handler = new CallbackHandler(function ($exception, $inspector, $run) {
if (is_a($exception, 'Kirby\Exception\Exception') === true) {
$httpCode = $exception->getHttpCode();
@@ -112,7 +139,46 @@ trait AppErrors
return Handler::QUIT;
});
$this->setWhoopsHandler($handler);
}
/**
* Enables Whoops with the specified handler
*
* @param Callable|\Whoops\Handler\HandlerInterface $handler
* @return void
*/
protected function setWhoopsHandler($handler): void
{
$whoops = $this->whoops();
$whoops->clearHandlers();
$whoops->pushHandler($handler);
$whoops->register();
$whoops->register(); // will only do something if not already registered
}
/**
* Clears the Whoops handlers and disables Whoops
*
* @return void
*/
protected function unsetWhoopsHandler(): void
{
$whoops = $this->whoops();
$whoops->clearHandlers();
$whoops->unregister(); // will only do something if currently registered
}
/**
* Returns the Whoops error handler instance
*
* @return \Whoops\Run
*/
protected function whoops()
{
if ($this->whoops !== null) {
return $this->whoops;
}
return $this->whoops = new Whoops();
}
}

View File

@@ -7,7 +7,7 @@ use Kirby\Exception\DuplicateException;
use Kirby\Form\Field as FormField;
use Kirby\Text\KirbyTag;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Collection;
use Kirby\Toolkit\Collection as ToolkitCollection;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
use Kirby\Toolkit\V;
@@ -30,6 +30,13 @@ trait AppPlugins
*/
protected static $plugins = [];
/**
* Cache for system extensions
*
* @var array
*/
protected static $systemExtensions = null;
/**
* The extension registry
*
@@ -47,6 +54,7 @@ trait AppPlugins
'components' => [],
'controllers' => [],
'collectionFilters' => [],
'collectionMethods' => [],
'fieldMethods' => [],
'fileMethods' => [],
'filesMethods' => [],
@@ -56,12 +64,14 @@ trait AppPlugins
'pageMethods' => [],
'pagesMethods' => [],
'pageModels' => [],
'permissions' => [],
'routes' => [],
'sections' => [],
'siteMethods' => [],
'snippets' => [],
'tags' => [],
'templates' => [],
'thirdParty' => [],
'translations' => [],
'userMethods' => [],
'userModels' => [],
@@ -69,13 +79,6 @@ trait AppPlugins
'validators' => []
];
/**
* Cache for system extensions
*
* @var array
*/
protected static $systemExtensions = null;
/**
* Flag when plugins have been loaded
* to not load them again
@@ -152,7 +155,18 @@ trait AppPlugins
*/
protected function extendCollectionFilters(array $filters): array
{
return $this->extensions['collectionFilters'] = Collection::$filters = array_merge(Collection::$filters, $filters);
return $this->extensions['collectionFilters'] = ToolkitCollection::$filters = array_merge(ToolkitCollection::$filters, $filters);
}
/**
* Registers additional collection methods
*
* @param array $methods
* @return array
*/
protected function extendCollectionMethods(array $methods): array
{
return $this->extensions['collectionMethods'] = Collection::$methods = array_merge(Collection::$methods, $methods);
}
/**
@@ -287,6 +301,10 @@ trait AppPlugins
$options = $prefixed;
}
// register each option in the nesting blacklist;
// this prevents Kirby from nesting the array keys inside each option
static::$nestIgnoreOptions = array_merge(static::$nestIgnoreOptions, array_keys($options));
return $this->extensions['options'] = $this->options = A::merge($options, $this->options, A::MERGE_REPLACE);
}
@@ -334,6 +352,22 @@ trait AppPlugins
return $this->extensions['pages'] = array_merge($this->extensions['pages'], $pages);
}
/**
* Registers additional permissions
*
* @param array $permissions
* @param \Kirby\Cms\Plugin|null $plugin
* @return array
*/
protected function extendPermissions(array $permissions, Plugin $plugin = null): array
{
if ($plugin !== null) {
$permissions = [$plugin->prefix() => $permissions];
}
return $this->extensions['permissions'] = Permissions::$extendedActions = array_merge(Permissions::$extendedActions, $permissions);
}
/**
* Registers additional routes
*
@@ -426,6 +460,20 @@ trait AppPlugins
return $this->extensions['translations'] = array_replace_recursive($this->extensions['translations'], $translations);
}
/**
* Add third party extensions to the registry
* so they can be used as plugins for plugins
* for example.
*
* @param string $type
* @param array $extensions
* @return array
*/
protected function extendThirdParty(array $extensions): array
{
return $this->extensions['thirdParty'] = array_replace_recursive($this->extensions['thirdParty'], $extensions);
}
/**
* Registers additional user methods
*
@@ -514,7 +562,7 @@ trait AppPlugins
$class = str_replace(['.', '-', '_'], '', $name) . 'Page';
// load the model class
include_once $model;
F::loadOnce($model);
if (class_exists($class) === true) {
$models[$name] = $class;
@@ -577,16 +625,18 @@ trait AppPlugins
*/
protected function extensionsFromSystem()
{
$root = $this->root('kirby');
// load static extensions only once
if (static::$systemExtensions === null) {
// Form Field Mixins
FormField::$mixins['filepicker'] = include static::$root . '/config/fields/mixins/filepicker.php';
FormField::$mixins['min'] = include static::$root . '/config/fields/mixins/min.php';
FormField::$mixins['options'] = include static::$root . '/config/fields/mixins/options.php';
FormField::$mixins['pagepicker'] = include static::$root . '/config/fields/mixins/pagepicker.php';
FormField::$mixins['picker'] = include static::$root . '/config/fields/mixins/picker.php';
FormField::$mixins['upload'] = include static::$root . '/config/fields/mixins/upload.php';
FormField::$mixins['userpicker'] = include static::$root . '/config/fields/mixins/userpicker.php';
FormField::$mixins['filepicker'] = include $root . '/config/fields/mixins/filepicker.php';
FormField::$mixins['min'] = include $root . '/config/fields/mixins/min.php';
FormField::$mixins['options'] = include $root . '/config/fields/mixins/options.php';
FormField::$mixins['pagepicker'] = include $root . '/config/fields/mixins/pagepicker.php';
FormField::$mixins['picker'] = include $root . '/config/fields/mixins/picker.php';
FormField::$mixins['upload'] = include $root . '/config/fields/mixins/upload.php';
FormField::$mixins['userpicker'] = include $root . '/config/fields/mixins/userpicker.php';
// Tag Aliases
KirbyTag::$aliases = [
@@ -612,32 +662,32 @@ trait AppPlugins
];
// 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';
PageBlueprint::$presets['pages'] = include $root . '/config/presets/pages.php';
PageBlueprint::$presets['page'] = include $root . '/config/presets/page.php';
PageBlueprint::$presets['files'] = include $root . '/config/presets/files.php';
// section mixins
Section::$mixins['empty'] = include static::$root . '/config/sections/mixins/empty.php';
Section::$mixins['headline'] = include static::$root . '/config/sections/mixins/headline.php';
Section::$mixins['help'] = include static::$root . '/config/sections/mixins/help.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::$mixins['empty'] = include $root . '/config/sections/mixins/empty.php';
Section::$mixins['headline'] = include $root . '/config/sections/mixins/headline.php';
Section::$mixins['help'] = include $root . '/config/sections/mixins/help.php';
Section::$mixins['layout'] = include $root . '/config/sections/mixins/layout.php';
Section::$mixins['max'] = include $root . '/config/sections/mixins/max.php';
Section::$mixins['min'] = include $root . '/config/sections/mixins/min.php';
Section::$mixins['pagination'] = include $root . '/config/sections/mixins/pagination.php';
Section::$mixins['parent'] = include $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';
Section::$types['info'] = include $root . '/config/sections/info.php';
Section::$types['pages'] = include $root . '/config/sections/pages.php';
Section::$types['files'] = include $root . '/config/sections/files.php';
Section::$types['fields'] = include $root . '/config/sections/fields.php';
static::$systemExtensions = [
'components' => include static::$root . '/config/components.php',
'blueprints' => include static::$root . '/config/blueprints.php',
'fields' => include static::$root . '/config/fields.php',
'fieldMethods' => include static::$root . '/config/methods.php',
'tags' => include static::$root . '/config/tags.php'
'components' => include $root . '/config/components.php',
'blueprints' => include $root . '/config/blueprints.php',
'fields' => include $root . '/config/fields.php',
'fieldMethods' => include $root . '/config/methods.php',
'tags' => include $root . '/config/tags.php'
];
}
@@ -656,6 +706,18 @@ trait AppPlugins
$this->extendTags(static::$systemExtensions['tags']);
}
/**
* Returns the native implementation
* of a core component
*
* @param string $component
* @return \Closure | false
*/
public function nativeComponent(string $component)
{
return static::$systemExtensions['components'][$component] ?? false;
}
/**
* Kirby plugin factory and getter
*
@@ -733,7 +795,7 @@ trait AppPlugins
continue;
}
include_once $entry;
F::loadOnce($entry);
$loaded[] = $dir;
}

View File

@@ -2,6 +2,7 @@
namespace Kirby\Cms;
use Closure;
use Throwable;
/**
@@ -36,12 +37,34 @@ trait AppUsers
/**
* Become any existing user
*
* @param string|null $who
* @return \Kirby\Cms\User|null
* @param string|null $who User ID or email address
* @param Closure|null $callback Optional action function that will be run with
* the permissions of the impersonated user; the
* impersonation will be reset afterwards
* @return mixed If called without callback: User that was impersonated;
* if called with callback: Return value from the callback
*/
public function impersonate(string $who = null)
public function impersonate(?string $who = null, ?Closure $callback = null)
{
return $this->auth()->impersonate($who);
$auth = $this->auth();
$userBefore = $auth->currentUserFromImpersonation();
$userAfter = $auth->impersonate($who);
if ($callback === null) {
return $userAfter;
}
try {
// bind the App object to the callback
return $callback->call($this, $userAfter);
} catch (Throwable $e) {
throw $e;
} finally {
// ensure that the impersonation is *always* reset
// to the original value, even if an error occurred
$auth->impersonate($userBefore !== null ? $userBefore->id() : null);
}
}
/**
@@ -77,20 +100,23 @@ trait AppUsers
* Returns a specific user by id
* or the current user if no id is given
*
* @param string $id
* @param string|null $id
* @param bool $allowImpersonation If set to false, only the actually
* logged in user will be returned
* (when `$id` is passed as `null`)
* @return \Kirby\Cms\User|null
*/
public function user(string $id = null)
public function user(?string $id = null, bool $allowImpersonation = true)
{
if ($id !== null) {
return $this->users()->find($id);
}
if (is_string($this->user) === true) {
if ($allowImpersonation === true && is_string($this->user) === true) {
return $this->auth()->impersonate($this->user);
} else {
try {
return $this->auth()->user();
return $this->auth()->user(null, $allowImpersonation);
} catch (Throwable $e) {
return null;
}

View File

@@ -85,6 +85,16 @@ class Auth
return $this->validatePassword($auth->username(), $auth->password());
}
/**
* Returns the currently impersonated user
*
* @return \Kirby\Cms\User|null
*/
public function currentUserFromImpersonation()
{
return $this->impersonate;
}
/**
* Returns the logged in user by checking
* the current session and finding a valid
@@ -124,10 +134,10 @@ class Auth
/**
* Become any existing user
*
* @param string|null $who
* @param string|null $who User ID or email address
* @return \Kirby\Cms\User|null
*/
public function impersonate(string $who = null)
public function impersonate(?string $who = null)
{
switch ($who) {
case null:
@@ -416,16 +426,19 @@ class Auth
/**
* Returns the current authentication type
*
* @param bool $allowImpersonation If set to false, 'impersonate' won't
* be returned as authentication type
* even if an impersonation is active
* @return string
*/
public function type(): string
public function type(bool $allowImpersonation = true): string
{
$basicAuth = $this->kirby->option('api.basicAuth', false);
$auth = $this->kirby->request()->auth();
if ($basicAuth === true && $auth && $auth->type() === 'basic') {
return 'basic';
} elseif ($this->impersonate !== null) {
} elseif ($allowImpersonation === true && $this->impersonate !== null) {
return 'impersonate';
} else {
return 'session';
@@ -436,13 +449,15 @@ class Auth
* Validates the currently logged in user
*
* @param \Kirby\Session\Session|array|null $session
* @param bool $allowImpersonation If set to false, only the actually
* logged in user will be returned
* @return \Kirby\Cms\User
*
* @throws \Throwable If an authentication error occured
*/
public function user($session = null)
public function user($session = null, bool $allowImpersonation = true)
{
if ($this->impersonate !== null) {
if ($allowImpersonation === true && $this->impersonate !== null) {
return $this->impersonate;
}

View File

@@ -45,7 +45,7 @@ class Collection extends BaseCollection
public function __call(string $key, $arguments)
{
// collection methods
if ($this->hasMethod($key)) {
if ($this->hasMethod($key) === true) {
return $this->callMethod($key, $arguments);
}
}

View File

@@ -4,6 +4,7 @@ namespace Kirby\Cms;
use Kirby\Exception\NotFoundException;
use Kirby\Toolkit\Controller;
use Kirby\Toolkit\F;
/**
* Manages and loads all collections
@@ -119,8 +120,8 @@ class Collections
// first check for collection file
$file = $kirby->root('collections') . '/' . $name . '.php';
if (file_exists($file)) {
$collection = require $file;
if (is_file($file) === true) {
$collection = F::load($file);
if (is_a($collection, 'Closure')) {
return $collection;

View File

@@ -257,6 +257,10 @@ class Content
public function update(array $content = null, bool $overwrite = false)
{
$this->data = $overwrite === true ? (array)$content : array_merge($this->data, (array)$content);
// clear cache of Field objects
$this->fields = [];
return $this;
}
}

View File

@@ -2,7 +2,7 @@
namespace Kirby\Cms;
use Kirby\Data\Yaml;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Toolkit\F;
@@ -108,7 +108,7 @@ class ContentLocks
// always read the whole file
rewind($handle);
$string = fread($handle, $filesize);
$data = Yaml::decode($string);
$data = Data::decode($string, 'yaml');
}
}
@@ -207,7 +207,7 @@ class ContentLocks
return F::remove($file);
}
$yaml = Yaml::encode($this->data[$file]);
$yaml = Data::encode($this->data[$file], 'yaml');
// delete all file contents first
if (rewind($handle) !== true || ftruncate($handle, 0) !== true) {

288
kirby/src/Cms/Event.php Executable file
View File

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

View File

@@ -326,14 +326,32 @@ class File extends ModelWithContent
}
/**
* Create a unique media hash
* Check if the file can be read by the current user
*
* @return bool
*/
public function isReadable(): bool
{
static $readable = [];
$template = $this->template();
if (isset($readable[$template]) === true) {
return $readable[$template];
}
return $readable[$template] = $this->permissions()->can('read');
}
/**
* Creates a unique media hash
*
* @internal
* @return string
*/
public function mediaHash(): string
{
return crc32($this->filename()) . '-' . $this->modifiedFile();
return $this->mediaToken() . '-' . $this->modifiedFile();
}
/**
@@ -347,6 +365,18 @@ class File extends ModelWithContent
return $this->parent()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->filename();
}
/**
* Creates a non-guessable token string for this file
*
* @internal
* @return string
*/
public function mediaToken(): string
{
$token = $this->kirby()->contentToken($this, $this->id());
return substr($token, 0, 10);
}
/**
* Returns the absolute Url to the file in the public media folder
*
@@ -375,12 +405,13 @@ class File extends ModelWithContent
*
* @param string $format
* @param string|null $handler date or strftime
* @param string|null $languageCode
* @return mixed
*/
public function modified(string $format = null, string $handler = null)
public function modified(string $format = null, string $handler = null, string $languageCode = null)
{
$file = $this->modifiedFile();
$content = $this->modifiedContent();
$content = $this->modifiedContent($languageCode);
$modified = max($file, $content);
if (is_null($format) === true) {
@@ -396,11 +427,12 @@ class File extends ModelWithContent
* Timestamp of the last modification
* of the content file
*
* @param string|null $languageCode
* @return int
*/
protected function modifiedContent(): int
protected function modifiedContent(string $languageCode = null): int
{
return F::modified($this->contentFile());
return F::modified($this->contentFile($languageCode));
}
/**

View File

@@ -38,7 +38,7 @@ trait FileActions
return $this;
}
return $this->commit('changeName', [$this, $name], function ($oldFile, $name) {
return $this->commit('changeName', ['file' => $this, 'name' => $name], function ($oldFile, $name) {
$newFile = $oldFile->clone([
'filename' => $name . '.' . $oldFile->extension(),
]);
@@ -87,7 +87,7 @@ trait FileActions
*/
public function changeSort(int $sort)
{
return $this->commit('changeSort', [$this, $sort], function ($file, $sort) {
return $this->commit('changeSort', ['file' => $this, 'position' => $sort], function ($file, $sort) {
return $file->save(['sort' => $sort]);
});
}
@@ -108,13 +108,24 @@ trait FileActions
*/
protected function commit(string $action, array $arguments, Closure $callback)
{
$old = $this->hardcopy();
$kirby = $this->kirby();
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
$this->rules()->$action(...$argumentValues);
$kirby->trigger('file.' . $action . ':before', $arguments);
$result = $callback(...$argumentValues);
if ($action === 'create') {
$argumentsAfter = ['file' => $result];
} elseif ($action === 'delete') {
$argumentsAfter = ['status' => $result, 'file' => $old];
} else {
$argumentsAfter = ['newFile' => $result, 'oldFile' => $old];
}
$kirby->trigger('file.' . $action . ':after', $argumentsAfter);
$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;
}
@@ -175,7 +186,7 @@ trait FileActions
$file = $file->clone(['content' => $form->strings(true)]);
// run the hook
return $file->commit('create', [$file, $upload], function ($file, $upload) {
return $file->commit('create', compact('file', 'upload'), function ($file, $upload) {
// delete all public versions
$file->unpublish();
@@ -211,7 +222,7 @@ trait FileActions
*/
public function delete(): bool
{
return $this->commit('delete', [$this], function ($file) {
return $this->commit('delete', ['file' => $this], function ($file) {
// remove all versions in the media folder
$file->unpublish();
@@ -243,7 +254,7 @@ trait FileActions
*/
public function publish()
{
Media::publish($this->root(), $this->mediaRoot());
Media::publish($this, $this->mediaRoot());
return $this;
}
@@ -273,7 +284,7 @@ trait FileActions
*/
public function replace(string $source)
{
return $this->commit('replace', [$this, new Image($source)], function ($file, $upload) {
return $this->commit('replace', ['file' => $this, 'upload' => new Image($source)], function ($file, $upload) {
// delete all public versions
$file->unpublish();
@@ -295,7 +306,7 @@ trait FileActions
*/
public function unpublish()
{
Media::unpublish($this->parent()->mediaRoot(), $this->filename());
Media::unpublish($this->parent()->mediaRoot(), $this);
return $this;
}
}

View File

@@ -26,6 +26,7 @@ class FileBlueprint extends Blueprint
'changeName' => null,
'create' => null,
'delete' => null,
'read' => null,
'replace' => null,
'update' => null,
]

View File

@@ -298,6 +298,6 @@ class Filename
'name' => $this->name(),
'attributes' => $this->attributesToString('-'),
'extension' => $this->extension()
]);
], '');
}
}

View File

@@ -55,7 +55,7 @@ class Files extends Collection
* Sort all given files by the
* order in the array
*
* @param array $files List of filenames
* @param array $files List of file ids
* @param int $offset Sorting offset
* @return self
*/

View File

@@ -126,7 +126,7 @@ trait HasChildren
* Finds one or multiple children by id
*
* @param string ...$arguments
* @return \Kirby\Cms\Page|\Kirby\Cms\Pages
* @return \Kirby\Cms\Page|\Kirby\Cms\Pages|null
*/
public function find(...$arguments)
{

View File

@@ -2,6 +2,8 @@
namespace Kirby\Cms;
use Kirby\Exception\BadMethodCallException;
/**
* HasMethods
*
@@ -31,7 +33,13 @@ trait HasMethods
*/
public function callMethod(string $method, array $args = [])
{
return static::$methods[$method]->call($this, ...$args);
$closure = $this->getMethod($method);
if ($closure === null) {
throw new BadMethodCallException('The method ' . $method . ' does not exist');
}
return $closure->call($this, ...$args);
}
/**
@@ -43,6 +51,29 @@ trait HasMethods
*/
public function hasMethod(string $method): bool
{
return isset(static::$methods[$method]) === true;
return $this->getMethod($method) !== null;
}
/**
* Returns a registered method by name, either from
* the current class or from a parent class ordered by
* inheritance order (top to bottom)
*
* @param string $method
* @return Closure|null
*/
protected function getMethod(string $method)
{
if (isset(static::$methods[$method]) === true) {
return static::$methods[$method];
}
foreach (class_parents($this) as $parent) {
if (isset($parent::$methods[$method]) === true) {
return $parent::$methods[$method];
}
}
return null;
}
}

View File

@@ -25,32 +25,19 @@ class KirbyTags extends \Kirby\Text\KirbyTags
* @param string $text
* @param array $data
* @param array $options
* @param array $hooks
* @param \Kirby\Cms\App $app
* @return string
*/
public static function parse(string $text = null, array $data = [], array $options = [], array $hooks = []): string
public static function parse(string $text = null, array $data = [], array $options = [], ?App $app = null): string
{
$text = static::hooks($hooks['kirbytags:before'] ?? [], $text, $data, $options);
if ($app !== null) {
$text = $app->apply('kirbytags:before', compact('text', 'data', 'options'), 'text');
}
$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|null
*/
protected static function hooks(array $hooks, string $text = null, array $data, array $options): ?string
{
foreach ($hooks as $hook) {
$text = $hook->call($data['kirby'], $text, $data, $options);
if ($app !== null) {
$text = $app->apply('kirbytags:after', compact('text', 'data', 'options'), 'text');
}
return $text;

View File

@@ -94,7 +94,7 @@ class Languages extends Collection
$files = glob(App::instance()->root('languages') . '/*.php');
foreach ($files as $file) {
$props = include $file;
$props = F::load($file);
if (is_array($props) === true) {
// inject the language code from the filename if it does not exist

View File

@@ -5,6 +5,7 @@ namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
use Throwable;
/**
@@ -41,9 +42,15 @@ class Media
// this should work for all original files
if ($file = $model->file($filename)) {
// the media hash is outdated. redirect to the correct url
// check if the request contained an outdated media hash
if ($file->mediaHash() !== $hash) {
return Response::redirect($file->mediaUrl(), 307);
// if at least the token was correct, redirect
if (Str::startsWith($hash, $file->mediaToken() . '-') === true) {
return Response::redirect($file->mediaUrl(), 307);
} else {
// don't leak the correct token
return new Response('Not Found', 'text/plain', 404);
}
}
// send the file to the browser
@@ -57,18 +64,18 @@ class Media
/**
* Copy the file to the final media folder location
*
* @param string $src
* @param \Kirby\Cms\File $file
* @param string $dest
* @return bool
*/
public static function publish(string $src, string $dest): bool
public static function publish(File $file, string $dest): bool
{
$filename = basename($src);
$src = $file->root();
$version = dirname($dest);
$directory = dirname($version);
// unpublish all files except stuff in the version folder
Media::unpublish($directory, $filename, $version);
Media::unpublish($directory, $file, $version);
// copy/overwrite the file to the dest folder
return F::copy($src, $dest, true);
@@ -125,21 +132,25 @@ class Media
}
/**
* Deletes all versions of the given filename
* Deletes all versions of the given file
* within the parent directory
*
* @param string $directory
* @param string $filename
* @param \Kirby\Cms\File $file
* @param string $ignore
* @return bool
*/
public static function unpublish(string $directory, string $filename, string $ignore = null): bool
public static function unpublish(string $directory, File $file, string $ignore = null): bool
{
if (is_dir($directory) === false) {
return true;
}
$versions = glob($directory . '/' . crc32($filename) . '*', GLOB_ONLYDIR);
// get both old and new versions (pre and post Kirby 3.4.0)
$versions = array_merge(
glob($directory . '/' . crc32($file->filename()) . '-*', GLOB_ONLYDIR),
glob($directory . '/' . $file->mediaToken() . '-*', GLOB_ONLYDIR)
);
// delete all versions of the file
foreach ($versions as $version) {

View File

@@ -334,6 +334,11 @@ abstract class ModelWithContent extends Model
}
if (is_string($settings) === true) {
// use defined icon in blueprint
if ($settings === 'icon') {
return [];
}
$settings = [
'query' => $settings
];
@@ -448,11 +453,15 @@ abstract class ModelWithContent extends Model
return null;
}
$result = Str::query($query, [
'kirby' => $this->kirby(),
'site' => is_a($this, 'Kirby\Cms\Site') ? $this : $this->site(),
static::CLASS_ALIAS => $this
]);
try {
$result = Str::query($query, [
'kirby' => $this->kirby(),
'site' => is_a($this, 'Kirby\Cms\Site') ? $this : $this->site(),
static::CLASS_ALIAS => $this
]);
} catch (Throwable $e) {
return null;
}
if ($expect !== null && is_a($result, $expect) !== true) {
return null;
@@ -611,19 +620,21 @@ abstract class ModelWithContent extends Model
* String template builder
*
* @param string|null $template
* @param array $data
* @param string $fallback Fallback for tokens in the template that cannot be replaced
* @return string
*/
public function toString(string $template = null): string
public function toString(string $template = null, array $data = [], string $fallback = ''): string
{
if ($template === null) {
return $this->id();
}
$result = Str::template($template, [
$result = Str::template($template, array_replace([
'kirby' => $this->kirby(),
'site' => is_a($this, 'Kirby\Cms\Site') ? $this : $this->site(),
static::CLASS_ALIAS => $this
]);
], $data), $fallback);
return $result;
}
@@ -691,7 +702,8 @@ abstract class ModelWithContent extends Model
}
}
return $this->commit('update', [$this, $form->data(), $form->strings(), $languageCode], function ($model, $values, $strings, $languageCode) {
$arguments = [static::CLASS_ALIAS => $this, 'values' => $form->data(), 'strings' => $form->strings(), 'languageCode' => $languageCode];
return $this->commit('update', $arguments, function ($model, $values, $strings, $languageCode) {
// save updated values
$model = $model->save($strings, $languageCode, true);

View File

@@ -3,6 +3,7 @@
namespace Kirby\Cms;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Http\Uri;
use Kirby\Toolkit\A;
@@ -344,7 +345,31 @@ class Page extends ModelWithContent
]);
// call the template controller if there's one.
return array_merge($kirby->controller($this->template()->name(), $data, $contentType), $data);
$controllerData = $kirby->controller($this->template()->name(), $data, $contentType);
// merge controller data with original data safely
if (empty($controllerData) === false) {
$classes = [
'kirby' => 'Kirby\Cms\App',
'site' => 'Kirby\Cms\Site',
'pages' => 'Kirby\Cms\Pages',
'page' => 'Kirby\Cms\Page'
];
foreach ($controllerData as $key => $value) {
if (array_key_exists($key, $classes) === true) {
if (is_a($value, $classes[$key]) === true) {
$data[$key] = $value;
} else {
throw new InvalidArgumentException('The returned variable "' . $key . '" from the controller "' . $this->template()->name() . '" is not of the required type "' . $classes[$key] . '"');
}
} else {
$data[$key] = $value;
}
}
}
return $data;
}
/**
@@ -453,6 +478,20 @@ class Page extends ModelWithContent
return new static($props);
}
/**
* Redirects to this page,
* wrapper for the `go()` helper
*
* @since 3.4.0
*
* @param array $options Options for `Kirby\Http\Uri` to create URL parts
* @param int $code HTTP status code
*/
public function go(array $options = [], int $code = 302)
{
go($this->url($options), $code);
}
/**
* Checks if the intended template
* for the page exists.
@@ -889,11 +928,16 @@ class Page extends ModelWithContent
*
* @param string $format
* @param string|null $handler
* @param string|null $languageCode
* @return int|string
*/
public function modified(string $format = null, string $handler = null)
public function modified(string $format = null, string $handler = null, string $languageCode = null)
{
return F::modified($this->contentFile(), $format, $handler ?? $this->kirby()->option('date.handler', 'date'));
return F::modified(
$this->contentFile($languageCode),
$format,
$handler ?? $this->kirby()->option('date.handler', 'date')
);
}
/**
@@ -1448,7 +1492,7 @@ class Page extends ModelWithContent
*/
protected function token(): string
{
return sha1($this->id() . $this->template());
return $this->kirby()->contentToken($this, $this->id() . $this->template());
}
/**

View File

@@ -42,7 +42,7 @@ trait PageActions
return $this;
}
return $this->commit('changeNum', [$this, $num], function ($oldPage, $num) {
return $this->commit('changeNum', ['page' => $this, 'num' => $num], function ($oldPage, $num) {
$newPage = $oldPage->clone([
'num' => $num,
'dirname' => null,
@@ -97,7 +97,8 @@ trait PageActions
return $this;
}
return $this->commit('changeSlug', [$this, $slug, $languageCode = null], function ($oldPage, $slug) {
$arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => null];
return $this->commit('changeSlug', $arguments, function ($oldPage, $slug) {
$newPage = $oldPage->clone([
'slug' => $slug,
'dirname' => null,
@@ -151,7 +152,8 @@ trait PageActions
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) {
$arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => $languageCode];
return $this->commit('changeSlug', $arguments, function ($page, $slug, $languageCode) {
// remove the slug if it's the same as the folder name
if ($slug === $page->uid()) {
$slug = null;
@@ -185,7 +187,8 @@ trait PageActions
protected function changeStatusToDraft()
{
$page = $this->commit('changeStatus', [$this, 'draft', null], function ($page) {
$arguments = ['page' => $this, 'status' => 'draft', 'position' => null];
$page = $this->commit('changeStatus', $arguments, function ($page) {
return $page->unpublish();
});
@@ -206,7 +209,8 @@ trait PageActions
return $this;
}
$page = $this->commit('changeStatus', [$this, 'listed', $num], function ($page, $status, $position) {
$arguments = ['page' => $this, 'status' => 'listed', 'position' => $num];
$page = $this->commit('changeStatus', $arguments, function ($page, $status, $position) {
return $page->publish()->changeNum($position);
});
@@ -226,7 +230,8 @@ trait PageActions
return $this;
}
$page = $this->commit('changeStatus', [$this, 'unlisted', null], function ($page) {
$arguments = ['page' => $this, 'status' => 'unlisted', 'position' => null];
$page = $this->commit('changeStatus', $arguments, function ($page) {
return $page->publish()->changeNum(null);
});
@@ -243,11 +248,11 @@ trait PageActions
*/
public function changeTemplate(string $template)
{
if ($template === $this->template()->name()) {
if ($template === $this->intendedTemplate()->name()) {
return $this;
}
return $this->commit('changeTemplate', [$this, $template], function ($oldPage, $template) {
return $this->commit('changeTemplate', ['page' => $this, 'template' => $template], function ($oldPage, $template) {
if ($this->kirby()->multilang() === true) {
$newPage = $this->clone([
'template' => $template
@@ -290,7 +295,8 @@ trait PageActions
*/
public function changeTitle(string $title, string $languageCode = null)
{
return $this->commit('changeTitle', [$this, $title, $languageCode], function ($page, $title, $languageCode) {
$arguments = ['page' => $this, 'title' => $title, 'languageCode' => $languageCode];
return $this->commit('changeTitle', $arguments, function ($page, $title, $languageCode) {
return $page->save(['title' => $title], $languageCode);
});
}
@@ -311,13 +317,27 @@ trait PageActions
*/
protected function commit(string $action, array $arguments, Closure $callback)
{
$old = $this->hardcopy();
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
$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();
$this->rules()->$action(...$argumentValues);
$kirby->trigger('page.' . $action . ':before', $arguments);
$result = $callback(...$argumentValues);
if ($action === 'create') {
$argumentsAfter = ['page' => $result];
} elseif ($action === 'duplicate') {
$argumentsAfter = ['duplicatePage' => $result];
} elseif ($action === 'delete') {
$argumentsAfter = ['status' => $result, 'page' => $old];
} else {
$argumentsAfter = ['newPage' => $result, 'oldPage' => $old];
}
$kirby->trigger('page.' . $action . ':after', $argumentsAfter);
$kirby->cache('pages')->flush();
return $result;
}
@@ -356,7 +376,9 @@ trait PageActions
'slug' => $slug,
]);
$ignore = [];
$ignore = [
$this->kirby()->locks()->file($this)
];
// don't copy files
if ($files === false) {
@@ -375,7 +397,7 @@ trait PageActions
// remove all translated slugs
if ($this->kirby()->multilang() === true) {
foreach ($this->kirby()->languages() as $language) {
if ($language->isDefault() === false) {
if ($language->isDefault() === false && $copy->translation($language)->exists() === true) {
$copy = $copy->save(['slug' => null], $language->code());
}
}
@@ -416,7 +438,7 @@ trait PageActions
$page = $page->clone(['content' => $form->strings(true)]);
// run the hooks and creation action
$page = $page->commit('create', [$page, $props], function ($page, $props) {
$page = $page->commit('create', ['page' => $page, 'input' => $props], function ($page, $props) {
// always create pages in the default language
if ($page->kirby()->multilang() === true) {
@@ -461,7 +483,8 @@ trait PageActions
'site' => $this->site(),
]);
return static::create($props);
$modelClass = Page::$models[$props['template']] ?? Page::class;
return $modelClass::create($props);
}
/**
@@ -520,7 +543,7 @@ trait PageActions
'kirby' => $app,
'page' => $app->page($this->id()),
'site' => $app->site(),
]);
], '');
return (int)$template;
}
@@ -534,7 +557,7 @@ trait PageActions
*/
public function delete(bool $force = false): bool
{
return $this->commit('delete', [$this, $force], function ($page, $force) {
return $this->commit('delete', ['page' => $this, 'force' => $force], function ($page, $force) {
// delete all files individually
foreach ($page->files() as $file) {
@@ -591,7 +614,8 @@ trait PageActions
// create the slug for the duplicate
$slug = Str::slug($slug ?? $this->slug() . '-copy');
return $this->commit('duplicate', [$this, $slug, $options], function ($page, $slug, $options) {
$arguments = ['originalPage' => $this, 'input' => $slug, 'options' => $options];
return $this->commit('duplicate', $arguments, function ($page, $slug, $options) {
return $this->copy([
'parent' => $this->parent(),
'slug' => $slug,

View File

@@ -238,11 +238,11 @@ class Pages extends Collection
public function findByIdRecursive(string $id, string $startAt = null, bool $multiLang = false)
{
$path = explode('/', $id);
$collection = $this;
$item = null;
$query = $startAt;
foreach ($path as $key) {
$collection = $item ? $item->children() : $this;
$query = ltrim($query . '/' . $key, '/');
$item = $collection->get($query) ?? null;
@@ -253,8 +253,6 @@ class Pages extends Collection
if ($item === null) {
return null;
}
$collection = $item->children();
}
return $item;
@@ -340,9 +338,12 @@ class Pages extends Collection
foreach ($this->data as $pageKey => $page) {
$this->index->data[$pageKey] = $page;
$index = $page->index($drafts);
foreach ($page->index($drafts) as $childKey => $child) {
$this->index->data[$childKey] = $child;
if ($index) {
foreach ($index as $childKey => $child) {
$this->index->data[$childKey] = $child;
}
}
}

View File

@@ -23,7 +23,7 @@ use Kirby\Toolkit\Pagination as BasePagination;
class Pagination extends BasePagination
{
/**
* Pagination method (param or query)
* Pagination method (param, query, none)
*
* @var string
*/
@@ -82,7 +82,7 @@ class Pagination extends BasePagination
if ($params['method'] === 'query') {
$params['page'] = $params['page'] ?? $params['url']->query()->get($params['variable']);
} else {
} elseif ($params['method'] === 'param') {
$params['page'] = $params['page'] ?? $params['url']->params()->get($params['variable']);
}
@@ -153,8 +153,10 @@ class Pagination extends BasePagination
if ($this->method === 'query') {
$url->query->$variable = $pageValue;
} else {
} elseif ($this->method === 'param') {
$url->params->$variable = $pageValue;
} else {
return null;
}
return $url->toString();

View File

@@ -89,7 +89,7 @@ class Panel
go($kirby->url('index') . '/' . $kirby->path());
}
} catch (Throwable $e) {
die('The panel assets cannot be installed properly. Please check permissions of your media folder.');
die('The Panel assets cannot be installed properly. ' . $e->getMessage());
}
// get the uri object for the panel url

View File

@@ -17,6 +17,8 @@ use Kirby\Exception\InvalidArgumentException;
*/
class Permissions
{
public static $extendedActions = [];
protected $actions = [
'access' => [
'panel' => true,
@@ -28,6 +30,7 @@ class Permissions
'changeName' => true,
'create' => true,
'delete' => true,
'read' => true,
'replace' => true,
'update' => true
],
@@ -75,6 +78,15 @@ class Permissions
public function __construct($settings = [])
{
// dynamically register the extended actions
foreach (static::$extendedActions as $key => $actions) {
if (isset($this->actions[$key]) === true) {
throw new InvalidArgumentException('The action ' . $key . ' is already a core action');
}
$this->actions[$key] = $actions;
}
if (is_array($settings) === true) {
return $this->setCategories($settings);
}

View File

@@ -2,8 +2,6 @@
namespace Kirby\Cms;
use Kirby\Toolkit\Str;
/**
* The Search class extracts the
* search logic from collections, to
@@ -31,100 +29,15 @@ class Search
/**
* Native search method to search for anything within the collection
*
* @param Collection $collection
* @param \Kirby\Cms\Collection $collection
* @param string $query
* @param mixed $params
* @return \Kirby\Cms\Collection|bool
*/
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 = mb_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 (is_a($item, 'Kirby\Cms\User') === true) {
$keys[] = 'name';
$keys[] = 'email';
$keys[] = 'role';
} elseif (is_a($item, 'Kirby\Cms\Page') === true) {
// apply the default score for pages
$options['score'] = array_merge([
'id' => 64,
'title' => 64,
], $options['score']);
}
if (empty($options['fields']) === false) {
$fields = array_map('strtolower', $options['fields']);
$keys = array_intersect($keys, $fields);
}
$item->searchHits = 0;
$item->searchScore = 0;
foreach ($keys as $key) {
$score = $options['score'][$key] ?? 1;
$value = $data[$key] ?? (string)$item->$key();
$lowerValue = mb_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');
$kirby = App::instance();
return $kirby->component('search')($kirby, $collection, $query, $params);
}
/**

View File

@@ -31,13 +31,17 @@ trait SiteActions
*/
protected function commit(string $action, array $arguments, Closure $callback)
{
$old = $this->hardcopy();
$kirby = $this->kirby();
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
$this->rules()->$action(...$argumentValues);
$kirby->trigger('site.' . $action . ':before', $arguments);
$result = $callback(...$argumentValues);
$kirby->trigger('site.' . $action . ':after', ['newSite' => $result, 'oldSite' => $old]);
$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;
}
@@ -51,7 +55,8 @@ trait SiteActions
*/
public function changeTitle(string $title, string $languageCode = null)
{
return $this->commit('changeTitle', [$this, $title, $languageCode], function ($site, $title, $languageCode) {
$arguments = ['site' => $this, 'title' => $title, 'languageCode' => $languageCode];
return $this->commit('changeTitle', $arguments, function ($site, $title, $languageCode) {
return $site->save(['title' => $title], $languageCode);
});
}

View File

@@ -9,6 +9,7 @@ use Kirby\Exception\PermissionException;
use Kirby\Http\Remote;
use Kirby\Http\Uri;
use Kirby\Http\Url;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
@@ -281,7 +282,9 @@ class System
* Loads the license file and returns
* the license information if available
*
* @return string|false
* @return string|bool License key or `false` if the current user has
* permissions for access.settings, otherwise just a
* boolean that tells whether a valid license is active
*/
public function license()
{
@@ -326,7 +329,14 @@ class System
return false;
}
return $license['license'];
// only return the actual license key if the
// current user has appropriate permissions
$user = $this->app->user();
if ($user && $user->role()->permissions()->for('access', 'settings') === true) {
return $license['license'];
} else {
return true;
}
}
/**
@@ -422,13 +432,17 @@ class System
*/
public function server(): bool
{
$servers = [
'apache',
'caddy',
'litespeed',
'nginx',
'php'
];
if ($servers = $this->app->option('servers')) {
$servers = A::wrap($servers);
} else {
$servers = [
'apache',
'caddy',
'litespeed',
'nginx',
'php'
];
}
$software = $_SERVER['SERVER_SOFTWARE'] ?? null;

View File

@@ -3,7 +3,6 @@
namespace Kirby\Cms;
use Kirby\Http\Url as BaseUrl;
use Kirby\Toolkit\Str;
/**
* The `Url` class extends the
@@ -61,36 +60,10 @@ class Url extends BaseUrl
*/
public static function to(string $path = null, $options = null): string
{
$kirby = App::instance();
$language = null;
$kirby = App::instance();
// get language from simple string option
if (is_string($options) === true) {
$language = $options;
$options = null;
}
// get language from array
if (is_array($options) === true && isset($options['language']) === true) {
$language = $options['language'];
unset($options['language']);
}
// get a language url for the linked page, if the page can be found
if ($kirby->multilang() === true) {
$parts = Str::split($path, '#');
if ($page = page($parts[0] ?? null)) {
$path = $page->url($language);
if (isset($parts[1]) === true) {
$path .= '#' . $parts[1];
}
}
}
return $kirby->component('url')($kirby, $path, $options, function (string $path = null, $options = null) {
return parent::to($path, $options);
return $kirby->component('url')($kirby, $path, $options, function (string $path = null, $options = null) use ($kirby) {
return $kirby->nativeComponent('url')($kirby, $path, $options);
});
}
}

View File

@@ -426,13 +426,13 @@ class User extends ModelWithContent
$session = $this->sessionFromOptions($session);
$kirby->trigger('user.login:before', $this, $session);
$kirby->trigger('user.login:before', ['user' => $this, 'session' => $session]);
$session->regenerateToken(); // privilege change
$session->data()->set('user.id', $this->id());
$this->kirby()->auth()->setUser($this);
$kirby->trigger('user.login:after', $this, $session);
$kirby->trigger('user.login:after', ['user' => $this, 'session' => $session]);
}
/**
@@ -446,7 +446,7 @@ class User extends ModelWithContent
$kirby = $this->kirby();
$session = $this->sessionFromOptions($session);
$kirby->trigger('user.logout:before', $this, $session);
$kirby->trigger('user.logout:before', ['user' => $this, 'session' => $session]);
// remove the user from the session for future requests
$session->data()->remove('user.id');
@@ -458,12 +458,12 @@ class User extends ModelWithContent
// session is now empty, we might as well destroy it
$session->destroy();
$kirby->trigger('user.logout:after', $this, null);
$kirby->trigger('user.logout:after', ['user' => $this, 'session' => null]);
} else {
// privilege change
$session->regenerateToken();
$kirby->trigger('user.logout:after', $this, $session);
$kirby->trigger('user.logout:after', ['user' => $this, 'session' => $session]);
}
}
@@ -515,11 +515,12 @@ class User extends ModelWithContent
*
* @param string $format
* @param string|null $handler
* @param string|null $languageCode
* @return int|string
*/
public function modified(string $format = 'U', string $handler = null)
public function modified(string $format = 'U', string $handler = null, string $languageCode = null)
{
$modifiedContent = F::modified($this->contentFile());
$modifiedContent = F::modified($this->contentFile($languageCode));
$modifiedIndex = F::modified($this->root() . '/index.php');
$modifiedTotal = max([$modifiedContent, $modifiedIndex]);
$handler = $handler ?? $this->kirby()->option('date.handler', 'date');
@@ -682,6 +683,41 @@ class User extends ModelWithContent
return $this->role = Role::nobody();
}
/**
* Returns all available roles
* for this user, that can be selected
* by the authenticated user
*
* @return \Kirby\Cms\Roles
*/
public function roles()
{
$kirby = $this->kirby();
$roles = $kirby->roles();
// a collection with just the one role of the user
$myRole = $roles->filterBy('id', $this->role()->id());
// if there's an authenticated user …
if ($user = $kirby->user()) {
// admin users can select pretty much any role
if ($user->isAdmin() === true) {
// except if the user is the last admin
if ($this->isLastAdmin() === true) {
// in which case they have to stay admin
return $myRole;
}
// return all roles for mighty admins
return $roles;
}
}
// any other user can only keep their role
return $myRole;
}
/**
* The absolute path to the user directory
*
@@ -844,15 +880,17 @@ class User extends ModelWithContent
* String template builder
*
* @param string|null $template
* @param array|null $data
* @param string $fallback Fallback for tokens in the template that cannot be replaced
* @return string
*/
public function toString(string $template = null): string
public function toString(string $template = null, array $data = [], string $fallback = ''): string
{
if ($template === null) {
$template = $this->email();
}
return parent::toString($template);
return parent::toString($template, $data);
}
/**

View File

@@ -29,7 +29,7 @@ trait UserActions
*/
public function changeEmail(string $email)
{
return $this->commit('changeEmail', [$this, $email], function ($user, $email) {
return $this->commit('changeEmail', ['user' => $this, 'email' => $email], function ($user, $email) {
$user = $user->clone([
'email' => $email
]);
@@ -50,7 +50,7 @@ trait UserActions
*/
public function changeLanguage(string $language)
{
return $this->commit('changeLanguage', [$this, $language], function ($user, $language) {
return $this->commit('changeLanguage', ['user' => $this, 'language' => $language], function ($user, $language) {
$user = $user->clone([
'language' => $language,
]);
@@ -71,7 +71,7 @@ trait UserActions
*/
public function changeName(string $name)
{
return $this->commit('changeName', [$this, $name], function ($user, $name) {
return $this->commit('changeName', ['user' => $this, 'name' => $name], function ($user, $name) {
$user = $user->clone([
'name' => $name
]);
@@ -92,7 +92,7 @@ trait UserActions
*/
public function changePassword(string $password)
{
return $this->commit('changePassword', [$this, $password], function ($user, $password) {
return $this->commit('changePassword', ['user' => $this, 'password' => $password], function ($user, $password) {
$user = $user->clone([
'password' => $password = User::hashPassword($password)
]);
@@ -111,7 +111,7 @@ trait UserActions
*/
public function changeRole(string $role)
{
return $this->commit('changeRole', [$this, $role], function ($user, $role) {
return $this->commit('changeRole', ['user' => $this, 'role' => $role], function ($user, $role) {
$user = $user->clone([
'role' => $role,
]);
@@ -144,13 +144,25 @@ trait UserActions
throw new PermissionException('The Kirby user cannot be changed');
}
$old = $this->hardcopy();
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
$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();
$this->rules()->$action(...$argumentValues);
$kirby->trigger('user.' . $action . ':before', $arguments);
$result = $callback(...$argumentValues);
if ($action === 'create') {
$argumentsAfter = ['user' => $result];
} elseif ($action === 'delete') {
$argumentsAfter = ['status' => $result, 'user' => $old];
} else {
$argumentsAfter = ['newUser' => $result, 'oldUser' => $old];
}
$kirby->trigger('user.' . $action . ':after', $argumentsAfter);
$kirby->cache('pages')->flush();
return $result;
}
@@ -181,7 +193,7 @@ trait UserActions
$user = $user->clone(['content' => $form->strings(true)]);
// run the hook
return $user->commit('create', [$user, $props], function ($user, $props) {
return $user->commit('create', ['user' => $user, 'input' => $props], function ($user, $props) {
$user->writeCredentials([
'email' => $user->email(),
'language' => $user->language(),
@@ -231,7 +243,7 @@ trait UserActions
*/
public function delete(): bool
{
return $this->commit('delete', [$this], function ($user) {
return $this->commit('delete', ['user' => $this], function ($user) {
if ($user->exists() === false) {
return true;
}
@@ -258,8 +270,10 @@ trait UserActions
*/
protected function readCredentials(): array
{
if (file_exists($this->root() . '/index.php') === true) {
$credentials = require $this->root() . '/index.php';
$path = $this->root() . '/index.php';
if (is_file($path) === true) {
$credentials = F::load($path);
return is_array($credentials) === false ? [] : $credentials;
} else {

View File

@@ -25,17 +25,7 @@ class UserPermissions extends ModelPermissions
protected function canChangeRole(): bool
{
// only one role, makes no sense to change it
if ($this->user->kirby()->roles()->count() < 2) {
return false;
}
// users who are not admins cannot change their own role
if ($this->user->is($this->model) === true && $this->user->isAdmin() === false) {
return false;
}
return $this->model->isLastAdmin() !== true;
return $this->model->roles()->count() > 1;
}
protected function canCreate(): bool

View File

@@ -3,6 +3,7 @@
namespace Kirby\Cms;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
/**
@@ -109,8 +110,9 @@ class Users extends Collection
}
// get role information
if (file_exists($root . '/' . $userDirectory . '/index.php') === true) {
$credentials = require $root . '/' . $userDirectory . '/index.php';
$path = $root . '/' . $userDirectory . '/index.php';
if (is_file($path) === true) {
$credentials = F::load($path);
}
// create user model based on role

View File

@@ -2,13 +2,13 @@
namespace Kirby\Data;
use Exception;
use Kirby\Exception\Exception;
use Kirby\Toolkit\F;
/**
* The `Data` class provides readers and
* writers for data. The class comes with
* four handlers for `json`, `php`, `txt`
* handlers for `json`, `php`, `txt`, `xml`
* and `yaml` encoded data, but can be
* extended and customized.
*
@@ -32,6 +32,7 @@ class Data
public static $aliases = [
'md' => 'txt',
'mdown' => 'txt',
'rss' => 'xml',
'yml' => 'yaml',
];
@@ -44,6 +45,7 @@ class Data
'json' => 'Kirby\Data\Json',
'php' => 'Kirby\Data\PHP',
'txt' => 'Kirby\Data\Txt',
'xml' => 'Kirby\Data\Xml',
'yaml' => 'Kirby\Data\Yaml',
];
@@ -73,23 +75,23 @@ class Data
/**
* Decodes data with the specified handler
*
* @param string $data
* @param mixed $string
* @param string $type
* @return array
*/
public static function decode(string $data = null, string $type): array
public static function decode($string = null, string $type): array
{
return static::handler($type)->decode($data);
return static::handler($type)->decode($string);
}
/**
* Encodes data with the specified handler
*
* @param array $data
* @param mixed $data
* @param string $type
* @return string
*/
public static function encode(array $data = null, string $type): string
public static function encode($data = null, string $type): string
{
return static::handler($type)->encode($data);
}
@@ -114,11 +116,11 @@ class Data
* the extension if not specified
*
* @param string $file
* @param array $data
* @param mixed $data
* @param string $type
* @return bool
*/
public static function write(string $file = null, array $data = [], string $type = null): bool
public static function write(string $file = null, $data = [], string $type = null): bool
{
return static::handler($type ?? F::extension($file))->write($file, $data);
}

View File

@@ -2,7 +2,7 @@
namespace Kirby\Data;
use Exception;
use Kirby\Exception\Exception;
use Kirby\Toolkit\F;
/**
@@ -23,7 +23,7 @@ abstract class Handler
*
* Needs to throw an Exception if the file can't be parsed.
*
* @param string $string
* @param mixed $string
* @return array
*/
abstract public static function decode($string): array;
@@ -55,10 +55,10 @@ abstract class Handler
* Writes data to a file
*
* @param string $file
* @param array $data
* @param mixed $data
* @return bool
*/
public static function write(string $file = null, array $data = []): bool
public static function write(string $file = null, $data = []): bool
{
return F::write($file, static::encode($data));
}

View File

@@ -2,7 +2,7 @@
namespace Kirby\Data;
use Exception;
use Kirby\Exception\InvalidArgumentException;
/**
* Simple Wrapper around json_encode and json_decode
@@ -29,17 +29,29 @@ class Json extends Handler
/**
* Parses an encoded JSON string and returns a multi-dimensional array
*
* @param string $json
* @param mixed $string
* @return array
*/
public static function decode($json): array
public static function decode($string): array
{
$result = json_decode($json, true);
if ($string === null) {
return [];
}
if (is_array($string) === true) {
return $string;
}
if (is_string($string) === false) {
throw new InvalidArgumentException('Invalid JSON data; please pass a string');
}
$result = json_decode($string, true);
if (is_array($result) === true) {
return $result;
} else {
throw new Exception('JSON string is invalid');
throw new InvalidArgumentException('JSON string is invalid');
}
}
}

View File

@@ -2,7 +2,8 @@
namespace Kirby\Data;
use Exception;
use Kirby\Exception\BadMethodCallException;
use Kirby\Exception\Exception;
use Kirby\Toolkit\F;
/**
@@ -23,7 +24,7 @@ class PHP extends Handler
* @param string $indent For internal use only
* @return string
*/
public static function encode($data, $indent = ''): string
public static function encode($data, string $indent = ''): string
{
switch (gettype($data)) {
case 'array':
@@ -46,14 +47,14 @@ class PHP extends Handler
}
/**
* PHP arrays don't have to be decoded
* PHP strings shouldn't be decoded manually
*
* @param array $array
* @param mixed $array
* @return array
*/
public static function decode($array): array
{
return $array;
throw new BadMethodCallException('The PHP::decode() method is not implemented');
}
/**
@@ -68,17 +69,17 @@ class PHP extends Handler
throw new Exception('The file "' . $file . '" does not exist');
}
return (array)(include $file);
return (array)F::load($file, []);
}
/**
* Creates a PHP file with the given data
*
* @param string $file
* @param array $data
* @param mixed $data
* @return bool
*/
public static function write(string $file = null, array $data = []): bool
public static function write(string $file = null, $data = []): bool
{
$php = static::encode($data);
$php = "<?php\n\nreturn $php;";

View File

@@ -2,6 +2,8 @@
namespace Kirby\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
@@ -25,7 +27,7 @@ class Txt extends Handler
{
$result = [];
foreach ((array)$data as $key => $value) {
foreach (A::wrap($data) as $key => $value) {
if (empty($key) === true || $value === null) {
continue;
}
@@ -48,7 +50,7 @@ class Txt extends Handler
{
// avoid problems with arrays
if (is_array($value) === true) {
$value = Yaml::encode($value);
$value = Data::encode($value, 'yaml');
// avoid problems with localized floats
} elseif (is_float($value) === true) {
$value = Str::float($value);
@@ -86,11 +88,23 @@ class Txt extends Handler
/**
* Parses a Kirby txt string and returns a multi-dimensional array
*
* @param string $string
* @param mixed $string
* @return array
*/
public static function decode($string): array
{
if ($string === null) {
return [];
}
if (is_array($string) === true) {
return $string;
}
if (is_string($string) === false) {
throw new InvalidArgumentException('Invalid TXT data; please pass a string');
}
// remove BOM
$string = str_replace("\xEF\xBB\xBF", '', $string);
// explode all fields by the line separator

64
kirby/src/Data/Xml.php Executable file
View File

@@ -0,0 +1,64 @@
<?php
namespace Kirby\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Xml as XmlConverter;
/**
* Simple Wrapper around the XML parser of the Toolkit
*
* @package Kirby Data
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Xml extends Handler
{
/**
* Converts an array to an encoded XML string
*
* @param mixed $data
* @return string
*/
public static function encode($data): string
{
return XmlConverter::create($data, 'data');
}
/**
* Parses an encoded XML string and returns a multi-dimensional array
*
* @param mixed $string
* @return array
*/
public static function decode($string): array
{
if ($string === null) {
return [];
}
if (is_array($string) === true) {
return $string;
}
if (is_string($string) === false) {
throw new InvalidArgumentException('Invalid XML data; please pass a string');
}
$result = XmlConverter::parse($string);
if (is_array($result) === true) {
// remove the root's name if it is the default <data> to ensure that
// the decoded data is the same as the input to the encode() method
if ($result['@name'] === 'data') {
unset($result['@name']);
}
return $result;
} else {
throw new InvalidArgumentException('XML string is invalid');
}
}
}

View File

@@ -2,7 +2,7 @@
namespace Kirby\Data;
use Exception;
use Kirby\Exception\InvalidArgumentException;
use Spyc;
/**
@@ -42,29 +42,33 @@ class Yaml extends Handler
/**
* Parses an encoded YAML string and returns a multi-dimensional array
*
* @param string $yaml
* @param mixed $string
* @return array
*/
public static function decode($yaml): array
public static function decode($string): array
{
if ($yaml === null) {
if ($string === null) {
return [];
}
if (is_array($yaml) === true) {
return $yaml;
if (is_array($string) === true) {
return $string;
}
if (is_string($string) === false) {
throw new InvalidArgumentException('Invalid YAML data; please pass a string');
}
// remove BOM
$yaml = str_replace("\xEF\xBB\xBF", '', $yaml);
$result = Spyc::YAMLLoadString($yaml);
$string = str_replace("\xEF\xBB\xBF", '', $string);
$result = Spyc::YAMLLoadString($string);
if (is_array($result)) {
return $result;
} else {
// apparently Spyc always returns an array, even for invalid YAML syntax
// so this Exception should currently never be thrown
throw new Exception('YAML string is invalid'); // @codeCoverageIgnore
throw new InvalidArgumentException('The YAML data cannot be parsed'); // @codeCoverageIgnore
}
}
}

View File

@@ -2,6 +2,7 @@
namespace Kirby\Database;
use Closure;
use Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
@@ -230,7 +231,7 @@ class Database
}
/**
* Sets the exception mode for the next query
* Sets the exception mode
*
* @param bool $fail
* @return \Kirby\Database\Database
@@ -369,7 +370,7 @@ class Database
$this->statement->execute($bindings);
$this->affected = $this->statement->rowCount();
$this->lastId = $this->connection->lastInsertId();
$this->lastId = Str::startsWith($query, 'insert ', true) ? $this->connection->lastInsertId() : null;
$this->lastError = null;
// store the final sql to add it to the trace later
@@ -395,9 +396,6 @@ class Database
'error' => $this->lastError
]);
// reset some stuff
$this->fail = false;
// return true or false on success or failure
return $this->lastError === null;
}
@@ -426,7 +424,11 @@ class Database
}
// define the default flag for the fetch method
$flags = $options['fetch'] === 'array' ? PDO::FETCH_ASSOC : PDO::FETCH_CLASS|PDO::FETCH_PROPS_LATE;
if ($options['fetch'] instanceof Closure || $options['fetch'] === 'array') {
$flags = PDO::FETCH_ASSOC;
} else {
$flags = PDO::FETCH_CLASS|PDO::FETCH_PROPS_LATE;
}
// add optional flags
if (empty($options['flag']) === false) {
@@ -434,7 +436,7 @@ class Database
}
// set the fetch mode
if ($options['fetch'] === 'array') {
if ($options['fetch'] instanceof Closure || $options['fetch'] === 'array') {
$this->statement->setFetchMode($flags);
} else {
$this->statement->setFetchMode($flags, $options['fetch']);
@@ -443,6 +445,13 @@ class Database
// fetch that stuff
$results = $this->statement->{$options['method']}();
// apply the fetch closure to all results if given
if ($options['fetch'] instanceof Closure) {
foreach ($results as $key => $result) {
$results[$key] = $options['fetch']($result, $key);
}
}
if ($options['iterator'] === 'array') {
return $this->lastResult = $results;
}
@@ -559,6 +568,11 @@ class Database
}
}
// update cache
if (in_array($table, $this->tableWhitelist) !== true) {
$this->tableWhitelist[] = $table;
}
return true;
}
@@ -571,7 +585,17 @@ class Database
public function dropTable($table): bool
{
$sql = $this->sql()->dropTable($table);
return $this->execute($sql['query'], $sql['bindings']);
if ($this->execute($sql['query'], $sql['bindings']) !== true) {
return false;
}
// update cache
$key = array_search($this->tableWhitelist, $table);
if ($key !== false) {
unset($this->tableWhitelist[$key]);
}
return true;
}
/**

View File

@@ -2,7 +2,7 @@
namespace Kirby\Database;
use InvalidArgumentException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Config;
/**
@@ -16,8 +16,6 @@ use Kirby\Toolkit\Config;
*/
class Db
{
const ERROR_UNKNOWN_METHOD = 0;
/**
* Query shortcuts
*
@@ -35,10 +33,11 @@ class Db
/**
* (Re)connect the database
*
* @param array $params Pass [] to use the default params from the config
* @param array|null $params Pass `[]` to use the default params from the config,
* don't pass any argument to get the current connection
* @return \Kirby\Database\Database
*/
public static function connect(array $params = null)
public static function connect(?array $params = null)
{
if ($params === null && static::$connection !== null) {
return static::$connection;
@@ -46,14 +45,15 @@ class Db
// try to connect with the default
// connection settings if no params are set
$params = $params ?? [
$defaults = [
'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', ''),
'prefix' => Config::get('db.prefix', '')
];
$params = $params ?? $defaults;
return static::$connection = new Database($params);
}
@@ -61,7 +61,7 @@ class Db
/**
* Returns the current database connection
*
* @return \Kirby\Database\Database
* @return \Kirby\Database\Database|null
*/
public static function connection()
{
@@ -69,7 +69,7 @@ class Db
}
/**
* Sets the current table, which should be queried. Returns a
* Sets the current table which should be queried. Returns a
* Query object, which can be used to build a full query for
* that table.
*
@@ -83,7 +83,7 @@ class Db
}
/**
* Executes a raw sql query which expects a set of results
* Executes a raw SQL query which expects a set of results
*
* @param string $query
* @param array $bindings
@@ -97,21 +97,22 @@ class Db
}
/**
* Executes a raw sql query which expects no set of results (i.e. update, insert, delete)
* Executes a raw SQL query which expects no set of results (i.e. update, insert, delete)
*
* @param string $query
* @param array $bindings
* @return mixed
* @return bool
*/
public static function execute(string $query, array $bindings = [])
public static function execute(string $query, array $bindings = []): bool
{
$db = static::connect();
return $db->execute($query, $bindings);
}
/**
* Magic calls for other static db methods,
* which are redircted to the database class if available
* Magic calls for other static Db methods are
* redirected to either a predefined query or
* the respective method of the Database object
*
* @param string $method
* @param mixed $arguments
@@ -123,20 +124,21 @@ class Db
return static::$queries[$method](...$arguments);
}
if (is_callable([static::$connection, $method]) === true) {
if (static::$connection !== null && method_exists(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);
throw new InvalidArgumentException('Invalid static Db method: ' . $method);
}
}
/**
* Shortcut for select clauses
* Shortcut for SELECT clauses
* @codeCoverageIgnore
*
* @param string $table The name of the table, which should be queried
* @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 mixed $where The WHERE clause; can be a string or an array
* @param string $order
* @param int $offset
* @param int $limit
@@ -148,10 +150,11 @@ Db::$queries['select'] = function (string $table, $columns = '*', $where = null,
/**
* Shortcut for selecting a single row in a table
* @codeCoverageIgnore
*
* @param string $table The name of the table, which should be queried
* @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 mixed $where The WHERE clause; can be a string or an array
* @param string $order
* @param int $offset
* @param int $limit
@@ -163,10 +166,11 @@ Db::$queries['first'] = Db::$queries['row'] = Db::$queries['one'] = function (st
/**
* Returns only values from a single column
* @codeCoverageIgnore
*
* @param string $table The name of the table, which should be queried
* @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 mixed $where The WHERE clause; can be a string or an array
* @param string $order
* @param int $offset
* @param int $limit
@@ -178,93 +182,101 @@ Db::$queries['column'] = function (string $table, string $column, $where = null,
/**
* Shortcut for inserting a new row into a table
* @codeCoverageIgnore
*
* @param string $table The name of the table, which should be queried
* @param array $values An array of values, which should be inserted
* @return bool
* @param string $table The name of the table which should be queried
* @param array $values An array of values which should be inserted
* @return int ID of the inserted row
*/
Db::$queries['insert'] = function (string $table, array $values) {
Db::$queries['insert'] = function (string $table, array $values): int {
return Db::table($table)->insert($values);
};
/**
* Shortcut for updating a row in a table
* @codeCoverageIgnore
*
* @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
* @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 bool
*/
Db::$queries['update'] = function (string $table, array $values, $where = null) {
Db::$queries['update'] = function (string $table, array $values, $where = null): bool {
return Db::table($table)->where($where)->update($values);
};
/**
* Shortcut for deleting rows in a table
* @codeCoverageIgnore
*
* @param string $table The name of the table, which should be queried
* @param mixed $where An optional where clause
* @param string $table The name of the table which should be queried
* @param mixed $where An optional WHERE clause
* @return bool
*/
Db::$queries['delete'] = function (string $table, $where = null) {
Db::$queries['delete'] = function (string $table, $where = null): bool {
return Db::table($table)->where($where)->delete();
};
/**
* Shortcut for counting rows in a table
* @codeCoverageIgnore
*
* @param string $table The name of the table, which should be queried
* @param mixed $where An optional where clause
* @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) {
Db::$queries['count'] = function (string $table, $where = null): int {
return Db::table($table)->where($where)->count();
};
/**
* Shortcut for calculating the minimum value in a column
* @codeCoverageIgnore
*
* @param string $table The name of the table, which should be queried
* @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
* @param mixed $where An optional WHERE clause
* @return float
*/
Db::$queries['min'] = function (string $table, string $column, $where = null) {
Db::$queries['min'] = function (string $table, string $column, $where = null): float {
return Db::table($table)->where($where)->min($column);
};
/**
* Shortcut for calculating the maximum value in a column
* @codeCoverageIgnore
*
* @param string $table The name of the table, which should be queried
* @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
* @param mixed $where An optional WHERE clause
* @return float
*/
Db::$queries['max'] = function (string $table, string $column, $where = null) {
Db::$queries['max'] = function (string $table, string $column, $where = null): float {
return Db::table($table)->where($where)->max($column);
};
/**
* Shortcut for calculating the average value in a column
* @codeCoverageIgnore
*
* @param string $table The name of the table, which should be queried
* @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
* @param mixed $where An optional WHERE clause
* @return float
*/
Db::$queries['avg'] = function (string $table, string $column, $where = null) {
Db::$queries['avg'] = function (string $table, string $column, $where = null): float {
return Db::table($table)->where($where)->avg($column);
};
/**
* Shortcut for calculating the sum of all values in a column
* @codeCoverageIgnore
*
* @param string $table The name of the table, which should be queried
* @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
* @param mixed $where An optional WHERE clause
* @return float
*/
Db::$queries['sum'] = function (string $table, string $column, $where = null) {
Db::$queries['sum'] = function (string $table, string $column, $where = null): float {
return Db::table($table)->where($where)->sum($column);
};

View File

@@ -30,8 +30,9 @@ class Query
/**
* The object which should be fetched for each row
* or function to call for each row
*
* @var string
* @var string|\Closure
*/
protected $fetch = 'Kirby\Toolkit\Obj';
@@ -219,13 +220,14 @@ class Query
}
/**
* Sets the object class, which should be fetched
* Set this to array to get a simple array instead of an object
* Sets the object class, which should be fetched;
* set this to `'array'` to get a simple array instead of an object;
* pass a function that receives the `$data` and the `$key` to generate arbitrary data structures
*
* @param string $fetch
* @param string|\Closure $fetch
* @return \Kirby\Database\Query
*/
public function fetch(string $fetch)
public function fetch($fetch)
{
$this->fetch = $fetch;
return $this;
@@ -559,55 +561,55 @@ class Query
/**
* Builds a count query
*
* @return \Kirby\Database\Query
* @return int
*/
public function count()
public function count(): int
{
return $this->aggregate('COUNT');
return (int)$this->aggregate('COUNT');
}
/**
* Builds a max query
*
* @param string $column
* @return \Kirby\Database\Query
* @return float
*/
public function max(string $column)
public function max(string $column): float
{
return $this->aggregate('MAX', $column);
return (float)$this->aggregate('MAX', $column);
}
/**
* Builds a min query
*
* @param string $column
* @return \Kirby\Database\Query
* @return float
*/
public function min(string $column)
public function min(string $column): float
{
return $this->aggregate('MIN', $column);
return (float)$this->aggregate('MIN', $column);
}
/**
* Builds a sum query
*
* @param string $column
* @return \Kirby\Database\Query
* @return float
*/
public function sum(string $column)
public function sum(string $column): float
{
return $this->aggregate('SUM', $column);
return (float)$this->aggregate('SUM', $column);
}
/**
* Builds an average query
*
* @param string $column
* @return \Kirby\Database\Query
* @return float
*/
public function avg(string $column)
public function avg(string $column): float
{
return $this->aggregate('AVG', $column);
return (float)$this->aggregate('AVG', $column);
}
/**
@@ -812,10 +814,16 @@ class Query
*/
public function column($column)
{
$sql = $this->database->sql();
$primaryKey = $sql->combineIdentifier($this->table, $this->primaryKeyName);
// if there isn't already an explicit order, order by the primary key
// instead of the column that was requested (which would be implied otherwise)
if ($this->order === null) {
$sql = $this->database->sql();
$primaryKey = $sql->combineIdentifier($this->table, $this->primaryKeyName);
$results = $this->query($this->select([$column])->order($primaryKey . ' ASC')->build('select'), [
$this->order($primaryKey . ' ASC');
}
$results = $this->query($this->select([$column])->build('select'), [
'iterator' => 'array',
'fetch' => 'array',
]);

View File

@@ -15,7 +15,7 @@ use Kirby\Toolkit\Str;
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Sql
abstract class Sql
{
/**
* List of literals which should not be escaped in queries
@@ -27,12 +27,21 @@ class Sql
/**
* The parent database connection
*
* @var Database
* @var \Kirby\Database\Database
*/
public $database;
protected $database;
/**
* List of used bindings; used to avoid
* duplicate binding names
*
* @var array
*/
protected $bindings = [];
/**
* Constructor
* @codeCoverageIgnore
*
* @param \Kirby\Database\Database $database
*/
@@ -44,51 +53,45 @@ class Sql
/**
* 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
* @param string $label String that only contains alphanumeric chars and
* underscores to use as a human-readable identifier
* @return string Binding name that is guaranteed to be unique for this connection
*/
public function bindingName(string $label): string
{
// make sure that the binding name is valid to prevent injections
if (!preg_match('/^[a-z0-9_]+$/', $label)) {
// make sure that the binding name is safe to prevent injections;
// otherwise use a generic label
if (!$label || preg_match('/^[a-zA-Z0-9_]+$/', $label) !== 1) {
$label = 'invalid';
}
return ':' . $label . '_' . Str::random(16);
// generate random bindings until the name is unique
do {
$binding = ':' . $label . '_' . Str::random(8, 'alphaNum');
} while (in_array($binding, $this->bindings) === true);
// cache the generated binding name for future invocations
$this->bindings[] = $binding;
return $binding;
}
/**
* Returns a list of columns for a specified table
* MySQL version
* Returns a query to list the columns of a specified table;
* the query needs to return rows with a column `name`
*
* @param string $table The table name
* @param string $table 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->name(),
$tableBinding => $table,
]
];
}
abstract public function columns(string $table): array;
/**
* Optionl default value definition for the column
* Returns a query snippet for a column default value
*
* @param array $column
* @return array
* @param string $name Column name
* @param array $column Column definition array with an optional `default` key
* @return array Array with a `query` string and a `bindings` array
*/
public function columnDefault(array $column): array
public function columnDefault(string $name, array $column): array
{
if (isset($column['default']) === false) {
return [
@@ -97,74 +100,63 @@ class Sql
];
}
$binding = $this->bindingName($column['name'] . '_default');
$binding = $this->bindingName($name . '_default');
return [
'query' => 'DEFAULT ' . $binding,
'bindings' => [
$binding = $column['default']
$binding => $column['default']
]
];
}
/**
* Returns a valid column name
* Returns the cleaned identifier based on the table and column name
*
* @param string $table
* @param string $column
* @param bool $enforceQualified
* @return string|null
* @param string $table Table name
* @param string $column Column name
* @param bool $enforceQualified If true, a qualified identifier is returned in all cases
* @return string|null Identifier or null if the table or column is invalid
*/
public function columnName(string $table, string $column, bool $enforceQualified = false): ?string
{
// ensure we have clean $table and $column values without qualified identifiers
list($table, $column) = $this->splitIdentifier($table, $column);
if ($this->validateColumn($table, $column) === true) {
// combine the identifiers again
if ($this->database->validateColumn($table, $column) === true) {
return $this->combineIdentifier($table, $column, $enforceQualified !== true);
}
// the table or column does not exist
return null;
}
/**
* Abstracted column types to simplify table
* creation for multiple database drivers
* @codeCoverageIgnore
*
* @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' => []
'id' => '{{ name }} INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY',
'varchar' => '{{ name }} varchar(255) {{ null }} {{ default }} {{ unique }}',
'text' => '{{ name }} TEXT {{ unique }}',
'int' => '{{ name }} INT(11) UNSIGNED {{ null }} {{ default }} {{ unique }}',
'timestamp' => '{{ name }} TIMESTAMP {{ null }} {{ default }} {{ unique }}'
];
}
/**
* 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
* @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
@@ -173,33 +165,29 @@ class Sql
}
/**
* Creates the create syntax for a single column
* Creates the CREATE TABLE syntax for a single column
*
* @param string $table
* @param array $column
* @return array
* @param string $name Column name
* @param array $column Column definition array; valid keys:
* - `type` (required): Column template to use
* - `null`: Whether the column may be NULL (boolean)
* - `key`: Index this column is part of; special values `'primary'` for PRIMARY KEY and `true` for automatic naming
* - `unique`: Whether the index (or if not set the column itself) has a UNIQUE constraint
* - `default`: Default value of this column
* @return array Array with `query` and `key` strings, a `unique` boolean and a `bindings` array
*/
public function createColumn(string $table, array $column): array
public function createColumn(string $name, array $column): array
{
// column type
if (isset($column['type']) === false) {
throw new InvalidArgumentException('No column type given for column ' . $column);
throw new InvalidArgumentException('No column type given for column ' . $name);
}
// 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)) {
$template = $this->columnTypes()[$column['type']] ?? null;
if (!$template) {
throw new InvalidArgumentException('Unsupported column type: ' . $column['type']);
}
// null
// null option
if (A::get($column, 'null') === false) {
$null = 'NOT NULL';
} else {
@@ -207,85 +195,124 @@ class Sql
}
// 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 (is_string($column['key']) === true) {
$column['key'] = strtolower($column['key']);
} elseif ($column['key'] === true) {
$column['key'] = $name . '_index';
}
}
if (in_array($column['key'], ['PRIMARY KEY', 'INDEX']) === true) {
$key = $column['key'];
// unique
$uniqueKey = false;
$uniqueColumn = null;
if (isset($column['unique']) === true && $column['unique'] === true) {
if (isset($column['key']) === true) {
// this column is part of an index, make that unique
$uniqueKey = true;
} else {
// make the column itself unique
$uniqueColumn = 'UNIQUE';
}
}
// default value
$columnDefault = $this->columnDefault($column);
$columnKey = $this->columnKey($column);
$columnDefault = $this->columnDefault($name, $column);
$query = trim(Str::template($template, [
'name' => $this->quoteIdentifier($column['name']),
'name' => $this->quoteIdentifier($name),
'null' => $null,
'key' => $columnKey['query'],
'default' => $columnDefault['query'],
]));
$bindings = array_merge($columnKey['bindings'], $columnDefault['bindings']);
'unique' => $uniqueColumn
], ''));
return [
'query' => $query,
'bindings' => $bindings,
'key' => $key
'bindings' => $columnDefault['bindings'],
'key' => $column['key'] ?? null,
'unique' => $uniqueKey
];
}
/**
* Creates a table with a simple scheme array for columns
* Default version for MySQL
* Creates the inner query for the columns in a CREATE TABLE query
*
* @param string $table The table name
* @param array $columns
* @return array
* @param array $columns Array of column definition arrays, see `Kirby\Database\Sql::createColumn()`
* @return array Array with a `query` string and `bindings`, `keys` and `unique` arrays
*/
public function createTable(string $table, array $columns = []): array
public function createTableInner(array $columns): array
{
$output = [];
$keys = [];
$query = [];
$bindings = [];
$keys = [];
$unique = [];
foreach ($columns as $name => $column) {
$sql = $this->createColumn($table, $column);
$sql = $this->createColumn($name, $column);
$output[] = $sql['query'];
// collect query and bindings
$query[] = $sql['query'];
$bindings += $sql['bindings'];
if ($sql['key']) {
$keys[$column['name']] = $sql['key'];
// make a list of keys per key name
if ($sql['key'] !== null) {
if (isset($keys[$sql['key']]) !== true) {
$keys[$sql['key']] = [];
}
$keys[$sql['key']][] = $name;
if ($sql['unique'] === true) {
$unique[$sql['key']] = true;
}
}
$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
'query' => implode(',' . PHP_EOL, $query),
'bindings' => $bindings,
'keys' => $keys,
'unique' => $unique
];
}
/**
* Builds a delete clause
* Creates a CREATE TABLE query
*
* @param array $params List of parameters for the delete clause. See defaults for more info.
* @param string $table Table name
* @param array $columns Array of column definition arrays, see `Kirby\Database\Sql::createColumn()`
* @return array Array with a `query` string and a `bindings` array
*/
public function createTable(string $table, array $columns = []): array
{
$inner = $this->createTableInner($columns);
// add keys
foreach ($inner['keys'] as $key => $columns) {
// quote each column name and make a list string out of the column names
$columns = implode(', ', array_map(function ($name) {
return $this->quoteIdentifier($name);
}, $columns));
if ($key === 'primary') {
$key = 'PRIMARY KEY';
} else {
$unique = isset($inner['unique'][$key]) === true ? 'UNIQUE ' : '';
$key = $unique . 'INDEX ' . $this->quoteIdentifier($key);
}
$inner['query'] .= ',' . PHP_EOL . $key . ' (' . $columns . ')';
}
return [
'query' => 'CREATE TABLE ' . $this->quoteIdentifier($table) . ' (' . PHP_EOL . $inner['query'] . PHP_EOL . ')',
'bindings' => $inner['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
@@ -546,19 +573,18 @@ class Sql
/**
* Quotes an identifier (table *or* column)
* Default version for MySQL
*
* @param $identifier string
* @return string
*/
public function quoteIdentifier(string $identifier): string
{
// * is special
// * is special, don't quote that
if ($identifier === '*') {
return $identifier;
}
// replace every backtick with two backticks
// escape backticks inside the identifier name
$identifier = str_replace('`', '``', $identifier);
// wrap in backticks
@@ -688,22 +714,12 @@ class Sql
}
/**
* Returns a list of tables for a specified database
* MySQL version
* Returns a query to list the tables of the current database;
* the query needs to return rows with a column `name`
*
* @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->name()
]
];
}
abstract public function tables(): array;
/**
* Validates and quotes a table name
@@ -781,10 +797,12 @@ class Sql
* @param string $table
* @param string $column
* @return bool
*
* @throws \Kirby\Exception\InvalidArgumentException If the column is invalid
*/
public function validateColumn(string $table, string $column): bool
{
if ($this->database->validateColumn($table, $column) === false) {
if ($this->database->validateColumn($table, $column) !== true) {
throw new InvalidArgumentException('Invalid column ' . $column);
}

View File

@@ -5,7 +5,7 @@ namespace Kirby\Database\Sql;
use Kirby\Database\Sql;
/**
* Mysql query builder
* MySQL query builder
*
* @package Kirby Database
* @author Bastian Allgeier <bastian@getkirby.com>
@@ -15,4 +15,45 @@ use Kirby\Database\Sql;
*/
class Mysql extends Sql
{
/**
* Returns a query to list the columns of a specified table;
* the query needs to return rows with a column `name`
*
* @param string $table 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->name(),
$tableBinding => $table,
]
];
}
/**
* Returns a query to list the tables of the current database;
* the query needs to return rows with a column `name`
*
* @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->name()
]
];
}
}

View File

@@ -5,7 +5,7 @@ namespace Kirby\Database\Sql;
use Kirby\Database\Sql;
/**
* Sqlite query builder
* SQLite query builder
*
* @package Kirby Database
* @author Bastian Allgeier <bastian@getkirby.com>
@@ -16,11 +16,11 @@ use Kirby\Database\Sql;
class Sqlite extends Sql
{
/**
* Returns a list of columns for a specified table
* SQLite version
* Returns a query to list the columns of a specified table;
* the query needs to return rows with a column `name`
*
* @param string $table The table name
* @return string
* @param string $table Table name
* @return array
*/
public function columns(string $table): array
{
@@ -30,30 +30,10 @@ class Sqlite extends Sql
];
}
/**
* Optional key definition for the column.
*
* @param array $column
* @return array
*/
public function columnKey(array $column): array
{
if (isset($column['key']) === false || $column['key'] === 'INDEX') {
return [
'query' => null,
'bindings' => []
];
}
return [
'query' => $column['key'],
'bindings' => []
];
}
/**
* Abstracted column types to simplify table
* creation for multiple database drivers
* @codeCoverageIgnore
*
* @return array
*/
@@ -61,33 +41,72 @@ class Sqlite extends Sql
{
return [
'id' => '{{ name }} INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL UNIQUE',
'varchar' => '{{ name }} TEXT {{ null }} {{ key }} {{ default }}',
'text' => '{{ name }} TEXT {{ null }} {{ key }} {{ default }}',
'int' => '{{ name }} INTEGER {{ null }} {{ key }} {{ default }}',
'timestamp' => '{{ name }} INTEGER {{ null }} {{ key }} {{ default }}'
'varchar' => '{{ name }} TEXT {{ null }} {{ default }} {{ unique }}',
'text' => '{{ name }} TEXT {{ null }} {{ default }} {{ unique }}',
'int' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}',
'timestamp' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}'
];
}
/**
* Combines an identifier (table and column)
* SQLite version
*
* @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
* @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
{
// SQLite doesn't support qualified column names for VALUES clauses
if ($values) {
if ($values === true) {
return $this->quoteIdentifier($column);
}
return $this->quoteIdentifier($table) . '.' . $this->quoteIdentifier($column);
}
/**
* Creates a CREATE TABLE query
*
* @param string $table Table name
* @param array $columns Array of column definition arrays, see `Kirby\Database\Sql::createColumn()`
* @return array Array with a `query` string and a `bindings` array
*/
public function createTable(string $table, array $columns = []): array
{
$inner = $this->createTableInner($columns);
// add keys
$keys = [];
foreach ($inner['keys'] as $key => $columns) {
// quote each column name and make a list string out of the column names
$columns = implode(', ', array_map(function ($name) {
return $this->quoteIdentifier($name);
}, $columns));
if ($key === 'primary') {
$inner['query'] .= ',' . PHP_EOL . 'PRIMARY KEY (' . $columns . ')';
} else {
// SQLite only supports index creation using a separate CREATE INDEX query
$unique = isset($inner['unique'][$key]) === true ? 'UNIQUE ' : '';
$keys[] = 'CREATE ' . $unique . 'INDEX ' . $this->quoteIdentifier($table . '_index_' . $key) .
' ON ' . $this->quoteIdentifier($table) . ' (' . $columns . ')';
}
}
$query = 'CREATE TABLE ' . $this->quoteIdentifier($table) . ' (' . PHP_EOL . $inner['query'] . PHP_EOL . ')';
if (empty($keys) === false) {
$query .= ';' . PHP_EOL . implode(';' . PHP_EOL, $keys);
}
return [
'query' => $query,
'bindings' => $inner['bindings']
];
}
/**
* Quotes an identifier (table *or* column)
*
@@ -101,7 +120,7 @@ class Sqlite extends Sql
return $identifier;
}
// replace every quote with two quotes
// escape quotes inside the identifier name
$identifier = str_replace('"', '""', $identifier);
// wrap in quotes
@@ -109,10 +128,9 @@ class Sqlite extends Sql
}
/**
* Returns a list of tables of the database
* SQLite version
* Returns a query to list the tables of the current database;
* the query needs to return rows with a column `name`
*
* @param string $database The database name
* @return string
*/
public function tables(): array

View File

@@ -5,6 +5,7 @@ namespace Kirby\Form;
use Exception;
use Kirby\Cms\Model;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Component;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\V;
@@ -324,8 +325,12 @@ class Field extends Component
}
}
if (empty($this->validate) === false) {
$errors = V::errors($this->value(), $this->validate);
if (
empty($this->validate) === false &&
($this->isEmpty() === false || $this->isRequired() === true)
) {
$rules = A::wrap($this->validate);
$errors = V::errors($this->value(), $rules);
if (empty($errors) === false) {
$this->errors = array_merge($this->errors, $errors);

View File

@@ -2,7 +2,7 @@
namespace Kirby\Form;
use Kirby\Data\Yaml;
use Kirby\Data\Data;
use Throwable;
/**
@@ -154,7 +154,7 @@ class Form
if ($value === null) {
$strings[$key] = null;
} elseif (is_array($value) === true) {
$strings[$key] = Yaml::encode($value);
$strings[$key] = Data::encode($value, 'yaml');
} else {
$strings[$key] = $value;
}

View File

@@ -3,7 +3,10 @@
namespace Kirby\Form;
use Kirby\Cms\Nest;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Http\Remote;
use Kirby\Http\Url;
use Kirby\Toolkit\Properties;
use Kirby\Toolkit\Query;
use Kirby\Toolkit\Str;
@@ -56,14 +59,31 @@ class OptionsApi
return $this->options;
}
$content = @file_get_contents($this->url());
if (Url::isAbsolute($this->url()) === true) {
// URL, request via cURL
$data = Remote::get($this->url())->json();
} else {
// local file, get contents locally
if (empty($content) === true) {
return [];
// ensure the file exists before trying to load it as the
// file_get_contents() warnings need to be suppressed
if (is_file($this->url()) !== true) {
throw new Exception('Local file ' . $this->url() . ' was not found');
}
$content = @file_get_contents($this->url());
if (is_string($content) !== true) {
throw new Exception('Unexpected read error'); // @codeCoverageIgnore
}
if (empty($content) === true) {
return [];
}
$data = json_decode($content, true);
}
$data = json_decode($content, true);
if (is_array($data) === false) {
throw new InvalidArgumentException('Invalid options format');
}

View File

@@ -35,18 +35,19 @@ class Cookie
* @param string $key The name of the cookie
* @param string $value The cookie content
* @param array $options Array of options:
* lifetime, path, domain, secure, httpOnly
* lifetime, path, domain, secure, httpOnly, sameSite
* @return bool true: cookie was created,
* false: cookie creation failed
*/
public static function set(string $key, string $value, array $options = []): bool
{
// extract options
$lifetime = $options['lifetime'] ?? 0;
$expires = static::lifetime($options['lifetime'] ?? 0);
$path = $options['path'] ?? '/';
$domain = $options['domain'] ?? null;
$secure = $options['secure'] ?? false;
$httpOnly = $options['httpOnly'] ?? true;
$httponly = $options['httpOnly'] ?? true;
$samesite = $options['sameSite'] ?? 'Lax';
// add an HMAC signature of the value
$value = static::hmac($value) . '+' . $value;
@@ -55,7 +56,14 @@ class Cookie
$_COOKIE[$key] = $value;
// store the cookie
return setcookie($key, $value, static::lifetime($lifetime), $path, $domain, $secure, $httpOnly);
// the array syntax is only supported by PHP 7.3+
// TODO: Always use the first alternative when support for PHP 7.2 is dropped
if (version_compare(PHP_VERSION, '7.3.0', '>=') === true) {
$options = compact('expires', 'path', 'domain', 'secure', 'httponly', 'samesite');
return setcookie($key, $value, $options);
} else {
return setcookie($key, $value, $expires, $path, $domain, $secure, $httponly);
}
}
/**

View File

@@ -300,7 +300,7 @@ class Header
$options = array_merge($defaults, $params);
header('Pragma: public');
header('Expires: 0');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $options['modified']) . ' GMT');
header('Content-Disposition: attachment; filename="' . $options['name'] . '"');
header('Content-Transfer-Encoding: binary');

View File

@@ -3,6 +3,8 @@
namespace Kirby\Http;
use Exception;
use Kirby\Cms\App;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
@@ -18,6 +20,9 @@ use Kirby\Toolkit\Str;
*/
class Remote
{
const CA_INTERNAL = 1;
const CA_SYSTEM = 2;
/**
* @var array
*/
@@ -25,6 +30,7 @@ class Remote
'agent' => null,
'basicAuth' => null,
'body' => true,
'ca' => self::CA_INTERNAL,
'data' => [],
'encoding' => 'utf-8',
'file' => null,
@@ -96,8 +102,17 @@ class Remote
*/
public function __construct(string $url, array $options = [])
{
$defaults = static::$defaults;
// update the defaults with App config if set;
// request the App instance lazily
$app = App::instance(null, true);
if ($app !== null) {
$defaults = array_merge($defaults, $app->option('remote', []));
}
// set all options
$this->options = array_merge(static::$defaults, $options);
$this->options = array_merge($defaults, $options);
// add the url
$this->options['url'] = $url;
@@ -138,7 +153,6 @@ class Remote
*/
public function fetch()
{
// curl options
$this->curlopt = [
CURLOPT_URL => $this->options['url'],
@@ -149,7 +163,6 @@ class Remote
CURLOPT_RETURNTRANSFER => $this->options['body'],
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 10,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HEADER => false,
CURLOPT_HEADERFUNCTION => function ($curl, $header) {
$parts = Str::split($header, ':');
@@ -163,6 +176,24 @@ class Remote
}
];
// determine the TLS CA to use
if (is_file($this->options['ca']) === true) {
$this->curlopt[CURLOPT_SSL_VERIFYPEER] = true;
$this->curlopt[CURLOPT_CAINFO] = $this->options['ca'];
} elseif (is_dir($this->options['ca']) === true) {
$this->curlopt[CURLOPT_SSL_VERIFYPEER] = true;
$this->curlopt[CURLOPT_CAPATH] = $this->options['ca'];
} elseif ($this->options['ca'] === self::CA_INTERNAL) {
$this->curlopt[CURLOPT_SSL_VERIFYPEER] = true;
$this->curlopt[CURLOPT_CAINFO] = dirname(__DIR__, 2) . '/cacert.pem';
} elseif ($this->options['ca'] === self::CA_SYSTEM) {
$this->curlopt[CURLOPT_SSL_VERIFYPEER] = true;
} elseif ($this->options['ca'] === false) {
$this->curlopt[CURLOPT_SSL_VERIFYPEER] = false;
} else {
throw new InvalidArgumentException('Invalid "ca" option for the Remote class');
}
// add the progress
if (is_callable($this->options['progress']) === true) {
$this->curlopt[CURLOPT_NOPROGRESS] = false;

View File

@@ -107,7 +107,7 @@ class Request
public function __construct(array $options = [])
{
$this->options = $options;
$this->method = $options['method'] ?? $_SERVER['REQUEST_METHOD'] ?? 'GET';
$this->method = $this->detectRequestMethod($options['method'] ?? null);
if (isset($options['body']) === true) {
$this->body = new Body($options['body']);
@@ -208,6 +208,39 @@ class Request
return array_merge($this->body()->toArray(), $this->query()->toArray());
}
/**
* Detect the request method from various
* options: given method, query string, server vars
*
* @param string $method
* @return string
*/
public function detectRequestMethod(string $method = null): string
{
// all possible methods
$methods = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH'];
// the request method can be overwritten with a header
$methodOverride = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? null);
if ($method === null && in_array($methodOverride, $methods) === true) {
$method = $methodOverride;
}
// final chain of options to detect the method
$method = $method ?? $_SERVER['REQUEST_METHOD'] ?? 'GET';
// uppercase the shit out of it
$method = strtoupper($method);
// sanitize the method
if (in_array($method, $methods) === false) {
$method = 'GET';
}
return $method;
}
/**
* Returns the domain
*

View File

@@ -151,9 +151,10 @@ class Response
*
* @param string $file
* @param string $filename
* @param array $props Custom overrides for response props (e.g. headers)
* @return self
*/
public static function download(string $file, string $filename = null)
public static function download(string $file, string $filename = null, array $props = [])
{
if (file_exists($file) === false) {
throw new Exception('The file could not be found');
@@ -164,19 +165,21 @@ class Response
$body = file_get_contents($file);
$size = strlen($body);
return new static([
$props = array_replace_recursive([
'body' => $body,
'type' => 'application/force-download',
'headers' => [
'Pragma' => 'public',
'Expires' => '0',
'Cache-Control' => 'no-cache, no-store, must-revalidate',
'Last-Modified' => gmdate('D, d M Y H:i:s', $modified) . ' GMT',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Transfer-Encoding' => 'binary',
'Content-Length' => $size,
'Connection' => 'close'
]
]);
], $props);
return new static($props);
}
/**
@@ -184,11 +187,17 @@ class Response
* sends the file content to the browser
*
* @param string $file
* @param array $props Custom overrides for response props (e.g. headers)
* @return self
*/
public static function file(string $file)
public static function file(string $file, array $props = [])
{
return new static(F::read($file), F::extensionToMime(F::extension($file)));
$props = array_merge([
'body' => F::read($file),
'type' => F::extensionToMime(F::extension($file))
], $props);
return new static($props);
}
/**
@@ -245,12 +254,12 @@ class Response
* @param int $code
* @return self
*/
public static function redirect(?string $location = null, ?int $code = null)
public static function redirect(string $location = '/', int $code = 302)
{
return new static([
'code' => $code ?? 302,
'code' => $code,
'headers' => [
'Location' => Url::unIdn($location ?? '/')
'Location' => Url::unIdn($location)
]
]);
}

View File

@@ -158,7 +158,7 @@ class Route
*
* @return void
*/
public function next(): void
public static function next(): void
{
throw new Exceptions\NextRouteException('next');
}

View File

@@ -107,13 +107,14 @@ class Router
$result = $route->action()->call($route, ...$route->arguments());
}
$loop = false;
$loop = false;
} catch (Exceptions\NextRouteException $e) {
$ignore[] = $route;
}
if (is_a(static::$afterEach, 'Closure') === true) {
$result = (static::$afterEach)($route, $path, $method, $result);
$final = $loop === false;
$result = (static::$afterEach)($route, $path, $method, $result, $final);
}
}

View File

@@ -119,8 +119,8 @@ class Url
// matches the following groups of URLs:
// //example.com/uri
// http://example.com/uri, https://example.com/uri, ftp://example.com/uri
// mailto:example@example.com
return preg_match('!^(//|[a-z0-9+-.]+://|mailto:|tel:)!i', $url) === 1;
// mailto:example@example.com, geo:49.0158,8.3239?z=11
return preg_match('!^(//|[a-z0-9+-.]+://|mailto:|tel:|geo:)!i', $url) === 1;
}
/**

View File

@@ -34,8 +34,8 @@ class Camera
*/
public function __construct(array $exif)
{
$this->make = @$exif['Make'];
$this->model = @$exif['Model'];
$this->make = $exif['Make'] ?? null;
$this->model = $exif['Model'] ?? null;
}
/**

View File

@@ -459,7 +459,8 @@ class Session
'lifetime' => $this->tokenExpiry,
'path' => Url::index(['host' => null, 'trailingSlash' => true]),
'secure' => Url::scheme() === 'https',
'httpOnly' => true
'httpOnly' => true,
'sameSite' => 'Lax'
]);
} else {
$this->needsRetransmission = true;

View File

@@ -84,7 +84,14 @@ class KirbyTag
public static function parse(string $string, array $data = [], array $options = [])
{
// remove the brackets, extract the first attribute (the tag type)
$tag = trim(rtrim(ltrim($string, '('), ')'));
$tag = trim(ltrim($string, '('));
// use substr instead of rtrim to keep non-tagged brackets
// (link: file.pdf text: Download (PDF))
if (substr($tag, -1) === ')') {
$tag = substr($tag, 0, -1);
}
$type = trim(substr($tag, 0, strpos($tag, ':')));
$type = strtolower($type);
$attr = static::$types[$type]['attr'] ?? [];

View File

@@ -3,6 +3,8 @@
namespace Kirby\Text;
use Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Str;
/**
* Parses and converts custom kirbytags in any
@@ -22,10 +24,31 @@ class KirbyTags
public static function parse(string $text = null, array $data = [], array $options = []): string
{
return preg_replace_callback('!(?=[^\]])\([a-z0-9_-]+:.*?\)!is', function ($match) use ($data, $options) {
$regex = '!
(?=[^\]]) # positive lookahead that matches a group after the main expression without including ] in the result
(?=\([a-z0-9_-]+:) # positive lookahead that requires starts with ( and lowercase ASCII letters, digits, underscores or hyphens followed with : immediately to the right of the current location
(\( # capturing group 1
(?:[^()]+|(?1))*+ # repetitions of any chars other than ( and ) or the whole group 1 pattern (recursed)
\)) # end of capturing group 1
!isx';
return preg_replace_callback($regex, function ($match) use ($data, $options) {
$debug = $options['debug'] ?? false;
try {
return static::$tagClass::parse($match[0], $data, $options)->render();
} catch (InvalidArgumentException $e) {
// stay silent in production and ignore non-existing tags
if ($debug !== true || Str::startsWith($e->getMessage(), 'Undefined tag type:') === true) {
return $match[0];
}
throw $e;
} catch (Exception $e) {
if ($debug === true) {
throw $e;
}
return $match[0];
}
}, $text);

View File

@@ -56,11 +56,11 @@ class Markdown
/**
* Parses the given text and returns the HTML
*
* @param string $text
* @param string|null $text
* @param bool $inline
* @return string
*/
public function parse(string $text, bool $inline = false): string
public function parse(string $text = null, bool $inline = false): string
{
if ($this->options['extra'] === true) {
$parser = new ParsedownExtra();

View File

@@ -115,10 +115,10 @@ class SmartyPants
/**
* Parses the given text
*
* @param string $text
* @param string|null $text
* @return string
*/
public function parse(string $text): string
public function parse(string $text = null): string
{
// prepare the text
$text = str_replace('&quot;', '"', $text);

View File

@@ -81,29 +81,38 @@ class A
return $array[$key];
}
// support dot notation
// extract data from nested array structures using the dot notation
if (strpos($key, '.') !== false) {
$keys = explode('.', $key);
$firstKey = array_shift($keys);
// if the input array also uses dot notation, try to find a subset of the $keys
if (isset($array[$firstKey]) === false) {
$currentKey = $firstKey;
while ($innerKey = array_shift($keys)) {
$currentKey = $currentKey . '.' . $innerKey;
$currentKey .= '.' . $innerKey;
if (isset($array[$currentKey]) === true && is_array($array[$currentKey])) {
// the element needs to exist and also needs to be an array; otherwise
// we cannot find the remaining keys within it (invalid array structure)
if (isset($array[$currentKey]) === true && is_array($array[$currentKey]) === true) {
// $keys only holds the remaining keys that have not been shifted off yet
return static::get($array[$currentKey], implode('.', $keys), $default);
}
}
// searching through the full chain of keys wasn't successful
return $default;
}
// if the input array uses a completely nested structure,
// recursively progress layer by layer
if (is_array($array[$firstKey]) === true) {
return static::get($array[$firstKey], implode('.', $keys), $default);
}
// the $firstKey element was found, but isn't an array, so we cannot
// find the remaining keys within it (invalid array structure)
return $default;
}
@@ -410,6 +419,89 @@ class A
return $missing;
}
/**
* Normalizes an array into a nested form by converting
* dot notation in keys to nested structures
*
* @param array $array
* @param array $ignore List of keys in dot notation that should
* not be converted to a nested structure
* @return array
*/
public static function nest(array $array, array $ignore = []): array
{
// convert a simple ignore list to a nested $key => true array
if (isset($ignore[0]) === true) {
$ignore = array_map(function () {
return true;
}, array_flip($ignore));
$ignore = A::nest($ignore);
}
$result = [];
foreach ($array as $fullKey => $value) {
// extract the first part of a multi-level key, keep the others
$subKeys = explode('.', $fullKey);
$key = array_shift($subKeys);
// skip the magic for ignored keys
if (isset($ignore[$key]) === true && $ignore[$key] === true) {
$result[$fullKey] = $value;
continue;
}
// untangle elements where the key uses dot notation
if (count($subKeys) > 0) {
$value = static::nestByKeys($value, $subKeys);
}
// now recursively do the same for each array level if needed
if (is_array($value) === true) {
$value = static::nest($value, $ignore[$key] ?? []);
}
// merge arrays with previous results if necessary
// (needed when the same keys are used both with and without dot notation)
if (
isset($result[$key]) === true &&
is_array($result[$key]) === true &&
is_array($value) === true
) {
$result[$key] = array_replace_recursive($result[$key], $value);
} else {
$result[$key] = $value;
}
}
return $result;
}
/**
* Recursively creates a nested array from a set of keys
* with a key on each level
*
* @param mixed $value Arbitrary value that will end up at the bottom of the tree
* @param array $keys List of keys to use sorted from the topmost level
* @return array|mixed Nested array or (if `$keys` is empty) the input `$value`
*/
public static function nestByKeys($value, array $keys)
{
// shift off the first key from the list
$firstKey = array_shift($keys);
// stop further recursion if there are no more keys
if ($firstKey === null) {
return $value;
}
// return one level of the output tree, recurse further
return [
$firstKey => static::nestByKeys($value, $keys)
];
}
/**
* Sorts a multi-dimensional array by a certain column
*

View File

@@ -26,6 +26,14 @@ class Collection extends Iterator implements Countable
*/
public static $filters = [];
/**
* Whether the collection keys should be
* treated as case-sensitive
*
* @var bool
*/
protected $caseSensitive = false;
/**
* Pagination object
* @var Pagination
@@ -48,9 +56,12 @@ class Collection extends Iterator implements Countable
* Constructor
*
* @param array $data
* @param bool $caseSensitive Whether the collection keys should be
* treated as case-sensitive
*/
public function __construct(array $data = [])
public function __construct(array $data = [], bool $caseSensitive = false)
{
$this->caseSensitive = $caseSensitive;
$this->set($data);
}
@@ -72,11 +83,11 @@ class Collection extends Iterator implements Countable
*/
public function __get($key)
{
if (isset($this->data[$key])) {
return $this->data[$key];
if ($this->caseSensitive === true) {
return $this->data[$key] ?? null;
}
return $this->data[strtolower($key)] ?? null;
return $this->data[$key] ?? $this->data[strtolower($key)] ?? null;
}
/**
@@ -87,7 +98,12 @@ class Collection extends Iterator implements Countable
*/
public function __set(string $key, $value)
{
$this->data[strtolower($key)] = $value;
if ($this->caseSensitive === true) {
$this->data[$key] = $value;
} else {
$this->data[strtolower($key)] = $value;
}
return $this;
}
@@ -1243,7 +1259,7 @@ Collection::$filters['!^='] = [
/**
* Between Filter
*/
Collection::$filters['between'] = [
Collection::$filters['between'] = Collection::$filters['..'] = [
'validator' => function ($value, $test) {
return V::between($value, ...$test) === true;
},
@@ -1294,3 +1310,67 @@ Collection::$filters['maxwords'] = [
Collection::$filters['minwords'] = [
'validator' => 'V::minWords',
];
/**
* Date Equals Filter
*/
Collection::$filters['date =='] = [
'validator' => function ($value, $test) {
return V::date($value, '==', $test);
}
];
/**
* Date Not Equals Filter
*/
Collection::$filters['date !='] = [
'validator' => function ($value, $test) {
return V::date($value, '!=', $test);
}
];
/**
* Date More Filter
*/
Collection::$filters['date >'] = [
'validator' => function ($value, $test) {
return V::date($value, '>', $test);
}
];
/**
* Date Min Filter
*/
Collection::$filters['date >='] = [
'validator' => function ($value, $test) {
return V::date($value, '>=', $test);
}
];
/**
* Date Less Filter
*/
Collection::$filters['date <'] = [
'validator' => function ($value, $test) {
return V::date($value, '<', $test);
}
];
/**
* Date Max Filter
*/
Collection::$filters['date <='] = [
'validator' => function ($value, $test) {
return V::date($value, '<=', $test);
}
];
/**
* Date Between Filter
*/
Collection::$filters['date between'] = Collection::$filters['date ..'] = [
'validator' => function ($value, $test) {
return V::date($value, '>=', $test[0]) &&
V::date($value, '<=', $test[1]);
}
];

View File

@@ -3,6 +3,7 @@
namespace Kirby\Toolkit;
use ArgumentCountError;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use TypeError;
@@ -224,8 +225,12 @@ class Component
$definition = static::$types[$type];
// load definitions from string
if (is_array($definition) === false) {
static::$types[$type] = $definition = include $definition;
if (is_string($definition) === true) {
if (is_file($definition) !== true) {
throw new Exception('Component definition ' . $definition . ' does not exist');
}
static::$types[$type] = $definition = F::load($definition);
}
return $definition;

View File

@@ -51,11 +51,11 @@ class Controller
public static function load(string $file)
{
if (file_exists($file) === false) {
if (is_file($file) === false) {
return null;
}
$function = require $file;
$function = F::load($file);
if (is_a($function, 'Closure') === false) {
return null;

View File

@@ -99,7 +99,7 @@ class Dir
}
/**
* Checks if the directory exsits on disk
* Checks if the directory exists on disk
*
* @param string $dir
* @return bool

View File

@@ -103,7 +103,7 @@ class F
],
];
public static $units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
public static $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
/**
* Appends new content to an existing file
@@ -357,19 +357,24 @@ class F
}
/**
* Loads a file and returns the result
* Loads a file and returns the result or `false` if the
* file to load does not exist
*
* @param string $file
* @param mixed $fallback
* @param array $data Optional array of variables to extract in the variable scope
* @return mixed
*/
public static function load(string $file, $fallback = null)
public static function load(string $file, $fallback = null, array $data = [])
{
if (file_exists($file) === false) {
if (is_file($file) === false) {
return $fallback;
}
$result = include $file;
// we use the loadIsolated() method here to prevent the included
// file from overwriting our $fallback in this variable scope; see
// https://www.php.net/manual/en/function.include.php#example-124
$result = static::loadIsolated($file, $data);
if ($fallback !== null && gettype($result) !== gettype($fallback)) {
return $fallback;
@@ -378,6 +383,39 @@ class F
return $result;
}
/**
* Loads a file with as little as possible in the variable scope
*
* @param string $file
* @param array $data Optional array of variables to extract in the variable scope
* @return mixed
*/
protected static function loadIsolated(string $file, array $data = [])
{
// extract the $data variables in this scope to be accessed by the included file;
// protect $file against being overwritten by a $data variable
$___file___ = $file;
extract($data);
return include $___file___;
}
/**
* Loads a file using `include_once()` and returns whether loading was successful
*
* @param string $file
* @return bool
*/
public static function loadOnce(string $file): bool
{
if (is_file($file) === false) {
return false;
}
include_once $file;
return true;
}
/**
* Returns the mime type of a file
*
@@ -498,7 +536,7 @@ class F
// avoid errors for invalid sizes
if ($size <= 0) {
return '0 kB';
return '0 KB';
}
// the math magic

View File

@@ -6,7 +6,7 @@ use Exception;
use Kirby\Http\Url;
/**
* Html builder for the most common elements
* HTML builder for the most common elements
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
@@ -14,58 +14,60 @@ use Kirby\Http\Url;
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Html
class Html extends Xml
{
/**
* An internal store for a html entities translation table
* An internal store for an HTML entities translation table
*
* @var array
*/
public static $entities;
/**
* Can be used to switch to trailing slashes if required
* Closing string for void tags;
* can be used to switch to trailing slashes if required
*
* ```php
* html::$void = ' />'
* Html::$void = ' />'
* ```
*
* @var string $void
* @var string
*/
public static $void = '>';
/**
* Generic HTML tag generator
* Can be called like `Html::p('A paragraph', ['class' => 'text'])`
*
* @param string $tag
* @param array $arguments
* @param string $tag Tag name
* @param array $arguments Further arguments for the Html::tag() method
* @return string
*/
public static function __callStatic(string $tag, array $arguments = []): string
{
if (static::isVoid($tag) === true) {
return Html::tag($tag, null, ...$arguments);
return static::tag($tag, null, ...$arguments);
}
return Html::tag($tag, ...$arguments);
return static::tag($tag, ...$arguments);
}
/**
* Generates an `a` tag
* Generates an `<a>` tag; automatically supports mailto: and tel: links
*
* @param string $href The url for the `a` tag
* @param mixed $text The optional text. If `null`, the url will be used as text
* @param string $href The URL for the `<a>` tag
* @param string|array|null $text The optional text; if `null`, the URL will be used as text
* @param array $attr Additional attributes for the tag
* @return string the generated html
* @return string The generated HTML
*/
public static function a(string $href = null, $text = null, array $attr = []): string
public static function a(string $href, $text = null, array $attr = []): string
{
if (Str::startsWith($href, 'mailto:')) {
return static::email($href, $text, $attr);
return static::email(substr($href, 7), $text, $attr);
}
if (Str::startsWith($href, 'tel:')) {
return static::tel($href, $text, $attr);
return static::tel(substr($href, 4), $text, $attr);
}
return static::link($href, $text, $attr);
@@ -74,92 +76,47 @@ class Html
/**
* Generates a single attribute or a list of attributes
*
* @param string $name mixed string: a single attribute with that name will be generated. array: a list of attributes will be generated. Don't pass a second argument in that case.
* @param string $value if used for a single attribute, pass the content for the attribute here
* @return string the generated html
* @param string|array $name String: A single attribute with that name will be generated.
* Key-value array: A list of attributes will be generated. Don't pass a second argument in that case.
* @param mixed $value If used with a `$name` string, pass the value of the attribute here.
* If used with a `$name` array, this can be set to `false` to disable attribute sorting.
* @return string|null The generated HTML attributes string
*/
public static function attr($name, $value = null): string
public static function attr($name, $value = null): ?string
{
if (is_array($name) === true) {
$attributes = [];
ksort($name);
foreach ($name as $key => $val) {
$a = static::attr($key, $val);
if ($a) {
$attributes[] = $a;
}
}
return implode(' ', $attributes);
// HTML supports boolean attributes without values
if (is_array($name) === false && is_bool($value) === true) {
return $value === true ? strtolower($name) : null;
}
if ($value === null || $value === '' || $value === []) {
return false;
}
// all other cases can share the XML variant
$attr = parent::attr($name, $value);
if ($value === ' ') {
return strtolower($name) . '=""';
}
if (is_bool($value) === true) {
return $value === true ? strtolower($name) : '';
}
if (is_array($value) === true) {
if (isset($value['value'], $value['escape'])) {
$value = $value['escape'] === true ? htmlspecialchars($value['value'], ENT_QUOTES, 'UTF-8') : $value['value'];
} else {
$value = implode(' ', array_filter($value, function ($value) {
return !empty($value) || is_numeric($value);
}));
}
} else {
$value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
return strtolower($name) . '="' . $value . '"';
// HTML supports named entities
$entities = parent::entities();
$html = array_keys($entities);
$xml = array_values($entities);
return str_replace($xml, $html, $attr);
}
/**
* Converts lines in a string into html breaks
* Converts lines in a string into HTML breaks
*
* @param string $string
* @return string
*/
public static function breaks(string $string = null): string
public static function breaks(string $string): string
{
return nl2br($string);
}
/**
* Removes all html tags and encoded chars from a string
* Generates an `<a>` tag with `mailto:`
*
* <code>
*
* echo html::decode('some uber <em>crazy</em> stuff');
* // output: some uber crazy stuff
*
* </code>
*
* @param string $string
* @return string The html string
*/
public static function decode(string $string = null): string
{
$string = strip_tags($string);
return html_entity_decode($string, ENT_COMPAT, 'utf-8');
}
/**
* Generates an `a` tag with `mailto:`
*
* @param string $email The url for the a tag
* @param mixed $text The optional text. If null, the url will be used as text
* @param string $email The email address
* @param string|array|null $text The optional text; if `null`, the email address will be used as text
* @param array $attr Additional attributes for the tag
* @return string the generated html
* @return string The generated HTML
*/
public static function email(string $email, $text = null, array $attr = []): string
{
@@ -168,8 +125,10 @@ class Html
}
if (empty($text) === true) {
// show only the eMail address without additional parameters (if the 'text' argument is empty)
$text = [Str::encode(Str::split($email, '?')[0])];
// show only the email address without additional parameters
$address = Str::contains($email, '?') ? Str::before($email, '?') : $email;
$text = [Str::encode($address)];
}
$email = Str::encode($email);
@@ -187,14 +146,18 @@ class Html
}
/**
* Converts a string to a html-safe string
* Converts a string to an HTML-safe string
*
* @param string $string
* @param bool $keepTags
* @return string The html string
* @param string|null $string
* @param bool $keepTags If true, existing tags won't be escaped
* @return string The HTML string
*/
public static function encode(string $string = null, bool $keepTags = false): string
public static function encode(?string $string, bool $keepTags = false): string
{
if ($string === null) {
return '';
}
if ($keepTags === true) {
$list = static::entities();
unset($list['"'], $list['<'], $list['>'], $list['&']);
@@ -209,24 +172,24 @@ class Html
}
/**
* Returns the entities translation table
* Returns the entity translation table
*
* @return array
*/
public static function entities(): array
{
return static::$entities = static::$entities ?? get_html_translation_table(HTML_ENTITIES);
return self::$entities = self::$entities ?? get_html_translation_table(HTML_ENTITIES);
}
/**
* Creates a figure tag with optional caption
* Creates a `<figure>` tag with optional caption
*
* @param string|array $content
* @param string|array $caption
* @param array $attr
* @return string
* @param string|array $content Contents of the `<figure>` tag
* @param string|array $caption Optional `<figcaption>` text to use
* @param array $attr Additional attributes for the `<figure>` tag
* @return string The generated HTML
*/
public static function figure($content, $caption = null, array $attr = []): string
public static function figure($content, $caption = '', array $attr = []): string
{
if ($caption) {
$figcaption = static::tag('figcaption', $caption);
@@ -242,14 +205,14 @@ class Html
}
/**
* Embeds a gist
* Embeds a GitHub Gist
*
* @param string $url
* @param string $file
* @param array $attr
* @return string
* @param string $url Gist URL
* @param string|null $file Optional specific file to embed
* @param array $attr Additional attributes for the `<script>` tag
* @return string The generated HTML
*/
public static function gist(string $url, string $file = null, array $attr = []): string
public static function gist(string $url, ?string $file = null, array $attr = []): string
{
if ($file === null) {
$src = $url . '.js';
@@ -257,29 +220,27 @@ class Html
$src = $url . '.js?file=' . $file;
}
return static::tag('script', null, array_merge($attr, [
'src' => $src
]));
return static::tag('script', '', array_merge($attr, ['src' => $src]));
}
/**
* Creates an iframe
* Creates an `<iframe>`
*
* @param string $src
* @param array $attr
* @return string
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string The generated HTML
*/
public static function iframe(string $src, array $attr = []): string
{
return static::tag('iframe', null, array_merge(['src' => $src], $attr));
return static::tag('iframe', '', array_merge(['src' => $src], $attr));
}
/**
* Generates an img tag
* Generates an `<img>` tag
*
* @param string $src The url of the image
* @param array $attr Additional attributes for the image tag
* @return string the generated html
* @param string $src The URL of the image
* @param array $attr Additional attributes for the `<img>` tag
* @return string The generated HTML
*/
public static function img(string $src, array $attr = []): string
{
@@ -288,7 +249,7 @@ class Html
'alt' => ' '
], $attr);
return static::tag('img', null, $attr);
return static::tag('img', '', $attr);
}
/**
@@ -322,14 +283,14 @@ class Html
}
/**
* Generates an `a` link tag
* Generates an `<a>` link tag (without automatic email: and tel: detection)
*
* @param string $href The url for the `a` tag
* @param mixed $text The optional text. If `null`, the url will be used as text
* @param string $href The URL for the `<a>` tag
* @param string|array|null $text The optional text; if `null`, the URL will be used as text
* @param array $attr Additional attributes for the tag
* @return string the generated html
* @return string The generated HTML
*/
public static function link(string $href = null, $text = null, array $attr = []): string
public static function link(string $href, $text = null, array $attr = []): string
{
$attr = array_merge(['href' => $href], $attr);
@@ -348,13 +309,13 @@ class Html
}
/**
* Add noopeener noreferrer to rels when target is `_blank`
* Add noopener & noreferrer to rels when target is `_blank`
*
* @param string $rel
* @param string $target
* @return string|null
* @param string|null $rel Current `rel` value
* @param string|null $target Current `target` value
* @return string|null New `rel` value or `null` if not needed
*/
public static function rel(string $rel = null, string $target = null)
public static function rel(?string $rel = null, ?string $target = null): ?string
{
$rel = trim($rel);
@@ -370,47 +331,35 @@ class Html
}
/**
* Generates an Html tag with optional content and attributes
* Builds an HTML tag
*
* @param string $name The name of the tag, i.e. `a`
* @param mixed $content The content if availble. Pass `null` to generate a self-closing tag, Pass an empty string to generate empty content
* @param string $name Tag name
* @param array|string|null $content Scalar value or array with multiple lines of content or `null` to
* generate a self-closing tag; pass an empty string to generate empty content
* @param array $attr An associative array with additional attributes for the tag
* @return string The generated Html
* @param string|null $indent Indentation string, defaults to two spaces or `null` for output on one line
* @param int $level Indentation level
* @return string The generated HTML
*/
public static function tag(string $name, $content = null, array $attr = []): string
public static function tag(string $name, $content = '', array $attr = null, string $indent = null, int $level = 0): string
{
$html = '<' . $name;
$attr = static::attr($attr);
if (empty($attr) === false) {
$html .= ' ' . $attr;
}
// force void elements to be self-closing
if (static::isVoid($name) === true) {
$html .= static::$void;
} else {
if (is_array($content) === true) {
$content = implode($content);
} else {
$content = static::encode($content, false);
}
$html .= '>' . $content . '</' . $name . '>';
$content = null;
}
return $html;
return parent::tag($name, $content, $attr, $indent, $level);
}
/**
* Generates an `a` tag for a phone number
* Generates an `<a>` tag for a phone number
*
* @param string $tel The phone number
* @param mixed $text The optional text. If `null`, the number will be used as text
* @param string|array|null $text The optional text; if `null`, the phone number will be used as text
* @param array $attr Additional attributes for the tag
* @return string the generated html
* @return string The generated HTML
*/
public static function tel($tel = null, $text = null, array $attr = []): string
public static function tel(string $tel, $text = null, array $attr = []): string
{
$number = preg_replace('![^0-9\+]+!', '', $tel);
@@ -422,16 +371,44 @@ class Html
}
/**
* Creates a video embed via iframe for Youtube or Vimeo
* videos. The embed Urls are automatically detected from
* the given URL.
* Properly encodes tag contents
*
* @param string $url
* @param array $options
* @param array $attr
* @return string
* @param mixed $value
* @return string|null
*/
public static function video(string $url, ?array $options = [], array $attr = []): string
public static function value($value): ?string
{
if ($value === true) {
return 'true';
}
if ($value === false) {
return 'false';
}
if (is_numeric($value) === true) {
return (string)$value;
}
if ($value === null || $value === '') {
return null;
}
return static::encode($value, false);
}
/**
* Creates a video embed via `<iframe>` for YouTube or Vimeo
* videos; the embed URLs are automatically detected from
* the given URL
*
* @param string $url Video URL
* @param array $options Additional `vimeo` and `youtube` options
* (will be used as query params in the embed URL)
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string The generated HTML
*/
public static function video(string $url, array $options = [], array $attr = []): string
{
// YouTube video
if (preg_match('!youtu!i', $url) === 1) {
@@ -447,14 +424,14 @@ class Html
}
/**
* Embeds a Vimeo video by URL in an iframe
* Embeds a Vimeo video by URL in an `<iframe>`
*
* @param string $url
* @param array $options
* @param array $attr
* @return string
* @param string $url Vimeo video URL
* @param array $options Query params for the embed URL
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string The generated HTML
*/
public static function vimeo(string $url, ?array $options = [], array $attr = []): string
public static function vimeo(string $url, array $options = [], array $attr = []): string
{
if (preg_match('!vimeo.com\/([0-9]+)!i', $url, $array) === 1) {
$id = $array[1];
@@ -477,59 +454,106 @@ class Html
}
/**
* Embeds a Youtube video by URL in an iframe
* Embeds a YouTube video by URL in an `<iframe>`
*
* @param string $url
* @param array $options
* @param array $attr
* @return string
* @param string $url YouTube video URL
* @param array $options Query params for the embed URL
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string The generated HTML
*/
public static function youtube(string $url, ?array $options = [], array $attr = []): string
public static function youtube(string $url, array $options = [], array $attr = []): string
{
// youtube embed domain
$domain = 'youtube.com';
$id = null;
// default YouTube embed domain
$domain = 'youtube.com';
$uri = 'embed/';
$id = null;
$urlOptions = [];
$schemes = [
// http://www.youtube.com/embed/d9NF2edxy-M
['pattern' => 'youtube.com\/embed\/([a-zA-Z0-9_-]+)'],
// https://www.youtube.com/embed/videoseries?list=PLj8e95eaxiB9goOAvINIy4Vt3mlWQJxys
[
'pattern' => 'youtube.com\/embed\/videoseries\?list=([a-zA-Z0-9_-]+)',
'uri' => 'embed/videoseries?list='
],
// https://www.youtube-nocookie.com/embed/videoseries?list=PLj8e95eaxiB9goOAvINIy4Vt3mlWQJxys
[
'pattern' => 'youtube-nocookie.com\/embed\/videoseries\?list=([a-zA-Z0-9_-]+)',
'domain' => 'www.youtube-nocookie.com',
'uri' => 'embed/videoseries?list='
],
// https://www.youtube.com/embed/d9NF2edxy-M
// https://www.youtube.com/embed/d9NF2edxy-M?start=10
['pattern' => 'youtube.com\/embed\/([a-zA-Z0-9_-]+)(?:\?(.+))?'],
// https://www.youtube-nocookie.com/embed/d9NF2edxy-M
// https://www.youtube-nocookie.com/embed/d9NF2edxy-M?start=10
[
'pattern' => 'youtube-nocookie.com\/embed\/([a-zA-Z0-9_-]+)',
'pattern' => 'youtube-nocookie.com\/embed\/([a-zA-Z0-9_-]+)(?:\?(.+))?',
'domain' => 'www.youtube-nocookie.com'
],
// https://www.youtube-nocookie.com/watch?v=d9NF2edxy-M
// https://www.youtube-nocookie.com/watch?v=d9NF2edxy-M&t=10
[
'pattern' => 'youtube-nocookie.com\/watch\?v=([a-zA-Z0-9_-]+)',
'pattern' => 'youtube-nocookie.com\/watch\?v=([a-zA-Z0-9_-]+)(?:&(.+))?',
'domain' => 'www.youtube-nocookie.com'
],
// http://www.youtube.com/watch?v=d9NF2edxy-M
['pattern' => 'v=([a-zA-Z0-9_-]+)'],
// http://youtu.be/d9NF2edxy-M
['pattern' => 'youtu.be\/([a-zA-Z0-9_-]+)']
// https://www.youtube-nocookie.com/playlist?list=PLj8e95eaxiB9goOAvINIy4Vt3mlWQJxys
[
'pattern' => 'youtube-nocookie.com\/playlist\?list=([a-zA-Z0-9_-]+)',
'domain' => 'www.youtube-nocookie.com',
'uri' => 'embed/videoseries?list='
],
// https://www.youtube.com/watch?v=d9NF2edxy-M
// https://www.youtube.com/watch?v=d9NF2edxy-M&t=10
['pattern' => 'youtube.com\/watch\?v=([a-zA-Z0-9_-]+)(?:&(.+))?'],
// https://www.youtube.com/playlist?list=PLj8e95eaxiB9goOAvINIy4Vt3mlWQJxys
[
'pattern' => 'youtube.com\/playlist\?list=([a-zA-Z0-9_-]+)',
'uri' => 'embed/videoseries?list='
],
// https://youtu.be/d9NF2edxy-M
// https://youtu.be/d9NF2edxy-M?t=10
['pattern' => 'youtu.be\/([a-zA-Z0-9_-]+)(?:\?(.+))?']
];
foreach ($schemes as $schema) {
if (preg_match('!' . $schema['pattern'] . '!i', $url, $array) === 1) {
$domain = $schema['domain'] ?? $domain;
$uri = $schema['uri'] ?? $uri;
$id = $array[1];
if (isset($array[2]) === true) {
parse_str($array[2], $urlOptions);
// convert video URL options to embed URL options
if (isset($urlOptions['t']) === true) {
$urlOptions['start'] = $urlOptions['t'];
unset($urlOptions['t']);
}
}
break;
}
}
// no match
if ($id === null) {
throw new Exception('Invalid Youtube source');
throw new Exception('Invalid YouTube source');
}
// build the options query
if (empty($options) === false) {
$query = '?' . http_build_query($options);
if (empty($options) === false || empty($urlOptions) === false) {
$query = (Str::contains($uri, '?') === true ? '&' : '?') . http_build_query(array_merge($urlOptions, $options));
} else {
$query = '';
}
$url = 'https://' . $domain . '/embed/' . $id . $query;
$url = 'https://' . $domain . '/' . $uri . $id . $query;
return static::iframe($url, array_merge(['allowfullscreen' => true], $attr));
}

View File

@@ -3,7 +3,6 @@
namespace Kirby\Toolkit;
use Closure;
use Exception;
/**
* Localization class, roughly inspired by VueI18n
@@ -191,7 +190,14 @@ class I18n
}
/**
* Translate amounts
* Translates amounts
*
* Translation definition options:
* - Translation is a simple string: `{{ count }}` gets replaced in the template
* - Translation is an array with a value for each count: Chooses the correct template and
* replaces `{{ count }}` in the template; if no specific template for the input count is
* defined, the template that is defined last in the translation array is used
* - Translation is a callback with a `$count` argument: Returns the callback return value
*
* @param string $key
* @param int $count
@@ -206,23 +212,18 @@ class I18n
return null;
}
if (is_a($translation, 'Closure') === true) {
return $translation($count);
}
if (is_string($translation) === true) {
return $translation;
}
if (count($translation) !== 3) {
throw new Exception('Please provide 3 translations');
}
switch ($count) {
case 0:
$message = $translation[0];
break;
case 1:
$message = $translation[1];
break;
default:
$message = $translation[2];
$message = $translation;
} else {
if (isset($translation[$count]) === true) {
$message = $translation[$count];
} else {
$message = end($translation);
}
}
return str_replace('{{ count }}', $count, $message);

View File

@@ -2,6 +2,9 @@
namespace Kirby\Toolkit;
use Kirby\Exception\BadMethodCallException;
use Kirby\Exception\InvalidArgumentException;
/**
* The Query class can be used to
* query arrays and objects, including their
@@ -15,14 +18,13 @@ namespace Kirby\Toolkit;
*/
class Query
{
const PARTS = '!([a-zA-Z_][a-zA-Z0-9_]*(\(.*?\))?)\.|' . self::SKIP . '!';
const METHOD = '!\((.*)\)!';
const PARAMETERS = '!,|' . self::SKIP . '!';
const PARTS = '!\.|(\(([^()]+|(?1))*+\))(*SKIP)(*FAIL)!'; // split by dot, but not inside (nested) parens
const PARAMETERS = '!,|' . self::SKIP . '!'; // split by comma, but not inside skip groups
const NO_PNTH = '\([^\(]+\)(*SKIP)(*FAIL)';
const NO_PNTH = '\([^(]+\)(*SKIP)(*FAIL)';
const NO_SQBR = '\[[^]]+\](*SKIP)(*FAIL)';
const NO_DLQU = '\"[^"]+\"(*SKIP)(*FAIL)';
const NO_SLQU = '\'[^\']+\'(*SKIP)(*FAIL)';
const NO_DLQU = '\"(?:[^"\\\\]|\\\\.)*\"(*SKIP)(*FAIL)'; // allow \" escaping inside string
const NO_SLQU = '\'(?:[^\'\\\\]|\\\\.)*\'(*SKIP)(*FAIL)'; // allow \' escaping inside string
const SKIP = self::NO_PNTH . '|' . self::NO_SQBR . '|' .
self::NO_DLQU . '|' . self::NO_SLQU;
@@ -43,10 +45,10 @@ class Query
/**
* Creates a new Query object
*
* @param string $query
* @param string|null $query
* @param array|object $data
*/
public function __construct(string $query = null, $data = [])
public function __construct(?string $query = null, $data = [])
{
$this->query = $query;
$this->data = $data;
@@ -54,7 +56,7 @@ class Query
/**
* Returns the query result if anything
* can be found. Otherwise returns null.
* can be found, otherwise returns null
*
* @return mixed
*/
@@ -69,10 +71,12 @@ class Query
/**
* Resolves the query if anything
* can be found. Otherwise returns null.
* can be found, otherwise returns null
*
* @param string $query
* @return mixed
*
* @throws Kirby\Exception\BadMethodCallException If an invalid method is accessed by the query
*/
protected function resolve(string $query)
{
@@ -85,27 +89,47 @@ class Query
$data = $this->data;
$value = null;
while (count($parts)) {
$part = array_shift($parts);
foreach ($parts as $part) {
$info = $this->part($part);
$method = $info['method'];
$value = null;
$args = $info['args'];
if (is_array($data)) {
$value = $data[$method] ?? null;
} elseif (is_object($data)) {
if (method_exists($data, $method) || method_exists($data, '__call')) {
$value = $data->$method(...$info['args']);
if (array_key_exists($method, $data) === true) {
$value = $data[$method];
if (is_a($value, 'Closure') === true) {
$value = $value(...$args);
} elseif ($args !== []) {
throw new InvalidArgumentException('Cannot access array element ' . $method . ' with arguments');
}
} else {
static::accessError($data, $method, 'property');
}
} elseif (is_object($data)) {
if (
method_exists($data, $method) === true ||
method_exists($data, '__call') === true
) {
$value = $data->$method(...$args);
} elseif (
$args === [] && (
property_exists($data, $method) === true ||
method_exists($data, '__get') === true
)
) {
$value = $data->$method;
} else {
$label = ($args === []) ? 'method/property' : 'method';
static::accessError($data, $method, $label);
}
} elseif (is_scalar($data)) {
return $data;
} else {
return null;
// further parts on a scalar/null value
static::accessError($data, $method, 'method/property');
}
if (is_array($value) || is_object($value)) {
$data = $value;
}
// continue with the current value for the next part
$data = $value;
}
return $value;
@@ -119,60 +143,54 @@ class Query
*/
protected function parts(string $query): array
{
$query = trim($query);
// match all parts but the last
preg_match_all(self::PARTS, $query, $match);
// remove all matched parts from the query to retrieve last part
foreach ($match[0] as $part) {
$query = Str::after($query, $part);
}
array_push($match[1], $query);
return $match[1];
return preg_split(self::PARTS, trim($query), -1, PREG_SPLIT_NO_EMPTY);
}
/**
* Analyzes each part of the query string and
* extracts methods and method arguments.
* extracts methods and method arguments
*
* @param string $part
* @return array
*/
protected function part(string $part): array
{
$args = [];
$method = preg_replace_callback(self::METHOD, function ($match) use (&$args) {
$args = preg_split(self::PARAMETERS, $match[1]);
$args = array_map('self::parameter', $args);
}, $part);
if (Str::endsWith($part, ')') === true) {
$method = Str::before($part, '(');
return [
'method' => $method,
'args' => $args
];
// the args are everything inside the *outer* parentheses
$args = Str::substr($part, Str::position($part, '(') + 1, -1);
$args = preg_split(self::PARAMETERS, $args);
$args = array_map('self::parameter', $args);
return compact('method', 'args');
} else {
return [
'method' => $part,
'args' => []
];
}
}
/**
* Converts a parameter of query to
* proper type.
* Converts a parameter of a query to
* its proper native PHP type
*
* @param mixed $arg
* @param string $arg
* @return mixed
*/
protected function parameter($arg)
protected function parameter(string $arg)
{
$arg = trim($arg);
// string with double quotes
if (substr($arg, 0, 1) === '"') {
return trim($arg, '"');
if (substr($arg, 0, 1) === '"' && substr($arg, -1) === '"') {
return str_replace('\"', '"', substr($arg, 1, -1));
}
// string with single quotes
if (substr($arg, 0, 1) === '\'') {
return trim($arg, '\'');
if (substr($arg, 0, 1) === "'" && substr($arg, -1) === "'") {
return str_replace("\'", "'", substr($arg, 1, -1));
}
// boolean or null
@@ -200,4 +218,27 @@ class Query
// resolve parameter for objects and methods itself
return $this->resolve($arg);
}
/**
* Throws an exception for an access to an invalid method
*
* @param mixed $data Variable on which the access was tried
* @param string $name Name of the method/property that was accessed
* @param string $label Type of the name (`method`, `property` or `method/property`)
* @return void
*
* @throws Kirby\Exception\BadMethodCallException
*/
protected static function accessError($data, string $name, string $label): void
{
$type = strtolower(gettype($data));
if ($type === 'double') {
$type = 'float';
}
$nonExisting = in_array($type, ['array', 'object']) ? 'non-existing ' : '';
$error = 'Access to ' . $nonExisting . $label . ' ' . $name . ' on ' . $type;
throw new BadMethodCallException($error);
}
}

View File

@@ -341,7 +341,7 @@ class Str
* @param string $rep The element, which should be added if the string is too long. Ellipsis is the default.
* @return string The shortened string
*/
public static function excerpt($string, $chars = 140, $strip = true, $rep = '…')
public static function excerpt($string, $chars = 140, $strip = true, $rep = ' …')
{
if ($strip === true) {
$string = strip_tags(str_replace('<', ' <', $string));
@@ -361,7 +361,7 @@ class Str
return $string;
}
return static::substr($string, 0, mb_strrpos(static::substr($string, 0, $chars), ' ')) . ' ' . $rep;
return static::substr($string, 0, mb_strrpos(static::substr($string, 0, $chars), ' ')) . $rep;
}
/**
@@ -897,10 +897,25 @@ class Str
{
return preg_replace_callback('!' . $start . '(.*?)' . $end . '!', function ($match) use ($data, $fallback) {
$query = trim($match[1]);
// if the placeholder contains a dot, it is a query
if (strpos($query, '.') !== false) {
return (new Query($match[1], $data))->result() ?? $fallback;
try {
$result = (new Query($match[1], $data))->result();
} catch (Exception $e) {
$result = null;
}
} else {
$result = $data[$query] ?? null;
}
return $data[$query] ?? $fallback;
// if we don't have a result, use the fallback if given
if ($result === null && $fallback !== null) {
$result = $fallback;
}
// if we still don't have a result, keep the original placeholder
return $result ?? $match[0];
}, $string);
}

View File

@@ -18,23 +18,21 @@ class Tpl
/**
* Renders the template
*
* @param string $__file
* @param array $__data
* @param string $file
* @param array $data
* @return string
*/
public static function load(string $__file = null, array $__data = []): string
public static function load(string $file = null, array $data = []): string
{
if (file_exists($__file) === false) {
if (is_file($file) === false) {
return '';
}
$exception = null;
ob_start();
extract($__data);
$exception = null;
try {
require $__file;
F::load($file, null, $data);
} catch (Throwable $e) {
$exception = $e;
}

View File

@@ -3,6 +3,7 @@
namespace Kirby\Toolkit;
use Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Http\Idn;
use ReflectionFunction;
use Throwable;
@@ -239,13 +240,47 @@ V::$validators = [
},
/**
* Checks for a valid date
* Checks for a valid date or compares two
* dates with each other.
*
* Pass only the first argument to check for a valid date.
* Pass an operator as second argument and another date as
* third argument to compare them.
*/
'date' => function ($value): bool {
$date = date_parse($value);
return $date !== false &&
$date['error_count'] === 0 &&
$date['warning_count'] === 0;
'date' => function (?string $value, string $operator = null, string $test = null): bool {
$args = func_get_args();
// simple date validation
if (count($args) === 1) {
$date = date_parse($value);
return $date !== false &&
$date['error_count'] === 0 &&
$date['warning_count'] === 0;
}
$value = strtotime($value);
$test = strtotime($test);
if (is_int($value) !== true || is_int($test) !== true) {
return false;
}
switch ($operator) {
case '!=':
return $value !== $test;
case '<':
return $value < $test;
case '>':
return $value > $test;
case '<=':
return $value <= $test;
case '>=':
return $value >= $test;
case '==':
return $value === $test;
}
throw new InvalidArgumentException('Invalid date comparison operator: "' . $operator . '". Allowed operators: "==", "!=", "<", "<=", ">", ">="');
},
/**

View File

@@ -60,7 +60,7 @@ class View
*/
public function exists(): bool
{
return file_exists($this->file()) === true;
return is_file($this->file()) === true;
}
/**
@@ -94,13 +94,11 @@ class View
throw new Exception($this->missingViewMessage());
}
$exception = null;
ob_start();
extract($this->data());
$exception = null;
try {
require $this->file();
F::load($this->file(), null, $this->data());
} catch (Throwable $e) {
$exception = $e;
}

View File

@@ -2,8 +2,10 @@
namespace Kirby\Toolkit;
use SimpleXMLElement;
/**
* XML parser and creator Class
* XML parser and creator class
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
@@ -14,7 +16,7 @@ namespace Kirby\Toolkit;
class Xml
{
/**
* Conversion table for html entities
* HTML to XML conversion table for entities
*
* @var array
*/
@@ -54,166 +56,349 @@ class Xml
];
/**
* Creates an XML string from an array
* Closing string for void tags
*
* @param string $props The source array
* @param string $name The name of the root element
* @param bool $head Include the xml declaration head or not
* @param int $level The indendation level
* @return string The XML string
* @var string
*/
public static function create($props, string $name = 'root', bool $head = true, $level = 0): string
public static $void = ' />';
/**
* Generates a single attribute or a list of attributes
*
* @param string|array $name String: A single attribute with that name will be generated.
* Key-value array: A list of attributes will be generated. Don't pass a second argument in that case.
* @param mixed $value If used with a `$name` string, pass the value of the attribute here.
* If used with a `$name` array, this can be set to `false` to disable attribute sorting.
* @return string|null The generated XML attributes string
*/
public static function attr($name, $value = null): ?string
{
$attributes = $props['@attributes'] ?? null;
$value = $props['@value'] ?? null;
$children = $props;
$indent = str_repeat(' ', $level);
$nextLevel = $level + 1;
if (is_array($name) === true) {
if ($value !== false) {
ksort($name);
}
if (is_array($children) === true) {
unset($children['@attributes'], $children['@value']);
$attributes = [];
foreach ($name as $key => $val) {
$a = static::attr($key, $val);
$childTags = [];
foreach ($children as $childName => $childItems) {
if (is_array($childItems) === true) {
// another tag with attributes
if (A::isAssociative($childItems) === true) {
$childTags[] = static::create($childItems, $childName, false, $level);
// just children
} else {
foreach ($childItems as $childItem) {
$childTags[] = static::create($childItem, $childName, false, $nextLevel);
}
}
} else {
$childTags[] = static::tag($childName, $childItems, null, $indent);
if ($a) {
$attributes[] = $a;
}
}
if (empty($childTags) === false) {
$value = $childTags;
}
return implode(' ', $attributes);
}
$result = $head === true ? '<?xml version="1.0" encoding="utf-8"?>' . PHP_EOL : null;
$result .= static::tag($name, $value, $attributes, $indent);
if ($value === null || $value === '' || $value === []) {
return null;
}
return $result;
if ($value === ' ') {
return strtolower($name) . '=""';
}
if (is_bool($value) === true) {
return $value === true ? strtolower($name) . '="' . strtolower($name) . '"' : null;
}
if (is_array($value) === true) {
if (isset($value['value'], $value['escape'])) {
$value = $value['escape'] === true ? static::encode($value['value']) : $value['value'];
} else {
$value = implode(' ', array_filter($value, function ($value) {
return !empty($value) || is_numeric($value);
}));
}
} else {
$value = static::encode($value);
}
return strtolower($name) . '="' . $value . '"';
}
/**
* Removes all xml entities from a string
* and convert them to html entities first
* and remove all html entities afterwards.
* Creates an XML string from an array
*
* <code>
* Supports special array keys `@name` (element name),
* `@attributes` (XML attribute key-value array),
* `@namespaces` (array with XML namespaces) and
* `@value` (element content)
*
* echo xml::decode('some <em>&#252;ber</em> crazy stuff');
* // output: some &uuml;ber crazy stuff
* @param array|string $props The source array or tag content (used internally)
* @param string $name The name of the root element
* @param bool $head Include the XML declaration head or not
* @param string $indent Indentation string, defaults to two spaces
* @param int $level The indendation level (used internally)
* @return string The XML string
*/
public static function create($props, string $name = 'root', bool $head = true, string $indent = ' ', int $level = 0): string
{
if (is_array($props) === true) {
if (A::isAssociative($props) === true) {
// a tag with attributes or named children
// extract metadata from special array keys
$name = $props['@name'] ?? $name;
$attributes = $props['@attributes'] ?? [];
$value = $props['@value'] ?? null;
if (isset($props['@namespaces'])) {
foreach ($props['@namespaces'] as $key => $namespace) {
$key = 'xmlns' . (($key)? ':' . $key : '');
$attributes[$key] = $namespace;
}
}
// continue with just the children
unset($props['@name'], $props['@attributes'], $props['@namespaces'], $props['@value']);
if (count($props) > 0) {
// there are children, use them instead of the value
$value = [];
foreach ($props as $childName => $childItem) {
// render the child, but don't include the indentation of the first line
$value[] = trim(static::create($childItem, $childName, false, $indent, $level + 1));
}
}
$result = static::tag($name, $value, $attributes, $indent, $level);
} else {
// just children
$result = [];
foreach ($props as $childItem) {
$result[] = static::create($childItem, $name, false, $indent, $level);
}
$result = implode(PHP_EOL, $result);
}
} else {
// scalar value
$result = static::tag($name, $props, null, $indent, $level);
}
if ($head === true) {
return '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL . $result;
} else {
return $result;
}
}
/**
* Removes all HTML/XML tags and encoded chars from a string
*
* </code>
* ```
* echo Xml::decode('some &uuml;ber <em>crazy</em> stuff');
* // output: some über crazy stuff
* ```
*
* @param string $string
* @param string|null $string
* @return string
*/
public static function decode(string $string = null): string
public static function decode(?string $string): string
{
return Html::decode($string);
if ($string === null) {
$string = '';
}
$string = strip_tags($string);
return html_entity_decode($string, ENT_COMPAT, 'utf-8');
}
/**
* Converts a string to a xml-safe string
* Converts it to html-safe first and then it
* will replace html entities to xml entities
* Converts a string to an XML-safe string
*
* <code>
* Converts it to HTML-safe first and then it
* will replace HTML entities with XML entities
*
* echo xml::encode('some über crazy stuff');
* ```php
* echo Xml::encode('some über crazy stuff');
* // output: some &#252;ber crazy stuff
* ```
*
* </code>
*
* @param string $string
* @param bool $html True: convert to html first
* @param string|null $string
* @param bool $html True = Convert to HTML-safe first
* @return string
*/
public static function encode(string $string = null, bool $html = true): string
public static function encode(?string $string, bool $html = true): string
{
if ($string === null) {
return '';
}
if ($html === true) {
$string = Html::encode($string, false);
}
$entities = static::entities();
$searches = array_keys($entities);
$values = array_values($entities);
$entities = self::entities();
$html = array_keys($entities);
$xml = array_values($entities);
return str_replace($searches, $values, $string);
return str_replace($html, $xml, $string);
}
/**
* Returns the html to xml entities translation table
* Returns the HTML-to-XML entity translation table
*
* @return array
*/
public static function entities(): array
{
return static::$entities;
return self::$entities;
}
/**
* Parses a XML string and returns an array
* Parses an XML string and returns an array
*
* @param string $xml
* @return array|false
* @return array|null Parsed array or `null` on error
*/
public static function parse(string $xml = null)
public static function parse(string $xml): ?array
{
$xml = preg_replace('/(<\/?)(\w+):([^>]*>)/', '$1$2$3', $xml);
$xml = @simplexml_load_string($xml, null, LIBXML_NOENT | LIBXML_NOCDATA);
$xml = @simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT);
$xml = @json_encode($xml);
$xml = @json_decode($xml, true);
return is_array($xml) === true ? $xml : false;
if (is_object($xml) !== true) {
return null;
}
return static::simplify($xml);
}
/**
* Breaks a SimpleXMLElement down into a simpler tree
* structure of arrays and strings
*
* @param \SimpleXMLElement $element
* @param bool $collectName Whether the element name should be collected (for the root element)
* @return array|string
*/
public static function simplify(SimpleXMLElement $element, bool $collectName = true)
{
// get all XML namespaces of the whole document to iterate over later;
// we don't need the global namespace (empty string) in the list
$usedNamespaces = $element->getNamespaces(true);
if (isset($usedNamespaces[''])) {
unset($usedNamespaces['']);
}
// now collect element metadata of the parent
$array = [];
if ($collectName === true) {
$array['@name'] = $element->getName();
}
// collect attributes with each defined document namespace;
// also check for attributes without any namespace
$attributeArray = [];
foreach (array_merge([0 => null], array_keys($usedNamespaces)) as $namespace) {
$prefix = ($namespace)? $namespace . ':' : '';
$attributes = $element->attributes($namespace, true);
foreach ($attributes as $key => $value) {
$attributeArray[$prefix . $key] = (string)$value;
}
}
if (count($attributeArray) > 0) {
$array['@attributes'] = $attributeArray;
}
// collect namespace definitions of this particular XML element
if ($namespaces = $element->getDocNamespaces(false, false)) {
$array['@namespaces'] = $namespaces;
}
// check for children with each defined document namespace;
// also check for children without any namespace
$hasChildren = false;
foreach (array_merge([0 => null], array_keys($usedNamespaces)) as $namespace) {
$prefix = ($namespace)? $namespace . ':' : '';
$children = $element->children($namespace, true);
if (count($children) > 0) {
// there are children, recursively simplify each one
$hasChildren = true;
// make a grouped collection of elements per element name
foreach ($children as $child) {
$array[$prefix . $child->getName()][] = static::simplify($child, false);
}
}
}
if ($hasChildren === true) {
// there were children of any namespace
// reduce elements where there is only one item
// of the respective type to a simple string;
// don't do anything with special `@` metadata keys
foreach ($array as $name => $item) {
if (substr($name, 0, 1) !== '@' && count($item) === 1) {
$array[$name] = $item[0];
}
}
return $array;
} else {
// we didn't find any XML children above, only use the string value
$element = (string)$element;
if (count($array) > 0) {
$array['@value'] = $element;
return $array;
} else {
return $element;
}
}
}
/**
* Builds an XML tag
*
* @param string $name
* @param mixed $content
* @param array $attr
* @param mixed $indent
* @return string
* @param string $name Tag name
* @param array|string|null $content Scalar value or array with multiple lines of content or `null` to
* generate a self-closing tag; pass an empty string to generate empty content
* @param array $attr An associative array with additional attributes for the tag
* @param string|null $indent Indentation string, defaults to two spaces or `null` for output on one line
* @param int $level Indentation level
* @return string The generated XML
*/
public static function tag(string $name, $content = null, array $attr = null, $indent = null): string
public static function tag(string $name, $content = '', array $attr = null, ?string $indent = null, int $level = 0): string
{
$attr = Html::attr($attr);
$start = '<' . $name . ($attr ? ' ' . $attr : null) . '>';
$end = '</' . $name . '>';
$attr = static::attr($attr);
$start = '<' . $name . ($attr ? ' ' . $attr : '') . '>';
$startShort = '<' . $name . ($attr ? ' ' . $attr : '') . static::$void;
$end = '</' . $name . '>';
$baseIndent = $indent ? str_repeat($indent, $level) : '';
if (is_array($content) === true) {
$xml = $indent . $start . PHP_EOL;
foreach ($content as $line) {
$xml .= $indent . $indent . $line . PHP_EOL;
if (is_string($indent) === true) {
$xml = $baseIndent . $start . PHP_EOL;
foreach ($content as $line) {
$xml .= $baseIndent . $indent . $line . PHP_EOL;
}
$xml .= $baseIndent . $end;
} else {
$xml = $start . implode($content) . $end;
}
$xml .= $indent . $end;
} elseif ($content === null) {
$xml = $baseIndent . $startShort;
} else {
$xml = $indent . $start . static::value($content) . $end;
$xml = $baseIndent . $start . static::value($content) . $end;
}
return $xml;
}
/**
* Encodes the value as cdata if necessary
* Properly encodes tag contents
*
* @param mixed $value
* @return mixed
* @return string|null
*/
public static function value($value)
public static function value($value): ?string
{
if ($value === true) {
return 'true';
@@ -224,23 +409,25 @@ class Xml
}
if (is_numeric($value) === true) {
return $value;
return (string)$value;
}
if ($value === null || $value === '') {
return null;
}
if (Str::contains($value, '<![CDATA[') === true) {
if (Str::startsWith($value, '<![CDATA[') === true) {
return $value;
}
$encoded = htmlentities($value);
if ($encoded === $value) {
// no CDATA block needed
return $value;
}
return '<![CDATA[' . static::encode($value) . ']]>';
// wrap everything in a CDATA block
// and ensure that it is not closed in the input string
return '<![CDATA[' . str_replace(']]>', ']]]]><![CDATA[>', $value) . ']]>';
}
}