update kirby to v5 and add refresh cache panel view button
This commit is contained in:
commit
9a86d41254
466 changed files with 19960 additions and 10497 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
137
public/kirby/src/Api/Controller/Changes.php
Normal file
137
public/kirby/src/Api/Controller/Changes.php
Normal 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'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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] = [
|
||||
|
|
|
|||
436
public/kirby/src/Api/Upload.php
Normal file
436
public/kirby/src/Api/Upload.php
Normal 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'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
160
public/kirby/src/Cache/RedisCache.php
Normal file
160
public/kirby/src/Cache/RedisCache.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ class Value
|
|||
/**
|
||||
* Cached value
|
||||
*/
|
||||
protected $value;
|
||||
protected mixed $value;
|
||||
|
||||
/**
|
||||
* the number of minutes until the value expires
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
130
public/kirby/src/Cms/Events.php
Normal file
130
public/kirby/src/Cms/Events.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 there’s 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
53
public/kirby/src/Cms/HasModels.php
Normal file
53
public/kirby/src/Cms/HasModels.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
public/kirby/src/Cms/LanguagePermissions.php
Normal file
22
public/kirby/src/Cms/LanguagePermissions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 couldn’t redirect you to the Hub');
|
||||
throw new Exception(
|
||||
message: 'We couldn’t redirect you to the Hub'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
243
public/kirby/src/Cms/ModelCommit.php
Normal file
243
public/kirby/src/Cms/ModelCommit.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
public/kirby/src/Cms/ModelState.php
Normal file
107
public/kirby/src/Cms/ModelState.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
236
public/kirby/src/Cms/PageCopy.php
Normal file
236
public/kirby/src/Cms/PageCopy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue