update kirby to v5 and add refresh cache panel view button

This commit is contained in:
isUnknown 2025-09-10 14:28:38 +02:00
commit 9a86d41254
466 changed files with 19960 additions and 10497 deletions

View file

@ -12,9 +12,7 @@ use Kirby\Http\Response;
use Kirby\Http\Route;
use Kirby\Http\Router;
use Kirby\Toolkit\Collection as BaseCollection;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Pagination;
use Kirby\Toolkit\Str;
use Throwable;
/**
@ -212,7 +210,7 @@ class Api
*/
public function clone(array $props = []): static
{
return new static(array_merge([
return new static([
'autentication' => $this->authentication,
'data' => $this->data,
'routes' => $this->routes,
@ -220,8 +218,9 @@ class Api
'collections' => $this->collections,
'models' => $this->models,
'requestData' => $this->requestData,
'requestMethod' => $this->requestMethod
], $props));
'requestMethod' => $this->requestMethod,
...$props
]);
}
/**
@ -235,7 +234,9 @@ class Api
array|BaseCollection|null $collection = null
): Collection {
if (isset($this->collections[$name]) === false) {
throw new NotFoundException(sprintf('The collection "%s" does not exist', $name));
throw new NotFoundException(
message: sprintf('The collection "%s" does not exist', $name)
);
}
return new Collection($this, $collection, $this->collections[$name]);
@ -262,7 +263,9 @@ class Api
}
if ($this->hasData($key) === false) {
throw new NotFoundException(sprintf('Api data for "%s" does not exist', $key));
throw new NotFoundException(
message: sprintf('Api data for "%s" does not exist', $key)
);
}
// lazy-load data wrapped in Closures
@ -322,7 +325,9 @@ class Api
$name ??= $this->match($this->models, $object);
if (isset($this->models[$name]) === false) {
throw new NotFoundException(sprintf('The model "%s" does not exist', $name ?? 'NULL'));
throw new NotFoundException(
message: sprintf('The model "%s" does not exist', $name ?? 'NULL')
);
}
return new Model($this, $object, $this->models[$name]);
@ -431,7 +436,9 @@ class Api
return $this->collection($collection, $object);
}
throw new NotFoundException(sprintf('The object "%s" cannot be resolved', get_class($object)));
throw new NotFoundException(
message: sprintf('The object "%s" cannot be resolved', $object::class)
);
}
/**
@ -541,7 +548,7 @@ class Api
'status' => 'error',
'message' => $e->getMessage(),
'code' => empty($e->getCode()) === true ? 500 : $e->getCode(),
'exception' => get_class($e),
'exception' => $e::class,
'key' => null,
'file' => F::relativepath($e->getFile(), $docRoot),
'line' => $e->getLine(),
@ -577,13 +584,12 @@ class Api
protected function setRequestData(
array|null $requestData = []
): static {
$defaults = [
$this->requestData = [
'query' => [],
'body' => [],
'files' => []
'files' => [],
...$requestData ?? []
];
$this->requestData = array_merge($defaults, (array)$requestData);
return $this;
}
@ -611,131 +617,6 @@ class Api
bool $single = false,
bool $debug = false
): array {
$trials = 0;
$uploads = [];
$errors = [];
$files = $this->requestFiles();
// get error messages from translation
$errorMessages = [
UPLOAD_ERR_INI_SIZE => I18n::translate('upload.error.iniSize'),
UPLOAD_ERR_FORM_SIZE => I18n::translate('upload.error.formSize'),
UPLOAD_ERR_PARTIAL => I18n::translate('upload.error.partial'),
UPLOAD_ERR_NO_FILE => I18n::translate('upload.error.noFile'),
UPLOAD_ERR_NO_TMP_DIR => I18n::translate('upload.error.tmpDir'),
UPLOAD_ERR_CANT_WRITE => I18n::translate('upload.error.cantWrite'),
UPLOAD_ERR_EXTENSION => I18n::translate('upload.error.extension')
];
if (empty($files) === true) {
$postMaxSize = Str::toBytes(ini_get('post_max_size'));
$uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));
if ($postMaxSize < $uploadMaxFileSize) {
throw new Exception(
I18n::translate(
'upload.error.iniPostSize',
'The uploaded file exceeds the post_max_size directive in php.ini'
)
);
}
throw new Exception(
I18n::translate(
'upload.error.noFiles',
'No files were uploaded'
)
);
}
foreach ($files as $upload) {
if (
isset($upload['tmp_name']) === false &&
is_array($upload) === true
) {
continue;
}
$trials++;
try {
if ($upload['error'] !== 0) {
throw new Exception(
$errorMessages[$upload['error']] ??
I18n::translate('upload.error.default', 'The file could not be uploaded')
);
}
// get the extension of the uploaded file
$extension = F::extension($upload['name']);
// try to detect the correct mime and add the extension
// accordingly. This will avoid .tmp filenames
if (
empty($extension) === true ||
in_array($extension, ['tmp', 'temp']) === true
) {
$mime = F::mime($upload['tmp_name']);
$extension = F::mimeToExtension($mime);
$filename = F::name($upload['name']) . '.' . $extension;
} else {
$filename = basename($upload['name']);
}
$source = dirname($upload['tmp_name']) . '/' . uniqid() . '.' . $filename;
// move the file to a location including the extension,
// for better mime detection
if (
$debug === false &&
move_uploaded_file($upload['tmp_name'], $source) === false
) {
throw new Exception(
I18n::translate('upload.error.cantMove')
);
}
$data = $callback($source, $filename);
if (is_object($data) === true) {
$data = $this->resolve($data)->toArray();
}
$uploads[$upload['name']] = $data;
} catch (Exception $e) {
$errors[$upload['name']] = $e->getMessage();
}
if ($single === true) {
break;
}
}
// return a single upload response
if ($trials === 1) {
if (empty($errors) === false) {
return [
'status' => 'error',
'message' => current($errors)
];
}
return [
'status' => 'ok',
'data' => current($uploads)
];
}
if (empty($errors) === false) {
return [
'status' => 'error',
'errors' => $errors
];
}
return [
'status' => 'ok',
'data' => $uploads
];
return (new Upload($this, $single, $debug))->process($callback);
}
}

View file

@ -40,7 +40,7 @@ class Collection
if ($data === null) {
if (($schema['default'] ?? null) instanceof Closure === false) {
throw new Exception('Missing collection data');
throw new Exception(message: 'Missing collection data');
}
$this->data = $schema['default']->call($this->api);
@ -50,7 +50,7 @@ class Collection
isset($schema['type']) === true &&
$this->data instanceof $schema['type'] === false
) {
throw new Exception('Invalid collection type');
throw new Exception(message: 'Invalid collection type');
}
}
@ -69,7 +69,7 @@ class Collection
}
if ($keys !== null && is_array($keys) === false) {
throw new Exception('Invalid select keys');
throw new Exception(message: 'Invalid select keys');
}
$this->select = $keys;

View file

@ -0,0 +1,137 @@
<?php
namespace Kirby\Api\Controller;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Content\Lock;
use Kirby\Filesystem\F;
use Kirby\Form\Fields;
use Kirby\Form\Form;
/**
* The Changes controller takes care of the request logic
* to save, discard and publish changes.
*
* @package Kirby Api
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Changes
{
/**
* Cleans up legacy lock files. The `discard`, `publish` and `save` actions
* are perfect for this cleanup job. They will be stopped early if
* the lock is still active and otherwise, we can use them to clean
* up outdated .lock files to keep the content folders clean. This
* can be removed as soon as old .lock files should no longer be around.
*
* @todo Remove in 6.0.0
*/
protected static function cleanup(ModelWithContent $model): void
{
F::remove(Lock::legacyFile($model));
}
/**
* Discards unsaved changes by deleting the changes version
*/
public static function discard(ModelWithContent $model): array
{
$model->version('changes')->delete('current');
// Removes the old .lock file when it is no longer needed
// @todo Remove in 6.0.0
static::cleanup($model);
return [
'status' => 'ok'
];
}
/**
* Saves the lastest state of changes first and then publishes them
*/
public static function publish(ModelWithContent $model, array $input): array
{
// save the given changes first
static::save(
model: $model,
input: $input
);
// Removes the old .lock file when it is no longer needed
// @todo Remove in 6.0.0
static::cleanup($model);
// get the changes version
$changes = $model->version('changes');
// if the changes version does not exist, we need to return early
if ($changes->exists('current') === false) {
return [
'status' => 'ok',
];
}
// publish the changes
$changes->publish(
language: 'current'
);
return [
'status' => 'ok'
];
}
/**
* Saves form input in a new or existing `changes` version
*/
public static function save(ModelWithContent $model, array $input): array
{
// Removes the old .lock file when it is no longer needed
// @todo Remove in 6.0.0
static::cleanup($model);
// get the current language
$language = Language::ensure('current');
// create the fields instance for the model
$fields = Fields::for($model, $language);
// get the changes and latest version for the model
$changes = $model->version('changes');
$latest = $model->version('latest');
// get the source version for the existing content
$source = $changes->exists($language) === true ? $changes : $latest;
$content = $source->content($language)->toArray();
// fill in the form values and pass through any values that are not
// defined as fields, such as uuid, title or similar.
$fields->fill(input: $content);
// submit the new values from the request input
$fields->submit(input: $input);
// save the changes
$changes->save(
fields: $fields->toStoredValues(),
language: $language
);
// if the changes are identical to the latest version,
// we can delete the changes version already at this point
if ($changes->isIdentical(version: $latest, language: $language)) {
$changes->delete(
language: $language
);
}
return [
'status' => 'ok'
];
}
}

View file

@ -49,7 +49,7 @@ class Model
if ($data === null) {
if (($schema['default'] ?? null) instanceof Closure === false) {
throw new Exception('Missing model data');
throw new Exception(message: 'Missing model data');
}
$this->data = $schema['default']->call($this->api);
@ -61,7 +61,7 @@ class Model
) {
$class = match ($this->data) {
null => 'null',
default => get_class($this->data),
default => $this->data::class,
};
throw new Exception(sprintf('Invalid model type "%s" expected: "%s"', $class, $schema['type']));
}
@ -82,7 +82,7 @@ class Model
}
if ($keys !== null && is_array($keys) === false) {
throw new Exception('Invalid select keys');
throw new Exception(message: 'Invalid select keys');
}
$this->select = $keys;
@ -109,7 +109,7 @@ class Model
if (is_string($value) === true) {
if ($value === 'any') {
throw new Exception('Invalid sub view: "any"');
throw new Exception(message: 'Invalid sub view: "any"');
}
$selection[$key] = [

View file

@ -0,0 +1,436 @@
<?php
namespace Kirby\Api;
use Closure;
use Exception;
use Kirby\Cms\App;
use Kirby\Cms\File;
use Kirby\Cms\FileRules;
use Kirby\Cms\Page;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* The Upload class handles file uploads in the
* context of the API. It adds support for chunked
* uploads.
*
* @package Kirby Api
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
readonly class Upload
{
public function __construct(
protected Api $api,
protected bool $single = true,
protected bool $debug = false
) {
}
/**
* Ensures a clean chunk ID by stripping forbidden characters
*
* @throws \Kirby\Exception\InvalidArgumentException Too short ID string
*/
public static function chunkId(string $id): string
{
$id = Str::slug($id, '', 'a-z0-9');
if (strlen($id) < 3) {
throw new InvalidArgumentException(
message: 'Chunk ID must at least be 3 characters long'
);
}
return $id;
}
/**
* Returns the ideal size for a file chunk
*/
public static function chunkSize(): int
{
$max = [
Str::toBytes(ini_get('upload_max_filesize')),
Str::toBytes(ini_get('post_max_size'))
];
// consider cloudflare proxy limit, if detected
if (isset($_SERVER['HTTP_CF_CONNECTING_IP']) === true) {
$max[] = Str::toBytes('100M');
}
// to be sure, only use 95% of the max possible upload size
return (int)floor(min($max) * 0.95);
}
/**
* Clean up tmp directory of stale files
*/
public static function cleanTmpDir(): void
{
foreach (Dir::files($dir = static::tmpDir(), [], true) as $file) {
// remove any file that hasn't been altered
// in the last 24 hours
if (F::modified($file) < time() - 86400) {
F::remove($file);
}
}
// remove tmp directory if completely empty
if (Dir::isEmpty($dir) === true) {
Dir::remove($dir);
}
}
/**
* Throws an exception with the appropriate translated error message
*
* @throws \Exception Any upload error
*/
public static function error(int $error): void
{
// get error messages from translation
$message = [
UPLOAD_ERR_INI_SIZE => I18n::translate('upload.error.iniSize'),
UPLOAD_ERR_FORM_SIZE => I18n::translate('upload.error.formSize'),
UPLOAD_ERR_PARTIAL => I18n::translate('upload.error.partial'),
UPLOAD_ERR_NO_FILE => I18n::translate('upload.error.noFile'),
UPLOAD_ERR_NO_TMP_DIR => I18n::translate('upload.error.tmpDir'),
UPLOAD_ERR_CANT_WRITE => I18n::translate('upload.error.cantWrite'),
UPLOAD_ERR_EXTENSION => I18n::translate('upload.error.extension')
];
throw new Exception(
message: $message[$error] ?? I18n::translate('upload.error.default', 'The file could not be uploaded')
);
}
/**
* Sanitize the filename and extension
* based on the detected mime type
*/
public static function filename(array $upload): string
{
// get the extension of the uploaded file
$extension = F::extension($upload['name']);
// try to detect the correct mime and add the extension
// accordingly. This will avoid .tmp filenames
if (
empty($extension) === true ||
in_array($extension, ['tmp', 'temp'], true) === true
) {
$mime = F::mime($upload['tmp_name']);
$extension = F::mimeToExtension($mime);
$filename = F::name($upload['name']) . '.' . $extension;
return $filename;
}
return basename($upload['name']);
}
/**
* Upload the files and call closure for each file
*
* @throws \Exception Any upload error
*/
public function process(Closure $callback): array
{
$files = $this->api->requestFiles();
$uploads = [];
$errors = [];
static::validateFiles($files);
foreach ($files as $upload) {
if (
isset($upload['tmp_name']) === false &&
is_array($upload) === true
) {
continue;
}
try {
if ($upload['error'] !== 0) {
static::error($upload['error']);
}
$filename = static::filename($upload);
$source = $this->source($upload['tmp_name'], $filename);
// if the file is uploaded in chunks…
if ($this->api->requestHeaders('Upload-Length')) {
$source = $this->processChunk($source, $filename);
}
// apply callback only to complete uploads
// (incomplete chunk request will return empty $source)
$data = match ($source) {
null => null,
default => $callback($source, $filename)
};
$uploads[$upload['name']] = match (true) {
is_object($data) => $this->api->resolve($data)->toArray(),
default => $data
};
} catch (Exception $e) {
$errors[$upload['name']] = $e->getMessage();
// clean up file from system tmp directory
F::unlink($upload['tmp_name']);
}
if ($this->single === true) {
break;
}
}
return static::response($uploads, $errors);
}
/**
* Handle chunked uploads by merging all chunks
* in the tmp directory and only returning the new
* $source path to the tmp file once complete
*
* @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
* @throws \Kirby\Exception\Exception Chunk offset does not match existing tmp file
* @throws \Kirby\Exception\InvalidArgumentException Too short ID string
* @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
*/
public function processChunk(
string $source,
string $filename
): string|null {
// ensure the tmp upload directory exists
Dir::make($dir = static::tmpDir());
// create path for file in tmp upload directory;
// prefix with id while file isn't completely uploaded yet
$id = $this->api->requestHeaders('Upload-Id', '');
$id = static::chunkId($id);
$total = (int)$this->api->requestHeaders('Upload-Length');
$filename = basename($filename);
$tmpRoot = $dir . '/' . $id . '-' . $filename;
// validate various aspects of the request
// to ensure the chunk isn't trying to do malicious actions
static::validateChunk(
source: $source,
tmp: $tmpRoot,
total: $total,
offset: $this->api->requestHeaders('Upload-Offset'),
template: $this->api->requestBody('template'),
);
// stream chunk content and append it to partial file
stream_copy_to_stream(
fopen($source, 'r'),
fopen($tmpRoot, 'a')
);
// clear file stat cache so the following call to `F::size`
// really returns the updated file size
clearstatcache();
// if file isn't complete yet, return early
if (F::size($tmpRoot) < $total) {
return null;
}
// remove id from partial filename now the file is complete,
// so we can pass the path from the tmp upload directory
// as new source path for the file back to the API upload method
rename(
$tmpRoot,
$source = $dir . '/' . $filename
);
return $source;
}
/**
* Convert uploads and errors in response array for API response
*/
public static function response(
array $uploads,
array $errors
): array {
if (count($uploads) + count($errors) <= 1) {
if (count($errors) > 0) {
return [
'status' => 'error',
'message' => current($errors)
];
}
return [
'status' => 'ok',
'data' => $uploads ? current($uploads) : null
];
}
if (count($errors) > 0) {
return [
'status' => 'error',
'errors' => $errors
];
}
return [
'status' => 'ok',
'data' => $uploads
];
}
/**
* Move the tmp file to a location including the extension,
* for better mime detection and return updated source path
*
* @codeCoverageIgnore
*/
public function source(string $source, string $filename): string
{
if ($this->debug === true) {
return $source;
}
$target = dirname($source) . '/' . uniqid() . '.' . $filename;
if (move_uploaded_file($source, $target)) {
return $target;
}
throw new Exception(
message: I18n::translate('upload.error.cantMove')
);
}
/**
* Returns root of directory used for
* temporarily storing (incomplete) uploads
* @codeCoverageIgnore
*/
protected static function tmpDir(): string
{
return App::instance()->root('cache') . '/.uploads';
}
/**
* Ensures the sent chunk is valid
*
* @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
* @throws \Kirby\Exception\InvalidArgumentException Chunk offset does not match existing tmp file
* @throws \Kirby\Exception\InvalidArgumentException The maximum file size for this blueprint was exceeded
* @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
*/
protected static function validateChunk(
string $source,
string $tmp,
int $total,
int $offset,
string|null $template = null
): void {
$file = new File([
'parent' => new Page(['slug' => 'tmp']),
'filename' => $filename = basename($tmp),
'template' => $template
]);
// if the blueprint `maxsize` option is set,
// ensure that the total size communicated in the header
// as well as the current tmp size after adding this chunk
// do not exceed the max limit
if (
($max = $file->blueprint()->accept()['maxsize'] ?? null) &&
(
$total > $max ||
(F::size($source) + F::size($tmp)) > $max
)
) {
throw new InvalidArgumentException(
key: 'file.maxsize'
);
}
// validate the first chunk
if ($offset === 0) {
// sent chunk is expected to be the first part,
// but tmp file already exists
if (F::exists($tmp) === true) {
throw new DuplicateException(
message: 'A tmp file upload with the same filename and upload id already exists: ' . $filename
);
}
// validate file (extension, name) for first chunk;
// will also be validate again by `$model->createFile()`
// when completely uploaded
FileRules::validFile($file, false);
// first chunk is valid
return;
}
// validate subsequent chunks:
// no tmp in place
if (F::exists($tmp) === false) {
throw new NotFoundException(
message: 'Chunk offset ' . $offset . ' for non-existing tmp file: ' . $filename
);
}
// sent chunk's offset is not the continuation of the tmp file
if ($offset !== F::size($tmp)) {
throw new InvalidArgumentException(
message: 'Chunk offset ' . $offset . ' does not match the existing tmp upload file size of ' . F::size($tmp)
);
}
}
/**
* Validate the files array for upload
*
* @throws \Exception No files were uploaded
*/
protected static function validateFiles(array $files): void
{
if ($files === []) {
$postMaxSize = Str::toBytes(ini_get('post_max_size'));
$uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));
// @codeCoverageIgnoreStart
if ($postMaxSize < $uploadMaxFileSize) {
throw new Exception(
message:
I18n::translate(
'upload.error.iniPostSize',
'The uploaded file exceeds the post_max_size directive in php.ini'
)
);
}
// @codeCoverageIgnoreEnd
throw new Exception(
message:
I18n::translate(
'upload.error.noFiles',
'No files were uploaded'
)
);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -68,10 +68,10 @@ class ApcuCache extends Cache
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
* <code>
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
* ```php
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{

View file

@ -70,7 +70,6 @@ abstract class Cache
return $this->expired($key) === false;
}
/**
* Calculates the expiration timestamp
*/
@ -132,13 +131,13 @@ abstract class Cache
/**
* Gets an item from the cache
*
* <code>
* // get an item from the cache driver
* $value = $cache->get('value');
* ```php
* // get an item from the cache driver
* $value = $cache->get('value');
*
* // return a default value if the requested item isn't cached
* $value = $cache->get('value', 'default value');
* </code>
* // return a default value if the requested item isn't cached
* $value = $cache->get('value', 'default value');
* ```
*/
public function get(string $key, $default = null)
{
@ -229,10 +228,10 @@ abstract class Cache
* returns whether the operation was successful;
* this needs to be defined by the driver
*
* <code>
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
* ```php
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* ```
*/
abstract public function set(string $key, $value, int $minutes = 0): bool;
}

View file

@ -32,13 +32,12 @@ class FileCache extends Cache
*/
public function __construct(array $options)
{
$defaults = [
parent::__construct([
'root' => null,
'prefix' => null,
'extension' => null
];
parent::__construct(array_merge($defaults, $options));
'extension' => null,
...$options
]);
// build the full root including prefix
$this->root = $this->options['root'];
@ -120,10 +119,10 @@ class FileCache extends Cache
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
* <code>
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
* ```php
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{
@ -196,7 +195,7 @@ class FileCache extends Cache
$files = array_diff($files, ['.', '..']);
if (empty($files) === true && Dir::remove($dir) === true) {
if ($files === [] && Dir::remove($dir) === true) {
// continue with the next level up
$dir = dirname($dir);
} else {

View file

@ -34,13 +34,12 @@ class MemCached extends Cache
*/
public function __construct(array $options = [])
{
$defaults = [
parent::__construct([
'host' => 'localhost',
'port' => 11211,
'prefix' => null,
];
parent::__construct(array_merge($defaults, $options));
...$options
]);
$this->connection = new MemcachedExt();
$this->enabled = $this->connection->addServer(
@ -62,10 +61,10 @@ class MemCached extends Cache
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
* <code>
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
* ```php
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{

View file

@ -31,10 +31,10 @@ class MemoryCache extends Cache
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
* <code>
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
* ```php
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{

View file

@ -26,10 +26,10 @@ class NullCache extends Cache
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
* <code>
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* </code>
* ```php
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{

View file

@ -0,0 +1,160 @@
<?php
namespace Kirby\Cache;
use Kirby\Cms\Helpers;
use Redis;
use Throwable;
/**
* Redis Cache Driver
*
* @package Kirby Cache
* @author Ahmet Bora <ahmet@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class RedisCache extends Cache
{
/**
* Store for the redis connection
*/
protected Redis $connection;
/**
* Sets all parameters which are needed to connect to Redis
*
* @param array $options 'host' (default: 127.0.0.1)
* 'port' (default: 6379)
*/
public function __construct(array $options = [])
{
$options = [
'host' => '127.0.0.1',
'port' => 6379,
...$options
];
parent::__construct($options);
// available options for the redis driver
$allowed = [
'host',
'port',
'readTimeout',
'connectTimeout',
'persistent',
'auth',
'ssl',
'retryInterval',
'backoff'
];
// filters only redis supported keys
$redisOptions = array_intersect_key($options, array_flip($allowed));
// creates redis connection
$this->connection = new Redis($redisOptions);
// sets the prefix if defined
if ($prefix = $options['prefix'] ?? null) {
$this->connection->setOption(Redis::OPT_PREFIX, rtrim($prefix, '/') . '/');
}
// selects the database if defined
$database = $options['database'] ?? null;
if ($database !== null) {
$this->connection->select($database);
}
}
/**
* Returns the database number
*/
public function databaseNum(): int
{
return $this->connection->getDbNum();
}
/**
* Returns whether the cache is ready to store values
*/
public function enabled(): bool
{
try {
return Helpers::handleErrors(
fn () => $this->connection->ping(),
fn (int $errno, string $errstr) => true,
fn () => false
);
} catch (Throwable) {
return false;
}
}
/**
* Determines if an item exists in the cache
*/
public function exists(string $key): bool
{
return $this->connection->exists($this->key($key)) !== 0;
}
/**
* Removes keys from the database
* and returns whether the operation was successful
*/
public function flush(): bool
{
return $this->connection->flushDB();
}
/**
* The key is not modified, because the prefix is added by the redis driver itself
*/
protected function key(string $key): string
{
return $key;
}
/**
* Removes an item from the cache
* and returns whether the operation was successful
*/
public function remove(string $key): bool
{
return $this->connection->del($this->key($key));
}
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*/
public function retrieve(string $key): Value|null
{
$value = $this->connection->get($this->key($key));
return Value::fromJson($value);
}
/**
* Writes an item to the cache for a given number of minutes
* and returns whether the operation was successful
*
* ```php
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{
$key = $this->key($key);
$value = (new Value($value, $minutes))->toJson();
if ($minutes > 0) {
return $this->connection->setex($key, $minutes * 60, $value);
}
return $this->connection->set($key, $value);
}
}

View file

@ -20,7 +20,7 @@ class Value
/**
* Cached value
*/
protected $value;
protected mixed $value;
/**
* the number of minutes until the value expires

View file

@ -55,9 +55,10 @@ class Api extends BaseApi
*/
public function clone(array $props = []): static
{
return parent::clone(array_merge([
'kirby' => $this->kirby
], $props));
return parent::clone([
'kirby' => $this->kirby,
...$props
]);
}
/**
@ -194,7 +195,9 @@ class Api extends BaseApi
string|null $path = null
): mixed {
if (!$section = $model->blueprint()?->section($name)) {
throw new NotFoundException('The section "' . $name . '" could not be found');
throw new NotFoundException(
message: 'The section "' . $name . '" could not be found'
);
}
$sectionApi = $this->clone([
@ -216,9 +219,7 @@ class Api extends BaseApi
*/
public function session(array $options = []): Session
{
return $this->kirby->session(array_merge([
'detect' => true
], $options));
return $this->kirby->session(['detect' => true, ...$options]);
}
/**
@ -234,7 +235,6 @@ class Api extends BaseApi
* returns the current authenticated user if no
* id is passed
*
* @param string|null $id User's id
* @throws \Kirby\Exception\NotFoundException if the user for the given id cannot be found
*/
public function user(string|null $id = null): User|null

View file

@ -3,7 +3,10 @@
namespace Kirby\Cms;
use Closure;
use Exception as GlobalException;
use Generator;
use Kirby\Content\Storage;
use Kirby\Content\VersionCache;
use Kirby\Data\Data;
use Kirby\Email\Email as BaseEmail;
use Kirby\Exception\ErrorPageException;
@ -68,9 +71,9 @@ class App
protected Core $core;
protected Language|null $defaultLanguage = null;
protected Environment|null $environment = null;
protected Events $events;
protected Language|null $language = null;
protected Languages|null $languages = null;
protected ContentLocks|null $locks = null;
protected bool|null $multilang = null;
protected string|null $nonce = null;
protected array $options;
@ -96,7 +99,11 @@ class App
*/
public function __construct(array $props = [], bool $setInstance = true)
{
$this->core = new Core($this);
$this->core = new Core($this);
$this->events = new Events($this);
// start with a fresh version cache
VersionCache::reset();
// register all roots to be able to load stuff afterwards
$this->bakeRoots($props['roots'] ?? []);
@ -128,13 +135,12 @@ class App
// configurable properties
$this->setLanguages($props['languages'] ?? null);
$this->setRoles($props['roles'] ?? null);
$this->setSite($props['site'] ?? null);
$this->setUser($props['user'] ?? null);
$this->setUsers($props['users'] ?? null);
// set the singleton
if (static::$instance === null || $setInstance === true) {
static::$instance = ModelWithContent::$kirby = Model::$kirby = $this;
static::$instance = ModelWithContent::$kirby = $this;
}
// setup the I18n class with the translation loader
@ -147,6 +153,11 @@ class App
$this->extensionsFromOptions();
$this->extensionsFromFolders();
// must be set after the extensions are loaded.
// the default storage instance must be defined
// and the App::$instance singleton needs to be set
$this->setSite($props['site'] ?? null);
// trigger hook for use in plugins
$this->trigger('system.loadPlugins:after');
@ -178,7 +189,7 @@ class App
/**
* Returns the Api instance
*
* @internal
* @unstable
*/
public function api(): Api
{
@ -190,59 +201,40 @@ class App
$extensions = $this->extensions['api'] ?? [];
$routes = (include $root . '/routes.php')($this);
$api = [
return $this->api = new Api([
'debug' => $this->option('debug', false),
'authentication' => $extensions['authentication'] ?? include $root . '/authentication.php',
'data' => $extensions['data'] ?? [],
'collections' => array_merge($extensions['collections'] ?? [], include $root . '/collections.php'),
'models' => array_merge($extensions['models'] ?? [], include $root . '/models.php'),
'routes' => array_merge($routes, $extensions['routes'] ?? []),
'data' => $extensions['data'] ?? [],
'collections' => [
...$extensions['collections'] ?? [],
...include $root . '/collections.php'
],
'models' => [
...$extensions['models'] ?? [],
...include $root . '/models.php'
],
'routes' => [
...$routes,
...$extensions['routes'] ?? []
],
'kirby' => $this,
];
return $this->api = new Api($api);
]);
}
/**
* Applies a hook to the given value
*
* @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)
* @param array $args Associative array of named arguments
* @param string|null $modify Key in $args that is modified by the hooks (default: first argument)
* @return mixed Resulting value as modified by the hooks
*/
public function apply(
string $name,
array $args,
string $modify,
Event|null $originalEvent = null
string|null $modify = null
): mixed {
$event = $originalEvent ?? new Event($name, $args);
if ($functions = $this->extension('hooks', $name)) {
foreach ($functions as $function) {
// bind the App object to the hook
$newValue = $event->call($this, $function);
// update value if one was returned
if ($newValue !== null) {
$event->updateArgument($modify, $newValue);
}
}
}
// 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);
return $this->events->apply($name, $args, $modify);
}
/**
@ -271,7 +263,10 @@ class App
// move the option to the plugin option array
// don't overwrite nested arrays completely but merge them
$this->options[$plugin] = array_replace_recursive($this->options[$plugin], [$option => $value]);
$this->options[$plugin] = array_replace_recursive(
$this->options[$plugin],
[$option => $value]
);
unset($this->options[$key]);
}
}
@ -287,7 +282,7 @@ class App
*/
protected function bakeRoots(array|null $roots = null): static
{
$roots = array_merge($this->core->roots(), (array)$roots);
$roots = [...$this->core->roots(), ...$roots ?? []];
$this->roots = Ingredients::bake($roots);
return $this;
}
@ -299,7 +294,7 @@ class App
*/
protected function bakeUrls(array|null $urls = null): static
{
$urls = array_merge($this->core->urls(), (array)$urls);
$urls = [...$this->core->urls(), ...$urls ?? []];
$this->urls = Ingredients::bake($urls);
return $this;
}
@ -318,9 +313,18 @@ class App
}
}
foreach (glob($this->root('blueprints') . '/' . $type . '/*.yml') as $blueprint) {
$name = F::name($blueprint);
$blueprints[$name] = $name;
try {
// protect against path traversal attacks
$root = $this->root('blueprints') . '/' . $type;
$realpath = Dir::realpath($root, $this->root('blueprints'));
foreach (glob($realpath . '/*.yml') as $blueprint) {
$name = F::name($blueprint);
$blueprints[$name] = $name;
}
} catch (GlobalException) {
// if the realpath operation failed, the following glob was skipped,
// keeping just the blueprints from extensions
}
ksort($blueprints);
@ -360,17 +364,18 @@ class App
* automatically injected
*
* @return \Kirby\Toolkit\Collection|null
* @todo 5.0 Add return type declaration
* @todo 6.0 Add return type declaration
*/
public function collection(string $name, array $options = [])
{
return $this->collections()->get($name, array_merge($options, [
return $this->collections()->get($name, [
...$options,
'kirby' => $this,
'site' => $site = $this->site(),
'pages' => new LazyValue(fn () => $site->children()),
'users' => new LazyValue(fn () => $this->users())
]));
]);
}
/**
@ -383,8 +388,6 @@ class App
/**
* Returns a core component
*
* @internal
*/
public function component(string $name): mixed
{
@ -393,8 +396,6 @@ class App
/**
* Returns the content extension
*
* @internal
*/
public function contentExtension(): string
{
@ -403,8 +404,6 @@ class App
/**
* Returns files that should be ignored when scanning folders
*
* @internal
*/
public function contentIgnore(): array
{
@ -415,15 +414,15 @@ class App
* 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 object|null $model Object to pass to the salt callback if configured
* @param string $value Model data to include in the generated token
*/
public function contentToken(mixed $model, string $value): string
public function contentToken(object|null $model, string $value): string
{
if (method_exists($model, 'root') === true) {
$default = $model->root();
} else {
$default = $this->root('content');
$default = $this->root('content');
if ($model !== null && method_exists($model, 'id') === true) {
$default .= '/' . $model->id();
}
$salt = $this->option('content.salt', $default);
@ -445,25 +444,30 @@ class App
string $contentType = 'html'
): array {
$name = basename(strtolower($name));
$data = [];
if ($controller = $this->controllerLookup($name, $contentType)) {
return (array)$controller->call($this, $arguments);
// always use the site controller as defaults, if available
$site = $this->controllerLookup('site', $contentType);
$site ??= $this->controllerLookup('site');
if ($site !== null) {
$data = (array)$site->call($this, $arguments);
}
if ($contentType !== 'html') {
// no luck for a specific representation controller?
// let's try the html controller instead
if ($controller = $this->controllerLookup($name)) {
return (array)$controller->call($this, $arguments);
}
// try to find a specific representation controller
$controller = $this->controllerLookup($name, $contentType);
// no luck for a specific representation controller?
// let's try the html controller instead
$controller ??= $this->controllerLookup($name);
if ($controller !== null) {
return [
...$data,
...(array)$controller->call($this, $arguments)
];
}
// still no luck? Let's take the site controller
if ($controller = $this->controllerLookup('site')) {
return (array)$controller->call($this, $arguments);
}
return [];
return $data;
}
/**
@ -478,7 +482,7 @@ class App
}
// controller from site root
$controller = Controller::load($this->root('controllers') . '/' . $name . '.php');
$controller = Controller::load($this->root('controllers') . '/' . $name . '.php', $this->root('controllers'));
// controller from extension
$controller ??= $this->extension('controllers', $name);
@ -576,11 +580,12 @@ class App
$visitor = $this->visitor();
foreach ($visitor->acceptedLanguages() as $acceptedLang) {
$closure = function ($language) use ($acceptedLang) {
$closure = static function ($language) use ($acceptedLang) {
$languageLocale = $language->locale(LC_ALL);
$acceptedLocale = $acceptedLang->locale();
return $languageLocale === $acceptedLocale ||
return
$languageLocale === $acceptedLocale ||
$acceptedLocale === Str::substr($languageLocale, 0, 2);
};
@ -715,7 +720,7 @@ class App
* Takes almost any kind of input and
* tries to convert it into a valid response
*
* @internal
* @unstable
*/
public function io(mixed $input): Response
{
@ -724,12 +729,11 @@ class App
// any direct exception will be turned into an error page
if ($input instanceof Throwable) {
if ($input instanceof Exception) {
$code = $input->getHttpCode();
} else {
$code = $input->getCode();
}
$message = $input->getMessage();
$code = match (true) {
$input instanceof Exception => $input->getHttpCode(),
default => $input->getCode()
};
if ($code < 400 || $code > 599) {
$code = 500;
@ -739,7 +743,7 @@ class App
return $response->code($code)->send($errorPage->render([
'errorCode' => $code,
'errorMessage' => $message,
'errorType' => get_class($input)
'errorType' => $input::class
]));
}
@ -770,10 +774,7 @@ class App
// lazily (only if they are not already set);
// the case-insensitive nature of headers will be
// handled by PHP's `header()` function
$data['headers'] = array_merge(
$response->headers(),
$data['headers']
);
$data['headers'] = [...$response->headers(), ...$data['headers']];
return new Response($data);
}
@ -811,13 +812,14 @@ class App
return $response->json($input)->send();
}
throw new InvalidArgumentException('Unexpected input');
throw new InvalidArgumentException(
message: 'Unexpected input'
);
}
/**
* Renders a single KirbyTag with the given attributes
*
* @internal
* @param string|array $type Tag type or array with all tag arguments
* (the key of the first element becomes the type)
*/
@ -848,33 +850,33 @@ class App
}
/**
* KirbyTags Parser
*
* @internal
* Parses and resolves KirbyTags in text
*/
public function kirbytags(string|null $text = null, array $data = []): string
{
public function kirbytags(
string|null $text = null,
array $data = []
): string {
$data['kirby'] ??= $this;
$data['site'] ??= $data['kirby']->site();
$data['parent'] ??= $data['site']->page();
$options = $this->options;
$text = $this->apply('kirbytags:before', compact('text', 'data', 'options'), 'text');
$text = $this->apply('kirbytags:before', compact('text', 'data', 'options'));
$text = KirbyTags::parse($text, $data, $options);
$text = $this->apply('kirbytags:after', compact('text', 'data', 'options'), 'text');
$text = $this->apply('kirbytags:after', compact('text', 'data', 'options'));
return $text;
}
/**
* Parses KirbyTags first and Markdown afterwards
*
* @internal
*/
public function kirbytext(string|null $text = null, array $options = []): string
{
$text = $this->apply('kirbytext:before', compact('text'), 'text');
public function kirbytext(
string|null $text = null,
array $options = []
): string {
$text = $this->apply('kirbytext:before', compact('text'));
$text = $this->kirbytags($text, $options);
$text = $this->markdown($text, $options['markdown'] ?? []);
@ -882,7 +884,7 @@ class App
$text = $this->smartypants($text);
}
$text = $this->apply('kirbytext:after', compact('text'), 'text');
$text = $this->apply('kirbytext:after', compact('text'));
return $text;
}
@ -906,8 +908,6 @@ class App
/**
* Returns the current language code
*
* @internal
*/
public function languageCode(string|null $languageCode = null): string|null
{
@ -925,7 +925,10 @@ class App
}
if ($this->languages !== null) {
return $clone === true ? clone $this->languages : $this->languages;
return match($clone) {
true => clone $this->languages,
false => $this->languages
};
}
return $this->languages = Languages::load();
@ -939,26 +942,16 @@ class App
return new Loader($this);
}
/**
* Returns the app's locks object
*/
public function locks(): ContentLocks
{
return $this->locks ??= new ContentLocks();
}
/**
* Parses Markdown
*
* @internal
*/
public function markdown(string|null $text = null, array|null $options = null): string
{
// merge global options with local options
$options = array_merge(
$this->options['markdown'] ?? [],
(array)$options
);
$options = [
...$this->options['markdown'] ?? [],
...$options ?? []
];
return ($this->component('markdown'))($this, $text, $options);
}
@ -1061,7 +1054,11 @@ class App
// merge into one clean options array;
// the `env.php` options always override everything else
$hostAddrOptions = $this->environment()->options($root);
$this->options = array_replace_recursive($this->options, $hostAddrOptions, $envOptions);
$this->options = array_replace_recursive(
$this->options,
$hostAddrOptions,
$envOptions
);
// reload the environment if the host/address config has overridden
// the `url` option; this ensures that the base URL is correct
@ -1115,14 +1112,21 @@ class App
$this->api = null;
}
if (isset($options['home']) === true || isset($options['error']) === true) {
if (
isset($options['home']) === true ||
isset($options['error']) === true
) {
$this->site = null;
}
// checks custom language definition for slugs
if ($slugsOption = $this->option('slugs')) {
// slugs option must be set to string or "slugs" => ["language" => "de"] as array
if (is_string($slugsOption) === true || isset($slugsOption['language']) === true) {
// slugs option must be set to string or
// "slugs" => ["language" => "de"] as array
if (
is_string($slugsOption) === true ||
isset($slugsOption['language']) === true
) {
$this->i18n();
}
}
@ -1184,7 +1188,7 @@ class App
string|null $path = null,
string|null $method = null
): Response|null {
if (($_ENV['KIRBY_RENDER'] ?? true) === false) {
if ((filter_var($_ENV['KIRBY_RENDER'] ?? true, FILTER_VALIDATE_BOOLEAN)) === false) {
return null;
}
@ -1211,7 +1215,7 @@ class App
/**
* Path resolver for the router
*
* @internal
* @unstable
* @throws \Kirby\Exception\NotFoundException if the home page cannot be found
*/
public function resolve(
@ -1238,7 +1242,9 @@ class App
return $homePage;
}
throw new NotFoundException('The home page does not exist');
throw new NotFoundException(
message: 'The home page does not exist'
);
}
// search for the page by path
@ -1248,7 +1254,7 @@ class App
if (!$page && $draft = $site->draft($path)) {
if (
$this->user() ||
$draft->isVerified($this->request()->get('token'))
$draft->renderVersionFromRequest() !== null
) {
$page = $draft;
}
@ -1287,16 +1293,46 @@ class App
}
}
// try to resolve clean URLs to site files
if (str_contains($path, '/') === false) {
return $this->resolveFile($site->file($path));
}
$id = dirname($path);
$filename = basename($path);
// try to resolve image urls for pages and drafts
// try to resolve clean URLs to files for pages and drafts
if ($page = $site->findPageOrDraft($id)) {
return $page->file($filename);
return $this->resolveFile($page->file($filename));
}
// try to resolve site files at least
return $site->file($filename);
// none of our resolvers were successful
return null;
}
/**
* Filters a resolved file object using the configuration
* @internal
*/
public function resolveFile(File|null $file): File|null
{
// shortcut for files that don't exist
if ($file === null) {
return null;
}
$option = $this->option('content.fileRedirects', false);
if ($option === true) {
return $file;
}
if ($option instanceof Closure) {
return $option($file) === true ? $file : null;
}
// option was set to `false` or an invalid value
return null;
}
/**
@ -1307,14 +1343,6 @@ class App
return $this->response ??= new Responder();
}
/**
* Returns all user roles
*/
public function roles(): Roles
{
return $this->roles ??= Roles::load($this->root('roles'));
}
/**
* Returns a system root
*/
@ -1341,8 +1369,6 @@ class App
/**
* Returns the Router singleton
*
* @internal
*/
public function router(): Router
{
@ -1374,8 +1400,6 @@ class App
/**
* Returns all defined routes
*
* @internal
*/
public function routes(): array
{
@ -1385,7 +1409,7 @@ class App
$registry = $this->extensions('routes');
$system = $this->core->routes();
$routes = array_merge($system['before'], $registry, $system['after']);
$routes = [...$system['before'], ...$registry, ...$system['after']];
return $this->routes = $routes;
}
@ -1423,8 +1447,6 @@ class App
/**
* Load and set the current language if it exists
* Otherwise fall back to the default language
*
* @internal
*/
public function setCurrentLanguage(
string|null $languageCode = null
@ -1434,7 +1456,8 @@ class App
return $this->language = null;
}
$this->language = $this->language($languageCode) ?? $this->defaultLanguage();
$this->language = $this->language($languageCode);
$this->language ??= $this->defaultLanguage();
Locale::set($this->language->locale());
@ -1509,7 +1532,7 @@ class App
*
* @return $this
*/
protected function setSite(Site|array|null $site = null): static
public function setSite(Site|array|null $site = null): static
{
if (is_array($site) === true) {
$site = new Site($site);
@ -1533,8 +1556,6 @@ class App
/**
* Applies the smartypants rule on the text
*
* @internal
*/
public function smartypants(string|null $text = null): string
{
@ -1551,8 +1572,8 @@ class App
if ($this->multilang() === true) {
$languageSmartypants = $this->language()->smartypants() ?? [];
if (empty($languageSmartypants) === false) {
$options = array_merge($options, $languageSmartypants);
if ($languageSmartypants !== []) {
$options = [...$options, ...$languageSmartypants];
}
}
@ -1580,7 +1601,7 @@ class App
$snippet = ($this->component('snippet'))(
$this,
$name,
array_merge($this->data, $data),
[...$this->data, ...$data],
$slots
);
@ -1592,6 +1613,14 @@ class App
return null;
}
/**
* Returns the default storage instance for a given Model
*/
public function storage(ModelWithContent $model): Storage
{
return $this->component('storage')($this, $model);
}
/**
* System check class
*/
@ -1603,8 +1632,6 @@ class App
/**
* Uses the template component to initialize
* and return the Template object
*
* @internal
*/
public function template(
string $name,
@ -1626,47 +1653,13 @@ class App
* Trigger a hook by name
*
* @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)
* @param array $args Associative array of named arguments
*/
public function trigger(
string $name,
array $args = [],
Event|null $originalEvent = null
array $args = []
): void {
$event = $originalEvent ?? new Event($name, $args);
if ($functions = $this->extension('hooks', $name)) {
static $level = 0;
static $triggered = [];
$level++;
foreach ($functions as $index => $function) {
if (in_array($function, $triggered[$name] ?? []) === true) {
continue;
}
// mark the hook as triggered, to avoid endless loops
$triggered[$name][] = $function;
// bind the App object to the hook
$event->call($this, $function);
}
$level--;
if ($level === 0) {
$triggered = [];
}
}
// trigger wildcard hooks if available
$nameWildcards = $event->nameWildcards();
if ($originalEvent === null && count($nameWildcards) > 0) {
foreach ($nameWildcards as $nameWildcard) {
$this->trigger($nameWildcard, $args, $event);
}
}
$this->events->trigger($name, $args);
}
/**
@ -1715,7 +1708,9 @@ class App
try {
return static::$version ??= Data::read(dirname(__DIR__, 2) . '/composer.json')['version'] ?? null;
} catch (Throwable) {
throw new LogicException('The Kirby version cannot be detected. The composer.json is probably missing or not readable.');
throw new LogicException(
message: 'The Kirby version cannot be detected. The composer.json is probably missing or not readable.'
);
}
}

View file

@ -40,10 +40,10 @@ trait AppCaches
$types = $this->extensions['cacheTypes'] ?? [];
if (array_key_exists($type, $types) === false) {
throw new InvalidArgumentException([
'key' => 'cache.type.invalid',
'data' => ['type' => $type]
]);
throw new InvalidArgumentException(
key: 'cache.type.invalid',
data: ['type' => $type]
);
}
$className = $types[$type];
@ -53,10 +53,10 @@ trait AppCaches
// check if it is a usable cache object
if ($cache instanceof Cache === false) {
throw new InvalidArgumentException([
'key' => 'cache.type.invalid',
'data' => ['type' => $type]
]);
throw new InvalidArgumentException(
key: 'cache.type.invalid',
data: ['type' => $type]
);
}
return $this->caches[$key] = $cache;
@ -79,7 +79,7 @@ trait AppCaches
$prefix =
str_replace(['/', ':'], '_', $this->system()->indexUrl()) .
'/' .
str_replace('.', '/', $key);
str_replace(['/', '.'], ['_', '/'], $key);
$defaults = [
'active' => true,
@ -93,7 +93,7 @@ trait AppCaches
return $defaults;
}
return array_merge($defaults, $options);
return [...$defaults, ...$options];
}
/**
@ -111,7 +111,7 @@ trait AppCaches
// plain keys without dots don't need further investigation
// since they can never be from a plugin.
if (strpos($key, '.') === false) {
if (str_contains($key, '.') === false) {
return $prefixedKey;
}

View file

@ -30,8 +30,6 @@ trait AppErrors
* Allows to disable Whoops globally in CI;
* can be overridden by explicitly setting
* the `whoops` option to `true` or `false`
*
* @internal
*/
public static bool $enableWhoops = true;
@ -148,11 +146,14 @@ trait AppErrors
if ($this->option('debug') === true) {
echo Response::json([
'status' => 'error',
'exception' => get_class($exception),
'exception' => $exception::class,
'code' => $code,
'message' => $exception->getMessage(),
'details' => $details,
'file' => F::relativepath($exception->getFile(), $this->environment()->get('DOCUMENT_ROOT', '')),
'file' => F::relativepath(
$exception->getFile(),
$this->environment()->get('DOCUMENT_ROOT', '')
),
'line' => $exception->getLine(),
], $httpCode);
} else {

View file

@ -11,6 +11,8 @@ use Kirby\Filesystem\F;
use Kirby\Filesystem\Mime;
use Kirby\Form\Field as FormField;
use Kirby\Image\Image;
use Kirby\Plugin\License;
use Kirby\Plugin\Plugin;
use Kirby\Text\KirbyTag;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Collection as ToolkitCollection;
@ -57,6 +59,7 @@ trait AppPlugins
'collectionMethods' => [],
'fieldMethods' => [],
'fileMethods' => [],
'filePreviews' => [],
'fileTypes' => [],
'filesMethods' => [],
'fields' => [],
@ -94,8 +97,7 @@ trait AppPlugins
/**
* Register all given extensions
*
* @internal
* @param \Kirby\Cms\Plugin $plugin|null The plugin which defined those extensions
* @param \Kirby\Plugin\Plugin|null $plugin The plugin which defined those extensions
*/
public function extend(
array $extensions,
@ -120,7 +122,11 @@ trait AppPlugins
$api['routes'] = $api['routes']($this);
}
return $this->extensions['api'] = A::merge($this->extensions['api'], $api, A::MERGE_APPEND);
return $this->extensions['api'] = A::merge(
$this->extensions['api'],
$api,
A::MERGE_APPEND
);
}
return $this->extensions['api'];
@ -144,7 +150,10 @@ trait AppPlugins
*/
protected function extendAssetMethods(array $methods): array
{
return $this->extensions['assetMethods'] = Asset::$methods = array_merge(Asset::$methods, $methods);
return $this->extensions['assetMethods'] = Asset::$methods = [
...Asset::$methods,
...$methods
];
}
/**
@ -152,7 +161,10 @@ trait AppPlugins
*/
protected function extendAuthChallenges(array $challenges): array
{
return $this->extensions['authChallenges'] = Auth::$challenges = array_merge(Auth::$challenges, $challenges);
return $this->extensions['authChallenges'] = Auth::$challenges = [
...Auth::$challenges,
...$challenges
];
}
/**
@ -160,7 +172,10 @@ trait AppPlugins
*/
protected function extendBlockMethods(array $methods): array
{
return $this->extensions['blockMethods'] = Block::$methods = array_merge(Block::$methods, $methods);
return $this->extensions['blockMethods'] = Block::$methods = [
...Block::$methods,
...$methods
];
}
/**
@ -168,7 +183,7 @@ trait AppPlugins
*/
protected function extendBlockModels(array $models): array
{
return $this->extensions['blockModels'] = Block::$models = array_merge(Block::$models, $models);
return $this->extensions['blockModels'] = Block::extendModels($models);
}
/**
@ -176,7 +191,10 @@ trait AppPlugins
*/
protected function extendBlocksMethods(array $methods): array
{
return $this->extensions['blockMethods'] = Blocks::$methods = array_merge(Blocks::$methods, $methods);
return $this->extensions['blockMethods'] = Blocks::$methods = [
...Blocks::$methods,
...$methods
];
}
/**
@ -184,7 +202,10 @@ trait AppPlugins
*/
protected function extendBlueprints(array $blueprints): array
{
return $this->extensions['blueprints'] = array_merge($this->extensions['blueprints'], $blueprints);
return $this->extensions['blueprints'] = [
...$this->extensions['blueprints'],
...$blueprints
];
}
/**
@ -192,7 +213,10 @@ trait AppPlugins
*/
protected function extendCacheTypes(array $cacheTypes): array
{
return $this->extensions['cacheTypes'] = array_merge($this->extensions['cacheTypes'], $cacheTypes);
return $this->extensions['cacheTypes'] = [
...$this->extensions['cacheTypes'],
...$cacheTypes
];
}
/**
@ -200,7 +224,10 @@ trait AppPlugins
*/
protected function extendCommands(array $commands): array
{
return $this->extensions['commands'] = array_merge($this->extensions['commands'], $commands);
return $this->extensions['commands'] = [
...$this->extensions['commands'],
...$commands
];
}
/**
@ -208,7 +235,10 @@ trait AppPlugins
*/
protected function extendCollectionFilters(array $filters): array
{
return $this->extensions['collectionFilters'] = ToolkitCollection::$filters = array_merge(ToolkitCollection::$filters, $filters);
return $this->extensions['collectionFilters'] = ToolkitCollection::$filters = [
...ToolkitCollection::$filters,
...$filters
];
}
/**
@ -216,7 +246,10 @@ trait AppPlugins
*/
protected function extendCollectionMethods(array $methods): array
{
return $this->extensions['collectionMethods'] = Collection::$methods = array_merge(Collection::$methods, $methods);
return $this->extensions['collectionMethods'] = Collection::$methods = [
...Collection::$methods,
...$methods
];
}
/**
@ -224,7 +257,10 @@ trait AppPlugins
*/
protected function extendCollections(array $collections): array
{
return $this->extensions['collections'] = array_merge($this->extensions['collections'], $collections);
return $this->extensions['collections'] = [
...$this->extensions['collections'],
...$collections
];
}
/**
@ -232,7 +268,10 @@ trait AppPlugins
*/
protected function extendComponents(array $components): array
{
return $this->extensions['components'] = array_merge($this->extensions['components'], $components);
return $this->extensions['components'] = [
...$this->extensions['components'],
...$components
];
}
/**
@ -240,7 +279,10 @@ trait AppPlugins
*/
protected function extendControllers(array $controllers): array
{
return $this->extensions['controllers'] = array_merge($this->extensions['controllers'], $controllers);
return $this->extensions['controllers'] = [
...$this->extensions['controllers'],
...$controllers
];
}
/**
@ -248,7 +290,24 @@ trait AppPlugins
*/
protected function extendFileMethods(array $methods): array
{
return $this->extensions['fileMethods'] = File::$methods = array_merge(File::$methods, $methods);
return $this->extensions['fileMethods'] = File::$methods = [
...File::$methods,
...$methods
];
}
/**
* Registers additional file preview handlers
* @since 5.0.0
*/
protected function extendFilePreviews(array $previews): array
{
return $this->extensions['filePreviews'] = [
...$previews,
// make sure new previews go first, so that custom
// handler can override core default previews
...$this->extensions['filePreviews'],
];
}
/**
@ -269,26 +328,36 @@ trait AppPlugins
F::$types[$type] = [];
}
if (in_array($extension, F::$types[$type]) === false) {
if (in_array($extension, F::$types[$type], true) === false) {
F::$types[$type][] = $extension;
}
}
if ($mime !== null) {
// if `Mime::$types[$extension]` is not already an array,
// make it one and append the new MIME type
// unless it's already in the list
if (array_key_exists($extension, Mime::$types) === true) {
// if `Mime::$types[$extension]` is not already an array, make it one
// and append the new MIME type unless it's already in the list
Mime::$types[$extension] = array_unique(array_merge((array)Mime::$types[$extension], (array)$mime));
Mime::$types[$extension] = array_unique([
...(array)Mime::$types[$extension],
...(array)$mime
]);
} else {
Mime::$types[$extension] = $mime;
}
}
if ($resizable === true && in_array($extension, Image::$resizableTypes) === false) {
if (
$resizable === true &&
in_array($extension, Image::$resizableTypes, true) === false
) {
Image::$resizableTypes[] = $extension;
}
if ($viewable === true && in_array($extension, Image::$viewableTypes) === false) {
if (
$viewable === true &&
in_array($extension, Image::$viewableTypes, true) === false
) {
Image::$viewableTypes[] = $extension;
}
}
@ -306,7 +375,10 @@ trait AppPlugins
*/
protected function extendFilesMethods(array $methods): array
{
return $this->extensions['filesMethods'] = Files::$methods = array_merge(Files::$methods, $methods);
return $this->extensions['filesMethods'] = Files::$methods = [
...Files::$methods,
...$methods
];
}
/**
@ -314,7 +386,10 @@ trait AppPlugins
*/
protected function extendFieldMethods(array $methods): array
{
return $this->extensions['fieldMethods'] = Field::$methods = array_merge(Field::$methods, array_change_key_case($methods));
return $this->extensions['fieldMethods'] = Field::$methods = [
...Field::$methods,
...array_change_key_case($methods)
];
}
/**
@ -322,7 +397,10 @@ trait AppPlugins
*/
protected function extendFields(array $fields): array
{
return $this->extensions['fields'] = FormField::$types = array_merge(FormField::$types, $fields);
return $this->extensions['fields'] = FormField::$types = [
...FormField::$types,
...$fields
];
}
/**
@ -358,7 +436,10 @@ trait AppPlugins
*/
protected function extendLayoutMethods(array $methods): array
{
return $this->extensions['layoutMethods'] = Layout::$methods = array_merge(Layout::$methods, $methods);
return $this->extensions['layoutMethods'] = Layout::$methods = [
...Layout::$methods,
...$methods
];
}
/**
@ -366,7 +447,10 @@ trait AppPlugins
*/
protected function extendLayoutColumnMethods(array $methods): array
{
return $this->extensions['layoutColumnMethods'] = LayoutColumn::$methods = array_merge(LayoutColumn::$methods, $methods);
return $this->extensions['layoutColumnMethods'] = LayoutColumn::$methods = [
...LayoutColumn::$methods,
...$methods
];
}
/**
@ -374,7 +458,10 @@ trait AppPlugins
*/
protected function extendLayoutsMethods(array $methods): array
{
return $this->extensions['layoutsMethods'] = Layouts::$methods = array_merge(Layouts::$methods, $methods);
return $this->extensions['layoutsMethods'] = Layouts::$methods = [
...Layouts::$methods,
...$methods
];
}
/**
@ -388,7 +475,11 @@ trait AppPlugins
$options = [$plugin->prefix() => $options];
}
return $this->extensions['options'] = $this->options = A::merge($options, $this->options, A::MERGE_REPLACE);
return $this->extensions['options'] = $this->options = A::merge(
$options,
$this->options,
A::MERGE_REPLACE
);
}
/**
@ -396,7 +487,10 @@ trait AppPlugins
*/
protected function extendPageMethods(array $methods): array
{
return $this->extensions['pageMethods'] = Page::$methods = array_merge(Page::$methods, $methods);
return $this->extensions['pageMethods'] = Page::$methods = [
...Page::$methods,
...$methods
];
}
/**
@ -404,7 +498,10 @@ trait AppPlugins
*/
protected function extendPagesMethods(array $methods): array
{
return $this->extensions['pagesMethods'] = Pages::$methods = array_merge(Pages::$methods, $methods);
return $this->extensions['pagesMethods'] = Pages::$methods = [
...Pages::$methods,
...$methods
];
}
/**
@ -412,7 +509,7 @@ trait AppPlugins
*/
protected function extendPageModels(array $models): array
{
return $this->extensions['pageModels'] = Page::$models = array_merge(Page::$models, $models);
return $this->extensions['pageModels'] = Page::extendModels($models);
}
/**
@ -420,7 +517,10 @@ trait AppPlugins
*/
protected function extendPages(array $pages): array
{
return $this->extensions['pages'] = array_merge($this->extensions['pages'], $pages);
return $this->extensions['pages'] = [
...$this->extensions['pages'],
...$pages
];
}
/**
@ -434,7 +534,10 @@ trait AppPlugins
$permissions = [$plugin->prefix() => $permissions];
}
return $this->extensions['permissions'] = Permissions::$extendedActions = array_merge(Permissions::$extendedActions, $permissions);
return $this->extensions['permissions'] = Permissions::$extendedActions = [
...Permissions::$extendedActions,
...$permissions
];
}
/**
@ -446,7 +549,10 @@ trait AppPlugins
$routes = $routes($this);
}
return $this->extensions['routes'] = array_merge($this->extensions['routes'], $routes);
return $this->extensions['routes'] = [
...$this->extensions['routes'],
...$routes
];
}
/**
@ -454,7 +560,10 @@ trait AppPlugins
*/
protected function extendSections(array $sections): array
{
return $this->extensions['sections'] = Section::$types = array_merge(Section::$types, $sections);
return $this->extensions['sections'] = Section::$types = [
...Section::$types,
...$sections
];
}
/**
@ -462,7 +571,10 @@ trait AppPlugins
*/
protected function extendSiteMethods(array $methods): array
{
return $this->extensions['siteMethods'] = Site::$methods = array_merge(Site::$methods, $methods);
return $this->extensions['siteMethods'] = Site::$methods = [
...Site::$methods,
...$methods
];
}
/**
@ -478,7 +590,10 @@ trait AppPlugins
*/
protected function extendSnippets(array $snippets): array
{
return $this->extensions['snippets'] = array_merge($this->extensions['snippets'], $snippets);
return $this->extensions['snippets'] = [
...$this->extensions['snippets'],
...$snippets
];
}
/**
@ -486,7 +601,10 @@ trait AppPlugins
*/
protected function extendStructureMethods(array $methods): array
{
return $this->extensions['structureMethods'] = Structure::$methods = array_merge(Structure::$methods, $methods);
return $this->extensions['structureMethods'] = Structure::$methods = [
...Structure::$methods,
...$methods
];
}
/**
@ -494,7 +612,10 @@ trait AppPlugins
*/
protected function extendStructureObjectMethods(array $methods): array
{
return $this->extensions['structureObjectMethods'] = StructureObject::$methods = array_merge(StructureObject::$methods, $methods);
return $this->extensions['structureObjectMethods'] = StructureObject::$methods = [
...StructureObject::$methods,
...$methods
];
}
/**
@ -502,7 +623,10 @@ trait AppPlugins
*/
protected function extendTags(array $tags): array
{
return $this->extensions['tags'] = KirbyTag::$types = array_merge(KirbyTag::$types, array_change_key_case($tags));
return $this->extensions['tags'] = KirbyTag::$types = [
...KirbyTag::$types,
...array_change_key_case($tags)
];
}
/**
@ -510,7 +634,10 @@ trait AppPlugins
*/
protected function extendTemplates(array $templates): array
{
return $this->extensions['templates'] = array_merge($this->extensions['templates'], $templates);
return $this->extensions['templates'] = [
...$this->extensions['templates'],
...$templates
];
}
/**
@ -518,7 +645,10 @@ trait AppPlugins
*/
protected function extendTranslations(array $translations): array
{
return $this->extensions['translations'] = array_replace_recursive($this->extensions['translations'], $translations);
return $this->extensions['translations'] = array_replace_recursive(
$this->extensions['translations'],
$translations
);
}
/**
@ -528,7 +658,10 @@ trait AppPlugins
*/
protected function extendThirdParty(array $extensions): array
{
return $this->extensions['thirdParty'] = array_replace_recursive($this->extensions['thirdParty'], $extensions);
return $this->extensions['thirdParty'] = array_replace_recursive(
$this->extensions['thirdParty'],
$extensions
);
}
/**
@ -536,7 +669,10 @@ trait AppPlugins
*/
protected function extendUserMethods(array $methods): array
{
return $this->extensions['userMethods'] = User::$methods = array_merge(User::$methods, $methods);
return $this->extensions['userMethods'] = User::$methods = [
...User::$methods,
...$methods
];
}
/**
@ -544,7 +680,7 @@ trait AppPlugins
*/
protected function extendUserModels(array $models): array
{
return $this->extensions['userModels'] = User::$models = array_merge(User::$models, $models);
return $this->extensions['userModels'] = User::extendModels($models);
}
/**
@ -552,7 +688,10 @@ trait AppPlugins
*/
protected function extendUsersMethods(array $methods): array
{
return $this->extensions['usersMethods'] = Users::$methods = array_merge(Users::$methods, $methods);
return $this->extensions['usersMethods'] = Users::$methods = [
...Users::$methods,
...$methods
];
}
/**
@ -560,13 +699,15 @@ trait AppPlugins
*/
protected function extendValidators(array $validators): array
{
return $this->extensions['validators'] = V::$validators = array_merge(V::$validators, $validators);
return $this->extensions['validators'] = V::$validators = [
...V::$validators,
...$validators
];
}
/**
* Returns a given extension by type and name
*
* @internal
* @param string $type i.e. `'hooks'`
* @param string $name i.e. `'page.delete:before'`
*/
@ -580,8 +721,6 @@ trait AppPlugins
/**
* Returns the extensions registry
*
* @internal
*/
public function extensions(string|null $type = null): array
{
@ -681,6 +820,7 @@ trait AppPlugins
$this->extendBlueprints($this->core->blueprints());
$this->extendFieldMethods($this->core->fieldMethods());
$this->extendFields($this->core->fields());
$this->extendFilePreviews($this->core->filePreviews());
$this->extendSections($this->core->sections());
$this->extendSnippets($this->core->snippets());
$this->extendTags($this->core->kirbyTags());
@ -716,7 +856,8 @@ trait AppPlugins
array|null $extends = null,
array $info = [],
string|null $root = null,
string|null $version = null
string|null $version = null,
Closure|string|array|null $license = null,
): Plugin|null {
if ($extends === null) {
return static::$plugins[$name] ?? null;
@ -726,6 +867,7 @@ trait AppPlugins
name: $name,
extends: $extends,
info: $info,
license: $license,
// TODO: Remove fallback to $extends in v7
root: $root ?? $extends['root'] ?? dirname(debug_backtrace()[0]['file']),
version: $version
@ -734,7 +876,9 @@ trait AppPlugins
$name = $plugin->name();
if (isset(static::$plugins[$name]) === true) {
throw new DuplicateException('The plugin "' . $name . '" has already been registered');
throw new DuplicateException(
message: 'The plugin "' . $name . '" has already been registered'
);
}
return static::$plugins[$name] = $plugin;
@ -744,7 +888,6 @@ trait AppPlugins
* Loads and returns all plugins in the site/plugins directory
* Loading only happens on the first call.
*
* @internal
* @param array|null $plugins Can be used to overwrite the plugins registry
*/
public function plugins(array|null $plugins = null): array
@ -779,7 +922,10 @@ trait AppPlugins
$loaded = [];
foreach (Dir::read($root) as $dirname) {
if (in_array(substr($dirname, 0, 1), ['.', '_']) === true) {
if (
str_starts_with($dirname, '.') ||
str_starts_with($dirname, '_')
) {
continue;
}

View file

@ -31,7 +31,7 @@ trait AppTranslations
$this->multilang() === true &&
$language = $this->languages()->find($locale)
) {
$data = array_merge($data, $language->translations());
$data = [...$data, ...$language->translations()];
}
@ -51,7 +51,7 @@ trait AppTranslations
if ($this->multilang() === true) {
// first try to fall back to the configured default language
$defaultCode = $this->defaultLanguage()->code();
$fallback = [$defaultCode];
$fallback = [$defaultCode];
// if the default language is specified with a country code
// (e.g. `en-us`), also try with just the language code
@ -105,8 +105,6 @@ trait AppTranslations
/**
* Set the current translation
*
* @internal
*/
public function setCurrentTranslation(string|null $translationCode = null): void
{
@ -121,7 +119,7 @@ trait AppTranslations
public function translation(string|null $locale = null): Translation
{
$locale ??= I18n::locale();
$locale = basename($locale);
$locale = basename($locale);
// prefer loading them from the translations collection
if ($this->translations instanceof Translations) {
@ -135,11 +133,15 @@ trait AppTranslations
// inject current language translations
if ($language = $this->language($locale)) {
$inject = array_merge($inject, $language->translations());
$inject = [...$inject, ...$language->translations()];
}
// load from disk instead
return Translation::load($locale, $this->root('i18n:translations') . '/' . $locale . '.json', $inject);
return Translation::load(
$locale,
$this->root('i18n:translations') . '/' . $locale . '.json',
$inject
);
}
/**
@ -161,14 +163,17 @@ trait AppTranslations
// merges language translations with extensions translations
if (empty($languageTranslations) === false) {
$translations[$languageCode] = array_merge(
$translations[$languageCode] ?? [],
$languageTranslations
);
$translations[$languageCode] = [
...$translations[$languageCode] ?? [],
...$languageTranslations
];
}
}
}
return $this->translations = Translations::load($this->root('i18n:translations'), $translations);
return $this->translations = Translations::load(
$this->root('i18n:translations'),
$translations
);
}
}

View file

@ -22,7 +22,6 @@ trait AppUsers
/**
* Returns the Authentication layer class
* @internal
*/
public function auth(): Auth
{
@ -67,6 +66,33 @@ trait AppUsers
}
}
/**
* Returns all user roles
*/
public function roles(): Roles
{
return $this->roles ??= Roles::load($this->root('roles'));
}
/**
* Returns a specific user role by id
* or the role of the current user if no id is given
*
* @param bool $allowImpersonation If set to false, only the role of the
* actually logged in user will be returned
* (when `$id` is passed as `null`)
*/
public function role(
string|null $id = null,
bool $allowImpersonation = true
): Role|null {
if ($id !== null) {
return $this->roles()->find($id);
}
return $this->user(null, $allowImpersonation)?->role();
}
/**
* Set the currently active user id
*

View file

@ -104,12 +104,10 @@ class Auth
if ($user === null) {
$this->kirby->trigger('user.login:failed', compact('email'));
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
throw new NotFoundException(
key: 'user.notFound',
data: ['name' => $email]
);
}
// try to find an enabled challenge that is available for that user
@ -140,16 +138,19 @@ class Auth
// if no suitable challenge was found, `$challenge === null` at this point
if ($challenge === null) {
throw new LogicException('Could not find a suitable authentication challenge');
throw new LogicException(
'Could not find a suitable authentication challenge'
);
}
} catch (Throwable $e) {
// only throw the exception in auth debug mode
$this->fail($e);
}
// always set the email and timeout, even if the challenge
// always set the email, mode and timeout, even if the challenge
// won't be created; this avoids leaking whether the user exists
$session->set('kirby.challenge.email', $email);
$session->set('kirby.challenge.mode', $mode);
$session->set('kirby.challenge.timeout', time() + $timeout);
// sleep for a random amount of milliseconds
@ -198,39 +199,52 @@ class Auth
* for a basic authentication header with
* valid credentials
*
* @param \Kirby\Http\Request\Auth\BasicAuth|null $auth
* @throws \Kirby\Exception\InvalidArgumentException if the authorization header is invalid
* @throws \Kirby\Exception\PermissionException if basic authentication is not allowed
*/
public function currentUserFromBasicAuth(BasicAuth|null $auth = null): User|null
{
if ($this->kirby->option('api.basicAuth', false) !== true) {
throw new PermissionException('Basic authentication is not activated');
throw new PermissionException(
'Basic authentication is not activated'
);
}
// if logging in with password is disabled, basic auth cannot be possible either
$loginMethods = $this->kirby->system()->loginMethods();
if (isset($loginMethods['password']) !== true) {
throw new PermissionException('Login with password is not enabled');
throw new PermissionException(
'Login with password is not enabled'
);
}
// if any login method requires 2FA, basic auth without 2FA would be a weakness
foreach ($loginMethods as $method) {
if (isset($method['2fa']) === true && $method['2fa'] === true) {
throw new PermissionException('Basic authentication cannot be used with 2FA');
throw new PermissionException(
'Basic authentication cannot be used with 2FA'
);
}
}
$request = $this->kirby->request();
$auth ??= $request->auth();
$request = $this->kirby->request();
$auth ??= $request->auth();
if (!$auth || $auth->type() !== 'basic') {
throw new InvalidArgumentException('Invalid authorization header');
throw new InvalidArgumentException(
'Invalid authorization header'
);
}
// only allow basic auth when https is enabled or insecure requests permitted
if ($request->ssl() === false && $this->kirby->option('api.allowInsecure', false) !== true) {
throw new PermissionException('Basic authentication is only allowed over HTTPS');
// only allow basic auth when https is enabled or
// insecure requests permitted
if (
$request->ssl() === false &&
$this->kirby->option('api.allowInsecure', false) !== true
) {
throw new PermissionException(
'Basic authentication is only allowed over HTTPS'
);
}
return $this->validatePassword($auth->username(), $auth->password());
@ -269,6 +283,7 @@ class Auth
if ($passwordTimestamp = $user->passwordTimestamp()) {
$loginTimestamp = $session->data()->get('kirby.loginTimestamp');
if (is_int($loginTimestamp) !== true) {
// session that was created before Kirby
// 3.5.8.3, 3.6.6.3, 3.7.5.2, 3.8.4.1 or 3.9.6
@ -329,7 +344,7 @@ class Auth
'id' => 'nobody',
'role' => 'nobody',
]),
default => ($this->kirby->users()->find($who) ?? throw new NotFoundException('The user "' . $who . '" cannot be found'))
default => $this->kirby->users()->find($who) ?? throw new NotFoundException(message: 'The user "' . $who . '" cannot be found'),
};
}
@ -444,7 +459,11 @@ class Auth
bool $allowImpersonation = true
): Status {
// try to return from cache
if ($this->status && $session === null && $allowImpersonation === true) {
if (
$this->status &&
$session === null &&
$allowImpersonation === true
) {
return $this->status;
}
@ -453,17 +472,18 @@ class Auth
$props = ['kirby' => $this->kirby];
if ($user = $this->user($sessionObj, $allowImpersonation)) {
// a user is currently logged in
if ($allowImpersonation === true && $this->impersonate !== null) {
$props['status'] = 'impersonated';
} else {
$props['status'] = 'active';
}
$props['email'] = $user->email();
$props['status'] = match (true) {
$allowImpersonation === true &&
$this->impersonate !== null => 'impersonated',
default => 'active'
};
$props['email'] = $user->email();
} elseif ($email = $sessionObj->get('kirby.challenge.email')) {
// a challenge is currently pending
$props['status'] = 'pending';
$props['email'] = $email;
$props['mode'] = $sessionObj->get('kirby.challenge.mode');
$props['challenge'] = $sessionObj->get('kirby.challenge.type');
$props['challengeFallback'] = A::last($this->enabledChallenges());
} else {
@ -492,10 +512,10 @@ class Auth
if ($this->isBlocked($email) === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
throw new PermissionException([
'details' => ['reason' => 'rate-limited'],
'fallback' => 'Rate limit exceeded'
]);
throw new PermissionException(
details: ['reason' => 'rate-limited'],
fallback: 'Rate limit exceeded'
);
}
}
@ -524,12 +544,10 @@ class Auth
}
}
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
throw new NotFoundException(
key: 'user.notFound',
data: ['name' => $email]
);
} catch (Throwable $e) {
$details = $e instanceof Exception ? $e->getDetails() : [];
@ -537,7 +555,7 @@ class Auth
if (($details['reason'] ?? null) !== 'rate-limited') {
try {
$this->track($email);
} catch (Throwable $e) {
} catch (Throwable) {
// $e is overwritten with the exception
// from the track method if there's one
}
@ -549,7 +567,7 @@ class Auth
// keep throwing the original error in debug mode,
// otherwise hide it to avoid leaking security-relevant information
$this->fail($e, new PermissionException(['key' => 'access.login']));
$this->fail($e, new PermissionException(key: 'access.login'));
}
}
@ -583,7 +601,8 @@ class Auth
// remove entries that are no longer needed
$originalLog = $log;
$time = time() - $this->kirby->option('auth.timeout', 3600);
$time = time() - $this->kirby->option('auth.timeout', 3600);
foreach ($log as $category => $entries) {
$log[$category] = array_filter(
$entries,
@ -619,6 +638,7 @@ class Auth
$session = $this->kirby->session();
$session->remove('kirby.challenge.code');
$session->remove('kirby.challenge.email');
$session->remove('kirby.challenge.mode');
$session->remove('kirby.challenge.timeout');
$session->remove('kirby.challenge.type');
@ -628,13 +648,12 @@ class Auth
/**
* Clears the cached user data after logout
* @internal
*/
public function flush(): void
{
$this->impersonate = null;
$this->status = null;
$this->user = null;
$this->status = null;
$this->user = null;
}
/**
@ -782,18 +801,20 @@ class Auth
try {
$session = $this->kirby->session();
// time-limiting; check this early so that we can destroy the session no
// matter if the user exists (avoids leaking user information to attackers)
// time-limiting; check this early so that we can
// destroy the session no matter if the user exists
// (avoids leaking user information to attackers)
$timeout = $session->get('kirby.challenge.timeout');
if ($timeout !== null && time() > $timeout) {
// this challenge can never be completed,
// so delete it immediately
$this->logout();
throw new PermissionException([
'details' => ['challengeDestroyed' => true],
'fallback' => 'Authentication challenge timeout'
]);
throw new PermissionException(
details: ['challengeDestroyed' => true],
fallback: 'Authentication challenge timeout'
);
}
// check if we have an active challenge
@ -807,20 +828,18 @@ class Auth
// (otherwise "faked" challenges would be leaked)
$challengeDestroyed = is_string($email) !== true;
throw new InvalidArgumentException([
'details' => compact('challengeDestroyed'),
'fallback' => 'No authentication challenge is active'
]);
throw new InvalidArgumentException(
details: compact('challengeDestroyed'),
fallback: 'No authentication challenge is active'
);
}
$user = $this->kirby->users()->find($email);
if ($user === null) {
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
throw new NotFoundException(
key: 'user.notFound',
data: ['name' => $email]
);
}
// rate-limiting
@ -833,16 +852,23 @@ class Auth
) {
$class = static::$challenges[$challenge];
if ($class::verify($user, $code) === true) {
$mode = $session->get('kirby.challenge.mode');
$this->logout();
$user->loginPasswordless();
// allow the user to set a new password without knowing the previous one
if ($mode === 'password-reset') {
$session->set('kirby.resetPassword', true);
}
// clear the status cache
$this->status = null;
return $user;
}
throw new PermissionException(['key' => 'access.code']);
throw new PermissionException(key: 'access.code');
}
throw new LogicException(
@ -867,10 +893,10 @@ class Auth
// even in production (used by the Panel to reset to the login form)
$challengeDestroyed = $details['challengeDestroyed'] ?? false;
$fallback = new PermissionException([
'details' => compact('challengeDestroyed'),
'key' => 'access.code'
]);
$fallback = new PermissionException(
details: compact('challengeDestroyed'),
key: 'access.code'
);
// keep throwing the original error in debug mode,
// otherwise hide it to avoid leaking security-relevant information

View file

@ -48,20 +48,30 @@ class EmailChallenge extends Challenge
$formatted = substr($code, 0, 3) . ' ' . substr($code, 3, 3);
// use the login templates for 2FA
$mode = $options['mode'];
if ($mode === '2fa') {
$mode = 'login';
}
$mode = match($options['mode']) {
'2fa' => 'login',
default => $options['mode']
};
$kirby = $user->kirby();
$from = $kirby->option(
'auth.challenge.email.from',
'noreply@' . $kirby->url('index', true)->host()
);
$name = $kirby->option(
'auth.challenge.email.fromName',
$kirby->site()->title()
);
$subject = $kirby->option(
'auth.challenge.email.subject',
I18n::translate('login.email.' . $mode . '.subject', null, $user->language())
);
$kirby->email([
'from' => $kirby->option('auth.challenge.email.from', 'noreply@' . $kirby->url('index', true)->host()),
'fromName' => $kirby->option('auth.challenge.email.fromName', $kirby->site()->title()),
'to' => $user,
'subject' => $kirby->option(
'auth.challenge.email.subject',
I18n::translate('login.email.' . $mode . '.subject', null, $user->language())
),
'from' => $from,
'fromName' => $name,
'to' => $user,
'subject' => $subject,
'template' => 'auth/' . $mode,
'data' => [
'user' => $user,

View file

@ -5,7 +5,7 @@ namespace Kirby\Cms\Auth;
use Kirby\Cms\App;
use Kirby\Cms\User;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Properties;
use Stringable;
/**
* Information container for the
@ -18,7 +18,7 @@ use Kirby\Toolkit\Properties;
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Status
class Status implements Stringable
{
/**
* Type of the active challenge
@ -41,6 +41,12 @@ class Status
*/
protected App $kirby;
/**
* Purpose of the challenge:
* `login|password-reset|2fa`
*/
protected string|null $mode;
/**
* Authentication status:
* `active|impersonated|pending|inactive`
@ -49,25 +55,24 @@ class Status
/**
* Class constructor
*
* @param array $props
*/
public function __construct(array $props)
{
if (in_array($props['status'], ['active', 'impersonated', 'pending', 'inactive']) !== true) {
throw new InvalidArgumentException([
'data' => [
if (in_array($props['status'], ['active', 'impersonated', 'pending', 'inactive'], true) !== true) {
throw new InvalidArgumentException(
data: [
'argument' => '$props[\'status\']',
'method' => 'Status::__construct'
]
]);
);
}
$this->kirby = $props['kirby'];
$this->challenge = $props['challenge'] ?? null;
$this->kirby = $props['kirby'];
$this->challenge = $props['challenge'] ?? null;
$this->challengeFallback = $props['challengeFallback'] ?? null;
$this->email = $props['email'] ?? null;
$this->status = $props['status'];
$this->email = $props['email'] ?? null;
$this->mode = $props['mode'] ?? null;
$this->status = $props['status'];
}
/**
@ -106,11 +111,11 @@ class Status
public function clone(array $props = []): static
{
return new static(array_replace_recursive([
'kirby' => $this->kirby,
'challenge' => $this->challenge,
'kirby' => $this->kirby,
'challenge' => $this->challenge,
'challengeFallback' => $this->challengeFallback,
'email' => $this->email,
'status' => $this->status,
'email' => $this->email,
'status' => $this->status,
], $props));
}
@ -122,6 +127,16 @@ class Status
return $this->email;
}
/**
* Returns the purpose of the challenge
*
* @return string `login|password-reset|2fa`
*/
public function mode(): string|null
{
return $this->mode;
}
/**
* Returns the authentication status
*
@ -140,6 +155,7 @@ class Status
return [
'challenge' => $this->challenge(),
'email' => $this->email(),
'mode' => $this->mode(),
'status' => $this->status()
];
}
@ -151,7 +167,7 @@ class Status
{
// for security, only return the user if they are
// already logged in
if (in_array($this->status(), ['active', 'impersonated']) !== true) {
if (in_array($this->status(), ['active', 'impersonated'], true) !== true) {
return null;
}

View file

@ -6,6 +6,7 @@ use Kirby\Content\Content;
use Kirby\Content\Field;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Str;
use Stringable;
use Throwable;
/**
@ -19,18 +20,16 @@ use Throwable;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Item<\Kirby\Cms\Blocks>
*/
class Block extends Item
class Block extends Item implements Stringable
{
use HasMethods;
use HasModels;
public const ITEMS_CLASS = Blocks::class;
/**
* Registry with all block models
*/
public static array $models = [];
protected Content $content;
protected bool $isHidden;
protected string $type;
@ -65,7 +64,9 @@ class Block extends Item
// @codeCoverageIgnoreEnd
if (isset($params['type']) === false) {
throw new InvalidArgumentException('The block type is missing');
throw new InvalidArgumentException(
message: 'The block type is missing'
);
}
// make sure the content is always defined as array to keep
@ -125,35 +126,12 @@ class Block extends Item
/**
* Constructs a block object with registering blocks models
* @internal
*
* @throws \Kirby\Exception\InvalidArgumentException
*/
public static function factory(array $params): static
{
$type = $params['type'] ?? null;
if (
empty($type) === false &&
$class = (static::$models[$type] ?? null)
) {
$object = new $class($params);
if ($object instanceof self) {
return $object;
}
}
// default model for blocks
if ($class = (static::$models['default'] ?? null)) {
$object = new $class($params);
if ($object instanceof self) {
return $object;
}
}
return new static($params);
return static::model($params['type'] ?? 'default', $params);
}
/**

View file

@ -51,7 +51,7 @@ class BlockConverter
public static function editorBlocks(array $blocks = []): array
{
if (empty($blocks) === true) {
if ($blocks === []) {
return $blocks;
}
@ -63,7 +63,7 @@ class BlockConverter
$listStart = null;
foreach ($blocks as $index => $block) {
if (in_array($block['type'], ['ul', 'ol']) === true) {
if (in_array($block['type'], ['ul', 'ol'], true) === true) {
$prev = $blocks[$index - 1] ?? null;
$next = $blocks[$index + 1] ?? null;
@ -132,12 +132,10 @@ class BlockConverter
public static function editorCustom(array $params): array
{
return [
'content' => array_merge(
$params['attrs'] ?? [],
[
'body' => $params['content'] ?? null
]
),
'content' => [
...$params['attrs'] ?? [],
'body' => $params['content'] ?? null
],
'type' => $params['type'] ?? 'unknown'
];
}

View file

@ -20,6 +20,8 @@ use Throwable;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Items<\Kirby\Cms\Block>
*/
class Blocks extends Items
{
@ -73,7 +75,7 @@ class Blocks extends Items
*/
protected static function extractFromLayouts(array $input): array
{
if (empty($input) === true) {
if ($input === []) {
return [];
}
@ -114,7 +116,10 @@ class Blocks extends Items
*/
public static function parse(array|string|null $input): array
{
if (empty($input) === false && is_array($input) === false) {
if (
empty($input) === false &&
is_array($input) === false
) {
try {
$input = Json::decode((string)$input);
} catch (Throwable) {
@ -127,17 +132,17 @@ class Blocks extends Items
// check for valid yaml
if (
empty($yaml) === true ||
$yaml === [] ||
(
isset($first['_key']) === false &&
isset($first['type']) === false
)
) {
throw new Exception('Invalid YAML');
} else {
$input = $yaml;
throw new Exception(message: 'Invalid YAML');
}
} catch (Throwable $e) {
$input = $yaml;
} catch (Throwable) {
// the next 2 lines remain after removing block.converter
// @codeCoverageIgnoreEnd
$parser = new Parsley((string)$input, new BlockSchema());

View file

@ -25,25 +25,17 @@ use Throwable;
*/
class Blueprint
{
public static $presets = [];
public static $loaded = [];
public static array $presets = [];
public static array $loaded = [];
protected $fields = [];
protected $model;
protected $props;
protected $sections = [];
protected $tabs = [];
protected array $fields = [];
protected ModelWithContent $model;
protected array $props;
protected array $sections = [];
protected array $tabs = [];
protected array|null $fileTemplates = null;
/**
* Magic getter/caller for any blueprint prop
*/
public function __call(string $key, array|null $arguments = null): mixed
{
return $this->props[$key] ?? null;
}
/**
* Creates a new blueprint object with the given props
*
@ -52,11 +44,15 @@ class Blueprint
public function __construct(array $props)
{
if (empty($props['model']) === true) {
throw new InvalidArgumentException('A blueprint model is required');
throw new InvalidArgumentException(
message: 'A blueprint model is required'
);
}
if ($props['model'] instanceof ModelWithContent === false) {
throw new InvalidArgumentException('Invalid blueprint model');
throw new InvalidArgumentException(
message: 'Invalid blueprint model'
);
}
$this->model = $props['model'];
@ -88,6 +84,14 @@ class Blueprint
$this->props = $props;
}
/**
* Magic getter/caller for any blueprint prop
*/
public function __call(string $key, array|null $arguments = null): mixed
{
return $this->props[$key] ?? null;
}
/**
* Improved `var_dump` output
*
@ -185,7 +189,10 @@ class Blueprint
foreach ($fieldsets as $fieldset) {
foreach (($fieldset['tabs'] ?? []) as $tab) {
$templates = array_merge($templates, $this->acceptedFileTemplatesFromFields($tab['fields'] ?? []));
$templates = [
...$templates,
...$this->acceptedFileTemplatesFromFields($tab['fields'] ?? [])
];
}
}
@ -207,6 +214,14 @@ class Blueprint
return [($uploads['template'] ?? 'default')];
}
/**
* Gathers custom config for Panel view buttons
*/
public function buttons(): array|false|null
{
return $this->props['buttons'] ?? null;
}
/**
* Converts all column definitions, that
* are not wrapped in a tab, into a generic tab
@ -393,10 +408,10 @@ class Blueprint
}
// neither a valid file nor array data
throw new NotFoundException([
'key' => 'blueprint.notFound',
'data' => ['name' => $name]
]);
throw new NotFoundException(
key: 'blueprint.notFound',
data: ['name' => $name]
);
}
/**
@ -512,14 +527,18 @@ class Blueprint
$props = static::extend($props);
if (isset($props['name']) === false) {
throw new InvalidArgumentException('The field name is missing');
throw new InvalidArgumentException(
message: 'The field name is missing'
);
}
$name = $props['name'];
$type = $props['type'] ?? $name;
if ($type !== 'group' && isset(Field::$types[$type]) === false) {
throw new InvalidArgumentException('Invalid field type ("' . $type . '")');
throw new InvalidArgumentException(
message: 'Invalid field type ("' . $type . '")'
);
}
// support for nested fields
@ -714,7 +733,7 @@ class Blueprint
$fields = Blueprint::fieldsProps($sectionProps['fields'] ?? []);
// inject guide fields guide
if (empty($fields) === true) {
if ($fields === []) {
$fields = [
$tabName . '-info' => [
'label' => 'Fields',

View file

@ -22,26 +22,35 @@ use Kirby\Uuid\Uuid;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @template TValue
* @extends \Kirby\Toolkit\Collection<TValue>
*/
class Collection extends BaseCollection
{
use HasMethods;
/**
* Stores the parent object, which is needed
* in some collections to get the finder methods right.
*
* @var object
* @var \Kirby\Cms\Pagination|null
*/
protected $parent;
protected $pagination;
/**
* Magic getter function
* Creates a new Collection with the given objects
*
* @param string $key
* @param mixed $arguments
* @return mixed
* @param object|null $parent Stores the parent object,
* which is needed in some collections
* to get the finder methods right
*/
public function __construct(
iterable $objects = [],
protected object|null $parent = null
) {
foreach ($objects as $object) {
$this->add($object);
}
}
public function __call(string $key, $arguments)
{
// collection methods
@ -50,21 +59,6 @@ class Collection extends BaseCollection
}
}
/**
* Creates a new Collection with the given objects
*
* @param array $objects
* @param object|null $parent
*/
public function __construct($objects = [], $parent = null)
{
$this->parent = $parent;
foreach ($objects as $object) {
$this->add($object);
}
}
/**
* Internal setter for each object in the Collection;
* override from the Toolkit Collection is needed to
@ -72,7 +66,7 @@ class Collection extends BaseCollection
* child classes can override it again to add validation
* and custom behavior depending on the object type
*
* @param object $object
* @param TValue $object
*/
public function __set(string $id, $object): void
{
@ -84,7 +78,7 @@ class Collection extends BaseCollection
* override from the Toolkit Collection is needed to
* make the CMS collections case-sensitive
*/
public function __unset($id)
public function __unset(string $id)
{
unset($this->data[$id]);
}
@ -94,12 +88,13 @@ class Collection extends BaseCollection
* an entire second collection to the
* current collection
*
* @param mixed $object
* @param static|TValue|array $object
* @return $this
*/
public function add($object)
public function add($object): static
{
if ($object instanceof self) {
$this->data = array_merge($this->data, $object->data);
$this->data = [...$this->data, ...$object->data];
} elseif (
is_object($object) === true &&
method_exists($object, 'id') === true
@ -115,12 +110,15 @@ class Collection extends BaseCollection
/**
* Appends an element to the data array
*
* @param mixed ...$args
* @param mixed $key Optional collection key, will be determined from the item if not given
* @param mixed $item
* @return \Kirby\Cms\Collection
* ```php
* $collection->append($object);
* $collection->append('key', $object);
* ```
*
* @param string|TValue ...$args
* @return $this
*/
public function append(...$args)
public function append(...$args): static
{
if (count($args) === 1) {
// try to determine the key from the provided item
@ -140,8 +138,7 @@ class Collection extends BaseCollection
/**
* Find a single element by an attribute and its value
*
* @param mixed $value
* @return mixed|null
* @return TValue|null
*/
public function findBy(string $attribute, $value)
{
@ -158,28 +155,31 @@ class Collection extends BaseCollection
* Groups the items by a given field or callback. Returns a collection
* with an item for each group and a collection for each group.
*
* @param string|Closure $field
* @param string|\Closure $field
* @param bool $caseInsensitive Ignore upper/lowercase for group names
* @return \Kirby\Cms\Collection
* @throws \Kirby\Exception\Exception
*/
public function group($field, bool $caseInsensitive = true)
{
if (is_string($field) === true) {
$groups = new Collection([], $this->parent());
public function group(
$field,
bool $caseInsensitive = true
): self {
$groups = new self(parent: $this->parent());
if (is_string($field) === true) {
foreach ($this->data as $key => $item) {
$value = $this->getAttribute($item, $field);
// make sure that there's always a proper value to group by
if (!$value) {
throw new InvalidArgumentException('Invalid grouping value for key: ' . $key);
throw new InvalidArgumentException(
message: 'Invalid grouping value for key: ' . $key
);
}
$value = (string)$value;
// ignore upper/lowercase for group names
if ($caseInsensitive === true) {
if ($caseInsensitive) {
$value = Str::lower($value);
}
@ -195,14 +195,17 @@ class Collection extends BaseCollection
return $groups;
}
return parent::group($field, $caseInsensitive);
// use the parent method but unwrap the Toolkit collection
// and rewrap it as a Cms\Collection instance
$groups->data = parent::group($field, $caseInsensitive)->data;
return $groups;
}
/**
* Checks if the given object or id
* is in the collection
*
* @param string|object $key
* @param string|TValue $key
*/
public function has($key): bool
{
@ -218,7 +221,7 @@ class Collection extends BaseCollection
* The method will automatically detect objects
* or ids and then search accordingly.
*
* @param string|object $needle
* @param string|TValue $needle
*/
public function indexOf($needle): int|false
{
@ -232,10 +235,10 @@ class Collection extends BaseCollection
/**
* Returns a Collection without the given element(s)
*
* @param mixed ...$keys any number of keys, passed as individual arguments
* @return \Kirby\Cms\Collection
* @param string|array|object ...$keys any number of keys,
* passed as individual arguments
*/
public function not(...$keys)
public function not(string|array|object ...$keys): static
{
$collection = $this->clone();
@ -259,10 +262,9 @@ class Collection extends BaseCollection
/**
* Add pagination and return a sliced set of data.
*
* @param mixed ...$arguments
* @return $this|static
*/
public function paginate(...$arguments)
public function paginate(...$arguments): static
{
$this->pagination = Pagination::for($this, ...$arguments);
@ -274,11 +276,9 @@ class Collection extends BaseCollection
}
/**
* Get the pagination object
*
* @return \Kirby\Cms\Pagination|null
* Get the previously added pagination object
*/
public function pagination()
public function pagination(): Pagination|null
{
return $this->pagination;
}
@ -286,7 +286,7 @@ class Collection extends BaseCollection
/**
* Returns the parent model
*/
public function parent()
public function parent(): object|null
{
return $this->parent;
}
@ -294,12 +294,15 @@ class Collection extends BaseCollection
/**
* Prepends an element to the data array
*
* @param mixed ...$args
* @param mixed $key Optional collection key, will be determined from the item if not given
* @param mixed $item
* @return \Kirby\Cms\Collection
* ```php
* $collection->prepend($object);
* $collection->prepend('key', $object);
* ```
*
* @param string|TValue ...$args
* @return $this
*/
public function prepend(...$args)
public function prepend(...$args): static
{
if (count($args) === 1) {
// try to determine the key from the provided item
@ -320,10 +323,8 @@ class Collection extends BaseCollection
* Runs a combination of filter, sort, not,
* offset, limit, search and paginate on the collection.
* Any part of the query is optional.
*
* @return static
*/
public function query(array $arguments = [])
public function query(array $arguments = []): static
{
$paginate = $arguments['paginate'] ?? null;
$search = $arguments['search'] ?? null;
@ -333,11 +334,13 @@ class Collection extends BaseCollection
$result = parent::query($arguments);
if (empty($search) === false) {
if (is_array($search) === true) {
$result = $result->search($search['query'] ?? null, $search['options'] ?? []);
} else {
$result = $result->search($search);
}
$result = match (true) {
is_array($search) => $result->search(
$search['query'] ?? null,
$search['options'] ?? []
),
default => $result->search($search)
};
}
if (empty($paginate) === false) {
@ -350,9 +353,9 @@ class Collection extends BaseCollection
/**
* Removes an object
*
* @param mixed $key the name of the key
* @param string|TValue $key the name of the key
*/
public function remove($key)
public function remove(string|object $key): static
{
if (is_object($key) === true) {
$key = $key->id();
@ -378,6 +381,22 @@ class Collection extends BaseCollection
*/
public function toArray(Closure|null $map = null): array
{
return parent::toArray($map ?? fn ($object) => $object->toArray());
return parent::toArray(
$map ?? fn ($object) => $object->toArray()
);
}
/**
* Updates an object in the collection
*
* @return $this
*/
public function update(string|object $key, $object = null): static
{
if (is_object($key) === true) {
return $this->update($key->id(), $key);
}
return $this->set($key, $object);
}
}

View file

@ -41,7 +41,7 @@ class Collections
* `$collections->myCollection()`
*
* @return \Kirby\Toolkit\Collection|null
* @todo 5.0 Add return type declaration
* @todo 6.0 Add return type declaration
*/
public function __call(string $name, array $arguments = [])
{
@ -52,8 +52,8 @@ class Collections
* Loads a collection by name if registered
*
* @return \Kirby\Toolkit\Collection|null
* @todo 5.0 Add deprecation warning when anything else than a Collection is returned
* @todo 6.0 Add PHP return type declaration for `Toolkit\Collection`
* @todo 6.0 Add deprecation warning when anything else than a Collection is returned
* @todo 7.0 Add PHP return type declaration for `Toolkit\Collection`
*/
public function get(string $name, array $data = [])
{
@ -105,10 +105,11 @@ class Collections
{
$kirby = App::instance();
// first check for collection file
$file = $kirby->root('collections') . '/' . $name . '.php';
// first check for collection file in the `collections` root
$root = $kirby->root('collections');
$file = $root . '/' . $name . '.php';
if (is_file($file) === true) {
if (F::exists($file, $root) === true) {
$collection = F::load($file, allowOutput: false);
if ($collection instanceof Closure) {
@ -119,7 +120,12 @@ class Collections
// fallback to collections from plugins
$collections = $kirby->extensions('collections');
return $collections[$name] ??
throw new NotFoundException('The collection cannot be found');
if ($collection = $collections[$name] ?? null) {
return $collection;
}
throw new NotFoundException(
message: 'The collection cannot be found'
);
}
}

View file

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

View file

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

View file

@ -6,10 +6,16 @@ use Kirby\Cache\ApcuCache;
use Kirby\Cache\FileCache;
use Kirby\Cache\MemCached;
use Kirby\Cache\MemoryCache;
use Kirby\Cache\RedisCache;
use Kirby\Cms\Auth\EmailChallenge;
use Kirby\Cms\Auth\TotpChallenge;
use Kirby\Form\Field\BlocksField;
use Kirby\Form\Field\EntriesField;
use Kirby\Form\Field\LayoutField;
use Kirby\Panel\Ui\FilePreviews\AudioFilePreview;
use Kirby\Panel\Ui\FilePreviews\ImageFilePreview;
use Kirby\Panel\Ui\FilePreviews\PdfFilePreview;
use Kirby\Panel\Ui\FilePreviews\VideoFilePreview;
/**
* The Core class lists all parts of Kirby
@ -147,6 +153,7 @@ class Core
public function caches(): array
{
return [
'changes' => true,
'updates' => true,
'uuid' => true,
];
@ -162,6 +169,7 @@ class Core
'file' => FileCache::class,
'memcached' => MemCached::class,
'memory' => MemoryCache::class,
'redis' => RedisCache::class
];
}
@ -244,6 +252,7 @@ class Core
'color' => $this->root . '/fields/color.php',
'date' => $this->root . '/fields/date.php',
'email' => $this->root . '/fields/email.php',
'entries' => EntriesField::class,
'files' => $this->root . '/fields/files.php',
'gap' => $this->root . '/fields/gap.php',
'headline' => $this->root . '/fields/headline.php',
@ -275,6 +284,19 @@ class Core
];
}
/**
* Returns a map of all default file preview handlers
*/
public function filePreviews(): array
{
return [
AudioFilePreview::class,
ImageFilePreview::class,
PdfFilePreview::class,
VideoFilePreview::class,
];
}
/**
* Returns a map of all kirbytag aliases
*/
@ -316,33 +338,33 @@ class Core
public function roots(): array
{
return $this->cache['roots'] ??= [
'kirby' => fn (array $roots) => dirname(__DIR__, 2),
'i18n' => fn (array $roots) => $roots['kirby'] . '/i18n',
'kirby' => fn (array $roots) => dirname(__DIR__, 2),
'i18n' => fn (array $roots) => $roots['kirby'] . '/i18n',
'i18n:translations' => fn (array $roots) => $roots['i18n'] . '/translations',
'i18n:rules' => fn (array $roots) => $roots['i18n'] . '/rules',
'index' => fn (array $roots) => static::$indexRoot ?? dirname(__DIR__, 3),
'assets' => fn (array $roots) => $roots['index'] . '/assets',
'content' => fn (array $roots) => $roots['index'] . '/content',
'media' => fn (array $roots) => $roots['index'] . '/media',
'panel' => fn (array $roots) => $roots['kirby'] . '/panel',
'site' => fn (array $roots) => $roots['index'] . '/site',
'accounts' => fn (array $roots) => $roots['site'] . '/accounts',
'blueprints' => fn (array $roots) => $roots['site'] . '/blueprints',
'cache' => fn (array $roots) => $roots['site'] . '/cache',
'collections' => fn (array $roots) => $roots['site'] . '/collections',
'commands' => fn (array $roots) => $roots['site'] . '/commands',
'config' => fn (array $roots) => $roots['site'] . '/config',
'controllers' => fn (array $roots) => $roots['site'] . '/controllers',
'languages' => fn (array $roots) => $roots['site'] . '/languages',
'license' => fn (array $roots) => $roots['config'] . '/.license',
'logs' => fn (array $roots) => $roots['site'] . '/logs',
'models' => fn (array $roots) => $roots['site'] . '/models',
'plugins' => fn (array $roots) => $roots['site'] . '/plugins',
'sessions' => fn (array $roots) => $roots['site'] . '/sessions',
'snippets' => fn (array $roots) => $roots['site'] . '/snippets',
'templates' => fn (array $roots) => $roots['site'] . '/templates',
'roles' => fn (array $roots) => $roots['blueprints'] . '/users',
'i18n:rules' => fn (array $roots) => $roots['i18n'] . '/rules',
'index' => fn (array $roots) => static::$indexRoot ?? dirname(__DIR__, 3),
'assets' => fn (array $roots) => $roots['index'] . '/assets',
'content' => fn (array $roots) => $roots['index'] . '/content',
'media' => fn (array $roots) => $roots['index'] . '/media',
'panel' => fn (array $roots) => $roots['kirby'] . '/panel',
'site' => fn (array $roots) => $roots['index'] . '/site',
'accounts' => fn (array $roots) => $roots['site'] . '/accounts',
'blueprints' => fn (array $roots) => $roots['site'] . '/blueprints',
'cache' => fn (array $roots) => $roots['site'] . '/cache',
'collections' => fn (array $roots) => $roots['site'] . '/collections',
'commands' => fn (array $roots) => $roots['site'] . '/commands',
'config' => fn (array $roots) => $roots['site'] . '/config',
'controllers' => fn (array $roots) => $roots['site'] . '/controllers',
'languages' => fn (array $roots) => $roots['site'] . '/languages',
'licenses' => fn (array $roots) => $roots['site'] . '/licenses',
'license' => fn (array $roots) => $roots['config'] . '/.license',
'logs' => fn (array $roots) => $roots['site'] . '/logs',
'models' => fn (array $roots) => $roots['site'] . '/models',
'plugins' => fn (array $roots) => $roots['site'] . '/plugins',
'sessions' => fn (array $roots) => $roots['site'] . '/sessions',
'snippets' => fn (array $roots) => $roots['site'] . '/snippets',
'templates' => fn (array $roots) => $roots['site'] . '/templates',
'roles' => fn (array $roots) => $roots['blueprints'] . '/users',
];
}
@ -388,6 +410,7 @@ class Core
public function sectionMixins(): array
{
return [
'batch' => $this->root . '/sections/mixins/batch.php',
'details' => $this->root . '/sections/mixins/details.php',
'empty' => $this->root . '/sections/mixins/empty.php',
'headline' => $this->root . '/sections/mixins/headline.php',

View file

@ -27,7 +27,7 @@ class Email
/**
* Props for the email object; will be passed to the
* Kirby\Email\Email class
* \Kirby\Email\Email class
*/
protected array $props;
@ -42,8 +42,7 @@ class Email
$this->options = App::instance()->option('email', []);
// build a prop array based on preset and props
$preset = $this->preset($preset);
$this->props = array_merge($preset, $props);
$this->props = [...$this->preset($preset), ...$props];
// add transport settings
$this->props['transport'] ??= $this->options['transport'] ?? [];
@ -79,10 +78,10 @@ class Email
// preset does not exist
if (isset($this->options['presets'][$preset]) !== true) {
throw new NotFoundException([
'key' => 'email.preset.notFound',
'data' => ['name' => $preset]
]);
throw new NotFoundException(
key: 'email.preset.notFound',
data: ['name' => $preset]
);
}
return $this->options['presets'][$preset];
@ -104,20 +103,20 @@ class Email
$html = $this->getTemplate($this->props['template'], 'html');
$text = $this->getTemplate($this->props['template'], 'text');
if ($html->exists()) {
$this->props['body'] = [
'html' => $html->render($data)
];
if ($html->exists() === true) {
$this->props['body'] = ['html' => $html->render($data)];
if ($text->exists()) {
if ($text->exists() === true) {
$this->props['body']['text'] = $text->render($data);
}
// fallback to single email text template
} elseif ($text->exists()) {
// fallback to single email text template
} elseif ($text->exists() === true) {
$this->props['body'] = $text->render($data);
} else {
throw new NotFoundException('The email template "' . $this->props['template'] . '" cannot be found');
throw new NotFoundException(
message: 'The email template "' . $this->props['template'] . '" cannot be found'
);
}
}
}
@ -190,7 +189,9 @@ class Email
}
} else {
// invalid input
throw new InvalidArgumentException('Invalid input for prop "' . $prop . '", expected string or "' . $class . '" object or collection');
throw new InvalidArgumentException(
message: 'Invalid input for prop "' . $prop . '", expected string or "' . $class . '" object or collection'
);
}
}
@ -235,6 +236,11 @@ class Email
*/
protected function transformUserMultiple(string $prop): void
{
$this->props[$prop] = $this->transformModel($prop, User::class, 'name', 'email');
$this->props[$prop] = $this->transformModel(
$prop,
User::class,
'name',
'email'
);
}
}

View file

@ -3,8 +3,10 @@
namespace Kirby\Cms;
use Closure;
use Kirby\Content\ImmutableMemoryStorage;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Controller;
use Stringable;
/**
* The Event object is created whenever the `$kirby->trigger()`
@ -19,14 +21,8 @@ use Kirby\Toolkit\Controller;
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Event
class Event implements Stringable
{
/**
* The full event name
* (e.g. `page.create:after`)
*/
protected string $name;
/**
* The event type
* (e.g. `page` in `page.create:after`)
@ -45,19 +41,16 @@ class Event
*/
protected string|null $state;
/**
* The event arguments
*/
protected array $arguments = [];
/**
* Class constructor
*
* @param string $name Full event name
* @param string $name Full event name (e.g. `page.create:after`)
* @param array $arguments Associative array of named event arguments
*/
public function __construct(string $name, array $arguments = [])
{
public function __construct(
protected string $name,
protected array $arguments = []
) {
// split the event name into `$type.$action:$state`
// $action and $state are optional;
// if there is more than one dot, $type will be greedy
@ -130,9 +123,11 @@ class Event
*/
public function call(object|null $bind, Closure $hook): mixed
{
// collect the list of possible hook arguments
$data = $this->arguments();
$data['event'] = $this;
// collect the list of possible event arguments
$data = [
...$this->arguments(),
'event' => $this
];
// magically call the hook with the arguments it requested
$hook = new Controller($hook);
@ -239,13 +234,43 @@ class Event
/**
* Updates a given argument with a new value
*
* @internal
* @unstable
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function updateArgument(string $name, $value): void
{
if (array_key_exists($name, $this->arguments) !== true) {
throw new InvalidArgumentException('The argument ' . $name . ' does not exist');
throw new InvalidArgumentException(
message: 'The argument ' . $name . ' does not exist'
);
}
// no new value has been supplied by the apply hook
if ($value === null) {
// To support legacy model modification
// in hooks without return values, we need to
// check the state of the updated argument.
// If the argument is an instance of ModelWithContent
// and the storage is an instance of ImmutableMemoryStorage,
// we can replace the argument with its clone to achieve
// the same effect as if the hook returned the modified model.
$state = $this->arguments[$name];
if ($state instanceof ModelWithContent) {
$storage = $state->storage();
if (
$storage instanceof ImmutableMemoryStorage &&
$storage->nextModel() !== null
) {
$this->arguments[$name] = $storage->nextModel();
}
}
// Otherwise, there's no need to update the argument
// if no new value is provided
return;
}
$this->arguments[$name] = $value;

View file

@ -0,0 +1,130 @@
<?php
namespace Kirby\Cms;
use Closure;
/**
* The `Events` class outsources the logic of
* `App::apply()` and `App::trigger()` methods
* and makes them easier and more predictable to test.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class Events
{
protected int $level = 0;
protected array $processed = [];
public function __construct(
protected App $app
) {
}
/**
* Runs the hook and applies the result to the argument
* specified by the $modify parameter. By default, the
* first argument is modified.
*/
public function apply(
string $name,
array $args = [],
string|null $modify = null
): mixed {
// modify the first argument by default
$modify ??= array_key_first($args);
return $this->process(
$name,
$args,
// update $modify value after each hook callback
fn ($event, $result) => $event->updateArgument($modify, $result),
// return the modified value
fn ($event) => $event->argument($modify)
);
}
/**
* Returns all matching hook handlers for the given event
*/
public function hooks(Event $event): array
{
// get all hooks for the event name
$name = $event->name();
$hooks = $this->app->extensions('hooks') ?? [];
$result = $hooks[$name] ?? [];
// get all hooks for the event name wildcards
foreach ($event->nameWildcards() as $wildcard) {
$result = [
...$result,
...$hooks[$wildcard] ?? []
];
}
return $result;
}
/**
* Runs the hook
*
* @return ($return is null ? void : mixed)
*/
protected function process(
string $name,
array $args,
Closure|null $afterEach = null,
Closure|null $return = null
) {
// create the event object and get all hook callbacks for this event
$event = new Event($name, $args);
$hooks = $this->hooks($event);
$this->level++;
foreach ($hooks as $hook) {
// skip hooks that have already been processed
if (in_array($hook, $this->processed[$name] ?? []) === true) {
continue;
}
// mark the hook as processed, to avoid endless loops
$this->processed[$name][] = $hook;
// bind the Kirby instance to the hook and run it
$result = $event->call($this->app, $hook);
// run the afterEach callback
if ($afterEach !== null) {
$afterEach($event, $result);
}
}
$this->level--;
// reset the protection after the last nesting level has been closed
if ($this->level === 0) {
$this->processed = [];
}
// run the return callback
if ($return !== null) {
return $return($event);
}
}
/**
* Runs the hook without modifying the arguments
*/
public function trigger(
string $name,
array $args = []
): void {
$this->process($name, $args);
}
}

View file

@ -16,6 +16,8 @@ use Kirby\Toolkit\Str;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Item<\Kirby\Cms\Fieldsets>
*/
class Fieldset extends Item
{
@ -40,7 +42,9 @@ class Fieldset extends Item
public function __construct(array $params = [])
{
if (empty($params['type']) === true) {
throw new InvalidArgumentException('The fieldset type is missing');
throw new InvalidArgumentException(
message: 'The fieldset type is missing'
);
}
$this->type = $params['id'] = $params['type'];
@ -73,10 +77,10 @@ class Fieldset extends Item
protected function createFields(array $fields = []): array
{
$fields = Blueprint::fieldsProps($fields);
$fields = $this->form($fields)->fields()->toArray();
$fields = $this->form($fields)->fields()->toProps();
// collect all fields
$this->fields = array_merge($this->fields, $fields);
$this->fields = [...$this->fields, ...$fields];
return $fields;
}
@ -136,7 +140,7 @@ class Fieldset extends Item
return false;
}
if (count($this->fields) === 0) {
if ($this->fields === []) {
return false;
}
@ -153,12 +157,17 @@ class Fieldset extends Item
*/
public function form(array $fields, array $input = []): Form
{
return new Form([
'fields' => $fields,
'model' => $this->parent,
'strict' => true,
'values' => $input,
]);
$form = new Form(
fields: $fields,
model: $this->parent,
);
$form->fill(
input: $input,
passthrough: false
);
return $form;
}
public function icon(): string|null

View file

@ -16,6 +16,8 @@ use Kirby\Toolkit\Str;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Items<\Kirby\Cms\Fieldset>
*/
class Fieldsets extends Items
{
@ -53,7 +55,7 @@ class Fieldsets extends Items
// extract groups
if ($fieldset['type'] === 'group') {
$result = static::createFieldsets($fieldset['fieldsets'] ?? []);
$fieldsets = array_merge($fieldsets, $result['fieldsets']);
$fieldsets = [...$fieldsets, ...$result['fieldsets']];
$label = $fieldset['label'] ?? Str::ucfirst($type);
$groups[$type] = [

View file

@ -29,6 +29,8 @@ use Kirby\Toolkit\Str;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Files>
*/
class File extends ModelWithContent
{
@ -77,10 +79,10 @@ class File extends ModelWithContent
*/
public function __construct(array $props)
{
parent::__construct($props);
if (isset($props['filename'], $props['parent']) === false) {
throw new InvalidArgumentException('The filename and parent are required');
throw new InvalidArgumentException(
message: 'The filename and parent are required'
);
}
$this->filename = $props['filename'];
@ -91,7 +93,14 @@ class File extends ModelWithContent
$this->root = null;
$this->url = $props['url'] ?? null;
// Set blueprint before setting content
// or translations in the parent constructor.
// Otherwise, the blueprint definition cannot be
// used when creating the right field values
// for the content.
$this->setBlueprint($props['blueprint'] ?? null);
parent::__construct($props);
}
/**
@ -124,10 +133,11 @@ class File extends ModelWithContent
*/
public function __debugInfo(): array
{
return array_merge($this->toArray(), [
return [
...$this->toArray(),
'content' => $this->content(),
'siblings' => $this->siblings(),
]);
];
}
/**
@ -223,54 +233,38 @@ class File extends ModelWithContent
/**
* Store the template in addition to the
* other content.
* @internal
* @unstable
*/
public function contentFileData(
array $data,
string|null $languageCode = null
): array {
$language = Language::ensure($languageCode);
// only add the template in, if the $data array
// doesn't explicitly unsets it
if (
array_key_exists('template', $data) === false &&
$template = $this->template()
) {
// doesn't explicitly unset it and it was already
// set in the content before
if (array_key_exists('template', $data) === false && $template = $this->template()) {
$data['template'] = $template;
}
// don't store the template field for the default template
if (($data['template'] ?? null) === 'default') {
unset($data['template']);
}
// only keep the template and sort fields in the
// default language
if ($language->isDefault() === false) {
unset($data['template'], $data['sort']);
return $data;
}
return $data;
}
/**
* Returns the directory in which
* the content file is located
* @internal
* @deprecated 4.0.0
* @todo Remove in v5
* @codeCoverageIgnore
*/
public function contentFileDirectory(): string
{
Helpers::deprecated('The internal $model->contentFileDirectory() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file');
return dirname($this->root());
}
/**
* Filename for the content file
* @internal
* @deprecated 4.0.0
* @todo Remove in v5
* @codeCoverageIgnore
*/
public function contentFileName(): string
{
Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file');
return $this->filename();
}
/**
* Constructs a File object
* @internal
*/
public static function factory(array $props): static
{
@ -298,10 +292,10 @@ class File extends ModelWithContent
*/
public function html(array $attr = []): string
{
return $this->asset()->html(array_merge(
['alt' => $this->alt()],
$attr
));
return $this->asset()->html([
'alt' => $this->alt(),
...$attr
]);
}
/**
@ -328,31 +322,26 @@ class File extends ModelWithContent
}
/**
* Checks if the files is accessible.
* This permission depends on the `read` option until v5
* Checks if the file is accessible to the current user
* This permission depends on the `read` option until v6
*/
public function isAccessible(): bool
{
// TODO: remove this check when `read` option deprecated in v5
// TODO: remove this check when `read` option deprecated in v6
if ($this->isReadable() === false) {
return false;
}
static $accessible = [];
$role = $this->kirby()->user()?->role()->id() ?? '__none__';
$template = $this->template() ?? '__none__';
$accessible[$role] ??= [];
return $accessible[$role][$template] ??= $this->permissions()->can('access');
return FilePermissions::canFromCache($this, 'access');
}
/**
* Check if the file can be listable by the current user
* This permission depends on the `read` option until v5
* This permission depends on the `read` option until v6
*/
public function isListable(): bool
{
// TODO: remove this check when `read` option deprecated in v5
// TODO: remove this check when `read` option deprecated in v6
if ($this->isReadable() === false) {
return false;
}
@ -362,32 +351,36 @@ class File extends ModelWithContent
return false;
}
static $listable = [];
$role = $this->kirby()->user()?->role()->id() ?? '__none__';
$template = $this->template() ?? '__none__';
$listable[$role] ??= [];
return $listable[$role][$template] ??= $this->permissions()->can('list');
return FilePermissions::canFromCache($this, 'list');
}
/**
* Check if the file can be read by the current user
*
* @todo Deprecate `read` option in v5 and make the necessary changes for `access` and `list` options.
* @todo Deprecate `read` option in v6 and make the necessary changes for `access` and `list` options.
*/
public function isReadable(): bool
{
static $readable = [];
$role = $this->kirby()->user()?->role()->id() ?? '__none__';
$role = $this->kirby()->role()?->id() ?? '__none__';
$template = $this->template() ?? '__none__';
$readable[$role] ??= [];
return $readable[$role][$template] ??= $this->permissions()->can('read');
}
/**
* Returns the absolute path to the media folder
* for the file and its versions
* @since 5.0.0
*/
public function mediaDir(): string
{
return $this->parent()->mediaDir() . '/' . $this->mediaHash();
}
/**
* Creates a unique media hash
* @internal
*/
public function mediaHash(): string
{
@ -396,16 +389,18 @@ class File extends ModelWithContent
/**
* Returns the absolute path to the file in the public media folder
* @internal
*
* @param string|null $filename Optional override for the filename
*/
public function mediaRoot(): string
public function mediaRoot(string|null $filename = null): string
{
return $this->parent()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->filename();
$filename ??= $this->filename();
return $this->mediaDir() . '/' . $filename;
}
/**
* Creates a non-guessable token string for this file
* @internal
*/
public function mediaToken(): string
{
@ -415,11 +410,15 @@ class File extends ModelWithContent
/**
* Returns the absolute Url to the file in the public media folder
* @internal
*
* @param string|null $filename Optional override for the filename
*/
public function mediaUrl(): string
public function mediaUrl(string|null $filename = null): string
{
return $this->parent()->mediaUrl() . '/' . $this->mediaHash() . '/' . $this->filename();
$url = $this->parent()->mediaUrl() . '/' . $this->mediaHash();
$filename ??= $this->filename();
return $url . '/' . $filename;
}
/**
@ -445,7 +444,7 @@ class File extends ModelWithContent
*/
protected function modifiedContent(string|null $languageCode = null): int
{
return $this->storage()->modified('published', $languageCode) ?? 0;
return $this->version('latest')->modified($languageCode ?? 'current') ?? 0;
}
/**
@ -487,7 +486,6 @@ class File extends ModelWithContent
/**
* Returns the parent id if a parent exists
* @internal
*/
public function parentId(): string
{
@ -560,7 +558,6 @@ class File extends ModelWithContent
/**
* Returns the parent Files collection
* @internal
*/
protected function siblingsCollection(): Files
{
@ -602,10 +599,12 @@ class File extends ModelWithContent
*/
public function toArray(): array
{
return array_merge(parent::toArray(), $this->asset()->toArray(), [
return [
...parent::toArray(),
...$this->asset()->toArray(),
'id' => $this->id(),
'template' => $this->template(),
]);
];
}
/**
@ -617,12 +616,20 @@ class File extends ModelWithContent
}
/**
* Simplified File URL that uses the parent
* Page URL and the filename as a more stable
* alternative for the media URLs.
* Clean file URL that uses the parent page URL
* and the filename as a more stable alternative
* for the media URLs if available. The `content.fileRedirects`
* option is used to disable this behavior or enable it
* on a per-file basis.
*/
public function previewUrl(): string|null
{
// check if the clean file URL is accessible,
// otherwise we need to fall back to the media URL
if ($this->kirby()->resolveFile($this) === null) {
return $this->url();
}
$parent = $this->parent();
$url = Url::to($this->id());
@ -651,6 +658,7 @@ class File extends ModelWithContent
return $url;
case 'user':
// there are no clean URL routes for user files
return $this->url();
default:
return $url;

View file

@ -3,10 +3,12 @@
namespace Kirby\Cms;
use Closure;
use Kirby\Content\ImmutableMemoryStorage;
use Kirby\Content\MemoryStorage;
use Kirby\Content\VersionCache;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\F;
use Kirby\Form\Form;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
@ -25,13 +27,6 @@ trait FileActions
File $file,
string|null $extension = null
): File {
if (
$extension === null ||
$extension === $file->extension()
) {
return $file;
}
return $file->changeName($file->name(), false, $extension);
}
@ -76,18 +71,20 @@ trait FileActions
}
if ($newFile->exists() === true) {
throw new LogicException('The new file exists and cannot be overwritten');
throw new LogicException(
message: 'The new file exists and cannot be overwritten'
);
}
// rename the main file
F::move($oldFile->root(), $newFile->root());
// hard reset for the version cache
// to avoid broken/overlapping file references
VersionCache::reset();
// move the content storage versions
foreach ($oldFile->storage()->all() as $version => $lang) {
$content = $oldFile->storage()->read($version, $lang);
$oldFile->storage()->delete($version, $lang);
$newFile->storage()->create($version, $lang, $content);
}
$oldFile->storage()->moveAll(to: $newFile->storage());
// update collections
$newFile->parent()->files()->remove($oldFile->id());
@ -107,10 +104,23 @@ trait FileActions
return $this;
}
$arguments = [
'file' => $this,
'position' => $sort
];
return $this->commit(
'changeSort',
['file' => $this, 'position' => $sort],
fn ($file, $sort) => $file->save(['sort' => $sort])
$arguments,
function ($file, $sort) {
// make sure to update the sort in the changes version as well
// otherwise the new sort would be lost as soon as the changes are saved
if ($file->version('changes')->exists() === true) {
$file->version('changes')->update(['sort' => $sort]);
}
return $file->save(['sort' => $sort]);
}
);
}
@ -132,16 +142,6 @@ trait FileActions
// convert to new template/blueprint incl. content
$file = $oldFile->convertTo($template);
// update template, prefer unset over writing `default`
if ($template === 'default') {
$template = null;
}
$file = $file->update(
['template' => $template],
'default'
);
// resize the file if configured by new blueprint
$create = $file->blueprint()->create();
$file = $file->manipulate($create);
@ -153,10 +153,10 @@ trait FileActions
/**
* Commits a file action, by following these steps
*
* 1. checks the action rules
* 2. sends the before hook
* 1. applies the `before` hook
* 2. checks the action rules
* 3. commits the store action
* 4. sends the after hook
* 4. applies the `after` hook
* 5. returns the result
*/
protected function commit(
@ -164,44 +164,27 @@ trait FileActions
array $arguments,
Closure $callback
): mixed {
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
$commit = new ModelCommit(
model: $this,
action: $action
);
$this->rules()->$action(...$argumentValues);
$kirby->trigger('file.' . $action . ':before', $arguments);
$result = $callback(...$argumentValues);
$argumentsAfter = match ($action) {
'create' => ['file' => $result],
'delete' => ['status' => $result, 'file' => $old],
default => ['newFile' => $result, 'oldFile' => $old]
};
$kirby->trigger('file.' . $action . ':after', $argumentsAfter);
$kirby->cache('pages')->flush();
return $result;
return $commit->call($arguments, $callback);
}
/**
* Copy the file to the given page
* @internal
*/
public function copy(Page $page): static
{
F::copy($this->root(), $page->root() . '/' . $this->filename());
$copy = $page->clone()->file($this->filename());
foreach ($this->storage()->all() as $version => $lang) {
$content = $this->storage()->read($version, $lang);
$copy->storage()->create($version, $lang, $content);
}
$copy = new static([
'parent' => $page,
'filename' => $this->filename(),
]);
// ensure the content is re-read after copying it
// @todo find a more elegant way
$copy = $page->clone()->file($this->filename());
$this->storage()->copyAll(to: $copy->storage());
// overwrite with new UUID (remove old, add new)
if (Uuids::enabled() === true) {
@ -221,51 +204,64 @@ trait FileActions
* @throws \Kirby\Exception\InvalidArgumentException
* @throws \Kirby\Exception\LogicException
*/
public static function create(array $props, bool $move = false): File
public static function create(array $props, bool $move = false): static
{
if (isset($props['source'], $props['parent']) === false) {
throw new InvalidArgumentException('Please provide the "source" and "parent" props for the File');
}
// prefer the filename from the props
$props['filename'] = F::safeName($props['filename'] ?? basename($props['source']));
$props['model'] = strtolower($props['template'] ?? 'default');
$props = static::normalizeProps($props);
// create the basic file and a test upload object
$file = static::factory($props);
$upload = $file->asset($props['source']);
$file = File::factory([
...$props,
'content' => null,
'translations' => null,
]);
// gather content
$content = $props['content'] ?? [];
$upload = $file->assetFactory($props['source']);
$existing = null;
// merge the content with the defaults
$props['content'] = [
...$file->createDefaultContent(),
...$props['content'],
];
// reuse the existing content if the uploaded file
// is identical to an existing file
if ($file->exists() === true) {
$existing = $file->parent()->file($file->filename());
if (
$file->sha1() === $upload->sha1() &&
$file->template() === $existing->template()
) {
// read the content of the existing file and use it
$props['content'] = $existing->content()->toArray();
}
}
// make sure that a UUID gets generated
// and added to content right away
if (
Uuids::enabled() === true &&
empty($content['uuid']) === true
) {
// sets the current uuid if it is the exact same file
if ($file->exists() === true) {
$existing = $file->parent()->file($file->filename());
if (
$file->sha1() === $upload->sha1() &&
$file->template() === $existing->template()
) {
// use existing content data if it is the exact same file
$content = $existing->content()->toArray();
}
}
$content['uuid'] ??= Uuid::generate();
if (Uuids::enabled() === true) {
$props['content']['uuid'] ??= Uuid::generate();
}
// create a form for the file
$form = Form::for($file, ['values' => $content]);
// keep the initial storage class
$storage = $file->storage()::class;
// make sure that the temporary page is stored in memory
$file->changeStorage(
toStorage: MemoryStorage::class,
// when theres already an existing file,
// we need to make sure that the content is
// copied to memory and the existing content
// storage entry is not deleted by this step
copy: $existing !== null
);
// inject the content
$file = $file->clone(['content' => $form->strings(true)]);
$file->setContent($props['content']);
// inject the translations
$file->setTranslations($props['translations'] ?? null);
// if the format is different from the original,
// we need to already rename it so that the correct file rules
@ -274,7 +270,7 @@ trait FileActions
// run the hook
$arguments = compact('file', 'upload');
return $file->commit('create', $arguments, function ($file, $upload) use ($create, $move) {
return $file->commit('create', $arguments, function ($file, $upload) use ($create, $move, $storage) {
// remove all public versions, lock and clear UUID cache
$file->unpublish();
@ -283,21 +279,18 @@ trait FileActions
// overwrite the original
if (F::$method($upload->root(), $file->root(), true) !== true) {
throw new LogicException('The file could not be created');
// @codeCoverageIgnoreStart
throw new LogicException(
message: 'The file could not be created'
);
// @codeCoverageIgnoreEnd
}
// resize the file on upload if configured
$file = $file->manipulate($create);
// store the content if necessary
// (always create files in the default language)
$file->save(
$file->content()->toArray(),
$file->kirby()->defaultLanguage()?->code()
);
// add the file to the list of siblings
$file->siblings()->append($file->id(), $file);
$file->changeStorage($storage);
// return a fresh clone
return $file->clone();
@ -311,17 +304,23 @@ trait FileActions
public function delete(): bool
{
return $this->commit('delete', ['file' => $this], function ($file) {
// remove all public versions, lock and clear UUID cache
$file->unpublish();
$old = $file->clone();
foreach ($file->storage()->all() as $version => $lang) {
$file->storage()->delete($version, $lang);
}
// keep the content in iummtable memory storage
// to still have access to it in after hooks
$file->changeStorage(ImmutableMemoryStorage::class);
F::remove($file->root());
// clear UUID cache
$file->uuid()?->clear();
// remove the file from the sibling collection
$file->parent()->files()->remove($file);
// remove all public versions and clear the UUID cache
$old->unpublish();
// delete all versions
$old->versions()->delete();
// delete the file from disk
F::remove($old->root());
return true;
});
@ -350,6 +349,31 @@ trait FileActions
return $file;
}
protected static function normalizeProps(array $props): array
{
if (isset($props['source'], $props['parent']) === false) {
throw new InvalidArgumentException(
message: 'Please provide the "source" and "parent" props for the File'
);
}
$content = $props['content'] ?? [];
$template = $props['template'] ?? 'default';
// prefer the filename from the props
$filename = $props['filename'] ?? null;
$filename ??= basename($props['source']);
$filename = F::safeName($props['filename']);
return [
...$props,
'content' => $content,
'filename' => $filename,
'model' => $props['model'] ?? $template,
'template' => $template,
];
}
/**
* Move the file to the public media folder
* if it's not already there.
@ -390,7 +414,9 @@ trait FileActions
// overwrite the original
if (F::$method($upload->root(), $file->root(), true) !== true) {
throw new LogicException('The file could not be created');
throw new LogicException(
message: 'The file could not be created'
);
}
// apply the resizing/crop options from the blueprint
@ -402,23 +428,6 @@ trait FileActions
});
}
/**
* Stores the content on disk
* @internal
*/
public function save(
array|null $data = null,
string|null $languageCode = null,
bool $overwrite = false
): static {
$file = parent::save($data, $languageCode, $overwrite);
// update model in siblings collection
$file->parent()->files()->set($file->id(), $file);
return $file;
}
/**
* Remove all public versions of this file
*
@ -430,9 +439,6 @@ trait FileActions
Media::unpublish($this->parent()->mediaRoot(), $this);
if ($onlyMedia !== true) {
// remove the lock
$this->lock()?->remove();
// clear UUID cache
$this->uuid()?->clear();
}

View file

@ -41,6 +41,7 @@ class FileBlueprint extends Blueprint
'list' => null,
'read' => null,
'replace' => null,
'sort' => null,
'update' => null,
]
);
@ -59,7 +60,7 @@ class FileBlueprint extends Blueprint
* file upload or `*` if all MIME types are allowed
*
* @deprecated 4.2.0 Use `acceptAttribute` instead
* @todo 5.0.0 Remove method
* @todo 6.0.0 Remove method
*/
public function acceptMime(): string
{
@ -82,7 +83,7 @@ class FileBlueprint extends Blueprint
if (is_array($accept['extension']) === true) {
// determine the main MIME type for each extension
$restrictions[] = array_map(
[Mime::class, 'fromExtension'],
Mime::fromExtension(...),
$accept['extension']
);
}
@ -93,7 +94,7 @@ class FileBlueprint extends Blueprint
foreach ($accept['type'] as $type) {
if ($extensions = F::typeToExtensions($type)) {
$mimes[] = array_map(
[Mime::class, 'fromExtension'],
Mime::fromExtension(...),
$extensions
);
}
@ -104,12 +105,11 @@ class FileBlueprint extends Blueprint
}
if ($restrictions !== []) {
if (count($restrictions) > 1) {
// only return the MIME types that are allowed by all restrictions
$mimes = array_intersect(...$restrictions);
} else {
$mimes = $restrictions[0];
}
// only return the MIME types that are allowed by all restrictions
$mimes = match (count($restrictions) > 1) {
true => array_intersect(...$restrictions),
false => $restrictions[0]
};
// filter out empty MIME types and duplicates
return implode(', ', array_filter(array_unique($mimes)));
@ -190,13 +190,13 @@ class FileBlueprint extends Blueprint
protected function normalizeAccept(mixed $accept = null): array
{
$accept = match (true) {
is_string($accept) => ['mime' => $accept],
is_string($accept) => ['mime' => $accept],
// explicitly no restrictions at all
$accept === true => ['mime' => null],
$accept === true => ['mime' => null],
// no custom restrictions
empty($accept) === true => [],
// custom restrictions
default => $accept
default => $accept
};
$accept = array_change_key_case($accept);
@ -225,7 +225,7 @@ class FileBlueprint extends Blueprint
$this->defaultTypes = true;
}
$accept = array_merge($defaults, $accept);
$accept = [...$defaults, ...$accept];
// normalize the MIME, extension and type from strings into arrays
if (is_string($accept['mime']) === true) {

View file

@ -129,7 +129,7 @@ trait FileModifications
$sizes = $this->kirby()->option('thumbs.srcsets.' . $sizes, []);
}
if (is_array($sizes) === false || empty($sizes) === true) {
if (is_array($sizes) === false || $sizes === []) {
return null;
}
@ -184,11 +184,12 @@ trait FileModifications
// fallback to content file options
if (($options['crop'] ?? false) === true) {
if ($this instanceof ModelWithContent === true) {
$options['crop'] = $this->focus()->value() ?? 'center';
} else {
$options['crop'] = 'center';
}
$options['crop'] = match (true) {
$this instanceof ModelWithContent
=> $this->focus()->value() ?? 'center',
default
=> 'center'
};
}
// fallback to global config options
@ -206,7 +207,9 @@ trait FileModifications
$result instanceof File === false &&
$result instanceof Asset === false
) {
throw new InvalidArgumentException('The file::version component must return a File, FileVersion or Asset object');
throw new InvalidArgumentException(
message: 'The file::version component must return a File, FileVersion or Asset object'
);
}
return $result;

View file

@ -13,7 +13,15 @@ namespace Kirby\Cms;
*/
class FilePermissions extends ModelPermissions
{
protected string $category = 'files';
protected const CATEGORY = 'files';
/**
* Used to cache once determined permissions in memory
*/
protected static function cacheKey(ModelWithContent|Language $model): string
{
return $model->template() ?? '__none__';
}
protected function canChangeTemplate(): bool
{

View file

@ -22,10 +22,10 @@ class FilePicker extends Picker
*/
public function defaults(): array
{
$defaults = parent::defaults();
$defaults['text'] = '{{ file.filename }}';
return $defaults;
return [
...parent::defaults(),
'text' => '{{ file.filename }}'
];
}
/**
@ -59,7 +59,9 @@ class FilePicker extends Picker
$files instanceof User => $files->files(),
$files instanceof Files => $files,
default => throw new InvalidArgumentException('Your query must return a set of files')
default => throw new InvalidArgumentException(
message: 'Your query must return a set of files'
)
};
// filter protected and hidden pages

View file

@ -27,40 +27,43 @@ class FileRules
* @throws \Kirby\Exception\DuplicateException If a file with this name exists
* @throws \Kirby\Exception\PermissionException If the user is not allowed to rename the file
*/
public static function changeName(File $file, string $name): bool
public static function changeName(File $file, string $name): void
{
if ($file->permissions()->changeName() !== true) {
throw new PermissionException([
'key' => 'file.changeName.permission',
'data' => ['filename' => $file->filename()]
]);
if ($file->permissions()->can('changeName') !== true) {
throw new PermissionException(
key: 'file.changeName.permission',
data: ['filename' => $file->filename()]
);
}
if (Str::length($name) === 0) {
throw new InvalidArgumentException([
'key' => 'file.changeName.empty'
]);
throw new InvalidArgumentException(
key: 'file.changeName.empty'
);
}
$parent = $file->parent();
$duplicate = $parent->files()->not($file)->findBy('filename', $name . '.' . $file->extension());
if ($duplicate) {
throw new DuplicateException([
'key' => 'file.duplicate',
'data' => ['filename' => $duplicate->filename()]
]);
throw new DuplicateException(
key: 'file.duplicate',
data: ['filename' => $duplicate->filename()]
);
}
return true;
}
/**
* Validates if the file can be sorted
*/
public static function changeSort(File $file, int $sort): bool
public static function changeSort(File $file, int $sort): void
{
return true;
if ($file->permissions()->can('sort') !== true) {
throw new PermissionException(
key: 'file.sort.permission',
data: ['filename' => $file->filename()]
);
}
}
/**
@ -69,13 +72,13 @@ class FileRules
* @throws \Kirby\Exception\LogicException If the template of the page cannot be changed at all
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the template
*/
public static function changeTemplate(File $file, string $template): bool
public static function changeTemplate(File $file, string $template): void
{
if ($file->permissions()->changeTemplate() !== true) {
throw new PermissionException([
'key' => 'file.changeTemplate.permission',
'data' => ['id' => $file->id()]
]);
if ($file->permissions()->can('changeTemplate') !== true) {
throw new PermissionException(
key: 'file.changeTemplate.permission',
data: ['id' => $file->id()]
);
}
$blueprints = $file->blueprints();
@ -84,19 +87,17 @@ class FileRules
// option for this file
if (
count($blueprints) <= 1 ||
in_array($template, array_column($blueprints, 'name')) === false
in_array($template, array_column($blueprints, 'name'), true) === false
) {
throw new LogicException([
'key' => 'file.changeTemplate.invalid',
'data' => [
throw new LogicException(
key: 'file.changeTemplate.invalid',
data: [
'id' => $file->id(),
'template' => $template,
'blueprints' => implode(', ', array_column($blueprints, 'name'))
]
]);
);
}
return true;
}
/**
@ -105,7 +106,7 @@ class FileRules
* @throws \Kirby\Exception\DuplicateException If a file with the same name exists
* @throws \Kirby\Exception\PermissionException If the user is not allowed to create the file
*/
public static function create(File $file, BaseFile $upload): bool
public static function create(File $file, BaseFile $upload): void
{
// We want to ensure that we are not creating duplicate files.
// If a file with the same name already exists
@ -121,28 +122,28 @@ class FileRules
$file->sha1() === $upload->sha1() &&
$file->template() === $existing->template()
) {
return true;
return;
}
// otherwise throw an error for duplicate file
throw new DuplicateException([
'key' => 'file.duplicate',
'data' => [
throw new DuplicateException(
key: 'file.duplicate',
data: [
'filename' => $file->filename()
]
]);
);
}
if ($file->permissions()->create() !== true) {
throw new PermissionException('The file cannot be created');
if ($file->permissions()->can('create') !== true) {
throw new PermissionException(
message: 'The file cannot be created'
);
}
static::validFile($file, $upload->mime());
$upload->match($file->blueprint()->accept());
$upload->validateContents(true);
return true;
}
/**
@ -150,13 +151,13 @@ class FileRules
*
* @throws \Kirby\Exception\PermissionException If the user is not allowed to delete the file
*/
public static function delete(File $file): bool
public static function delete(File $file): void
{
if ($file->permissions()->delete() !== true) {
throw new PermissionException('The file cannot be deleted');
if ($file->permissions()->can('delete') !== true) {
throw new PermissionException(
message: 'The file cannot be deleted'
);
}
return true;
}
/**
@ -165,10 +166,12 @@ class FileRules
* @throws \Kirby\Exception\PermissionException If the user is not allowed to replace the file
* @throws \Kirby\Exception\InvalidArgumentException If the file type of the new file is different
*/
public static function replace(File $file, BaseFile $upload): bool
public static function replace(File $file, BaseFile $upload): void
{
if ($file->permissions()->replace() !== true) {
throw new PermissionException('The file cannot be replaced');
if ($file->permissions()->can('replace') !== true) {
throw new PermissionException(
message: 'The file cannot be replaced'
);
}
static::validMime($file, $upload->mime());
@ -177,16 +180,14 @@ class FileRules
(string)$upload->mime() !== (string)$file->mime() &&
(string)$upload->extension() !== (string)$file->extension()
) {
throw new InvalidArgumentException([
'key' => 'file.mime.differs',
'data' => ['mime' => $file->mime()]
]);
throw new InvalidArgumentException(
key: 'file.mime.differs',
data: ['mime' => $file->mime()]
);
}
$upload->match($file->blueprint()->accept());
$upload->validateContents(true);
return true;
}
/**
@ -194,13 +195,13 @@ class FileRules
*
* @throws \Kirby\Exception\PermissionException If the user is not allowed to update the file
*/
public static function update(File $file, array $content = []): bool
public static function update(File $file, array $content = []): void
{
if ($file->permissions()->update() !== true) {
throw new PermissionException('The file cannot be updated');
if ($file->permissions()->can('update') !== true) {
throw new PermissionException(
message: 'The file cannot be updated'
);
}
return true;
}
/**
@ -208,16 +209,16 @@ class FileRules
*
* @throws \Kirby\Exception\InvalidArgumentException If the extension is missing or forbidden
*/
public static function validExtension(File $file, string $extension): bool
public static function validExtension(File $file, string $extension): void
{
// make it easier to compare the extension
$extension = strtolower($extension);
if (empty($extension) === true) {
throw new InvalidArgumentException([
'key' => 'file.extension.missing',
'data' => ['filename' => $file->filename()]
]);
throw new InvalidArgumentException(
key: 'file.extension.missing',
data: ['filename' => $file->filename()]
);
}
if (
@ -225,50 +226,45 @@ class FileRules
Str::contains($extension, 'phar') !== false ||
Str::contains($extension, 'pht') !== false
) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
'data' => ['type' => 'PHP']
]);
throw new InvalidArgumentException(
key: 'file.type.forbidden',
data: ['type' => 'PHP']
);
}
if (Str::contains($extension, 'htm') !== false) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
'data' => ['type' => 'HTML']
]);
throw new InvalidArgumentException(
key: 'file.type.forbidden',
data: ['type' => 'HTML']
);
}
if (V::in($extension, ['exe', App::instance()->contentExtension()]) !== false) {
throw new InvalidArgumentException([
'key' => 'file.extension.forbidden',
'data' => ['extension' => $extension]
]);
throw new InvalidArgumentException(
key: 'file.extension.forbidden',
data: ['extension' => $extension]
);
}
return true;
}
/**
* Validates the extension, MIME type and filename
*
* @param $mime If not passed, the MIME type is detected from the file,
* if `false`, the MIME type is not validated for performance reasons
* @param string|false|null $mime If not passed, the MIME type is detected from the file,
* if `false`, the MIME type is not validated for performance reasons
* @throws \Kirby\Exception\InvalidArgumentException If the extension, MIME type or filename is missing or forbidden
*/
public static function validFile(
File $file,
string|false|null $mime = null
): bool {
$validMime = match ($mime) {
// request to skip the MIME check for performance reasons
false => true,
default => static::validMime($file, $mime ?? $file->mime())
};
): void {
// request to skip the MIME check for performance reasons
if ($mime !== false) {
static::validMime($file, $mime ?? $file->mime());
}
return
$validMime &&
static::validExtension($file, $file->extension()) &&
static::validFilename($file, $file->filename());
static::validExtension($file, $file->extension());
static::validFilename($file, $file->filename());
}
/**
@ -276,35 +272,33 @@ class FileRules
*
* @throws \Kirby\Exception\InvalidArgumentException If the filename is missing or forbidden
*/
public static function validFilename(File $file, string $filename): bool
public static function validFilename(File $file, string $filename): void
{
// make it easier to compare the filename
$filename = strtolower($filename);
// check for missing filenames
if (empty($filename)) {
throw new InvalidArgumentException([
'key' => 'file.name.missing'
]);
throw new InvalidArgumentException(
key: 'file.name.missing'
);
}
// Block htaccess files
if (Str::startsWith($filename, '.ht')) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
'data' => ['type' => 'Apache config']
]);
throw new InvalidArgumentException(
key: 'file.type.forbidden',
data: ['type' => 'Apache config']
);
}
// Block invisible files
if (Str::startsWith($filename, '.')) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
'data' => ['type' => 'invisible']
]);
throw new InvalidArgumentException(
key: 'file.type.forbidden',
data: ['type' => 'invisible']
);
}
return true;
}
/**
@ -312,32 +306,30 @@ class FileRules
*
* @throws \Kirby\Exception\InvalidArgumentException If the MIME type is missing or forbidden
*/
public static function validMime(File $file, string|null $mime = null): bool
public static function validMime(File $file, string|null $mime = null): void
{
// make it easier to compare the mime
$mime = strtolower($mime ?? '');
if (empty($mime)) {
throw new InvalidArgumentException([
'key' => 'file.mime.missing',
'data' => ['filename' => $file->filename()]
]);
throw new InvalidArgumentException(
key: 'file.mime.missing',
data: ['filename' => $file->filename()]
);
}
if (Str::contains($mime, 'php')) {
throw new InvalidArgumentException([
'key' => 'file.type.forbidden',
'data' => ['type' => 'PHP']
]);
throw new InvalidArgumentException(
key: 'file.type.forbidden',
data: ['type' => 'PHP']
);
}
if (V::in($mime, ['text/html', 'application/x-msdownload'])) {
throw new InvalidArgumentException([
'key' => 'file.mime.forbidden',
'data' => ['mime' => $mime]
]);
throw new InvalidArgumentException(
key: 'file.mime.forbidden',
data:['mime' => $mime]
);
}
return true;
}
}

View file

@ -2,6 +2,7 @@
namespace Kirby\Cms;
use Kirby\Filesystem\Asset;
use Kirby\Filesystem\IsFile;
/**
@ -18,7 +19,7 @@ class FileVersion
use IsFile;
protected array $modifications;
protected $original;
protected File|Asset $original;
public function __construct(array $props)
{
@ -108,10 +109,10 @@ class FileVersion
*/
public function toArray(): array
{
$array = array_merge(
$this->asset()->toArray(),
['modifications' => $this->modifications()]
);
$array = [
...$this->asset()->toArray(),
'modifications' => $this->modifications()
];
ksort($array);

View file

@ -2,9 +2,12 @@
namespace Kirby\Cms;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\F;
use Kirby\Uuid\HasUuids;
use Throwable;
/**
* The `$files` object extends the general
@ -19,6 +22,8 @@ use Kirby\Uuid\HasUuids;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Collection<\Kirby\Cms\File>
*/
class Files extends Collection
{
@ -29,12 +34,17 @@ class Files extends Collection
*/
public static array $methods = [];
/**
* @var \Kirby\Cms\Page|\Kirby\Cms\Site|\Kirby\Cms\User
*/
protected object|null $parent = null;
/**
* Adds a single file or
* an entire second collection to the
* current collection
*
* @param \Kirby\Cms\Files|\Kirby\Cms\File|string $object
* @param static|\Kirby\Cms\File|string $object
* @return $this
* @throws \Kirby\Exception\InvalidArgumentException When no `File` or `Files` object or an ID of an existing file is passed
*/
@ -42,23 +52,25 @@ class Files extends Collection
{
// add a files collection
if ($object instanceof self) {
$this->data = array_merge($this->data, $object->data);
$this->data = [...$this->data, ...$object->data];
// add a file by id
// add a file by id
} elseif (
is_string($object) === true &&
$file = App::instance()->file($object)
) {
$this->__set($file->id(), $file);
// add a file object
// add a file object
} elseif ($object instanceof File) {
$this->__set($object->id(), $object);
// give a useful error message on invalid input;
// silently ignore "empty" values for compatibility with existing setups
// give a useful error message on invalid input;
// silently ignore "empty" values for compatibility with existing setups
} elseif (in_array($object, [null, false, true], true) !== true) {
throw new InvalidArgumentException('You must pass a Files or File object or an ID of an existing file to the Files collection');
throw new InvalidArgumentException(
message: 'You must pass a Files or File object or an ID of an existing file to the Files collection'
);
}
return $this;
@ -84,11 +96,48 @@ class Files extends Collection
return $this;
}
/**
* Deletes the files with the given IDs
* if they exist in the collection
*
* @throws \Kirby\Exception\Exception If not all files could be deleted
*/
public function delete(array $ids): void
{
$exceptions = [];
// delete all pages and collect errors
foreach ($ids as $id) {
try {
$model = $this->get($id);
if ($model instanceof File === false) {
throw new NotFoundException(
key: 'file.undefined'
);
}
$model->delete();
} catch (Throwable $e) {
$exceptions[$id] = $e;
}
}
if ($exceptions !== []) {
throw new Exception(
key: 'file.delete.multiple',
details: $exceptions
);
}
}
/**
* Creates a files collection from an array of props
*/
public static function factory(array $files, Page|Site|User $parent): static
{
public static function factory(
array $files,
Page|Site|User $parent
): static {
$collection = new static([], $parent);
foreach ($files as $props) {
@ -126,7 +175,7 @@ class Files extends Collection
* `null` for the current locale,
* `false` to disable number formatting
*/
public function niceSize($locale = null): string
public function niceSize(string|false|null $locale = null): string
{
return F::niceSize($this->size(), $locale);
}

View file

@ -38,12 +38,10 @@ class Find
return $file;
}
throw new NotFoundException([
'key' => 'file.notFound',
'data' => [
'filename' => $filename
]
]);
throw new NotFoundException(
key: 'file.notFound',
data: ['filename' => $filename]
);
}
/**
@ -58,12 +56,10 @@ class Find
return $language;
}
throw new NotFoundException([
'key' => 'language.notFound',
'data' => [
'code' => $code
]
]);
throw new NotFoundException(
key: 'language.notFound',
data: ['code' => $code]
);
}
/**
@ -83,12 +79,10 @@ class Find
return $page;
}
throw new NotFoundException([
'key' => 'page.notFound',
'data' => [
'slug' => $id
]
]);
throw new NotFoundException(
key: 'page.notFound',
data: ['slug' => $id]
);
}
/**
@ -100,8 +94,11 @@ class Find
*/
public static function parent(string $path): ModelWithContent
{
$path = trim($path, '/');
$modelType = in_array($path, ['site', 'account']) ? $path : trim(dirname($path), '/');
$path = trim($path, '/');
$modelType = match ($path) {
'site', 'account' => $path,
default => trim(dirname($path), '/')
};
$modelTypes = [
'site' => 'site',
'users' => 'user',
@ -126,12 +123,14 @@ class Find
// and filename
'file' => static::file(...preg_split('$.*\K(/files/)$', $path)),
'user' => $kirby->user(basename($path)),
default => throw new InvalidArgumentException('Invalid model type: ' . $modelType)
default => throw new InvalidArgumentException(
message: 'Invalid model type: ' . $modelType
)
};
return $model ?? throw new NotFoundException([
'key' => $modelName . '.undefined'
]);
return $model ?? throw new NotFoundException(
key: $modelName . '.undefined'
);
}
/**
@ -159,17 +158,15 @@ class Find
$kirby->option('api.allowImpersonation', false)
);
return $user ?? throw new NotFoundException([
'key' => 'user.undefined'
]);
return $user ?? throw new NotFoundException(
key: 'user.undefined'
);
}
// get a specific user by id
return $kirby->user($id) ?? throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $id
]
]);
return $kirby->user($id) ?? throw new NotFoundException(
key: 'user.notFound',
data: ['name' => $id]
);
}
}

View file

@ -43,10 +43,11 @@ trait HasFiles
*/
public function createFile(array $props, bool $move = false): File
{
$props = array_merge($props, [
$props = [
...$props,
'parent' => $this,
'url' => null
]);
];
return File::create($props, $move);
}
@ -75,7 +76,7 @@ trait HasFiles
return Uuid::for($filename, $this->$in())->model();
}
if (strpos($filename, '/') !== false) {
if (str_contains($filename, '/') === true) {
$path = dirname($filename);
$filename = basename($filename);

View file

@ -24,24 +24,24 @@ trait HasMethods
/**
* Calls a registered method class with the
* passed arguments
* @internal
*
* @throws \Kirby\Exception\BadMethodCallException
*/
public function callMethod(string $method, array $args = []): mixed
protected function callMethod(string $method, array $args = []): mixed
{
$closure = $this->getMethod($method);
if ($closure === null) {
throw new BadMethodCallException('The method ' . $method . ' does not exist');
throw new BadMethodCallException(
message: 'The method ' . $method . ' does not exist'
);
}
return $closure->call($this, ...$args);
}
/**
* Checks if the object has a registered method
* @internal
* Checks if the object has a registered custom method
*/
public function hasMethod(string $method): bool
{

View file

@ -0,0 +1,53 @@
<?php
namespace Kirby\Cms;
/**
* HasModels
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
trait HasModels
{
/**
* Registry with all custom models
*/
public static array $models = [];
/**
* Adds new models to the registry
* @internal
*/
public static function extendModels(array $models): array
{
return static::$models = [
...static::$models,
...array_change_key_case($models, CASE_LOWER)
];
}
/**
* Creates an object from model if it has been registered
*/
public static function model(string $name, array $props = []): static
{
$name = strtolower($name);
$class = static::$models[$name] ?? null;
$class ??= static::$models['default'] ?? null;
if ($class !== null) {
$object = new $class($props);
if ($object instanceof self) {
return $object;
}
}
return new static($props);
}
}

View file

@ -2,6 +2,8 @@
namespace Kirby\Cms;
use Kirby\Toolkit\Collection;
/**
* This trait is used by pages, files and users
* to handle navigation through parent collections
@ -11,28 +13,81 @@ namespace Kirby\Cms;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @template TCollection of \Kirby\Toolkit\Collection
*/
trait HasSiblings
{
/**
* Checks if there's a next item in the collection
*
* @param TCollection|null $collection
*/
public function hasNext(Collection|null $collection = null): bool
{
return $this->next($collection) !== null;
}
/**
* Checks if there's a previous item in the collection
*
* @param TCollection|null $collection
*/
public function hasPrev(Collection|null $collection = null): bool
{
return $this->prev($collection) !== null;
}
/**
* Returns the position / index in the collection
*
* @param \Kirby\Cms\Collection|null $collection
* @param TCollection|null $collection
*/
public function indexOf($collection = null): int|false
public function indexOf(Collection|null $collection = null): int|false
{
$collection ??= $this->siblingsCollection();
return $collection->indexOf($this);
}
/**
* Checks if the item is the first in the collection
*
* @param TCollection|null $collection
*/
public function isFirst(Collection|null $collection = null): bool
{
$collection ??= $this->siblingsCollection();
return $collection->first()->is($this);
}
/**
* Checks if the item is the last in the collection
*
* @param TCollection|null $collection
*/
public function isLast(Collection|null $collection = null): bool
{
$collection ??= $this->siblingsCollection();
return $collection->last()->is($this);
}
/**
* Checks if the item is at a certain position
*
* @param TCollection|null $collection
*/
public function isNth(int $n, Collection|null $collection = null): bool
{
return $this->indexOf($collection) === $n;
}
/**
* Returns the next item in the collection if available
* @todo `static` return type hint is not 100% accurate because of
* quirks in the `Form` classes; would break if enforced
* (https://github.com/getkirby/kirby/pull/5175)
*
* @param \Kirby\Cms\Collection|null $collection
*
* @param TCollection|null $collection
* @return static|null
*/
public function next($collection = null)
@ -44,11 +99,10 @@ trait HasSiblings
/**
* Returns the end of the collection starting after the current item
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Collection
* @param TCollection|null $collection
* @return TCollection
*/
public function nextAll($collection = null)
public function nextAll(Collection|null $collection = null): Collection
{
$collection ??= $this->siblingsCollection();
return $collection->slice($this->indexOf($collection) + 1);
@ -60,11 +114,10 @@ trait HasSiblings
* quirks in the `Form` classes; would break if enforced
* (https://github.com/getkirby/kirby/pull/5175)
*
* @param \Kirby\Cms\Collection|null $collection
*
* @param TCollection|null $collection
* @return static|null
*/
public function prev($collection = null)
public function prev(Collection|null $collection = null)
{
$collection ??= $this->siblingsCollection();
return $collection->nth($this->indexOf($collection) - 1);
@ -73,11 +126,10 @@ trait HasSiblings
/**
* Returns the beginning of the collection before the current item
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Collection
* @param TCollection|null $collection
* @return TCollection
*/
public function prevAll($collection = null)
public function prevAll(Collection|null $collection = null): Collection
{
$collection ??= $this->siblingsCollection();
return $collection->slice(0, $this->indexOf($collection));
@ -86,9 +138,9 @@ trait HasSiblings
/**
* Returns all sibling elements
*
* @return \Kirby\Cms\Collection
* @return TCollection
*/
public function siblings(bool $self = true)
public function siblings(bool $self = true): Collection
{
$siblings = $this->siblingsCollection();
@ -100,54 +152,8 @@ trait HasSiblings
}
/**
* Checks if there's a next item in the collection
*
* @param \Kirby\Cms\Collection|null $collection
* Returns the collection of siblings
* @return TCollection
*/
public function hasNext($collection = null): bool
{
return $this->next($collection) !== null;
}
/**
* Checks if there's a previous item in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*/
public function hasPrev($collection = null): bool
{
return $this->prev($collection) !== null;
}
/**
* Checks if the item is the first in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*/
public function isFirst($collection = null): bool
{
$collection ??= $this->siblingsCollection();
return $collection->first()->is($this);
}
/**
* Checks if the item is the last in the collection
*
* @param \Kirby\Cms\Collection|null $collection
*/
public function isLast($collection = null): bool
{
$collection ??= $this->siblingsCollection();
return $collection->last()->is($this);
}
/**
* Checks if the item is at a certain position
*
* @param \Kirby\Cms\Collection|null $collection
*/
public function isNth(int $n, $collection = null): bool
{
return $this->indexOf($collection) === $n;
}
abstract protected function siblingsCollection(): Collection;
}

View file

@ -28,7 +28,7 @@ class Helpers
* Helpers::$deprecations['<deprecation-key>'] = false;
* ```
*/
public static $deprecations = [
public static array $deprecations = [
// The internal `$model->contentFile*()` methods have been deprecated
'model-content-file' => true,
@ -38,11 +38,12 @@ class Helpers
// TODO: switch to true in v6
'plugin-extends-root' => false,
// Passing a single space as value to `Xml::attr()` has been
// deprecated. In a future version, passing a single space won't
// render an empty value anymore but a single space.
// To render an empty value, please pass an empty string.
'xml-attr-single-space' => true,
// The `Content\Translation` class keeps a set of methods from the old
// `ContentTranslation` class for compatibility that should no longer be used.
// Some of them can be replaced by using `Version` class methods instead
// (see method comments). `Content\Translation::contentFile` should be avoided
// entirely and has no recommended replacement.
'translation-methods' => true
];
/**
@ -116,6 +117,18 @@ class Helpers
) {
$override = null;
// check if the LC_MESSAGES constant is defined
// some environments do not support LC_MESSAGES especially on Windows
// LC_MESSAGES constant is available if PHP was compiled with libintl
if (defined('LC_MESSAGES') === true) {
// backup current locale
$locale = setlocale(LC_MESSAGES, 0);
// set locale to C so that errors and warning messages are
// printed in English for robust comparisons in the handler
setlocale(LC_MESSAGES, 'C');
}
/**
* @psalm-suppress UndefinedVariable
*/
@ -151,6 +164,12 @@ class Helpers
// action or the standard error handler threw an
// exception; this avoids modifying global state
restore_error_handler();
// check if the LC_MESSAGES constant is defined
if (defined('LC_MESSAGES') === true) {
// reset to original locale
setlocale(LC_MESSAGES, $locale);
}
}
return $override ?? $result;
@ -159,7 +178,6 @@ class Helpers
/**
* Checks if a helper was overridden by the user
* by setting the `KIRBY_HELPER_*` constant
* @internal
*
* @param string $name Name of the helper
*/
@ -189,6 +207,8 @@ class Helpers
return count($value);
}
throw new InvalidArgumentException('Could not determine the size of the given value');
throw new InvalidArgumentException(
message: 'Could not determine the size of the given value'
);
}
}

View file

@ -3,6 +3,8 @@
namespace Kirby\Cms;
use Kirby\Filesystem\F;
use Kirby\Plugin\Assets;
use Kirby\Plugin\Plugin;
use Kirby\Toolkit\A;
/**
@ -26,14 +28,14 @@ class Html extends \Kirby\Toolkit\Html
* @param string|array|null $options Pass an array of attributes for the link tag or a media attribute string
*/
public static function css(
string|array|Plugin|PluginAssets $url,
string|array|Plugin|Assets $url,
string|array|null $options = null
): string|null {
if ($url instanceof Plugin) {
$url = $url->assets();
}
if ($url instanceof PluginAssets) {
if ($url instanceof Assets) {
$url = $url->css()->values(fn ($asset) => $asset->url());
}
@ -65,9 +67,7 @@ class Html extends \Kirby\Toolkit\Html
$url = ($kirby->component('css'))($kirby, $url, $options);
$url = Url::to($url);
$attr = array_merge((array)$options, [
'href' => $url
]);
$attr = [...$options ?? [], 'href' => $url];
return '<link ' . static::attr($attr) . '>';
}
@ -92,14 +92,14 @@ class Html extends \Kirby\Toolkit\Html
* @since 3.7.0
*/
public static function js(
string|array|Plugin|PluginAssets $url,
string|array|Plugin|Assets $url,
string|array|bool|null $options = null
): string|null {
if ($url instanceof Plugin) {
$url = $url->assets();
}
if ($url instanceof PluginAssets) {
if ($url instanceof Assets) {
$url = $url->js()->values(fn ($asset) => $asset->url());
}
@ -122,7 +122,7 @@ class Html extends \Kirby\Toolkit\Html
$url = ($kirby->component('js'))($kirby, $url, $options);
$url = Url::to($url);
$attr = array_merge((array)$options, ['src' => $url]);
$attr = [...$options ?? [], 'src' => $url];
return '<script ' . static::attr($attr) . '></script>';
}

View file

@ -18,17 +18,12 @@ use Closure;
*/
class Ingredients
{
/**
* @var array
*/
protected $ingredients = [];
/**
* Creates a new ingredient collection
*/
public function __construct(array $ingredients)
{
$this->ingredients = $ingredients;
public function __construct(
protected array $ingredients = []
) {
}
/**

View file

@ -20,6 +20,9 @@ use Kirby\Toolkit\Str;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @template TCollection of \Kirby\Cms\Items
* @use \Kirby\Cms\HasSiblings<TCollection>
*/
class Item
{

View file

@ -15,6 +15,9 @@ use Kirby\Exception\InvalidArgumentException;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @template TValue of \Kirby\Cms\Item
* @extends \Kirby\Cms\Collection<TValue>
*/
class Items extends Collection
{
@ -32,7 +35,7 @@ class Items extends Collection
/**
* @var \Kirby\Cms\ModelWithContent
*/
protected $parent;
protected object|null $parent = null;
public function __construct($objects = [], array $options = [])
{
@ -56,7 +59,7 @@ class Items extends Collection
}
if (is_array($params) === false) {
throw new InvalidArgumentException('Invalid item options');
throw new InvalidArgumentException(message: 'Invalid item options');
}
// create a new collection of blocks
@ -64,7 +67,9 @@ class Items extends Collection
foreach ($items as $item) {
if (is_array($item) === false) {
throw new InvalidArgumentException('Invalid data for ' . static::ITEM_CLASS);
throw new InvalidArgumentException(
message: 'Invalid data for ' . static::ITEM_CLASS
);
}
// inject properties from the parent

View file

@ -5,12 +5,11 @@ namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\PermissionException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Locale;
use Kirby\Toolkit\Str;
use Throwable;
use Stringable;
/**
* The `$language` object represents
@ -27,11 +26,18 @@ use Throwable;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Languages>
*/
class Language
class Language implements Stringable
{
use HasSiblings;
/**
* Short human-readable version used in template queries
*/
public const CLASS_ALIAS = 'language';
/**
* The parent Kirby instance
*/
@ -42,6 +48,7 @@ class Language
protected string $direction;
protected array $locale;
protected string $name;
protected bool $single;
protected array $slugs;
protected array $smartypants;
protected array $translations;
@ -53,14 +60,17 @@ class Language
public function __construct(array $props)
{
if (isset($props['code']) === false) {
throw new InvalidArgumentException('The property "code" is required');
throw new InvalidArgumentException(
message: 'The property "code" is required'
);
}
static::$kirby = $props['kirby'] ?? null;
$this->code = trim($props['code']);
$this->code = basename(trim($props['code'])); // prevent path traversal
$this->default = ($props['default'] ?? false) === true;
$this->direction = ($props['direction'] ?? null) === 'rtl' ? 'rtl' : 'ltr';
$this->name = trim($props['name'] ?? $this->code);
$this->single = $props['single'] ?? false;
$this->slugs = $props['slugs'] ?? [];
$this->smartypants = $props['smartypants'] ?? [];
$this->translations = $props['translations'] ?? [];
@ -142,22 +152,12 @@ class Language
/**
* Creates a new language object
* @internal
*/
public static function create(array $props): static
{
$kirby = App::instance();
$user = $kirby->user();
if (
$user === null ||
$user->role()->permissions()->for('languages', 'create') === false
) {
throw new PermissionException(['key' => 'language.create.permission']);
}
$props['code'] = Str::slug($props['code'] ?? null);
$kirby = App::instance();
$languages = $kirby->languages();
$props['code'] = Str::slug($props['code'] ?? null);
// make the first language the default language
if ($languages->count() === 0) {
@ -166,25 +166,30 @@ class Language
$language = new static($props);
// trigger before hook
$kirby->trigger(
// validate the new language
LanguageRules::create($language);
// apply before hook
$language = $kirby->apply(
'language.create:before',
[
'input' => $props,
'language' => $language
]
],
'language'
);
// validate the new language
// re-validate the language after before hook was applied
LanguageRules::create($language);
$language->save();
// convert content storage to multilang
if ($languages->count() === 0) {
foreach ($kirby->models() as $model) {
$model->storage()->convertLanguage(
'default',
$language->code()
$model->storage()->moveLanguage(
Language::single(),
$language
);
}
}
@ -192,13 +197,14 @@ class Language
// update the main languages collection in the app instance
$kirby->languages(false)->append($language->code(), $language);
// trigger after hook
$kirby->trigger(
// apply after hook
$language = $kirby->apply(
'language.create:after',
[
'input' => $props,
'language' => $language
]
],
'language'
);
return $language;
@ -207,41 +213,36 @@ class Language
/**
* Delete the current language and
* all its translation files
* @internal
*
* @throws \Kirby\Exception\Exception
*/
public function delete(): bool
{
$kirby = App::instance();
$user = $kirby->user();
$code = $this->code();
if (
$user === null ||
$user->role()->permissions()->for('languages', 'delete') === false
) {
throw new PermissionException(['key' => 'language.delete.permission']);
}
if ($this->isDeletable() === false) {
throw new Exception('The language cannot be deleted');
}
// trigger before hook
$kirby->trigger('language.delete:before', [
'language' => $this
]);
if (F::remove($this->root()) !== true) {
throw new Exception('The language could not be deleted');
// validate the language rules
LanguageRules::delete($this);
// apply before hook
$language = $kirby->apply(
'language.delete:before',
['language' => $this]
);
// re-validate the language rules after before hook was applied
LanguageRules::delete($language);
if (F::remove($language->root()) !== true) {
throw new Exception(message: 'The language could not be deleted');
}
// if needed, convert content storage to single lang
foreach ($kirby->models() as $model) {
if ($this->isLast() === true) {
$model->storage()->convertLanguage($code, 'default');
if ($language->isLast() === true) {
$model->storage()->moveLanguage($this, Language::single());
} else {
$model->storage()->deleteLanguage($code);
$model->storage()->deleteLanguage($this);
}
}
@ -250,7 +251,7 @@ class Language
// trigger after hook
$kirby->trigger('language.delete:after', [
'language' => $this
'language' => $language
]);
return true;
@ -264,6 +265,34 @@ class Language
return $this->direction;
}
/**
* Converts a "user-facing" language code to a `Language` object
*
* @throws \Kirby\Exception\NotFoundException If the language does not exist
* @unstable
*/
public static function ensure(self|string|null $code = null): static
{
if ($code instanceof self) {
return $code;
}
$kirby = App::instance();
// single language
if ($kirby->multilang() === false) {
return static::single();
}
// look up the actual language object if possible
if ($language = $kirby->language($code)) {
return $language;
}
// validate the language code
throw new NotFoundException(message: 'Invalid language: ' . $code);
}
/**
* Check if the language file exists
*/
@ -272,6 +301,16 @@ class Language
return file_exists($this->root());
}
/**
* Checks if the language is the same
* as the given language or language code
* @since 5.0.0
*/
public function is(self|string $language): bool
{
return $this->code() === static::ensure($language)->code();
}
/**
* Checks if this is the default language
* for the site.
@ -286,6 +325,11 @@ class Language
*/
public function isDeletable(): bool
{
// a single-language object cannot be deleted
if ($this->isSingle() === true) {
return false;
}
// the default language can only be deleted if it's the last
if ($this->isDefault() === true && $this->isLast() === false) {
return false;
@ -302,6 +346,14 @@ class Language
return App::instance()->languages()->count() === 1;
}
/**
* Checks if this is the single language object
*/
public function isSingle(): bool
{
return $this->single;
}
/**
* The id is required for collections
* to work properly. The code is used as id
@ -325,6 +377,7 @@ class Language
public static function loadRules(string $code): array
{
$kirby = App::instance();
$code = basename($code); // prevent path traversal
$code = Str::contains($code, '.') ? Str::before($code, '.') : $code;
$file = $kirby->root('i18n:rules') . '/' . $code . '.json';
@ -332,11 +385,7 @@ class Language
$file = $kirby->root('i18n:rules') . '/' . Str::before($code, '_') . '.json';
}
try {
return Data::read($file);
} catch (\Exception) {
return [];
}
return Data::read($file, fail: false);
}
/**
@ -388,6 +437,14 @@ class Language
return $path . '/(:all?)';
}
/**
* Returns the permissions object for this language
*/
public function permissions(): LanguagePermissions
{
return new LanguagePermissions($this);
}
/**
* Returns the absolute path to the language file
*/
@ -408,30 +465,28 @@ class Language
/**
* Get slug rules for language
* @internal
*/
public function rules(): array
{
$code = $this->locale(LC_CTYPE);
$data = static::loadRules($code);
return array_merge($data, $this->slugs());
return [
...static::loadRules($code),
...$this->slugs()
];
}
/**
* Saves the language settings in the languages folder
* @internal
*
* @return $this
*/
public function save(): static
{
try {
$existingData = Data::read($this->root());
} catch (Throwable) {
$existingData = [];
}
$existingData = Data::read($this->root(), fail: false);
$props = [
$data = [
...$existingData,
'code' => $this->code(),
'default' => $this->isDefault(),
'direction' => $this->direction(),
@ -441,8 +496,6 @@ class Language
'url' => $this->url,
];
$data = array_merge($existingData, $props);
ksort($data);
Data::write($this->root(), $data);
@ -453,11 +506,25 @@ class Language
/**
* Private siblings collector
*/
protected function siblingsCollection(): Collection
protected function siblingsCollection(): Languages
{
return App::instance()->languages();
}
/**
* Create a placeholder language object in a
* single-language installation
*/
public static function single(): static
{
return new static([
'code' => 'en',
'default' => true,
'locale' => App::instance()->option('locale', 'en_US.utf-8'),
'single' => true
]);
}
/**
* Returns the custom slug rules for this language
*/
@ -475,8 +542,7 @@ class Language
}
/**
* Returns the most important
* properties as array
* Returns the most important properties as array
*/
public function toArray(): array
{
@ -511,19 +577,10 @@ class Language
/**
* Update language properties and save them
* @internal
*/
public function update(array|null $props = null): static
{
$kirby = App::instance();
$user = $kirby->user();
if (
$user === null ||
$user->role()->permissions()->for('languages', 'update') === false
) {
throw new PermissionException(['key' => 'language.update.permission']);
}
// don't change the language code
unset($props['code']);
@ -531,23 +588,27 @@ class Language
// make sure the slug is nice and clean
$props['slug'] = Str::slug($props['slug'] ?? null);
$updated = $this->clone($props);
// trigger before hook
$language = $kirby->apply(
'language.update:before',
[
'language' => $this,
'input' => $props
]
);
// updated language object
$language = $language->clone($props);
if (isset($props['translations']) === true) {
$updated->translations = $props['translations'];
$language->translations = $props['translations'];
}
// validate the updated language
LanguageRules::update($updated);
// trigger before hook
$kirby->trigger('language.update:before', [
'language' => $this,
'input' => $props
]);
// validate the language rules after before hook was applied
LanguageRules::update($language, $this);
// if language just got promoted to be the new default language…
if ($this->isDefault() === false && $updated->isDefault() === true) {
if ($this->isDefault() === false && $language->isDefault() === true) {
// convert the current default to a non-default language
$previous = $kirby->defaultLanguage()?->clone(['default' => false])->save();
$kirby->languages(false)->set($previous->code(), $previous);
@ -557,27 +618,20 @@ class Language
}
}
// if language was the default language and got demoted…
if (
$this->isDefault() === true &&
$updated->isDefault() === false &&
$kirby->defaultLanguage()->code() === $this->code()
) {
// ensure another language has already been set as default
throw new LogicException('Please select another language to be the primary language');
}
$language = $updated->save();
$language = $language->save();
// make sure the language is also updated in the languages collection
$kirby->languages(false)->set($language->code(), $language);
// trigger after hook
$kirby->trigger('language.update:after', [
'newLanguage' => $language,
'oldLanguage' => $this,
'input' => $props
]);
$language = $kirby->apply(
'language.update:after',
[
'newLanguage' => $language,
'oldLanguage' => $this,
'input' => $props
]
);
return $language;
}
@ -586,14 +640,19 @@ class Language
* Returns a language variable object
* for the key in the translations array
*/
public function variable(string $key, bool $decode = false): LanguageVariable
{
public function variable(
string $key,
bool $decode = false
): LanguageVariable {
// allows decoding if base64-url encoded url is sent
// for compatibility of different environments
if ($decode === true) {
$key = rawurldecode(base64_decode($key));
}
return new LanguageVariable($this, $key);
return new LanguageVariable(
language: $this,
key: $key
);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Kirby\Cms;
/**
* LanguagePermissions
*
* @package Kirby Cms
* @author Ahmet Bora <ahmet@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class LanguagePermissions extends ModelPermissions
{
protected const CATEGORY = 'languages';
protected function canDelete(): bool
{
return $this->model->isDeletable() === true;
}
}

View file

@ -60,7 +60,7 @@ class LanguageRouter
$languages = Str::split(strtolower($route['language']), '|');
// validate the language
return in_array($language->code(), $languages) === true;
return in_array($language->code(), $languages, true) === true;
}));
// add the page-scope if necessary
@ -80,7 +80,9 @@ class LanguageRouter
$routes[$index]['pattern'] = $patterns;
$routes[$index]['page'] = $page;
} else {
throw new NotFoundException('The page "' . $pageId . '" does not exist');
throw new NotFoundException(
message: 'The page "' . $pageId . '" does not exist'
);
}
}
}

View file

@ -129,11 +129,10 @@ class LanguageRoutes
// if there's just one language,
// we take that to render the home page
if ($languages->count() === 1) {
$currentLanguage = $languages->first();
} else {
$currentLanguage = $kirby->defaultLanguage();
}
$currentLanguage = match ($languages->count()) {
1 => $languages->first(),
default => $kirby->defaultLanguage()
};
// language detection on the home page with / as URL
if ($kirby->url() !== $currentLanguage->url()) {

View file

@ -4,6 +4,8 @@ namespace Kirby\Cms;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\Str;
/**
@ -21,31 +23,71 @@ class LanguageRules
* Validates if the language can be created
*
* @throws \Kirby\Exception\DuplicateException If the language already exists
* @throws \Kirby\Exception\PermissionException If current user has not sufficient permissions
*/
public static function create(Language $language): bool
public static function create(Language $language): void
{
static::validLanguageCode($language);
static::validLanguageName($language);
if ($language->exists() === true) {
throw new DuplicateException([
'key' => 'language.duplicate',
'data' => [
'code' => $language->code()
]
]);
throw new DuplicateException(
key: 'language.duplicate',
data: ['code' => $language->code()]
);
}
return true;
if ($language->permissions()->can('create') !== true) {
throw new PermissionException(
key: 'language.create.permission'
);
}
}
/**
* Validates if the language can be deleted
*
* @throws \Kirby\Exception\LogicException If the language cannot be deleted
* @throws \Kirby\Exception\PermissionException If current user has not sufficient permissions
*/
public static function delete(Language $language): void
{
if ($language->permissions()->can('delete') !== true) {
throw new PermissionException(
key: 'language.delete.permission'
);
}
}
/**
* Validates if the language can be updated
*/
public static function update(Language $language): void
{
static::validLanguageCode($language);
static::validLanguageName($language);
public static function update(
Language $newLanguage,
Language|null $oldLanguage = null
): void {
static::validLanguageCode($newLanguage);
static::validLanguageName($newLanguage);
$kirby = App::instance();
// if language was the default language and got demoted…
if (
$oldLanguage?->isDefault() === true &&
$newLanguage->isDefault() === false &&
$kirby->defaultLanguage()->code() === $oldLanguage?->code()
) {
// ensure another language has already been set as default
throw new LogicException(
message: 'Please select another language to be the primary language'
);
}
if ($newLanguage->permissions()->can('update') !== true) {
throw new PermissionException(
key: 'language.update.permission'
);
}
}
/**
@ -53,19 +95,17 @@ class LanguageRules
*
* @throws \Kirby\Exception\InvalidArgumentException If the language code is not valid
*/
public static function validLanguageCode(Language $language): bool
public static function validLanguageCode(Language $language): void
{
if (Str::length($language->code()) < 2) {
throw new InvalidArgumentException([
'key' => 'language.code',
'data' => [
throw new InvalidArgumentException(
key: 'language.code',
data: [
'code' => $language->code(),
'name' => $language->name()
]
]);
);
}
return true;
}
/**
@ -73,18 +113,16 @@ class LanguageRules
*
* @throws \Kirby\Exception\InvalidArgumentException If the language name is invalid
*/
public static function validLanguageName(Language $language): bool
public static function validLanguageName(Language $language): void
{
if (Str::length($language->name()) < 1) {
throw new InvalidArgumentException([
'key' => 'language.name',
'data' => [
throw new InvalidArgumentException(
key: 'language.name',
data: [
'code' => $language->code(),
'name' => $language->name()
]
]);
);
}
return true;
}
}

View file

@ -34,14 +34,18 @@ class LanguageVariable
*/
public static function create(
string $key,
string|null $value = null
string|array|null $value = null
): static {
if (is_numeric($key) === true) {
throw new InvalidArgumentException('The variable key must not be numeric');
throw new InvalidArgumentException(
message: 'The variable key must not be numeric'
);
}
if (empty($key) === true) {
throw new InvalidArgumentException('The variable needs a valid key');
throw new InvalidArgumentException(
message: 'The variable needs a valid key'
);
}
$kirby = App::instance();
@ -50,15 +54,19 @@ class LanguageVariable
if ($kirby->translation()->get($key) !== null) {
if (isset($translations[$key]) === true) {
throw new DuplicateException('The variable already exists');
throw new DuplicateException(
message: 'The variable already exists'
);
}
throw new DuplicateException('The variable is part of the core translation and cannot be overwritten');
throw new DuplicateException(
message: 'The variable is part of the core translation and cannot be overwritten'
);
}
$translations[$key] = $value ?? '';
$language->update(['translations' => $translations]);
$language = $language->update(['translations' => $translations]);
return $language->variable($key);
}
@ -91,6 +99,15 @@ class LanguageVariable
return isset($language->translations()[$this->key]) === true;
}
/**
* Checks if the value is an array
* @since 5.0.0
*/
public function hasMultipleValues(): bool
{
return is_array($this->value()) === true;
}
/**
* Returns the unique key for the variable
*/
@ -102,7 +119,7 @@ class LanguageVariable
/**
* Sets a new value for the language variable
*/
public function update(string|null $value = null): static
public function update(string|array|null $value = null): static
{
$translations = $this->language->translations();
$translations[$this->key] = $value ?? '';
@ -115,7 +132,7 @@ class LanguageVariable
/**
* Returns the value if the variable has been translated.
*/
public function value(): string|null
public function value(): string|array|null
{
return $this->language->translations()[$this->key] ?? null;
}

View file

@ -13,6 +13,8 @@ use Kirby\Filesystem\F;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Collection<\Kirby\Cms\Language>
*/
class Languages extends Collection
{
@ -37,7 +39,9 @@ class Languages extends Collection
);
if (count($defaults) > 1) {
throw new DuplicateException('You cannot have multiple default languages. Please check your language config files.');
throw new DuplicateException(
message: 'You cannot have multiple default languages. Please check your language config files.'
);
}
parent::__construct($objects, null);
@ -53,7 +57,6 @@ class Languages extends Collection
/**
* Creates a new language with the given props
* @internal
*/
public function create(array $props): Language
{
@ -69,8 +72,26 @@ class Languages extends Collection
}
/**
* Convert all defined languages to a collection
* @internal
* Provides a collection of installed languages, even
* if in single-language mode. In single-language mode
* `Language::single()` is used to create the default language
*
* @unstable
*/
public static function ensure(): static
{
$kirby = App::instance();
if ($kirby->multilang() === true) {
return $kirby->languages();
}
return new static([Language::single()]);
}
/**
* Load all languages from the languages directory
* and convert them to a collection
*/
public static function load(): static
{

View file

@ -14,6 +14,8 @@ use Kirby\Content\Content;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Item<\Kirby\Cms\Layouts>
*/
class Layout extends Item
{

View file

@ -14,6 +14,8 @@ use Kirby\Toolkit\Str;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Item<\Kirby\Cms\LayoutColumns>
*/
class LayoutColumn extends Item
{

View file

@ -11,6 +11,8 @@ namespace Kirby\Cms;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Items<\Kirby\Cms\LayoutColumn>
*/
class LayoutColumns extends Items
{

View file

@ -15,6 +15,8 @@ use Throwable;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Items<\Kirby\Cms\Layout>
*/
class Layouts extends Items
{

View file

@ -24,7 +24,8 @@ class License
{
public const HISTORY = [
'3' => '2019-02-05',
'4' => '2023-11-28'
'4' => '2023-11-28',
'5' => '2025-06-24'
];
protected const SALT = 'kwAHMLyLPBnHEskzH9pPbJsBxQhKXZnX';
@ -42,9 +43,13 @@ class License
protected string|null $date = null,
protected string|null $signature = null,
) {
// normalize arguments
$this->code = $this->code !== null ? trim($this->code) : null;
$this->email = $this->email !== null ? $this->normalizeEmail($this->email) : null;
if ($code !== null) {
$this->code = trim($code);
}
if ($email !== null) {
$this->email = $this->normalizeEmail($email);
}
}
/**
@ -181,7 +186,9 @@ class License
// rather throw an exception to avoid further issues
// @codeCoverageIgnoreStart
if ($release === false) {
throw new InvalidArgumentException('The version for your license could not be found');
throw new InvalidArgumentException(
message: 'The version for your license could not be found'
);
}
// @codeCoverageIgnoreEnd
@ -335,15 +342,21 @@ class License
public function register(): static
{
if ($this->type() === LicenseType::Invalid) {
throw new InvalidArgumentException(['key' => 'license.format']);
throw new InvalidArgumentException(
key: 'license.format'
);
}
if ($this->hasValidEmailAddress() === false) {
throw new InvalidArgumentException(['key' => 'license.email']);
throw new InvalidArgumentException(
key: 'license.email'
);
}
if ($this->domain === null) {
throw new InvalidArgumentException(['key' => 'license.domain']);
throw new InvalidArgumentException(
key: 'license.domain'
);
}
// @codeCoverageIgnoreStart
@ -386,7 +399,10 @@ class License
if ($response->code() !== 200) {
$message = $response->json()['message'] ?? 'The request failed';
throw new LogicException($message, $response->code());
throw new LogicException(
key: $response->code(),
message: $message,
);
}
return $response->json();
@ -399,9 +415,9 @@ class License
public function save(): bool
{
if ($this->status()->activatable() !== true) {
throw new InvalidArgumentException([
'key' => 'license.verification'
]);
throw new InvalidArgumentException(
key: 'license.verification'
);
}
// where to store the license file
@ -453,10 +469,10 @@ class License
public function status(): LicenseStatus
{
return $this->status ??= match (true) {
$this->isMissing() === true => LicenseStatus::Missing,
$this->isLegacy() === true => LicenseStatus::Legacy,
$this->isInactive() === true => LicenseStatus::Inactive,
default => LicenseStatus::Active
$this->isMissing() => LicenseStatus::Missing,
$this->isLegacy() => LicenseStatus::Legacy,
$this->isInactive() => LicenseStatus::Inactive,
default => LicenseStatus::Active
};
}
@ -510,7 +526,9 @@ class License
if (empty($response['url']) === false) {
// validate the redirect URL
if (Str::startsWith($response['url'], static::hub()) === false) {
throw new Exception('We couldnt redirect you to the Hub');
throw new Exception(
message: 'We couldnt redirect you to the Hub'
);
}
return [

View file

@ -43,6 +43,11 @@ enum LicenseStatus: string
*/
case Missing = 'missing';
/**
* The license status is unknown
*/
case Unknown = 'unknown';
/**
* Checks if the license can be saved when it
* was entered in the activation dialog;
@ -65,8 +70,8 @@ enum LicenseStatus: string
public function dialog(): string|null
{
return match ($this) {
static::Missing => 'registration',
static::Demo => null,
static::Missing => 'registration',
default => 'license'
};
}
@ -79,11 +84,12 @@ enum LicenseStatus: string
public function icon(): string
{
return match ($this) {
static::Missing => 'key',
static::Legacy => 'alert',
static::Inactive => 'clock',
static::Active => 'check',
static::Demo => 'preview',
static::Inactive => 'clock',
static::Legacy => 'alert',
static::Missing => 'key',
static::Unknown => 'question',
};
}
@ -112,9 +118,9 @@ enum LicenseStatus: string
public function renewable(): bool
{
return match ($this) {
static::Demo,
static::Active => false,
default => true
static::Active,
static::Demo => false,
default => true
};
}
@ -126,11 +132,12 @@ enum LicenseStatus: string
public function theme(): string
{
return match ($this) {
static::Missing => 'love',
static::Legacy => 'negative',
static::Inactive => 'notice',
static::Active => 'positive',
static::Demo => 'notice',
static::Inactive => 'notice',
static::Legacy => 'negative',
static::Missing => 'love',
static::Unknown => 'passive',
};
}

View file

@ -28,24 +28,10 @@ use Kirby\Filesystem\F;
*/
class Loader
{
/**
* @var \Kirby\Cms\App
*/
protected $kirby;
/**
* @var bool
*/
protected $withPlugins;
/**
* @param \Kirby\Cms\App $kirby
* @param bool $withPlugins
*/
public function __construct(App $kirby, bool $withPlugins = true)
{
$this->kirby = $kirby;
$this->withPlugins = $withPlugins;
public function __construct(
protected App $kirby,
protected bool $withPlugins = true
) {
}
/**
@ -63,7 +49,10 @@ class Loader
public function areas(): array
{
$areas = [];
$extensions = $this->withPlugins === true ? $this->kirby->extensions('areas') : [];
$extensions = match ($this->withPlugins) {
true => $this->kirby->extensions('areas'),
false => []
};
// load core areas and extend them with elements
// from plugins if they exist
@ -121,7 +110,10 @@ class Loader
*/
public function extensions(string $type): array
{
return $this->withPlugins === false ? $this->kirby->core()->$type() : $this->kirby->extensions($type);
return match ($this->withPlugins) {
true => $this->kirby->extensions($type),
false => $this->kirby->core()->$type()
};
}
/**
@ -174,12 +166,11 @@ class Loader
*/
public function resolveArea(string|array|Closure $area): array
{
$area = $this->resolve($area);
$dropdowns = $area['dropdowns'] ?? [];
$area = $this->resolve($area);
// convert closure dropdowns to an array definition
// otherwise they cannot be merged properly later
foreach ($dropdowns as $key => $dropdown) {
foreach ($area['dropdowns'] ?? [] as $key => $dropdown) {
if ($dropdown instanceof Closure) {
$area['dropdowns'][$key] = [
'options' => $dropdown

View file

@ -95,31 +95,40 @@ class Media
string $filename
): Response|false {
$kirby = App::instance();
$index = $kirby->root('index');
$media = $kirby->root('media');
$root = match (true) {
// assets
is_string($model)
=> $kirby->root('media') . '/assets/' . $model . '/' . $hash,
=> $media . '/assets/' . $model . '/' . $hash,
// parent files for file model that already included hash
$model instanceof File
=> dirname($model->mediaRoot()),
=> $model->mediaDir(),
// model files
default
=> $model->mediaRoot() . '/' . $hash
};
$thumb = $root . '/' . $filename;
$job = $root . '/.jobs/' . $filename . '.json';
try {
// prevent path traversal
$root = Dir::realpath($root, $media);
$thumb = $root . '/' . $filename;
$job = $root . '/.jobs/' . $filename . '.json';
$options = Data::read($job);
} catch (Throwable) {
// send a customized error message to make clearer what happened here
throw new NotFoundException('The thumbnail configuration could not be found');
throw new NotFoundException(
message: 'The thumbnail configuration could not be found'
);
}
if (empty($options['filename']) === true) {
throw new InvalidArgumentException('Incomplete thumbnail configuration');
throw new InvalidArgumentException(
message: 'Incomplete thumbnail configuration'
);
}
try {
@ -127,7 +136,12 @@ class Media
// this adds support for custom assets
$source = match (true) {
is_string($model) === true
=> $kirby->root('index') . '/' . $model . '/' . $options['filename'],
=> F::realpath(
$index . '/' . $model . '/' . $options['filename'],
$index
),
$model instanceof File
=> $model->root(),
default
=> $model->file($options['filename'])->root()
};
@ -161,10 +175,10 @@ class Media
}
// 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)
);
$versions = [
...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

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

View file

@ -0,0 +1,243 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\Exception;
/**
* The ModelCommit class is used to commit a given model action
* in the model action classes. It takes care of running
* the `before` and `after` hooks and updating the state
* of the given model.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ModelCommit
{
protected App $kirby;
protected string $prefix;
public function __construct(
protected ModelWithContent $model,
protected string $action
) {
$this->kirby = $this->model->kirby();
$this->prefix = $this->model::CLASS_ALIAS;
}
/**
* Runs the `after` hook and returns the result.
*/
public function after(mixed $state): mixed
{
// run the `after` hook
$arguments = $this->afterHookArguments($state);
$hook = $this->hook('after', $arguments);
// flush the page cache after any model action
$this->kirby->cache('pages')->flush();
return $hook['result'];
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given model action. It's a wrapper around the
* more specific `afterHookArgumentsFor*Actions` methods.
*/
public function afterHookArguments(mixed $state): array
{
return match (true) {
$this->model instanceof File =>
$this->afterHookArgumentsForFileActions($this->model, $this->action, $state),
$this->model instanceof Page =>
$this->afterHookArgumentsForPageActions($this->model, $this->action, $state),
$this->model instanceof Site =>
$this->afterHookArgumentsForSiteActions($this->model, $state),
$this->model instanceof User =>
$this->afterHookArgumentsForUserActions($this->model, $this->action, $state),
default =>
throw new Exception('Invalid model class')
};
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given page action.
*/
protected function afterHookArgumentsForPageActions(
Page $model,
string $action,
mixed $state
): array {
return match ($action) {
'create' => [
'page' => $state
],
'duplicate' => [
'duplicatePage' => $state,
'originalPage' => $model
],
'delete' => [
'status' => $state,
'page' => $model
],
default => [
'newPage' => $state,
'oldPage' => $model
]
};
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given file action.
*/
protected function afterHookArgumentsForFileActions(
File $model,
string $action,
mixed $state
): array {
return match ($action) {
'create' => [
'file' => $state
],
'delete' => [
'status' => $state,
'file' => $model
],
default => [
'newFile' => $state,
'oldFile' => $model
]
};
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given site action.
*/
protected function afterHookArgumentsForSiteActions(
Site $model,
mixed $state
): array {
return [
'newSite' => $state,
'oldSite' => $model
];
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given user action.
*/
protected function afterHookArgumentsForUserActions(
User $model,
string $action,
mixed $state
): array {
return match ($action) {
'create' => [
'user' => $state
],
'delete' => [
'status' => $state,
'user' => $model
],
default => [
'newUser' => $state,
'oldUser' => $model
]
};
}
/**
* Runs the `before` hook and modifies the arguments
*/
public function before(array $arguments): array
{
// check model rules
$this->validate($arguments);
// run the `before` hook
$hook = $this->hook('before', $arguments);
// check model rules again, after the hook got applied
$this->validate($hook['arguments']);
return $hook['arguments'];
}
/**
* Handles the full call of the given action,
* runs the `before` and `after` hooks and updates
* the state of the given model.
*/
public function call(array $arguments, Closure $callback): mixed
{
// run the before hook
$arguments = $this->before($arguments);
// run the commit action
$state = $callback(...array_values($arguments));
// update the state for the after hook
ModelState::update(
method: $this->action,
current: $this->model,
next: $state
);
// run the after hook and return the result
return $this->after($state);
}
/**
* Runs the given hook and modifies the first argument
* of the given arguments array. It returns an array with
* `arguments` and `result` keys.
*/
public function hook(string $hook, array $arguments): array
{
// the very first argument (which should be the model)
// is modified by the return value from the hook (if any returned)
$appliedTo = array_key_first($arguments);
// run the hook and modify the first argument
$arguments[$appliedTo] = $this->kirby->apply(
// e.g. page.create:before
$this->prefix . '.' . $this->action . ':' . $hook,
$arguments,
$appliedTo
);
return [
'arguments' => $arguments,
'result' => $arguments[$appliedTo],
];
}
/**
* Checks the model rules for the given action
* if there's a matching rule method.
*/
public function validate(array $arguments): void
{
$rules = match (true) {
$this->model instanceof File => FileRules::class,
$this->model instanceof Page => PageRules::class,
$this->model instanceof Site => SiteRules::class,
$this->model instanceof User => UserRules::class,
default => throw new Exception('Invalid model class') // @codeCoverageIgnore
};
if (method_exists($rules, $this->action) === true) {
$rules::{$this->action}(...array_values($arguments));
}
}
}

View file

@ -2,6 +2,7 @@
namespace Kirby\Cms;
use Kirby\Exception\LogicException;
use Kirby\Toolkit\A;
/**
@ -15,18 +16,17 @@ use Kirby\Toolkit\A;
*/
abstract class ModelPermissions
{
protected string $category;
protected ModelWithContent $model;
protected const CATEGORY = 'model';
protected array $options;
protected Permissions $permissions;
protected User $user;
public function __construct(ModelWithContent $model)
protected static array $cache = [];
public function __construct(protected ModelWithContent|Language $model)
{
$this->model = $model;
$this->options = $model->blueprint()->options();
$this->user = $model->kirby()->user() ?? User::nobody();
$this->permissions = $this->user->role()->permissions();
$this->options = match (true) {
$model instanceof ModelWithContent => $model->blueprint()->options(),
default => []
};
}
public function __call(string $method, array $arguments = []): bool
@ -43,6 +43,17 @@ abstract class ModelPermissions
return $this->toArray();
}
/**
* Can be overridden by specific child classes
* to return a model-specific value used to
* cache a once determined permission in memory
* @codeCoverageIgnore
*/
protected static function cacheKey(ModelWithContent|Language $model): string
{
return '';
}
/**
* Returns whether the current user is allowed to do
* a certain action on the model
@ -53,8 +64,9 @@ abstract class ModelPermissions
string $action,
bool $default = false
): bool {
$user = $this->user->id();
$role = $this->user->role()->id();
$user = static::user();
$userId = $user->id();
$role = $user->role()->id();
// users with the `nobody` role can do nothing
// that needs a permission check
@ -73,7 +85,7 @@ abstract class ModelPermissions
}
// the almighty `kirby` user can do anything
if ($user === 'kirby' && $role === 'admin') {
if ($userId === 'kirby' && $role === 'admin') {
return true;
}
@ -103,7 +115,32 @@ abstract class ModelPermissions
}
}
return $this->permissions->for($this->category, $action, $default);
$permissions = $user->role()->permissions();
return $permissions->for(static::category($this->model), $action, $default);
}
/**
* Quickly determines a permission for the current user role
* and model blueprint unless dynamic checking is required
*/
public static function canFromCache(
ModelWithContent|Language $model,
string $action,
bool $default = false
): bool {
$role = $model->kirby()->role()?->id() ?? '__none__';
$category = static::category($model);
$cacheKey = $category . '.' . $action . '/' . static::cacheKey($model) . '/' . $role;
if (isset(static::$cache[$cacheKey]) === true) {
return static::$cache[$cacheKey];
}
if (method_exists(static::class, 'can' . $action) === true) {
throw new LogicException('Cannot use permission cache for dynamically-determined permission');
}
return static::$cache[$cacheKey] = $model->permissions()->can($action, $role, $default);
}
/**
@ -119,6 +156,15 @@ abstract class ModelPermissions
return $this->can($action, !$default) === false;
}
/**
* Can be overridden by specific child classes
* if the permission category needs to be dynamic
*/
protected static function category(ModelWithContent|Language $model): string
{
return static::CATEGORY;
}
public function toArray(): array
{
$array = [];
@ -129,4 +175,12 @@ abstract class ModelPermissions
return $array;
}
/**
* Returns the currently logged in user
*/
protected static function user(): User
{
return App::instance()->user() ?? User::nobody();
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace Kirby\Cms;
/**
* The ModelState class is used to update app-wide model states.
* It's mainly used in the `ModelCommit` class to update the
* state of the given model after the action has been
* executed.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ModelState
{
/**
* Updates the state of the given model.
*/
public static function update(
string $method,
ModelWithContent $current,
ModelWithContent|bool|null $next = null,
ModelWithContent|Site|null $parent = null
): void {
// normalize the method
$method = match ($method) {
'append', 'create' => 'append',
'remove', 'delete' => 'remove',
'duplicate' => false, // The models need to take care of this
default => 'update'
};
if ($method === false) {
return;
}
match (true) {
$current instanceof File => static::updateFile($method, $current, $next),
$current instanceof Page => static::updatePage($method, $current, $next, $parent),
$current instanceof Site => static::updateSite($current, $next),
$current instanceof User => static::updateUser($method, $current, $next),
};
}
/**
* Updates the state of the given file.
*/
protected static function updateFile(
string $method,
File $current,
File|bool|null $next = null
): void {
$next = $next instanceof File ? $next : $current;
// update the files collection
$next->parent()->files()->$method($next);
}
/**
* Updates the state of the given page.
*/
protected static function updatePage(
string $method,
Page $current,
Page|bool|null $next = null,
Page|Site|null $parent = null
): void {
$next = $next instanceof Page ? $next : $current;
$parent ??= $next->parentModel();
if ($next->isDraft() === true) {
$parent->drafts()->$method($next);
} else {
$parent->children()->$method($next);
}
// update the childrenAndDrafts() cache
$parent->childrenAndDrafts()->$method($next);
}
/**
* Updates the state of the given site.
*/
protected static function updateSite(
Site $current,
Site|null $next = null
): void {
App::instance()->setSite($next ?? $current);
}
/**
* Updates the state of the given user.
*/
protected static function updateUser(
string $method,
User $current,
User|bool|null $next = null
): void {
$next = $next instanceof User ? $next : $current;
// update the users collection
App::instance()->users()->$method($next);
}
}

View file

@ -4,17 +4,23 @@ namespace Kirby\Cms;
use Closure;
use Kirby\Content\Content;
use Kirby\Content\ContentStorage;
use Kirby\Content\ContentTranslation;
use Kirby\Content\PlainTextContentStorageHandler;
use Kirby\Content\ImmutableMemoryStorage;
use Kirby\Content\Lock;
use Kirby\Content\MemoryStorage;
use Kirby\Content\Storage;
use Kirby\Content\Translation;
use Kirby\Content\Translations;
use Kirby\Content\Version;
use Kirby\Content\VersionId;
use Kirby\Content\Versions;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Form\Fields;
use Kirby\Form\Form;
use Kirby\Panel\Model;
use Kirby\Toolkit\Str;
use Kirby\Uuid\Identifiable;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
use Stringable;
use Throwable;
/**
@ -26,7 +32,7 @@ use Throwable;
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
abstract class ModelWithContent implements Identifiable
abstract class ModelWithContent implements Identifiable, Stringable
{
/**
* Each model must define a CLASS_ALIAS
@ -42,11 +48,9 @@ abstract class ModelWithContent implements Identifiable
*/
public array|null $blueprints = null;
public Content|null $content;
public static App $kirby;
protected Site|null $site;
protected ContentStorage $storage;
public Collection|null $translations = null;
protected Storage $storage;
/**
* Store values used to initilaize object
@ -74,7 +78,7 @@ abstract class ModelWithContent implements Identifiable
public function blueprints(string|null $inSection = null): array
{
// helper function
$toBlueprints = function (array $sections): array {
$toBlueprints = static function (array $sections): array {
$blueprints = [];
foreach ($sections as $section) {
@ -100,6 +104,28 @@ abstract class ModelWithContent implements Identifiable
return $this->blueprints ??= $toBlueprints($blueprint->sections());
}
/**
* Moves or copies the model to a new storage instance/type
* @since 5.0.0
* @unstable
*/
public function changeStorage(Storage|string $toStorage, bool $copy = false): static
{
if (is_string($toStorage) === true) {
if (is_subclass_of($toStorage, Storage::class) === false) {
throw new InvalidArgumentException('Invalid storage class');
}
$toStorage = new $toStorage($this);
}
$method = $copy ? 'copyAll' : 'moveAll';
$this->storage()->$method(to: $toStorage);
$this->storage = $toStorage;
return $this;
}
/**
* Creates a new instance with the same
* initial properties
@ -108,7 +134,22 @@ abstract class ModelWithContent implements Identifiable
*/
public function clone(array $props = []): static
{
return new static(array_replace_recursive($this->propertyData, $props));
$props = array_replace_recursive($this->propertyData, $props);
$clone = new static($props);
// Move the clone to a new instance of the same storage class
// The storage classes might need to rely on the model instance
// and thus we need to make sure that the cloned object is
// passed on to the new storage instance
$storage = match (true) {
isset($props['content']),
isset($props['translations']) => $clone->storage()::class,
default => $this->storage()::class
};
$clone->changeStorage($storage);
return $clone;
}
/**
@ -127,88 +168,22 @@ abstract class ModelWithContent implements Identifiable
*/
public function content(string|null $languageCode = null): Content
{
// single language support
if ($this->kirby()->multilang() === false) {
if ($this->content instanceof Content) {
return $this->content;
}
// don't normalize field keys (already handled by the `Data` class)
return $this->content = new Content($this->readContent(), $this, false);
}
// get the targeted language
$language = $this->kirby()->language($languageCode);
$language = Language::ensure($languageCode ?? 'current');
$versionId = VersionId::$render ?? 'latest';
$version = $this->version($versionId);
// stop if the language does not exist
if ($language === null) {
throw new InvalidArgumentException('Invalid language: ' . $languageCode);
if ($version->exists($language) === true) {
return $version->content($language);
}
// only fetch from cache for the current language
if ($languageCode === null && $this->content instanceof Content) {
return $this->content;
}
// get the translation by code
$translation = $this->translation($language->code());
// don't normalize field keys (already handled by the `ContentTranslation` class)
$content = new Content($translation->content(), $this, false);
// only store the content for the current language
if ($languageCode === null) {
$this->content = $content;
}
return $content;
}
/**
* Returns the absolute path to the content file;
* NOTE: only supports the published content file
* (use `$model->storage()->contentFile()` for other versions)
* @internal
* @deprecated 4.0.0
* @todo Remove in v5
* @codeCoverageIgnore
*
* @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist
*/
public function contentFile(
string|null $languageCode = null,
bool $force = false
): string {
Helpers::deprecated('The internal $model->contentFile() method has been deprecated. You can use $model->storage()->contentFile() instead, however please note that this method is also internal and may be removed in the future.', 'model-content-file');
return $this->storage()->contentFile(
$this->storage()->defaultVersion(),
$languageCode,
$force
);
}
/**
* Returns an array with all content files;
* NOTE: only supports the published content file
* (use `$model->storage()->contentFiles()` for other versions)
* @deprecated 4.0.0
* @todo Remove in v5
* @codeCoverageIgnore
*/
public function contentFiles(): array
{
Helpers::deprecated('The internal $model->contentFiles() method has been deprecated. You can use $model->storage()->contentFiles() instead, however please note that this method is also internal and may be removed in the future.', 'model-content-file');
return $this->storage()->contentFiles(
$this->storage()->defaultVersion()
);
return $this->version()->content($language);
}
/**
* Prepares the content that should be written
* to the text file
* @internal
* @unstable
*/
public function contentFileData(
array $data,
@ -217,96 +192,77 @@ abstract class ModelWithContent implements Identifiable
return $data;
}
/**
* Returns the absolute path to the
* folder in which the content file is
* located
* @internal
* @deprecated 4.0.0
* @todo Remove in v5
* @codeCoverageIgnore
*/
public function contentFileDirectory(): string|null
{
Helpers::deprecated('The internal $model->contentFileDirectory() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file');
return $this->root();
}
/**
* Returns the extension of the content file
* @internal
* @deprecated 4.0.0
* @todo Remove in v5
* @codeCoverageIgnore
*/
public function contentFileExtension(): string
{
Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file');
return $this->kirby()->contentExtension();
}
/**
* Needs to be declared by the final model
* @internal
* @deprecated 4.0.0
* @todo Remove in v5
* @codeCoverageIgnore
*/
abstract public function contentFileName(): string;
/**
* Converts model to new blueprint
* incl. its content for all translations
*/
protected function convertTo(string $blueprint): static
{
// first close object with new blueprint as template
// Keep a copy of the old model with the original storage handler.
// This will be used to delete the old versions.
$old = $this->clone();
// Clone the object with the new blueprint as template
$new = $this->clone(['template' => $blueprint]);
// temporary compatibility change (TODO: also convert changes)
$identifier = $this->storage()->defaultVersion();
// Make sure to use the same storage class as the original model.
// This is needed if the model has been constructed with `content` or
// `translations` props. In this case, the storage would be set to
// `MemoryStorage` in the clone method again, even if it might have been
// changed before.
$new->changeStorage($this->storage()::class);
// for multilang, we go through all translations and
// covnert the content for each of them, remove the content file
// to rewrite it with converted content afterwards
if ($this->kirby()->multilang() === true) {
$translations = [];
// Copy this instance into immutable storage.
// Moving the content would prematurely delete the old content storage entries.
// But we need to keep them until the new content is written.
$this->changeStorage(
toStorage: new ImmutableMemoryStorage(
model: $this,
nextModel: $new
),
copy: true
);
foreach ($this->kirby()->languages()->codes() as $code) {
if ($this->translation($code)?->exists() === true) {
$content = $this->content($code)->convertTo($blueprint);
// Get all languages to loop through
$languages = Languages::ensure();
// delete the old text file
$this->storage()->delete(
$identifier,
$code
);
// save to re-create the translation content file
// with the converted/updated content
$new->save($content, $code);
// Loop through all versions
foreach ($old->versions() as $oldVersion) {
// Loop through all languages
foreach ($languages as $language) {
// Skip non-existing versions
if ($oldVersion->exists($language) === false) {
continue;
}
$translations[] = [
'code' => $code,
'content' => $content ?? null
];
}
// Convert the content to the new blueprint
$content = $oldVersion->content($language)->convertTo($blueprint);
// cloning the object with the new translations content ensures
// that `propertyData` prop does not hold any old translations
// content that could surface on subsequent cloning
return $new->clone(['translations' => $translations]);
// Delete the old versions. This will also remove the
// content files from the storage if this is a plain text
// storage instance.
$oldVersion->delete($language);
// Save to re-create the new version
// with the converted/updated content
$new->version($oldVersion->id())->save($content, $language);
}
}
// for single language setups, we do the same,
// just once for the main content
$content = $this->content()->convertTo($blueprint);
return $new;
}
// delete the old text file
$this->storage()->delete($identifier, 'default');
return $new->save($content);
/**
* Creates default content for the model, by using our
* Form class to generate the defaults, based on the
* model's blueprint setup.
*
* @since 5.0.0
*/
public function createDefaultContent(): array
{
$fields = Fields::for($this, 'default');
return $fields->fill($fields->defaults())->toStoredValues();
}
/**
@ -334,7 +290,7 @@ abstract class ModelWithContent implements Identifiable
$errors = [];
foreach ($this->blueprint()->sections() as $section) {
$errors = array_merge($errors, $section->errors());
$errors = [...$errors, ...$section->errors()];
}
return $errors;
@ -384,11 +340,11 @@ abstract class ModelWithContent implements Identifiable
/**
* Checks if the model is locked for the current user
* @deprecated 5.0.0 Use `->lock()->isLocked()` instead
*/
public function isLocked(): bool
{
$lock = $this->lock();
return $lock && $lock->isLocked() === true;
return $this->lock()->isLocked() === true;
}
/**
@ -396,7 +352,7 @@ abstract class ModelWithContent implements Identifiable
*/
public function isValid(): bool
{
return Form::for($this)->isValid() === true;
return $this->version('latest')->isValid('current');
}
/**
@ -408,28 +364,11 @@ abstract class ModelWithContent implements Identifiable
}
/**
* Returns the lock object for this model
*
* Only if a content directory exists,
* virtual pages will need to overwrite this method
* Returns lock for the model
*/
public function lock(): ContentLock|null
public function lock(): Lock
{
$dir = $this->root();
if ($this::CLASS_ALIAS === 'file') {
$dir = dirname($dir);
}
if (
$this->kirby()->option('content.locking', true) &&
is_string($dir) === true &&
file_exists($dir) === true
) {
return new ContentLock($this);
}
return null;
return $this->version('changes')->lock('*');
}
/**
@ -450,16 +389,12 @@ abstract class ModelWithContent implements Identifiable
*/
public function purge(): static
{
$this->blueprints = null;
$this->content = null;
$this->translations = null;
$this->blueprints = null;
return $this;
}
/**
* Creates a string query, starting from the model
* @internal
*/
public function query(
string|null $query = null,
@ -490,20 +425,12 @@ abstract class ModelWithContent implements Identifiable
/**
* Read the content from the content file
* @internal
* @deprecated 5.0.0 Use `->version()->read()` instead
*/
public function readContent(string|null $languageCode = null): array
{
try {
return $this->storage()->read(
$this->storage()->defaultVersion(),
$languageCode
);
} catch (NotFoundException) {
// only if the content file really does not exist, it's ok
// to return empty content. Otherwise this could lead to
// content loss in case of file reading issues
return [];
}
Helpers::deprecated('$model->readContent() is deprecated. Use $model->version()->read() instead.'); // @codeCoverageIgnore
return $this->version()->read($languageCode ?? 'default') ?? [];
}
/**
@ -512,90 +439,63 @@ abstract class ModelWithContent implements Identifiable
abstract public function root(): string|null;
/**
* Stores the content on disk
* @internal
* Low-level method to save the model with the given data.
* Consider using `::update()` instead.
*/
public function save(
array|null $data = null,
string|null $languageCode = null,
bool $overwrite = false
): static {
if ($this->kirby()->multilang() === true) {
return $this->saveTranslation($data, $languageCode, $overwrite);
}
return $this->saveContent($data, $overwrite);
}
/**
* Save the single language content
*/
protected function saveContent(
array|null $data = null,
bool $overwrite = false
): static {
// create a clone to avoid modifying the original
$clone = $this->clone();
// merge the new data with the existing content
$clone->content()->update($data, $overwrite);
// move the old model into memory
$this->changeStorage(
toStorage: new ImmutableMemoryStorage(
model: $this,
nextModel: $clone
),
copy: true
);
// send the full content array to the writer
$clone->writeContent($clone->content()->toArray());
// update the clone
$clone->version()->save(
$data ?? [],
$languageCode ?? 'current',
$overwrite
);
ModelState::update(
method: 'set',
current: $this,
next: $clone
);
return $clone;
}
/**
* Save a translation
*
* @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist
* @deprecated 5.0.0 Use $model->save() instead
*/
protected function saveContent(
array|null $data = null,
bool $overwrite = false
): static {
Helpers::deprecated('$model->saveContent() is deprecated. Use $model->save() instead.');
return $this->save($data, 'default', $overwrite);
}
/**
* @deprecated 5.0.0 Use $model->save() instead
*/
protected function saveTranslation(
array|null $data = null,
string|null $languageCode = null,
bool $overwrite = false
): static {
// create a clone to not touch the original
$clone = $this->clone();
// fetch the matching translation and update all the strings
$translation = $clone->translation($languageCode);
if ($translation === null) {
throw new InvalidArgumentException('Invalid language: ' . $languageCode);
}
// get the content to store
$content = $translation->update($data, $overwrite)->content();
$kirby = $this->kirby();
$languageCode = $kirby->languageCode($languageCode);
// remove all untranslatable fields
if ($languageCode !== $kirby->defaultLanguage()->code()) {
foreach ($this->blueprint()->fields() as $field) {
if (($field['translate'] ?? true) === false) {
$content[strtolower($field['name'])] = null;
}
}
// remove UUID for non-default languages
if (Uuids::enabled() === true && isset($content['uuid']) === true) {
$content['uuid'] = null;
}
// merge the translation with the new data
$translation->update($content, true);
}
// send the full translation array to the writer
$clone->writeContent($translation->content(), $languageCode);
// reset the content object
$clone->content = null;
// return the updated model
return $clone;
Helpers::deprecated('$model->saveTranslation() is deprecated. Use $model->save() instead.');
return $this->save($data, $languageCode ?? 'default', $overwrite);
}
/**
@ -605,11 +505,13 @@ abstract class ModelWithContent implements Identifiable
*/
protected function setContent(array|null $content = null): static
{
if ($content !== null) {
$content = new Content($content, $this);
if ($content === null) {
return $this;
}
$this->content = $content;
$this->changeStorage(MemoryStorage::class, copy: true);
$this->version()->save($content, 'default');
return $this;
}
@ -620,18 +522,18 @@ abstract class ModelWithContent implements Identifiable
*/
protected function setTranslations(array|null $translations = null): static
{
if ($translations !== null) {
$this->translations = new Collection();
foreach ($translations as $props) {
$props['parent'] = $this;
$translation = new ContentTranslation($props);
$this->translations->data[$translation->code()] = $translation;
}
} else {
$this->translations = null;
if ($translations === null) {
return $this;
}
$this->changeStorage(MemoryStorage::class, copy: true);
Translations::create(
model: $this,
version: $this->version(),
translations: $translations
);
return $this;
}
@ -645,14 +547,10 @@ abstract class ModelWithContent implements Identifiable
/**
* Returns the content storage handler
* @internal
*/
public function storage(): ContentStorage
public function storage(): Storage
{
return $this->storage ??= new ContentStorage(
model: $this,
handler: PlainTextContentStorageHandler::class
);
return $this->storage ??= $this->kirby()->storage($this);
}
/**
@ -701,7 +599,7 @@ abstract class ModelWithContent implements Identifiable
}
if ($handler !== 'template' && $handler !== 'safeTemplate') {
throw new InvalidArgumentException('Invalid toString handler'); // @codeCoverageIgnore
throw new InvalidArgumentException(message: 'Invalid toString handler'); // @codeCoverageIgnore
}
$result = Str::$handler($template, array_replace([
@ -720,44 +618,36 @@ abstract class ModelWithContent implements Identifiable
*/
public function __toString(): string
{
return $this->id();
return (string)$this->id();
}
/**
* Returns a single translation by language code
* If no code is specified the current translation is returned
*
* @throws \Kirby\Exception\NotFoundException If the language does not exist
*/
public function translation(
string|null $languageCode = null
): ContentTranslation|null {
if ($language = $this->kirby()->language($languageCode)) {
return $this->translations()->find($language->code());
}
): Translation {
$language = Language::ensure($languageCode ?? 'current');
return null;
return new Translation(
model: $this,
version: $this->version(),
language: $language
);
}
/**
* Returns the translations collection
*/
public function translations(): Collection
public function translations(): Translations
{
if ($this->translations !== null) {
return $this->translations;
}
$this->translations = new Collection();
foreach ($this->kirby()->languages() as $language) {
$translation = new ContentTranslation([
'parent' => $this,
'code' => $language->code(),
]);
$this->translations->data[$translation->code()] = $translation;
}
return $this->translations;
return Translations::load(
model: $this,
version: $this->version()
);
}
/**
@ -770,26 +660,26 @@ abstract class ModelWithContent implements Identifiable
string|null $languageCode = null,
bool $validate = false
): static {
$form = Form::for($this, [
'ignoreDisabled' => $validate === false,
'input' => $input,
'language' => $languageCode,
]);
$form = Form::for(
model: $this,
language: $languageCode,
);
// validate the input
if ($validate === true && $form->isInvalid() === true) {
throw new InvalidArgumentException([
'fallback' => 'Invalid form with errors',
'details' => $form->errors()
]);
$form->submit(
input: $input ?? [],
force: $validate === false
);
if ($validate === true) {
$form->validate();
}
return $this->commit(
'update',
[
static::CLASS_ALIAS => $this,
'values' => $form->data(),
'strings' => $form->strings(),
'values' => $form->toFormValues(),
'strings' => $form->toStoredValues(),
'languageCode' => $languageCode
],
fn ($model, $values, $strings, $languageCode) =>
@ -806,24 +696,37 @@ abstract class ModelWithContent implements Identifiable
return Uuid::for($this);
}
/**
* Returns a content version instance
* @since 5.0.0
*/
public function version(VersionId|string|null $versionId = null): Version
{
return new Version(
model: $this,
id: VersionId::from($versionId ?? 'latest')
);
}
/**
* Returns a versions collection
* @since 5.0.0
*/
public function versions(): Versions
{
return Versions::load($this);
}
/**
* Low level data writer method
* to store the given data on disk or anywhere else
* @internal
* @deprecated 5.0.0 Use `->version()->save()` instead
*/
public function writeContent(array $data, string|null $languageCode = null): bool
{
$data = $this->contentFileData($data, $languageCode);
$id = $this->storage()->defaultVersion();
try {
// we can only update if the version already exists
$this->storage()->update($id, $languageCode, $data);
} catch (NotFoundException) {
// otherwise create a new version
$this->storage()->create($id, $languageCode, $data);
}
Helpers::deprecated('$model->writeContent() is deprecated. Use $model->version()->save() instead.'); // @codeCoverageIgnore
$this->version()->save($data, $languageCode ?? 'default', true);
return true;
}
}

View file

@ -13,6 +13,8 @@ use Kirby\Toolkit\Collection as BaseCollection;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Toolkit\Collection<\Kirby\Cms\NestObject|\Kirby\Content\Field>
*/
class NestCollection extends BaseCollection
{
@ -23,6 +25,8 @@ class NestCollection extends BaseCollection
*/
public function toArray(Closure|null $map = null): array
{
return parent::toArray($map ?? fn ($object) => $object->toArray());
return parent::toArray(
$map ?? fn ($object) => $object->toArray()
);
}
}

View file

@ -4,12 +4,12 @@ namespace Kirby\Cms;
use Closure;
use Kirby\Content\Field;
use Kirby\Content\VersionId;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Http\Response;
use Kirby\Http\Uri;
use Kirby\Panel\Page as Panel;
use Kirby\Template\Template;
use Kirby\Toolkit\A;
@ -28,12 +28,15 @@ use Throwable;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Pages>
*/
class Page extends ModelWithContent
{
use HasChildren;
use HasFiles;
use HasMethods;
use HasModels;
use HasSiblings;
use PageActions;
use PageSiblings;
@ -46,11 +49,6 @@ class Page extends ModelWithContent
*/
public static array $methods = [];
/**
* Registry with all Page models
*/
public static array $models = [];
/**
* The PageBlueprint object
*/
@ -125,11 +123,11 @@ class Page extends ModelWithContent
public function __construct(array $props)
{
if (isset($props['slug']) === false) {
throw new InvalidArgumentException('The page slug is required');
throw new InvalidArgumentException(
message: 'The page slug is required'
);
}
parent::__construct($props);
$this->slug = $props['slug'];
// Sets the dirname manually, which works
// more reliable in connection with the inventory
@ -140,7 +138,15 @@ class Page extends ModelWithContent
$this->parent = $props['parent'] ?? null;
$this->root = $props['root'] ?? null;
// Set blueprint before setting content
// or translations in the parent constructor.
// Otherwise, the blueprint definition cannot be
// used when creating the right field values
// for the content.
$this->setBlueprint($props['blueprint'] ?? null);
parent::__construct($props);
$this->setChildren($props['children'] ?? null);
$this->setDrafts($props['drafts'] ?? null);
$this->setFiles($props['files'] ?? null);
@ -173,13 +179,14 @@ class Page extends ModelWithContent
*/
public function __debugInfo(): array
{
return array_merge($this->toArray(), [
return [
...$this->toArray(),
'content' => $this->content(),
'children' => $this->children(),
'siblings' => $this->siblings(),
'translations' => $this->translations(),
'files' => $this->files(),
]);
];
}
/**
@ -220,8 +227,10 @@ class Page extends ModelWithContent
return $this->blueprints;
}
$blueprints = [];
$templates = $this->blueprint()->changeTemplate() ?? $this->blueprint()->options()['changeTemplate'] ?? [];
$blueprints = [];
$templates = $this->blueprint()->changeTemplate() ?? null;
$templates ??= $this->blueprint()->options()['changeTemplate'] ?? [];
$currentTemplate = $this->intendedTemplate()->name();
if (is_array($templates) === false) {
@ -229,7 +238,7 @@ class Page extends ModelWithContent
}
// add the current template to the array if it's not already there
if (in_array($currentTemplate, $templates) === false) {
if (in_array($currentTemplate, $templates, true) === false) {
array_unshift($templates, $currentTemplate);
}
@ -255,7 +264,7 @@ class Page extends ModelWithContent
/**
* Builds the cache id for the page
*/
protected function cacheId(string $contentType): string
protected function cacheId(string $contentType, VersionId $versionId): string
{
$cacheId = [$this->id()];
@ -263,6 +272,7 @@ class Page extends ModelWithContent
$cacheId[] = $this->kirby()->language()->code();
}
$cacheId[] = $versionId->value();
$cacheId[] = $contentType;
return implode('.', $cacheId);
@ -282,23 +292,8 @@ class Page extends ModelWithContent
]);
}
/**
* Returns the content text file
* which is found by the inventory method
* @internal
* @deprecated 4.0.0
* @todo Remove in v5
* @codeCoverageIgnore
*/
public function contentFileName(string|null $languageCode = null): string
{
Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file');
return $this->intendedTemplate()->name();
}
/**
* Call the page controller
* @internal
*
* @throws \Kirby\Exception\InvalidArgumentException If the controller returns invalid objects for `kirby`, `site`, `pages` or `page`
*/
@ -307,12 +302,13 @@ class Page extends ModelWithContent
string $contentType = 'html'
): array {
// create the template data
$data = array_merge($data, [
$data = [
...$data,
'kirby' => $kirby = $this->kirby(),
'site' => $site = $this->site(),
'pages' => new LazyValue(fn () => $site->children()),
'page' => new LazyValue(fn () => $site->visit($this))
]);
];
// call the template controller if there's one.
$controllerData = $kirby->controller(
@ -324,7 +320,7 @@ class Page extends ModelWithContent
// merge controller data with original data safely
// to provide original data to template even if
// it wasn't returned by the controller explicitly
if (empty($controllerData) === false) {
if ($controllerData !== []) {
$classes = [
'kirby' => App::class,
'site' => Site::class,
@ -339,7 +335,9 @@ class Page extends ModelWithContent
// original data was overwritten, but matches expected type
$value instanceof $classes[$key] => $value,
// throw error if data was overwritten with wrong type
default => throw new InvalidArgumentException('The returned variable "' . $key . '" from the controller "' . $this->template()->name() . '" is not of the required type "' . $classes[$key] . '"')
default => throw new InvalidArgumentException(
message: 'The returned variable "' . $key . '" from the controller "' . $this->template()->name() . '" is not of the required type "' . $classes[$key] . '"'
)
};
}
}
@ -361,7 +359,7 @@ class Page extends ModelWithContent
}
/**
* Sorting number + Slug
* Returns the directory name (UID with optional sorting number)
*/
public function dirname(): string
{
@ -377,7 +375,8 @@ class Page extends ModelWithContent
}
/**
* Sorting number + Slug
* Returns the directory path relative to the `content` root
* (including optional sorting numbers and draft directories)
*/
public function diruri(): string
{
@ -385,11 +384,10 @@ class Page extends ModelWithContent
return $this->diruri;
}
if ($this->isDraft() === true) {
$dirname = '_drafts/' . $this->dirname();
} else {
$dirname = $this->dirname();
}
$dirname = match ($this->isDraft()) {
true => '_drafts/' . $this->dirname(),
false => $this->dirname()
};
if ($parent = $this->parent()) {
return $this->diruri = $parent->diruri() . '/' . $dirname;
@ -409,11 +407,10 @@ class Page extends ModelWithContent
/**
* Constructs a Page object and also
* takes page models into account.
* @internal
*/
public static function factory($props): static
{
return static::model($props['model'] ?? 'default', $props);
return static::model($props['model'] ?? $props['template'] ?? 'default', $props);
}
/**
@ -466,13 +463,13 @@ class Page extends ModelWithContent
return $this->intendedTemplate;
}
return $this->setTemplate($this->inventory()['template'])->intendedTemplate();
return $this
->setTemplate($this->inventory()['template'])
->intendedTemplate();
}
/**
* Returns the inventory of files
* children and content files
* @internal
* Returns the inventory of files children and content files
*/
public function inventory(): array
{
@ -513,22 +510,17 @@ class Page extends ModelWithContent
}
/**
* Checks if the page is accessible that accessible and listable.
* This permission depends on the `read` option until v5
* Checks if the page is accessible to the current user
* This permission depends on the `read` option until v6
*/
public function isAccessible(): bool
{
// TODO: remove this check when `read` option deprecated in v5
// TODO: remove this check when `read` option deprecated in v6
if ($this->isReadable() === false) {
return false;
}
static $accessible = [];
$role = $this->kirby()->user()?->role()->id() ?? '__none__';
$template = $this->intendedTemplate()->name();
$accessible[$role] ??= [];
return $accessible[$role][$template] ??= $this->permissions()->can('access');
return PagePermissions::canFromCache($this, 'access');
}
/**
@ -553,7 +545,7 @@ class Page extends ModelWithContent
* pages cache. This will also check if one
* of the ignore rules from the config kick in.
*/
public function isCacheable(): bool
public function isCacheable(VersionId|null $versionId = null): bool
{
$kirby = $this->kirby();
$cache = $kirby->cache('pages');
@ -565,11 +557,16 @@ class Page extends ModelWithContent
return false;
}
// updating the changes version does not flush the pages cache
if ($versionId?->is('changes') === true) {
return false;
}
// inspect the current request
$request = $kirby->request();
// disable the pages cache for any request types but GET or HEAD
if (in_array($request->method(), ['GET', 'HEAD']) === false) {
if (in_array($request->method(), ['GET', 'HEAD'], true) === false) {
return false;
}
@ -592,7 +589,7 @@ class Page extends ModelWithContent
// ignore pages by id
if (is_array($ignore) === true) {
if (in_array($this->id(), $ignore) === true) {
if (in_array($this->id(), $ignore, true) === true) {
return false;
}
}
@ -676,11 +673,11 @@ class Page extends ModelWithContent
/**
* Check if the page can be listable by the current user
* This permission depends on the `read` option until v5
* This permission depends on the `read` option until v6
*/
public function isListable(): bool
{
// TODO: remove this check when `read` option deprecated in v5
// TODO: remove this check when `read` option deprecated in v6
if ($this->isReadable() === false) {
return false;
}
@ -690,12 +687,7 @@ class Page extends ModelWithContent
return false;
}
static $listable = [];
$role = $this->kirby()->user()?->role()->id() ?? '__none__';
$template = $this->intendedTemplate()->name();
$listable[$role] ??= [];
return $listable[$role][$template] ??= $this->permissions()->can('list');
return PagePermissions::canFromCache($this, 'list');
}
/**
@ -709,7 +701,8 @@ class Page extends ModelWithContent
public function isMovableTo(Page|Site $parent): bool
{
try {
return PageRules::move($this, $parent);
PageRules::move($this, $parent);
return true;
} catch (Throwable) {
return false;
}
@ -743,12 +736,12 @@ class Page extends ModelWithContent
/**
* Check if the page can be read by the current user
* @todo Deprecate `read` option in v5 and make the necessary changes for `access` and `list` options.
* @todo Deprecate `read` option in v6 and make the necessary changes for `access` and `list` options.
*/
public function isReadable(): bool
{
static $readable = [];
$role = $this->kirby()->user()?->role()->id() ?? '__none__';
$role = $this->kirby()->role()?->id() ?? '__none__';
$template = $this->intendedTemplate()->name();
$readable[$role] ??= [];
@ -772,64 +765,29 @@ class Page extends ModelWithContent
}
/**
* Checks if the page access is verified.
* This is only used for drafts so far.
* @internal
* Returns the absolute path to the media folder for the page
*/
public function isVerified(string|null $token = null): bool
{
if (
$this->isPublished() === true &&
$this->parents()->findBy('status', 'draft') === null
) {
return true;
}
if ($token === null) {
return false;
}
return $this->token() === $token;
}
/**
* Returns the root to the media folder for the page
* @internal
*/
public function mediaRoot(): string
public function mediaDir(): string
{
return $this->kirby()->root('media') . '/pages/' . $this->id();
}
/**
* @see `::mediaDir`
*/
public function mediaRoot(): string
{
return $this->mediaDir();
}
/**
* The page's base URL for any files
* @internal
*/
public function mediaUrl(): string
{
return $this->kirby()->url('media') . '/pages/' . $this->id();
}
/**
* Creates a page model if it has been registered
* @internal
*/
public static function model(string $name, array $props = []): static
{
$class = static::$models[$name] ?? null;
$class ??= static::$models['default'] ?? null;
if ($class !== null) {
$object = new $class($props);
if ($object instanceof self) {
return $object;
}
}
return new static($props);
}
/**
* Returns the last modification date of the page
*/
@ -838,11 +796,8 @@ class Page extends ModelWithContent
string|null $handler = null,
string|null $languageCode = null
): int|string|false|null {
$identifier = $this->isDraft() === true ? 'changes' : 'published';
$modified = $this->storage()->modified(
$identifier,
$languageCode
$modified = $this->version()->modified(
$languageCode ?? 'current'
);
if ($modified === null) {
@ -878,7 +833,6 @@ class Page extends ModelWithContent
/**
* Returns the parent id, if a parent exists
* @internal
*/
public function parentId(): string|null
{
@ -889,7 +843,6 @@ class Page extends ModelWithContent
* Returns the parent model,
* which can either be another Page
* or the Site
* @internal
*/
public function parentModel(): Page|Site
{
@ -904,7 +857,7 @@ class Page extends ModelWithContent
$parents = new Pages();
$page = $this->parent();
while ($page !== null) {
while ($page instanceof Page) {
$parents->append($page->id(), $page);
$page = $page->parent();
}
@ -930,30 +883,16 @@ class Page extends ModelWithContent
}
/**
* Draft preview Url
* @internal
* Returns the preview URL with authentication for drafts and versions
* @unstable
*/
public function previewUrl(): string|null
public function previewUrl(VersionId|string $versionId = 'latest'): string|null
{
$preview = $this->blueprint()->preview();
if ($preview === false) {
if ($this->permissions()->can('preview') !== true) {
return null;
}
$url = match ($preview) {
true => $this->url(),
default => $preview
};
if ($this->isDraft() === true) {
$uri = new Uri($url);
$uri->query->token = $this->token();
$url = $uri->toString();
}
return $url;
return $this->version($versionId)->url();
}
/**
@ -964,17 +903,31 @@ class Page extends ModelWithContent
* the default template.
*
* @param string $contentType
* @param \Kirby\Content\VersionId|string|null $versionId Optional override for the auto-detected version to render
* @throws \Kirby\Exception\NotFoundException If the default template cannot be found
*/
public function render(array $data = [], $contentType = 'html'): string
{
public function render(
array $data = [],
$contentType = 'html',
VersionId|string|null $versionId = null
): string {
$kirby = $this->kirby();
$cache = $cacheId = $html = null;
// if not manually overridden, first use a globally set
// version ID (e.g. when rendering within another render),
// otherwise auto-detect from the request and fall back to
// the latest version if request is unauthenticated (no valid token);
// make sure to convert it to an object no matter what happened
$versionId ??= VersionId::$render;
$versionId ??= $this->renderVersionFromRequest();
$versionId ??= 'latest';
$versionId = VersionId::from($versionId);
// try to get the page from cache
if (empty($data) === true && $this->isCacheable() === true) {
if ($data === [] && $this->isCacheable($versionId) === true) {
$cache = $kirby->cache('pages');
$cacheId = $this->cacheId($contentType);
$cacheId = $this->cacheId($contentType, $versionId);
$result = $cache->get($cacheId);
$html = $result['html'] ?? null;
$response = $result['response'] ?? [];
@ -995,37 +948,39 @@ class Page extends ModelWithContent
// fetch the page regularly
if ($html === null) {
if ($contentType === 'html') {
$template = $this->template();
} else {
$template = $this->representation($contentType);
}
// set `VersionId::$render` to the intended version (only) while we render
$html = VersionId::render($versionId, function () use ($kirby, $data, $contentType) {
$template = match ($contentType) {
'html' => $this->template(),
default => $this->representation($contentType)
};
if ($template->exists() === false) {
throw new NotFoundException([
'key' => 'template.default.notFound'
]);
}
if ($template->exists() === false) {
throw new NotFoundException(
key: 'template.default.notFound'
);
}
$kirby->data = $this->controller($data, $contentType);
$kirby->data = $this->controller($data, $contentType);
// trigger before hook and apply for `data`
$kirby->data = $kirby->apply('page.render:before', [
'contentType' => $contentType,
'data' => $kirby->data,
'page' => $this
], 'data');
// trigger before hook and apply for `data`
$kirby->data = $kirby->apply('page.render:before', [
'contentType' => $contentType,
'data' => $kirby->data,
'page' => $this
], 'data');
// render the page
$html = $template->render($kirby->data);
// render the page
$html = $template->render($kirby->data);
// trigger after hook and apply for `html`
$html = $kirby->apply('page.render:after', [
'contentType' => $contentType,
'data' => $kirby->data,
'html' => $html,
'page' => $this
], 'html');
// trigger after hook and apply for `html`
return $kirby->apply('page.render:after', [
'contentType' => $contentType,
'data' => $kirby->data,
'html' => $html,
'page' => $this
], 'html');
});
// cache the result
$response = $kirby->response();
@ -1043,7 +998,42 @@ class Page extends ModelWithContent
}
/**
* @internal
* Determines which version (if any) can be rendered
* based on the token authentication in the current request
* @unstable
*/
public function renderVersionFromRequest(): VersionId|null
{
$request = $this->kirby()->request();
$token = $request->get('_token', '');
try {
$versionId = VersionId::from($request->get('_version', ''));
} catch (InvalidArgumentException) {
// ignore invalid enum values in the request
$versionId = VersionId::latest();
}
// authenticated requests can always be trusted
$expectedToken = $this->version($versionId)->previewToken();
if ($token !== '' && hash_equals($expectedToken, $token) === true) {
return $versionId;
}
// published pages with published parents can render
// the latest version without (valid) token
if (
$this->isPublished() === true &&
$this->parents()->findBy('status', 'draft') === null
) {
return VersionId::latest();
}
// drafts cannot be accessed without authentication
return null;
}
/**
* @throws \Kirby\Exception\NotFoundException If the content representation cannot be found
*/
public function representation(mixed $type): Template
@ -1056,7 +1046,9 @@ class Page extends ModelWithContent
return $representation;
}
throw new NotFoundException('The content representation cannot be found');
throw new NotFoundException(
message: 'The content representation cannot be found'
);
}
/**
@ -1109,7 +1101,7 @@ class Page extends ModelWithContent
protected function setTemplate(string|null $template = null): static
{
if ($template !== null) {
$this->intendedTemplate = $this->kirby()->template($template);
$this->intendedTemplate = $this->kirby()->template(strtolower($template));
}
return $this;
@ -1199,7 +1191,8 @@ class Page extends ModelWithContent
*/
public function toArray(): array
{
return array_merge(parent::toArray(), [
return [
...parent::toArray(),
'children' => $this->children()->keys(),
'files' => $this->files()->keys(),
'id' => $this->id(),
@ -1212,19 +1205,7 @@ class Page extends ModelWithContent
'uid' => $this->uid(),
'uri' => $this->uri(),
'url' => $this->url()
]);
}
/**
* Returns a verification token, which
* is used for the draft authentication
*/
protected function token(): string
{
return $this->kirby()->contentToken(
$this,
$this->id() . $this->template()
);
];
}
/**

View file

@ -3,12 +3,14 @@
namespace Kirby\Cms;
use Closure;
use Kirby\Content\ImmutableMemoryStorage;
use Kirby\Content\MemoryStorage;
use Kirby\Content\VersionCache;
use Kirby\Content\VersionId;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Form\Form;
use Kirby\Toolkit\A;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
@ -26,76 +28,6 @@ use Kirby\Uuid\Uuids;
*/
trait PageActions
{
/**
* Adapts necessary modifications which page uuid, page slug and files uuid
* of copy objects for single or multilang environments
* @internal
*/
protected function adaptCopy(Page $copy, bool $files = false, bool $children = false): Page
{
if ($this->kirby()->multilang() === true) {
foreach ($this->kirby()->languages() as $language) {
// overwrite with new UUID for the page and files
// for default language (remove old, add new)
if (
Uuids::enabled() === true &&
$language->isDefault() === true
) {
$copy = $copy->save(['uuid' => Uuid::generate()], $language->code());
// regenerate UUIDs of page files
if ($files !== false) {
foreach ($copy->files() as $file) {
$file->save(['uuid' => Uuid::generate()], $language->code());
}
}
// regenerate UUIDs of all page children
if ($children !== false) {
foreach ($copy->index(true) as $child) {
// always adapt files of subpages as they are currently always copied;
// but don't adapt children because we already operate on the index
$this->adaptCopy($child, true);
}
}
}
// remove all translated slugs
if (
$language->isDefault() === false &&
$copy->translation($language)->exists() === true
) {
$copy = $copy->save(['slug' => null], $language->code());
}
}
return $copy;
}
// overwrite with new UUID for the page and files (remove old, add new)
if (Uuids::enabled() === true) {
$copy = $copy->save(['uuid' => Uuid::generate()]);
// regenerate UUIDs of page files
if ($files !== false) {
foreach ($copy->files() as $file) {
$file->save(['uuid' => Uuid::generate()]);
}
}
// regenerate UUIDs of all page children
if ($children !== false) {
foreach ($copy->index(true) as $child) {
// always adapt files of subpages as they are currently always copied;
// but don't adapt children because we already operate on the index
$this->adaptCopy($child, true);
}
}
}
return $copy;
}
/**
* Changes the sorting number.
* The sorting number must already be correct
@ -109,7 +41,9 @@ trait PageActions
public function changeNum(int|null $num = null): static
{
if ($this->isDraft() === true) {
throw new LogicException('Drafts cannot change their sorting number');
throw new LogicException(
message: 'Drafts cannot change their sorting number'
);
}
// don't run the action if everything stayed the same
@ -119,9 +53,10 @@ trait PageActions
return $this->commit('changeNum', ['page' => $this, 'num' => $num], function ($oldPage, $num) {
$newPage = $oldPage->clone([
'num' => $num,
'dirname' => null,
'root' => null
'num' => $num,
'dirname' => null,
'root' => null,
'template' => $oldPage->intendedTemplate()->name(),
]);
// actually move the page on disk
@ -131,13 +66,12 @@ trait PageActions
// of the moved new page to use fly actions on old page in loop
$oldPage->root = $newPage->root();
} else {
throw new LogicException('The page directory cannot be moved');
throw new LogicException(
message: 'The page directory cannot be moved'
);
}
}
// overwrite the child in the parent page
static::updateParentCollections($newPage, 'set');
return $newPage;
});
}
@ -153,13 +87,14 @@ trait PageActions
string|null $languageCode = null
): static {
// always sanitize the slug
$slug = Url::slug($slug);
$slug = Url::slug($slug);
$language = Language::ensure($languageCode ?? 'current');
// in multi-language installations the slug for the non-default
// languages is stored in the text file. The changeSlugForLanguage
// method takes care of that.
if ($this->kirby()->language($languageCode)?->isDefault() === false) {
return $this->changeSlugForLanguage($slug, $languageCode);
if ($language->isDefault() === false) {
return $this->changeSlugForLanguage($slug, $language->code());
}
// if the slug stays exactly the same,
@ -168,35 +103,45 @@ trait PageActions
return $this;
}
$arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => null];
return $this->commit('changeSlug', $arguments, function ($oldPage, $slug) {
$arguments = [
'page' => $this,
'slug' => $slug,
'languageCode' => null,
'language' => $language
];
return $this->commit('changeSlug', $arguments, function ($oldPage, $slug, $languageCode, $language) {
$newPage = $oldPage->clone([
'slug' => $slug,
'dirname' => null,
'root' => null
'slug' => $slug,
'dirname' => null,
'root' => null,
'template' => $oldPage->intendedTemplate()->name(),
]);
// clear UUID cache recursively (for children and files as well)
$oldPage->uuid()?->clear(true);
if ($oldPage->exists() === true) {
// remove the lock of the old page
$oldPage->lock()?->remove();
// actually move stuff on disk
if (Dir::move($oldPage->root(), $newPage->root()) !== true) {
throw new LogicException('The page directory cannot be moved');
throw new LogicException(
message: 'The page directory cannot be moved'
);
}
// hard reset for the version cache
// to avoid broken/overlapping page references
VersionCache::reset();
// remove from the siblings
static::updateParentCollections($oldPage, 'remove');
ModelState::update(
method: 'remove',
current: $oldPage,
);
Dir::remove($oldPage->mediaRoot());
}
// overwrite the new page in the parent collection
static::updateParentCollections($newPage, 'set');
return $newPage;
});
}
@ -211,29 +156,34 @@ trait PageActions
string $slug,
string|null $languageCode = null
): static {
$language = $this->kirby()->language($languageCode);
if (!$language) {
throw new NotFoundException('The language: "' . $languageCode . '" does not exist');
}
$language = Language::ensure($languageCode ?? 'current');
if ($language->isDefault() === true) {
throw new InvalidArgumentException('Use the changeSlug method to change the slug for the default language');
throw new InvalidArgumentException(
message: 'Use the changeSlug method to change the slug for the default language'
);
}
$arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => $language->code()];
return $this->commit('changeSlug', $arguments, function ($page, $slug, $languageCode) {
$arguments = [
'page' => $this,
'slug' => $slug,
'languageCode' => $language->code(),
'language' => $language
];
return $this->commit('changeSlug', $arguments, function ($page, $slug, $languageCode, $language) {
// remove the slug if it's the same as the folder name
if ($slug === $page->uid()) {
$slug = null;
}
$newPage = $page->save(['slug' => $slug], $languageCode);
// make sure to update the slug in the changes version as well
// otherwise the new slug would be lost as soon as the changes are saved
if ($page->version('changes')->exists($language) === true) {
$page->version('changes')->update(['slug' => $slug], $language);
}
// overwrite the updated page in the parent collection
static::updateParentCollections($newPage, 'set');
return $newPage;
return $page->save(['slug' => $slug], $languageCode);
});
}
@ -247,13 +197,17 @@ trait PageActions
* @param int|null $position Optional sorting number
* @throws \Kirby\Exception\InvalidArgumentException If an invalid status is being passed
*/
public function changeStatus(string $status, int|null $position = null): static
{
public function changeStatus(
string $status,
int|null $position = null
): static {
return match ($status) {
'draft' => $this->changeStatusToDraft(),
'listed' => $this->changeStatusToListed($position),
'unlisted' => $this->changeStatusToUnlisted(),
default => throw new InvalidArgumentException('Invalid status: ' . $status)
default => throw new InvalidArgumentException(
message: 'Invalid status: ' . $status
)
};
}
@ -350,12 +304,7 @@ trait PageActions
return $this->commit('changeTemplate', ['page' => $this, 'template' => $template], function ($oldPage, $template) {
// convert for new template/blueprint
$page = $oldPage->convertTo($template);
// update the parent collection
static::updateParentCollections($page, 'set');
return $page;
return $oldPage->convertTo($template);
});
}
@ -366,36 +315,34 @@ trait PageActions
string $title,
string|null $languageCode = null
): static {
// if the `$languageCode` argument is not set and is not the default language
// the `$languageCode` argument is sent as the current language
if (
$languageCode === null &&
$language = $this->kirby()->language()
) {
if ($language->isDefault() === false) {
$languageCode = $language->code();
$language = Language::ensure($languageCode ?? 'current');
$arguments = [
'page' => $this,
'title' => $title,
'languageCode' => $languageCode,
'language' => $language
];
return $this->commit('changeTitle', $arguments, function ($page, $title, $languageCode, $language) {
// make sure to update the title in the changes version as well
// otherwise the new title would be lost as soon as the changes are saved
if ($page->version('changes')->exists($language) === true) {
$page->version('changes')->update(['title' => $title], $language);
}
}
$arguments = ['page' => $this, 'title' => $title, 'languageCode' => $languageCode];
return $this->commit('changeTitle', $arguments, function ($page, $title, $languageCode) {
$page = $page->save(['title' => $title], $languageCode);
// flush the parent cache to get children and drafts right
static::updateParentCollections($page, 'set');
return $page;
return $page->save(['title' => $title], $language->code());
});
}
/**
* Commits a page action, by following these steps
*
* 1. checks the action rules
* 2. sends the before hook
* 1. applies the `before` hook
* 2. checks the action rules
* 3. commits the store action
* 4. sends the after hook
* 4. applies the `after` hook
* 5. returns the result
*/
protected function commit(
@ -403,57 +350,37 @@ trait PageActions
array $arguments,
Closure $callback
): mixed {
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
$commit = new ModelCommit(
model: $this,
action: $action
);
$this->rules()->$action(...$argumentValues);
$kirby->trigger('page.' . $action . ':before', $arguments);
$result = $callback(...$argumentValues);
if ($action === 'create') {
$argumentsAfter = ['page' => $result];
} elseif ($action === 'duplicate') {
$argumentsAfter = ['duplicatePage' => $result, 'originalPage' => $old];
} elseif ($action === 'delete') {
$argumentsAfter = ['status' => $result, 'page' => $old];
} else {
$argumentsAfter = ['newPage' => $result, 'oldPage' => $old];
}
$kirby->trigger('page.' . $action . ':after', $argumentsAfter);
$kirby->cache('pages')->flush();
return $result;
return $commit->call($arguments, $callback);
}
/**
* Copies the page to a new parent
*
* @throws \Kirby\Exception\DuplicateException If the page already exists
*
* @internal
*/
public function copy(array $options = []): static
{
$slug = $options['slug'] ?? $this->slug();
$isDraft = $options['isDraft'] ?? $this->isDraft();
$parent = $options['parent'] ?? null;
$parentModel = $options['parent'] ?? $this->site();
$num = $options['num'] ?? null;
$children = $options['children'] ?? false;
$files = $options['files'] ?? false;
$slug = $options['slug'] ?? $this->slug();
$isDraft = $options['isDraft'] ?? $this->isDraft();
$parent = $options['parent'] ?? null;
$parentModel = $options['parent'] ?? $this->site();
$num = $options['num'] ?? null;
$children = $options['children'] ?? false;
$files = $options['files'] ?? false;
// clean up the slug
$slug = Url::slug($slug);
if ($parentModel->findPageOrDraft($slug)) {
throw new DuplicateException([
'key' => 'page.duplicate',
'data' => [
'slug' => $slug
]
]);
throw new DuplicateException(
key: 'page.duplicate',
data: ['slug' => $slug]
);
}
$tmp = new static([
@ -463,9 +390,7 @@ trait PageActions
'slug' => $slug,
]);
$ignore = [
$this->kirby()->locks()->file($this)
];
$ignore = [];
// don't copy files
if ($files === false) {
@ -473,8 +398,8 @@ trait PageActions
$ignore[] = $file->root();
// append all content files
array_push($ignore, ...$file->storage()->contentFiles('published'));
array_push($ignore, ...$file->storage()->contentFiles('changes'));
array_push($ignore, ...$file->storage()->contentFiles(VersionId::latest()));
array_push($ignore, ...$file->storage()->contentFiles(VersionId::changes()));
}
}
@ -483,10 +408,19 @@ trait PageActions
$copy = $parentModel->clone()->findPageOrDraft($slug);
// normalize copy object
$copy = $this->adaptCopy($copy, $files, $children);
$copy = PageCopy::process(
copy: $copy,
original: $this,
withFiles: $files,
withChildren: $children
);
// add copy to siblings
static::updateParentCollections($copy, 'append', $parentModel);
ModelState::update(
method: 'append',
current: $copy,
parent: $parentModel
);
return $copy;
}
@ -496,41 +430,39 @@ trait PageActions
*/
public static function create(array $props): Page
{
// clean up the slug
$props['slug'] = Url::slug($props['slug'] ?? $props['content']['title'] ?? null);
$props['template'] = $props['model'] = strtolower($props['template'] ?? 'default');
$props['isDraft'] ??= $props['draft'] ?? true;
$props = self::normalizeProps($props);
// make sure that a UUID gets generated and
// added to content right away
$props['content'] ??= [];
// create the instance without content or translations
// to avoid that the page is created in memory storage
$page = Page::factory([
...$props,
'content' => null,
'translations' => null
]);
// merge the content with the defaults
$props['content'] = [
...$page->createDefaultContent(),
...$props['content'],
];
// make sure that a UUID gets generated
// and added to content right away
if (Uuids::enabled() === true) {
$props['content']['uuid'] ??= Uuid::generate();
}
// create a temporary page object
$page = Page::factory($props);
// keep the initial storage class
$storage = $page->storage()::class;
// always create pages in the default language
if ($page->kirby()->multilang() === true) {
$languageCode = $page->kirby()->defaultLanguage()->code();
} else {
$languageCode = null;
}
// create a form for the page
// use always default language to fill form with default values
$form = Form::for(
$page,
[
'language' => $languageCode,
'values' => $props['content']
]
);
// make sure that the temporary page is stored in memory
$page->changeStorage(MemoryStorage::class);
// inject the content
$page = $page->clone(['content' => $form->strings(true)]);
$page->setContent($props['content']);
// inject the translations
$page->setTranslations($props['translations'] ?? null);
// run the hooks and creation action
$page = $page->commit(
@ -539,14 +471,9 @@ trait PageActions
'page' => $page,
'input' => $props
],
function ($page, $props) use ($languageCode) {
// write the content file
$page = $page->save($page->content()->toArray(), $languageCode);
// flush the parent cache to get children and drafts right
static::updateParentCollections($page, 'append');
return $page;
function ($page) use ($storage) {
// move to final storage
return $page->changeStorage($storage);
}
);
@ -563,14 +490,15 @@ trait PageActions
*/
public function createChild(array $props): Page
{
$props = array_merge($props, [
$props = [
...$props,
'url' => null,
'num' => null,
'parent' => $this,
'site' => $this->site(),
]);
];
$modelClass = Page::$models[$props['template'] ?? null] ?? Page::class;
$modelClass = static::$models[$props['template'] ?? null] ?? static::class;
return $modelClass::create($props);
}
@ -590,8 +518,7 @@ trait PageActions
// the $format needs to produce only digits,
// so it can be converted to integer below
$format = $mode === 'date' ? 'Ymd' : 'YmdHi';
$lang = $this->kirby()->defaultLanguage() ?? null;
$field = $this->content($lang)->get('date');
$field = $this->content('default')->get('date');
$date = $field->isEmpty() ? 'now' : $field;
return (int)date($format, strtotime($date));
case 'default':
@ -624,7 +551,7 @@ trait PageActions
$template = Str::template($mode, [
'kirby' => $app,
'page' => $app->page($this->id()),
'page' => $this,
'site' => $app->site(),
], ['fallback' => '']);
@ -638,42 +565,35 @@ trait PageActions
public function delete(bool $force = false): bool
{
return $this->commit('delete', ['page' => $this, 'force' => $force], function ($page, $force) {
$old = $page->clone();
// keep the content in iummtable memory storage
// to still have access to it in after hooks
$page->changeStorage(ImmutableMemoryStorage::class);
// clear UUID cache
$page->uuid()?->clear();
// delete all files individually
foreach ($page->files() as $file) {
foreach ($old->files() as $file) {
$file->delete();
}
// delete all children individually
foreach ($page->children() as $child) {
foreach ($old->childrenAndDrafts() as $child) {
$child->delete(true);
}
// actually remove the page from disc
if ($page->exists() === true) {
// delete all public media files
Dir::remove($page->mediaRoot());
// delete all versions,
// the plain text storage handler will then clean
// up the directory if it's empty
$old->versions()->delete();
// delete the content folder for this page
Dir::remove($page->root());
// if the page is a draft and the _drafts folder
// is now empty. clean it up.
if ($page->isDraft() === true) {
$draftsDir = dirname($page->root());
if (Dir::isEmpty($draftsDir) === true) {
Dir::remove($draftsDir);
}
}
}
static::updateParentCollections($page, 'remove');
if ($page->isDraft() === false) {
$page->resortSiblingsAfterUnlisting();
if (
$old->isListed() === true &&
$old->blueprint()->num() === 'default'
) {
$old->resortSiblingsAfterUnlisting();
}
return true;
@ -733,17 +653,16 @@ trait PageActions
$page->uuid()?->clear(true);
// move drafts into the drafts folder of the parent
if ($page->isDraft() === true) {
$newRoot = $parent->root() . '/_drafts/' . $page->dirname();
} else {
$newRoot = $parent->root() . '/' . $page->dirname();
}
$newRoot = match ($page->isDraft()) {
true => $parent->root() . '/_drafts/' . $page->dirname(),
false => $parent->root() . '/' . $page->dirname()
};
// try to move the page directory on disk
if (Dir::move($page->root(), $newRoot) !== true) {
throw new LogicException([
'key' => 'page.move.directory'
]);
throw new LogicException(
key: 'page.move.directory'
);
}
// flush all collection caches to be sure that
@ -752,19 +671,33 @@ trait PageActions
// double-check if the new child can actually be found
if (!$newPage = $parent->childrenAndDrafts()->find($page->slug())) {
throw new LogicException([
'key' => 'page.move.notFound'
]);
throw new LogicException(
key: 'page.move.notFound'
);
}
return $newPage;
});
}
protected static function normalizeProps(array $props): array
{
$content = $props['content'] ?? [];
$template = $props['template'] ?? 'default';
return [
...$props,
'content' => $content,
'isDraft' => $props['isDraft'] ?? $props['draft'] ?? true,
'model' => $props['model'] ?? $template,
'slug' => Url::slug($props['slug'] ?? $content['title'] ?? null),
'template' => $template,
];
}
/**
* @return $this|static
* @throws \Kirby\Exception\LogicException If the folder cannot be moved
* @internal
*/
public function publish(): static
{
@ -773,14 +706,17 @@ trait PageActions
}
$page = $this->clone([
'isDraft' => false,
'root' => null
'isDraft' => false,
'root' => null,
'template' => $this->intendedTemplate()->name(),
]);
// actually do it on disk
if ($this->exists() === true) {
if (Dir::move($this->root(), $page->root()) !== true) {
throw new LogicException('The draft folder cannot be moved');
throw new LogicException(
message: 'The draft folder cannot be moved'
);
}
// Get the draft folder and check if there are any other drafts
@ -829,29 +765,32 @@ trait PageActions
*/
protected function resortSiblingsAfterListing(int|null $position = null): bool
{
// get all siblings including the current page
$siblings = $this
->parentModel()
->children()
$parent = $this->parentModel();
$siblings = $parent->children();
// Get all listed siblings including the current page
$listed = $siblings
->listed()
->append($this)
->filter(fn ($page) => $page->blueprint()->num() === 'default');
// get a non-associative array of ids
$keys = $siblings->keys();
// Get a non-associative array of ids
$keys = $listed->keys();
$index = array_search($this->id(), $keys);
// if the page is not included in the siblings something went wrong
// If the page is not included in the siblings something went wrong
if ($index === false) {
throw new LogicException('The page is not included in the sorting index');
throw new LogicException(
message: 'The page is not included in the sorting index'
);
}
if ($position > count($keys)) {
$position = count($keys);
}
// move the current page number in the array of keys
// subtract 1 from the num and the position, because of the
// Move the current page number in the array of keys.
// Subtract 1 from the num and the position, because of the
// zero-based array keys
$sorted = A::move($keys, $index, $position - 1);
@ -860,11 +799,14 @@ trait PageActions
continue;
}
$siblings->get($id)?->changeNum($key + 1);
// Apply the new sorting number
// and update the new object in the siblings collection
$newSibling = $listed->get($id)?->changeNum($key + 1);
$siblings->update($newSibling);
}
$parent = $this->parentModel();
$parent->children = $parent->children()->sort('num', 'asc');
// Update the parent's children collection with the new sorting
$parent->children = $siblings->sort('isListed', 'desc', 'num', 'asc');
$parent->childrenAndDrafts = null;
return true;
@ -877,19 +819,26 @@ trait PageActions
{
$index = 0;
$parent = $this->parentModel();
$siblings = $parent
->children()
$siblings = $parent->children();
// Get all listed siblings excluding the current page
$listed = $siblings
->listed()
->not($this)
->filter(fn ($page) => $page->blueprint()->num() === 'default');
if ($siblings->count() > 0) {
foreach ($siblings as $sibling) {
if ($listed->count() > 0) {
foreach ($listed as $sibling) {
$index++;
$sibling->changeNum($index);
// Apply the new sorting number
// and update the new object in the siblings collection
$newSibling = $sibling->changeNum($index);
$siblings->update($newSibling);
}
$parent->children = $siblings->sort('num', 'asc');
// Update the parent's children collection with the new sorting
$parent->children = $siblings->sort('isListed', 'desc', 'num', 'asc');
$parent->childrenAndDrafts = null;
}
@ -897,26 +846,7 @@ trait PageActions
}
/**
* Stores the content on disk
* @internal
*/
public function save(
array|null $data = null,
string|null $languageCode = null,
bool $overwrite = false
): static {
$page = parent::save($data, $languageCode, $overwrite);
// overwrite the updated page in the parent collection
static::updateParentCollections($page, 'set');
return $page;
}
/**
* Convert a page from listed or
* unlisted to draft.
* @internal
* Convert a page from listed or unlisted to draft
*
* @return $this|static
* @throws \Kirby\Exception\LogicException If the folder cannot be moved
@ -928,16 +858,19 @@ trait PageActions
}
$page = $this->clone([
'isDraft' => true,
'num' => null,
'dirname' => null,
'root' => null
'isDraft' => true,
'num' => null,
'dirname' => null,
'root' => null,
'template' => $this->intendedTemplate()->name(),
]);
// actually do it on disk
if ($this->exists() === true) {
if (Dir::move($this->root(), $page->root()) !== true) {
throw new LogicException('The page folder cannot be moved to drafts');
throw new LogicException(
message: 'The page folder cannot be moved to drafts'
);
}
}
@ -973,14 +906,11 @@ trait PageActions
// if num is created from page content, update num on content update
if (
$page->isListed() === true &&
in_array($page->blueprint()->num(), ['zero', 'default']) === false
in_array($page->blueprint()->num(), ['zero', 'default'], true) === false
) {
$page = $page->changeNum($page->createNum());
}
// overwrite the updated page in the parent collection
static::updateParentCollections($page, 'set');
return $page;
}
@ -988,29 +918,18 @@ trait PageActions
* Updates parent collections with the new page object
* after a page action
*
* @param \Kirby\Cms\Page $page
* @param string $method Method to call on the parent collections
* @param \Kirby\Cms\Page|null $parentMdel
* @deprecated 5.0.0 Use ModelState::update instead
*/
protected static function updateParentCollections(
$page,
string $method,
$parentModel = null
Page $page,
string|false $method,
Page|Site|null $parentModel = null
): void {
$parentModel ??= $page->parentModel();
// method arguments depending on the called method
$args = $method === 'remove' ? [$page] : [$page->id(), $page];
if ($page->isDraft() === true) {
$parentModel->drafts()->$method(...$args);
} else {
$parentModel->children()->$method(...$args);
}
// update the childrenAndDrafts() cache if it is initialized
if ($parentModel->childrenAndDrafts !== null) {
$parentModel->childrenAndDrafts()->$method(...$args);
}
ModelState::update(
method: $method,
current: $page,
next: $page,
parent: $parentModel
);
}
}

View file

@ -67,8 +67,6 @@ class PageBlueprint extends Blueprint
/**
* Normalizes the ordering number
*
* @param mixed $num
*/
protected function normalizeNum($num): string
{
@ -82,8 +80,6 @@ class PageBlueprint extends Blueprint
/**
* Normalizes the available status options for the page
*
* @param mixed $status
*/
protected function normalizeStatus($status): array
{
@ -113,7 +109,7 @@ class PageBlueprint extends Blueprint
// clean up and translate each status
foreach ($status as $key => $options) {
// skip invalid status definitions
if (in_array($key, ['draft', 'listed', 'unlisted']) === false || $options === false) {
if (in_array($key, ['draft', 'listed', 'unlisted'], true) === false || $options === false) {
unset($status[$key]);
continue;
}

View file

@ -0,0 +1,236 @@
<?php
namespace Kirby\Cms;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
/**
* Normalizes a newly generated copy of a page,
* adapting page slugs, UUIDs etc.
* (for single as well as multilang setups)
*
* @package Kirby Cms
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class PageCopy
{
public function __construct(
public Page $copy,
public Page|null $original = null,
public bool $withFiles = false,
public bool $withChildren = false,
public array $uuids = []
) {
}
/**
* Converts UUIDs for copied pages,
* replacing the old UUID with a newly generated one
* for all newly generated pages and files
*/
public function convertUuids(Language|null $language): void
{
if (Uuids::enabled() === false) {
return;
}
if (
$language instanceof Language &&
$language->isDefault() === false
) {
return;
}
// store old UUID
$old = $this->copy->uuid()->toString();
// re-generate UUID for the page
$this->copy = $this->copy->save(
['uuid' => Uuid::generate()],
$language?->code()
);
// track UUID change
$this->uuids[$old] = $this->copy->uuid()->toString();
$this->convertFileUuids($language);
$this->convertChildrenUuids($language);
}
/**
* Re-generate UUIDs for each child recursively
* and merge with the tracked changed UUIDs
*/
protected function convertChildrenUuids(Language|null $language): void
{
// re-generate UUIDs and track changes
if ($this->withChildren === true) {
foreach ($this->copy->childrenAndDrafts() as $child) {
// always adapt files of subpages as they are
// currently always copied; adapt children recursively
$child = new PageCopy(
$child,
withChildren: true,
withFiles: true,
uuids: $this->uuids
);
$child->convertUuids($language);
$this->uuids = [...$this->uuids, ...$child->uuids];
}
}
// if children have not been copied over,
// track all children UUIDs from original page to
// remove/replace with empty string
if ($this->withChildren === false) {
foreach ($this->original?->index(drafts: true) ?? [] as $child) {
$this->uuids[$child->uuid()->toString()] = '';
foreach ($child->files() as $file) {
$this->uuids[$file->uuid()->toString()] = '';
}
}
}
}
/**
* Re-generate UUID for each file and track the change
*/
protected function convertFileUuids(Language|null $language): void
{
// re-generate UUIDs and track changes
if ($this->withFiles === true) {
foreach ($this->copy->files() as $file) {
// store old file UUID
$old = $file->uuid()->toString();
// re-generate UUID for the file
$file = $file->save(
['uuid' => Uuid::generate()],
$language?->code()
);
// track UUID change
$this->uuids[$old] = $file->uuid()->toString();
}
}
// if files have not been copied over,
// track file UUIDs from original page to
// remove/replace with empty string
if ($this->withFiles === false) {
foreach ($this->original?->files() ?? [] as $file) {
$this->uuids[$file->uuid()->toString()] = '';
}
}
}
/**
* Returns all languages to adapt
*
* @todo Refactor once singe-lang mode also works with a language object
*/
public function languages(): Languages|iterable
{
$kirby = App::instance();
if ($kirby->multilang() === true) {
return $kirby->languages();
}
return [null];
}
/**
* Processes the copy with all necessary adaptations.
* Main method to use if not familiar with individual steps.
*/
public static function process(
Page $copy,
Page|null $original = null,
bool $withFiles = false,
bool $withChildren = false
): Page {
$converter = new static($copy, $original, $withFiles, $withChildren);
// loop through all languages to remove slug from non-default
// languages and re-generate UUIDs (and track changes)
foreach ($converter->languages() as $language) {
$converter->removeSlug($language);
$converter->convertUuids($language);
}
// apply all tracked UUID changes at once
$converter->replaceUuids();
return $converter->copy;
}
/**
* Removes translated slug for copied page.
* This is needed to avoid translated slug
* collisions with the original page.
*/
public function removeSlug(Language|null $language): void
{
// single lang setup
if ($language === null) {
return;
}
// don't remove slug from default language
if ($language->isDefault() === true) {
return;
}
if ($this->copy->translation($language)->exists() === true) {
$this->copy = $this->copy->save(
['slug' => null],
$language->code()
);
}
}
/**
* Replace old UUIDs with new UUIDs in the content
*/
public function replaceUuids(): void
{
if (Uuids::enabled() === false) {
return;
}
foreach ($this->copy->storage()->all() as $versionId => $language) {
$this->copy->storage()->replaceStrings(
$versionId,
$language,
$this->uuids
);
}
if ($this->withFiles === true) {
foreach ($this->copy->files() as $file) {
foreach ($file->storage()->all() as $versionId => $language) {
$file->storage()->replaceStrings(
$versionId,
$language,
$this->uuids
);
}
}
}
if ($this->withChildren === true) {
foreach ($this->copy->childrenAndDrafts() as $child) {
$child = new PageCopy($child, withFiles: true, withChildren: true, uuids: $this->uuids);
$child->replaceUuids();
}
}
}
}

View file

@ -13,7 +13,15 @@ namespace Kirby\Cms;
*/
class PagePermissions extends ModelPermissions
{
protected string $category = 'pages';
protected const CATEGORY = 'pages';
/**
* Used to cache once determined permissions in memory
*/
protected static function cacheKey(ModelWithContent|Language $model): string
{
return $model->intendedTemplate()->name();
}
protected function canChangeSlug(): bool
{

View file

@ -29,12 +29,13 @@ class PagePicker extends Picker
*/
public function defaults(): array
{
return array_merge(parent::defaults(), [
return [
...parent::defaults(),
// Page ID of the selected parent. Used to navigate
'parent' => null,
'parent' => null,
// enable/disable subpage navigation
'subpages' => true,
]);
];
}
/**
@ -126,13 +127,13 @@ class PagePicker extends Picker
if (empty($this->options['query']) === true) {
$items = $this->itemsForParent();
// when subpage navigation is enabled, a parent
// might be passed in addition to the query.
// The parent then takes priority.
// when subpage navigation is enabled, a parent
// might be passed in addition to the query.
// The parent then takes priority.
} elseif ($this->options['subpages'] === true && empty($this->options['parent']) === false) {
$items = $this->itemsForParent();
// search by query
// search by query
} else {
$items = $this->itemsForQuery();
}
@ -178,7 +179,9 @@ class PagePicker extends Picker
$items instanceof Page => $items->children(),
$items instanceof Pages => $items,
default => throw new InvalidArgumentException('Your query must return a set of pages')
default => throw new InvalidArgumentException(
message: 'Your query must return a set of pages'
)
};
return $this->itemsForQuery = $items;

View file

@ -25,13 +25,11 @@ class PageRules
*
* @throws \Kirby\Exception\InvalidArgumentException If the given number is invalid
*/
public static function changeNum(Page $page, int|null $num = null): bool
public static function changeNum(Page $page, int|null $num = null): void
{
if ($num !== null && $num < 0) {
throw new InvalidArgumentException(['key' => 'page.num.invalid']);
throw new InvalidArgumentException(key: 'page.num.invalid');
}
return true;
}
/**
@ -40,15 +38,13 @@ class PageRules
* @throws \Kirby\Exception\DuplicateException If a page with this slug already exists
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the slug
*/
public static function changeSlug(Page $page, string $slug): bool
public static function changeSlug(Page $page, string $slug): void
{
if ($page->permissions()->changeSlug() !== true) {
throw new PermissionException([
'key' => 'page.changeSlug.permission',
'data' => [
'slug' => $page->slug()
]
]);
if ($page->permissions()->can('changeSlug') !== true) {
throw new PermissionException(
key: 'page.changeSlug.permission',
data: ['slug' => $page->slug()]
);
}
self::validateSlugLength($slug);
@ -58,24 +54,18 @@ class PageRules
$drafts = $page->parentModel()->drafts();
if ($siblings->find($slug)?->is($page) === false) {
throw new DuplicateException([
'key' => 'page.duplicate',
'data' => [
'slug' => $slug
]
]);
throw new DuplicateException(
key: 'page.duplicate',
data: ['slug' => $slug]
);
}
if ($drafts->find($slug)?->is($page) === false) {
throw new DuplicateException([
'key' => 'page.draft.duplicate',
'data' => [
'slug' => $slug
]
]);
throw new DuplicateException(
key: 'page.draft.duplicate',
data: ['slug' => $slug]
);
}
return true;
}
/**
@ -87,16 +77,18 @@ class PageRules
Page $page,
string $status,
int|null $position = null
): bool {
): void {
if (isset($page->blueprint()->status()[$status]) === false) {
throw new InvalidArgumentException(['key' => 'page.status.invalid']);
throw new InvalidArgumentException(key: 'page.status.invalid');
}
return match ($status) {
match ($status) {
'draft' => static::changeStatusToDraft($page),
'listed' => static::changeStatusToListed($page, $position),
'unlisted' => static::changeStatusToUnlisted($page),
default => throw new InvalidArgumentException(['key' => 'page.status.invalid'])
default => throw new InvalidArgumentException(
key: 'page.status.invalid'
)
};
}
@ -105,27 +97,21 @@ class PageRules
*
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status or the page cannot be converted to a draft
*/
public static function changeStatusToDraft(Page $page): bool
public static function changeStatusToDraft(Page $page): void
{
if ($page->permissions()->changeStatus() !== true) {
throw new PermissionException([
'key' => 'page.changeStatus.permission',
'data' => [
'slug' => $page->slug()
]
]);
if ($page->permissions()->can('changeStatus') !== true) {
throw new PermissionException(
key: 'page.changeStatus.permission',
data: ['slug' => $page->slug()]
);
}
if ($page->isHomeOrErrorPage() === true) {
throw new PermissionException([
'key' => 'page.changeStatus.toDraft.invalid',
'data' => [
'slug' => $page->slug()
]
]);
throw new PermissionException(
key: 'page.changeStatus.toDraft.invalid',
data: ['slug' => $page->slug()]
);
}
return true;
}
/**
@ -134,30 +120,26 @@ class PageRules
* @throws \Kirby\Exception\InvalidArgumentException If the given position is invalid
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status or the status for the page cannot be changed by any user
*/
public static function changeStatusToListed(Page $page, int $position): bool
public static function changeStatusToListed(Page $page, int $position): void
{
// no need to check for status changing permissions,
// instead we need to check for sorting permissions
if ($page->isListed() === true) {
if ($page->isSortable() !== true) {
throw new PermissionException([
'key' => 'page.sort.permission',
'data' => [
'slug' => $page->slug()
]
]);
throw new PermissionException(
key: 'page.sort.permission',
data: ['slug' => $page->slug()]
);
}
return true;
return;
}
static::publish($page);
if ($position !== null && $position < 0) {
throw new InvalidArgumentException(['key' => 'page.num.invalid']);
throw new InvalidArgumentException(key: 'page.num.invalid');
}
return true;
}
/**
@ -168,8 +150,6 @@ class PageRules
public static function changeStatusToUnlisted(Page $page)
{
static::publish($page);
return true;
}
/**
@ -178,30 +158,26 @@ class PageRules
* @throws \Kirby\Exception\LogicException If the template of the page cannot be changed at all
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the template
*/
public static function changeTemplate(Page $page, string $template): bool
public static function changeTemplate(Page $page, string $template): void
{
if ($page->permissions()->changeTemplate() !== true) {
throw new PermissionException([
'key' => 'page.changeTemplate.permission',
'data' => [
'slug' => $page->slug()
]
]);
if ($page->permissions()->can('changeTemplate') !== true) {
throw new PermissionException(
key: 'page.changeTemplate.permission',
data: ['slug' => $page->slug()]
);
}
$blueprints = $page->blueprints();
if (
count($blueprints) <= 1 ||
in_array($template, array_column($blueprints, 'name')) === false
in_array($template, array_column($blueprints, 'name'), true) === false
) {
throw new LogicException([
'key' => 'page.changeTemplate.invalid',
'data' => ['slug' => $page->slug()]
]);
throw new LogicException(
key: 'page.changeTemplate.invalid',
data: ['slug' => $page->slug()]
);
}
return true;
}
/**
@ -210,20 +186,16 @@ class PageRules
* @throws \Kirby\Exception\InvalidArgumentException If the new title is empty
* @throws \Kirby\Exception\PermissionException If the user is not allowed to change the title
*/
public static function changeTitle(Page $page, string $title): bool
public static function changeTitle(Page $page, string $title): void
{
if ($page->permissions()->changeTitle() !== true) {
throw new PermissionException([
'key' => 'page.changeTitle.permission',
'data' => [
'slug' => $page->slug()
]
]);
if ($page->permissions()->can('changeTitle') !== true) {
throw new PermissionException(
key: 'page.changeTitle.permission',
data: ['slug' => $page->slug()]
);
}
static::validateTitleLength($title);
return true;
}
/**
@ -233,27 +205,23 @@ class PageRules
* @throws \Kirby\Exception\InvalidArgumentException If the slug is invalid
* @throws \Kirby\Exception\PermissionException If the user is not allowed to create this page
*/
public static function create(Page $page): bool
public static function create(Page $page): void
{
if ($page->permissions()->create() !== true) {
throw new PermissionException([
'key' => 'page.create.permission',
'data' => [
'slug' => $page->slug()
]
]);
if ($page->permissions()->can('create') !== true) {
throw new PermissionException(
key: 'page.create.permission',
data: ['slug' => $page->slug()]
);
}
self::validateSlugLength($page->slug());
self::validateSlugProtectedPaths($page, $page->slug());
if ($page->exists() === true) {
throw new DuplicateException([
'key' => 'page.draft.duplicate',
'data' => [
'slug' => $page->slug()
]
]);
throw new DuplicateException(
key: 'page.draft.duplicate',
data: ['slug' => $page->slug()]
);
}
$siblings = $page->parentModel()->children();
@ -261,20 +229,18 @@ class PageRules
$slug = $page->slug();
if ($siblings->find($slug)) {
throw new DuplicateException([
'key' => 'page.duplicate',
'data' => ['slug' => $slug]
]);
throw new DuplicateException(
key: 'page.duplicate',
data: ['slug' => $slug]
);
}
if ($drafts->find($slug)) {
throw new DuplicateException([
'key' => 'page.draft.duplicate',
'data' => ['slug' => $slug]
]);
throw new DuplicateException(
key: 'page.draft.duplicate',
data: ['slug' => $slug]
);
}
return true;
}
/**
@ -283,22 +249,18 @@ class PageRules
* @throws \Kirby\Exception\LogicException If the page has children and should not be force-deleted
* @throws \Kirby\Exception\PermissionException If the user is not allowed to delete the page
*/
public static function delete(Page $page, bool $force = false): bool
public static function delete(Page $page, bool $force = false): void
{
if ($page->permissions()->delete() !== true) {
throw new PermissionException([
'key' => 'page.delete.permission',
'data' => [
'slug' => $page->slug()
]
]);
if ($page->permissions()->can('delete') !== true) {
throw new PermissionException(
key: 'page.delete.permission',
data: ['slug' => $page->slug()]
);
}
if (($page->hasChildren() === true || $page->hasDrafts() === true) && $force === false) {
throw new LogicException(['key' => 'page.delete.hasChildren']);
throw new LogicException(key: 'page.delete.hasChildren');
}
return true;
}
/**
@ -310,56 +272,52 @@ class PageRules
Page $page,
string $slug,
array $options = []
): bool {
if ($page->permissions()->duplicate() !== true) {
throw new PermissionException([
'key' => 'page.duplicate.permission',
'data' => [
'slug' => $page->slug()
]
]);
): void {
if ($page->permissions()->can('duplicate') !== true) {
throw new PermissionException(
key: 'page.duplicate.permission',
data: ['slug' => $page->slug()]
);
}
self::validateSlugLength($slug);
return true;
}
/**
* Check if the page can be moved
* to the given parent
*/
public static function move(Page $page, Site|Page $parent): bool
public static function move(Page $page, Site|Page $parent): void
{
// if nothing changes, there's no need for checks
if ($parent->is($page->parent()) === true) {
return true;
return;
}
if ($page->permissions()->move() !== true) {
throw new PermissionException([
'key' => 'page.move.permission',
'data' => [
'slug' => $page->slug()
]
]);
if ($page->permissions()->can('move') !== true) {
throw new PermissionException(
key: 'page.move.permission',
data: ['slug' => $page->slug()]
);
}
// the page cannot be moved into itself
if ($parent instanceof Page && ($page->is($parent) === true || $page->isAncestorOf($parent) === true)) {
throw new LogicException([
'key' => 'page.move.ancestor',
]);
if (
$parent instanceof Page &&
(
$page->is($parent) === true ||
$page->isAncestorOf($parent) === true
)
) {
throw new LogicException(key: 'page.move.ancestor');
}
// check for duplicates
if ($parent->childrenAndDrafts()->find($page->slug())) {
throw new DuplicateException([
'key' => 'page.move.duplicate',
'data' => [
'slug' => $page->slug(),
]
]);
throw new DuplicateException(
key: 'page.move.duplicate',
data: ['slug' => $page->slug()]
);
}
$allowed = [];
@ -399,41 +357,37 @@ class PageRules
$allowed !== [] &&
in_array($page->intendedTemplate()->name(), $allowed) === false
) {
throw new PermissionException([
'key' => 'page.move.template',
'data' => [
throw new PermissionException(
key: 'page.move.template',
data: [
'template' => $page->intendedTemplate()->name(),
'parent' => $parent->id() ?? '/',
]
]);
);
}
return true;
}
/**
* Check if the page can be published
* (status change from draft to listed or unlisted)
*/
public static function publish(Page $page): bool
public static function publish(Page $page): void
{
if ($page->permissions()->changeStatus() !== true) {
throw new PermissionException([
'key' => 'page.changeStatus.permission',
'data' => [
if ($page->permissions()->can('changeStatus') !== true) {
throw new PermissionException(
key: 'page.changeStatus.permission',
data: [
'slug' => $page->slug()
]
]);
);
}
if ($page->isDraft() === true && empty($page->errors()) === false) {
throw new PermissionException([
'key' => 'page.changeStatus.incomplete',
'details' => $page->errors()
]);
throw new PermissionException(
key: 'page.changeStatus.incomplete',
details: $page->errors()
);
}
return true;
}
/**
@ -441,18 +395,14 @@ class PageRules
*
* @throws \Kirby\Exception\PermissionException If the user is not allowed to update the page
*/
public static function update(Page $page, array $content = []): bool
public static function update(Page $page, array $content = []): void
{
if ($page->permissions()->update() !== true) {
throw new PermissionException([
'key' => 'page.update.permission',
'data' => [
'slug' => $page->slug()
]
]);
if ($page->permissions()->can('update') !== true) {
throw new PermissionException(
key: 'page.update.permission',
data: ['slug' => $page->slug()]
);
}
return true;
}
/**
@ -466,21 +416,17 @@ class PageRules
$slugLength = Str::length($slug);
if ($slugLength === 0) {
throw new InvalidArgumentException([
'key' => 'page.slug.invalid',
]);
throw new InvalidArgumentException(key: 'page.slug.invalid');
}
if ($slugsMaxlength = App::instance()->option('slugs.maxlength', 255)) {
$maxlength = (int)$slugsMaxlength;
if ($slugLength > $maxlength) {
throw new InvalidArgumentException([
'key' => 'page.slug.maxlength',
'data' => [
'length' => $maxlength
]
]);
throw new InvalidArgumentException(
key: 'page.slug.maxlength',
data: ['length' => $maxlength]
);
}
}
}
@ -505,12 +451,10 @@ class PageRules
$index = array_search($slug, $paths);
if ($index !== false) {
throw new InvalidArgumentException([
'key' => 'page.changeSlug.reserved',
'data' => [
'path' => $paths[$index]
]
]);
throw new InvalidArgumentException(
key: 'page.changeSlug.reserved',
data: ['path' => $paths[$index]]
);
}
}
}
@ -523,9 +467,7 @@ class PageRules
public static function validateTitleLength(string $title): void
{
if (Str::length($title) === 0) {
throw new InvalidArgumentException([
'key' => 'page.changeTitle.empty',
]);
throw new InvalidArgumentException(key: 'page.changeTitle.empty');
}
}
}

View file

@ -16,10 +16,8 @@ trait PageSiblings
/**
* Checks if there's a next listed
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*/
public function hasNextListed($collection = null): bool
public function hasNextListed(Pages|null $collection = null): bool
{
return $this->nextListed($collection) !== null;
}
@ -27,10 +25,8 @@ trait PageSiblings
/**
* Checks if there's a next unlisted
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*/
public function hasNextUnlisted($collection = null): bool
public function hasNextUnlisted(Pages|null $collection = null): bool
{
return $this->nextUnlisted($collection) !== null;
}
@ -38,10 +34,8 @@ trait PageSiblings
/**
* Checks if there's a previous listed
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*/
public function hasPrevListed($collection = null): bool
public function hasPrevListed(Pages|null $collection = null): bool
{
return $this->prevListed($collection) !== null;
}
@ -49,68 +43,48 @@ trait PageSiblings
/**
* Checks if there's a previous unlisted
* page in the siblings collection
*
* @param \Kirby\Cms\Collection|null $collection
*/
public function hasPrevUnlisted($collection = null): bool
public function hasPrevUnlisted(Pages|null $collection = null): bool
{
return $this->prevUnlisted($collection) !== null;
}
/**
* Returns the next listed page if it exists
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Page|null
*/
public function nextListed($collection = null)
public function nextListed(Pages|null $collection = null): Page|null
{
return $this->nextAll($collection)->listed()->first();
}
/**
* Returns the next unlisted page if it exists
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Page|null
*/
public function nextUnlisted($collection = null)
public function nextUnlisted(Pages|null $collection = null): Page|null
{
return $this->nextAll($collection)->unlisted()->first();
}
/**
* Returns the previous listed page
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Page|null
*/
public function prevListed($collection = null)
public function prevListed(Pages|null $collection = null): Page|null
{
return $this->prevAll($collection)->listed()->last();
}
/**
* Returns the previous unlisted page
*
* @param \Kirby\Cms\Collection|null $collection
*
* @return \Kirby\Cms\Page|null
*/
public function prevUnlisted($collection = null)
public function prevUnlisted(Pages|null $collection = null): Page|null
{
return $this->prevAll($collection)->unlisted()->last();
}
/**
* Private siblings collector
*
* @return \Kirby\Cms\Collection
*/
protected function siblingsCollection()
protected function siblingsCollection(): Pages
{
if ($this->isDraft() === true) {
return $this->parentModel()->drafts();
@ -121,11 +95,12 @@ trait PageSiblings
/**
* Returns siblings with the same template
*
* @return \Kirby\Cms\Pages
*/
public function templateSiblings(bool $self = true)
public function templateSiblings(bool $self = true): Pages
{
return $this->siblings($self)->filter('intendedTemplate', $this->intendedTemplate()->name());
return $this->siblings($self)->filter(
'intendedTemplate',
$this->intendedTemplate()->name()
);
}
}

View file

@ -2,8 +2,11 @@
namespace Kirby\Cms;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Uuid\HasUuids;
use Throwable;
/**
* The `$pages` object refers to a
@ -20,6 +23,8 @@ use Kirby\Uuid\HasUuids;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Collection<\Kirby\Cms\Page>
*/
class Pages extends Collection
{
@ -27,23 +32,24 @@ class Pages extends Collection
/**
* Cache for the index only listed and unlisted pages
*
* @var \Kirby\Cms\Pages|null
*/
protected $index = null;
protected Pages|null $index = null;
/**
* Cache for the index all statuses also including drafts
*
* @var \Kirby\Cms\Pages|null
*/
protected $indexWithDrafts = null;
protected Pages|null $indexWithDrafts = null;
/**
* All registered pages methods
*/
public static array $methods = [];
/**
* @var \Kirby\Cms\Page|\Kirby\Cms\Site|null
*/
protected object|null $parent = null;
/**
* Adds a single page or
* an entire second collection to the
@ -59,23 +65,25 @@ class Pages extends Collection
// add a pages collection
if ($object instanceof self) {
$this->data = array_merge($this->data, $object->data);
$this->data = [...$this->data, ...$object->data];
// add a page by id
// add a page by id
} elseif (
is_string($object) === true &&
$page = $site->find($object)
) {
$this->__set($page->id(), $page);
// add a page object
// add a page object
} elseif ($object instanceof Page) {
$this->__set($object->id(), $object);
// give a useful error message on invalid input;
// silently ignore "empty" values for compatibility with existing setups
// give a useful error message on invalid input;
// silently ignore "empty" values for compatibility with existing setups
} elseif (in_array($object, [null, false, true], true) !== true) {
throw new InvalidArgumentException('You must pass a Pages or Page object or an ID of an existing page to the Pages collection');
throw new InvalidArgumentException(
message: 'You must pass a Pages or Page object or an ID of an existing page to the Pages collection'
);
}
return $this;
@ -92,9 +100,9 @@ class Pages extends Collection
/**
* Returns all children for each page in the array
*/
public function children(): Pages
public function children(): static
{
$children = new Pages([]);
$children = new static([]);
foreach ($this->data as $page) {
foreach ($page->children() as $childKey => $child) {
@ -113,6 +121,41 @@ class Pages extends Collection
return $this->files()->filter('type', 'code');
}
/**
* Deletes the pages with the given IDs
* if they exist in the collection
*
* @throws \Kirby\Exception\Exception If not all pages could be deleted
*/
public function delete(array $ids): void
{
$exceptions = [];
// delete all pages and collect errors
foreach ($ids as $id) {
try {
$model = $this->get($id);
if ($model instanceof Page === false) {
throw new NotFoundException(
key: 'page.undefined',
);
}
$model->delete();
} catch (Throwable $e) {
$exceptions[$id] = $e;
}
}
if ($exceptions !== []) {
throw new Exception(
key: 'page.delete.multiple',
details: $exceptions
);
}
}
/**
* Returns all documents of all children
*/
@ -124,9 +167,9 @@ class Pages extends Collection
/**
* Fetch all drafts for all pages in the collection
*/
public function drafts(): Pages
public function drafts(): static
{
$drafts = new Pages([]);
$drafts = new static([]);
foreach ($this->data as $page) {
foreach ($page->drafts() as $draftKey => $draft) {
@ -203,7 +246,7 @@ class Pages extends Collection
$key = trim($key, '/');
// strip extensions from the id
if (strpos($key, '.') !== false) {
if (str_contains($key, '.') === true) {
$info = pathinfo($key);
if ($info['dirname'] !== '.') {
@ -243,14 +286,12 @@ class Pages extends Collection
/**
* Finds a child or child of a child recursively
*
* @return mixed
*/
protected function findByKeyRecursive(
string $id,
string|null $startAt = null,
bool $multiLang = false
) {
): Page|null {
$path = explode('/', $id);
$item = null;
$query = $startAt;
@ -294,12 +335,8 @@ class Pages extends Collection
/**
* Custom getter that is able to find
* extension pages
*
* @param string $key
* @param mixed $default
* @return \Kirby\Cms\Page|null
*/
public function get($key, $default = null)
public function get(string $key, mixed $default = null): Page|null
{
if ($key === null) {
return null;
@ -323,19 +360,17 @@ class Pages extends Collection
/**
* Create a recursive flat index of all
* pages and subpages, etc.
*
* @return \Kirby\Cms\Pages
*/
public function index(bool $drafts = false)
public function index(bool $drafts = false): static
{
// get object property by cache mode
$index = $drafts === true ? $this->indexWithDrafts : $this->index;
if ($index instanceof self) {
if ($index instanceof Pages) {
return $index;
}
$index = new Pages([]);
$index = new static([]);
foreach ($this->data as $pageKey => $page) {
$index->data[$pageKey] = $page;
@ -374,10 +409,9 @@ class Pages extends Collection
/**
* Include all given items in the collection
*
* @param mixed ...$args
* @return $this|static
*/
public function merge(...$args)
public function merge(string|Pages|Page|array ...$args): static
{
// merge multiple arguments at once
if (count($args) > 1) {
@ -398,9 +432,9 @@ class Pages extends Collection
}
// merge an entire collection
if ($args[0] instanceof self) {
$collection = clone $this;
$collection->data = array_merge($collection->data, $args[0]->data);
if ($args[0] instanceof Pages) {
$collection = clone $this;
$collection->data = [...$collection->data, ...$args[0]->data];
return $collection;
}
@ -430,10 +464,9 @@ class Pages extends Collection
* Filter all pages by excluding the given template
* @since 3.3.0
*
* @param string|array $templates
* @return \Kirby\Cms\Pages
* @return $this|static
*/
public function notTemplate($templates)
public function notTemplate(string|array|null $templates): static
{
if (empty($templates) === true) {
return $this;
@ -444,8 +477,7 @@ class Pages extends Collection
}
return $this->filter(
fn ($page) =>
!in_array($page->intendedTemplate()->name(), $templates)
fn ($page) => in_array($page->intendedTemplate()->name(), $templates, true) === false
);
}
@ -466,10 +498,9 @@ class Pages extends Collection
/**
* Filter all pages by the given template
*
* @param string|array $templates
* @return \Kirby\Cms\Pages
* @return $this|static
*/
public function template($templates)
public function template(string|array|null $templates): static
{
if (empty($templates) === true) {
return $this;
@ -480,8 +511,7 @@ class Pages extends Collection
}
return $this->filter(
fn ($page) =>
in_array($page->intendedTemplate()->name(), $templates)
fn ($page) => in_array($page->intendedTemplate()->name(), $templates, true)
);
}

View file

@ -24,24 +24,18 @@ class Pagination extends BasePagination
{
/**
* Pagination method (param, query, none)
*
* @var string
*/
protected $method;
protected string $method;
/**
* The base URL
*
* @var string
*/
protected $url;
protected Uri $url;
/**
* Variable name for query strings
*
* @var string
*/
protected $variable;
protected string $variable;
/**
* Creates the pagination object. As a new
@ -78,11 +72,11 @@ class Pagination extends BasePagination
]);
}
if ($params['method'] === 'query') {
$params['page'] ??= $params['url']->query()->get($params['variable']);
} elseif ($params['method'] === 'param') {
$params['page'] ??= $params['url']->params()->get($params['variable']);
}
$params['page'] ??= match ($params['method']) {
'query' => $params['url']->query()->get($params['variable']),
'param' => $params['url']->params()->get($params['variable']),
default => null
};
parent::__construct($params);
@ -134,20 +128,22 @@ class Pagination extends BasePagination
$url = clone $this->url;
$variable = $this->variable;
if ($this->hasPage($page) === false) {
if (
$this->hasPage($page) === false ||
in_array($this->method, ['query', 'param'], true) === false
) {
return null;
}
$pageValue = $page === 1 ? null : $page;
if ($this->method === 'query') {
$url->query->$variable = $pageValue;
} elseif ($this->method === 'param') {
$url->params->$variable = $pageValue;
} else {
return null;
if ($page === 1) {
$page = null;
}
match ($this->method) {
'query' => $url->query->$variable = $page,
'param' => $url->params->$variable = $page
};
return $url->toString();
}

View file

@ -37,6 +37,7 @@ class Permissions
'list' => true,
'read' => true,
'replace' => true,
'sort' => true,
'update' => true
],
'languages' => [
@ -95,7 +96,9 @@ class Permissions
// 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');
throw new InvalidArgumentException(
message: 'The action ' . $key . ' is already a core action'
);
}
$this->actions[$key] = $actions;
@ -177,14 +180,14 @@ class Permissions
*/
protected function setCategories(array $settings): static
{
foreach ($settings as $categoryName => $categoryActions) {
if (is_bool($categoryActions) === true) {
$this->setCategory($categoryName, $categoryActions);
foreach ($settings as $name => $actions) {
if (is_bool($actions) === true) {
$this->setCategory($name, $actions);
}
if (is_array($categoryActions) === true) {
foreach ($categoryActions as $actionName => $actionSetting) {
$this->setAction($categoryName, $actionName, $actionSetting);
if (is_array($actions) === true) {
foreach ($actions as $action => $setting) {
$this->setAction($name, $action, $setting);
}
}
}
@ -199,11 +202,13 @@ class Permissions
protected function setCategory(string $category, bool $setting): static
{
if ($this->hasCategory($category) === false) {
throw new InvalidArgumentException('Invalid permissions category');
throw new InvalidArgumentException(
message: 'Invalid permissions category'
);
}
foreach ($this->actions[$category] as $actionName => $actionSetting) {
$this->actions[$category][$actionName] = $setting;
foreach ($this->actions[$category] as $action => $actionSetting) {
$this->actions[$category][$action] = $setting;
}
return $this;

View file

@ -23,7 +23,7 @@ abstract class Picker
*/
public function __construct(array $params = [])
{
$this->options = array_merge($this->defaults(), $params);
$this->options = [...$this->defaults(), ...$params];
$this->kirby = $this->options['model']->kirby();
$this->site = $this->kirby->site();
}

View file

@ -5,6 +5,7 @@ namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Filesystem\Mime;
use Kirby\Toolkit\Str;
use Stringable;
/**
* Global response configuration
@ -15,7 +16,7 @@ use Kirby\Toolkit\Str;
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Responder
class Responder implements Stringable
{
/**
* Timestamp when the response expires
@ -134,7 +135,7 @@ class Responder
public function usesCookie(string $name): void
{
// only add unique names
if (in_array($name, $this->usesCookies) === false) {
if (in_array($name, $this->usesCookies, true) === false) {
$this->usesCookies[] = $name;
}
}
@ -187,7 +188,9 @@ class Responder
$parsedExpires = strtotime($expires);
if (is_int($parsedExpires) !== true) {
throw new InvalidArgumentException('Invalid time string "' . $expires . '"');
throw new InvalidArgumentException(
message: 'Invalid time string "' . $expires . '"'
);
}
$expires = $parsedExpires;
@ -293,7 +296,7 @@ class Responder
}
// lazily inject (never override custom headers)
return array_merge($injectedHeaders, $this->headers);
return [...$injectedHeaders, ...$this->headers];
}
$this->headers = $headers;
@ -384,8 +387,9 @@ class Responder
* all caches due to using dynamic data based on auth
* and/or cookies; the request data only matters if it
* is actually used/relied on by the response
*
* @since 3.7.0
* @internal
* @unstable
*/
public static function isPrivate(bool $usesAuth, array $usesCookies): bool
{

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