Upgrade to 3.8.1

This commit is contained in:
Bastian Allgeier
2022-10-18 14:11:15 +02:00
parent 94b2a32baf
commit 9c93e01c3a
71 changed files with 633 additions and 5705 deletions

View File

@@ -1062,7 +1062,7 @@ class App
// load the main config options
$root = $this->root('config');
$options = F::load($root . '/config.php', []);
$options = F::load($root . '/config.php', [], allowOutput: false);
// merge into one clean options array
return $this->options = array_replace_recursive(Config::$data, $options);
@@ -1080,7 +1080,7 @@ class App
$root = $this->root('config');
// first load `config/env.php` to access its `url` option
$envOptions = F::load($root . '/env.php', []);
$envOptions = F::load($root . '/env.php', [], allowOutput: false);
// use the option from the main `config.php`,
// but allow the `env.php` to override it

View File

@@ -163,19 +163,19 @@ trait AppErrors
$whoops = $this->whoops();
$whoops->clearHandlers();
$whoops->pushHandler($handler);
$whoops->pushHandler($this->getExceptionHookWhoopsHandler());
$whoops->pushHandler($this->getAdditionalWhoopsHandler());
$whoops->register(); // will only do something if not already registered
}
/**
* Initializes a callback handler for triggering the `system.exception` hook
*
* @return \Whoops\Handler\CallbackHandler
* Whoops callback handler for additional error handling
* (`system.exception` hook and output to error log)
*/
protected function getExceptionHookWhoopsHandler(): CallbackHandler
protected function getAdditionalWhoopsHandler(): CallbackHandler
{
return new CallbackHandler(function ($exception, $inspector, $run) {
$this->trigger('system.exception', compact('exception'));
error_log($exception);
return Handler::DONE;
});
}

View File

@@ -717,7 +717,7 @@ trait AppPlugins
$class = str_replace(['.', '-', '_'], '', $name) . 'Page';
// load the model class
F::loadOnce($model);
F::loadOnce($model, allowOutput: false);
if (class_exists($class) === true) {
$models[$name] = $class;
@@ -908,7 +908,7 @@ trait AppPlugins
$styles = $dir . '/index.css';
if (is_file($entry) === true) {
F::loadOnce($entry);
F::loadOnce($entry, allowOutput: false);
} elseif (is_file($script) === true || is_file($styles) === true) {
// if no PHP file is present but an index.js or index.css,
// register as anonymous plugin (without actual extensions)

View File

@@ -120,12 +120,12 @@ trait AppUsers
if ($allowImpersonation === true && is_string($this->user) === true) {
return $this->auth()->impersonate($this->user);
} else {
try {
return $this->auth()->user(null, $allowImpersonation);
} catch (Throwable) {
return null;
}
}
try {
return $this->auth()->user(null, $allowImpersonation);
} catch (Throwable) {
return null;
}
}

View File

@@ -96,10 +96,7 @@ class Auth
*/
public function createChallenge(string $email, bool $long = false, string $mode = 'login')
{
$email = $this->validateEmail($email);
// rate-limit the number of challenges for DoS/DDoS protection
$this->track($email, false);
$email = Idn::decodeEmail($email);
$session = $this->kirby->session([
'createMode' => 'cookie',
@@ -108,8 +105,29 @@ class Auth
$timeout = $this->kirby->option('auth.challenge.timeout', 10 * 60);
$challenge = null;
if ($user = $this->kirby->users()->find($email)) {
// catch every exception to hide them from attackers
// unless auth debugging is enabled
try {
$this->checkRateLimit($email);
// rate-limit the number of challenges for DoS/DDoS protection
$this->track($email, false);
// try to find the provided user
$user = $this->kirby->users()->find($email);
if ($user === null) {
$this->kirby->trigger('user.login:failed', compact('email'));
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
}
// try to find an enabled challenge that is available for that user
$challenge = null;
foreach ($this->enabledChallenges() as $name) {
$class = static::$challenges[$name] ?? null;
if (
@@ -131,23 +149,13 @@ class Auth
}
}
// if no suitable challenge was found, `$challenge === null` at this point;
// only leak this in debug mode
if ($challenge === null && $this->kirby->option('debug') === true) {
// if no suitable challenge was found, `$challenge === null` at this point
if ($challenge === null) {
throw new LogicException('Could not find a suitable authentication challenge');
}
} else {
$this->kirby->trigger('user.login:failed', compact('email'));
// only leak the non-existing user in debug mode
if ($this->kirby->option('debug') === true) {
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
}
} catch (Throwable $e) {
// only throw the exception in auth debug mode
$this->fail($e);
}
// always set the email and timeout, even if the challenge
@@ -158,7 +166,7 @@ class Auth
// sleep for a random amount of milliseconds
// to make automated attacks harder and to
// avoid leaking whether the user exists
usleep(random_int(1000, 300000));
usleep(random_int(50000, 300000));
// clear the status cache
$this->status = null;
@@ -485,34 +493,21 @@ class Auth
}
/**
* Ensures that email addresses with IDN domains are in Unicode format
* and that the rate limit was not exceeded
*
* @param string $email
* @return string The normalized Unicode email address
* Ensures that the rate limit was not exceeded
*
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded
*/
protected function validateEmail(string $email): string
protected function checkRateLimit(string $email): void
{
// ensure that email addresses with IDN domains are in Unicode format
$email = Idn::decodeEmail($email);
// check for blocked ips
if ($this->isBlocked($email) === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
if ($this->kirby->option('debug') === true) {
$message = 'Rate limit exceeded';
} else {
// avoid leaking security-relevant information
$message = ['key' => 'access.login'];
}
throw new PermissionException($message);
throw new PermissionException([
'details' => ['reason' => 'rate-limited'],
'fallback' => 'Rate limit exceeded'
]);
}
return $email;
}
/**
@@ -529,10 +524,12 @@ class Auth
*/
public function validatePassword(string $email, string $password)
{
$email = $this->validateEmail($email);
$email = Idn::decodeEmail($email);
// validate the user
try {
$this->checkRateLimit($email);
// validate the user and its password
if ($user = $this->kirby->users()->find($email)) {
if ($user->validatePassword($password) === true) {
return $user;
@@ -546,20 +543,25 @@ class Auth
]
]);
} catch (Throwable $e) {
// log invalid login trial
$this->track($email);
$details = is_a($e, 'Kirby\Exception\Exception') === true ? $e->getDetails() : [];
// log invalid login trial unless the rate limit is already active
if (($details['reason'] ?? null) !== 'rate-limited') {
try {
$this->track($email);
} catch (Throwable $e) {
// $e is overwritten with the exception
// from the track method if there's one
}
}
// sleep for a random amount of milliseconds
// to make automated attacks harder
usleep(random_int(1000, 2000000));
usleep(random_int(10000, 2000000));
// keep throwing the original error in debug mode,
// otherwise hide it to avoid leaking security-relevant information
if ($this->kirby->option('debug') === true) {
throw $e;
}
throw new PermissionException(['key' => 'access.login']);
$this->fail($e, new PermissionException(['key' => 'access.login']));
}
}
@@ -841,10 +843,7 @@ class Auth
}
// rate-limiting
if ($this->isBlocked($email) === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
throw new PermissionException('Rate limit exceeded');
}
$this->checkRateLimit($email);
if (
isset(static::$challenges[$challenge]) === true &&
@@ -860,36 +859,67 @@ class Auth
$this->status = null;
return $user;
} else {
throw new PermissionException(['key' => 'access.code']);
}
throw new PermissionException(['key' => 'access.code']);
}
throw new LogicException('Invalid authentication challenge: ' . $challenge);
} catch (Throwable $e) {
if (empty($email) === false && $e->getMessage() !== 'Rate limit exceeded') {
$details = $e instanceof \Kirby\Exception\Exception ? $e->getDetails() : [];
if (
empty($email) === false &&
($details['reason'] ?? null) !== 'rate-limited'
) {
$this->track($email);
}
// sleep for a random amount of milliseconds
// to make automated attacks harder and to
// avoid leaking whether the user exists
usleep(random_int(1000, 2000000));
usleep(random_int(10000, 2000000));
// specifically copy over the marker for a destroyed challenge
// 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'
]);
// keep throwing the original error in debug mode,
// otherwise hide it to avoid leaking security-relevant information
if ($this->kirby->option('debug') === true) {
throw $e;
} else {
// specifically copy over the marker for a destroyed challenge
// even in production (used by the Panel to reset to the login form)
$challengeDestroyed = $e->getDetails()['challengeDestroyed'] ?? false;
$this->fail($e, $fallback);
}
}
throw new PermissionException([
'details' => compact('challengeDestroyed'),
'key' => 'access.code'
]);
}
/**
* Throws an exception only in debug mode, otherwise falls back
* to a public error without sensitive information
*
* @throws \Throwable Either the passed `$exception` or the `$fallback`
* (no exception if debugging is disabled and no fallback was passed)
*/
protected function fail(Throwable $exception, Throwable $fallback = null): void
{
$debug = $this->kirby->option('auth.debug', 'log');
// throw the original exception only in debug mode
if ($debug === true) {
throw $exception;
}
// otherwise hide the real error and only print it to the error log
// unless disabled by setting `auth.debug` to `false`
if ($debug === 'log') {
error_log($exception); // @codeCoverageIgnore
}
// only throw an error in production if requested by the calling method
if ($fallback !== null) {
throw $fallback;
}
}

View File

@@ -95,9 +95,9 @@ class Status
if ($automaticFallback === false) {
return $this->challenge;
} else {
return $this->challenge ?? $this->challengeFallback;
}
return $this->challenge ?? $this->challengeFallback;
}
/**

View File

@@ -731,7 +731,7 @@ class Blueprint
$preset = static::$presets[$props['preset']];
if (is_string($preset) === true) {
$preset = require $preset;
$preset = F::load($preset, allowOutput: false);
}
return $preset($props);

View File

@@ -114,11 +114,14 @@ class Collection extends BaseCollection
{
if (count($args) === 1) {
// try to determine the key from the provided item
if (is_object($args[0]) === true && is_callable([$args[0], 'id']) === true) {
if (
is_object($args[0]) === true &&
is_callable([$args[0], 'id']) === true
) {
return parent::append($args[0]->id(), $args[0]);
} else {
return parent::append($args[0]);
}
return parent::append($args[0]);
}
return parent::append(...$args);

View File

@@ -123,7 +123,7 @@ class Collections
$file = $kirby->root('collections') . '/' . $name . '.php';
if (is_file($file) === true) {
$collection = F::load($file);
$collection = F::load($file, allowOutput: false);
if ($collection instanceof Closure) {
return $collection;

View File

@@ -115,7 +115,7 @@ class Content
$oldField = $oldFields->get($name);
// field name and type matches with old template
if ($oldField && $oldField->type() === $newField->type()) {
if ($oldField?->type() === $newField->type()) {
$data[$name] = $content->get($name)->value();
} else {
$data[$name] = $newField->default();

View File

@@ -451,9 +451,9 @@ class File extends ModelWithContent
* Return the permanent URL to the file using its UUID
* @since 3.8.0
*/
public function permalink(): string
public function permalink(): string|null
{
return $this->uuid()->url();
return $this->uuid()?->url();
}
/**

View File

@@ -8,6 +8,7 @@ use Kirby\Exception\LogicException;
use Kirby\Filesystem\F;
use Kirby\Form\Form;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
/**
* FileActions
@@ -155,7 +156,9 @@ trait FileActions
$copy = $page->clone()->file($this->filename());
// overwrite with new UUID (remove old, add new)
$copy = $copy->save(['uuid' => Uuid::generate()]);
if (Uuids::enabled() === true) {
$copy = $copy->save(['uuid' => Uuid::generate()]);
}
return $copy;
}
@@ -191,7 +194,9 @@ trait FileActions
// make sure that a UUID gets generated and
// added to content right away
$content['uuid'] = Uuid::generate();
if (Uuids::enabled() === true) {
$content['uuid'] ??= Uuid::generate();
}
// create a form for the file
$form = Form::for($file, ['values' => $content]);
@@ -336,7 +341,7 @@ trait FileActions
$this->lock()?->remove();
// clear UUID cache
$this->uuid()->clear();
$this->uuid()?->clear();
}
return $this;

View File

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

View File

@@ -160,12 +160,12 @@ class Loader
{
if (is_string($item) === true) {
$item = match (F::extension($item)) {
'php' => require $item,
'php' => F::load($item, allowOutput: false),
default => Data::read($item)
};
}
if (is_callable($item)) {
if (is_callable($item) === true) {
$item = $item($this->kirby);
}

View File

@@ -632,7 +632,7 @@ abstract class ModelWithContent extends Model implements Identifiable
* Returns the model's UUID
* @since 3.8.0
*/
public function uuid(): Uuid
public function uuid(): Uuid|null
{
return Uuid::for($this);
}

View File

@@ -964,9 +964,9 @@ class Page extends ModelWithContent
* Return the permanent URL to the page using its UUID
* @since 3.8.0
*/
public function permalink(): string
public function permalink(): string|null
{
return $this->uuid()->url();
return $this->uuid()?->url();
}
/**

View File

@@ -14,6 +14,7 @@ use Kirby\Toolkit\A;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
/**
* PageActions
@@ -108,7 +109,7 @@ trait PageActions
]);
// clear UUID cache recursively (for children and files as well)
$oldPage->uuid()->clear(true);
$oldPage->uuid()?->clear(true);
if ($oldPage->exists() === true) {
// remove the lock of the old page
@@ -443,7 +444,9 @@ trait PageActions
}
// overwrite with new UUID (remove old, add new)
$copy = $copy->save(['uuid' => Uuid::generate()]);
if (Uuids::enabled() === true) {
$copy = $copy->save(['uuid' => Uuid::generate()]);
}
// add copy to siblings
static::updateParentCollections($copy, 'append', $parentModel);
@@ -467,7 +470,10 @@ trait PageActions
// make sure that a UUID gets generated and
// added to content right away
$props['content'] ??= [];
$props['content']['uuid'] ??= Uuid::generate();
if (Uuids::enabled() === true) {
$props['content']['uuid'] ??= Uuid::generate();
}
// create a temporary page object
$page = Page::factory($props);
@@ -596,7 +602,7 @@ trait PageActions
{
return $this->commit('delete', ['page' => $this, 'force' => $force], function ($page, $force) {
// clear UUID cache
$page->uuid()->clear();
$page->uuid()?->clear();
// delete all files individually
foreach ($page->files() as $file) {

View File

@@ -122,9 +122,9 @@ trait PageSiblings
{
if ($this->isDraft() === true) {
return $this->parentModel()->drafts();
} else {
return $this->parentModel()->children();
}
return $this->parentModel()->children();
}
/**

View File

@@ -2,12 +2,15 @@
namespace Kirby\Cms;
use Composer\InstalledVersions;
use Exception;
use Kirby\Cms\System\UpdateStatus;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\V;
use Throwable;
/**
* Represents a Plugin and handles parsing of
@@ -269,9 +272,22 @@ class Plugin extends Model
*/
public function version(): string|null
{
$version = $this->info()['version'] ?? null;
$composerName = $this->info()['name'] ?? null;
$version = $this->info()['version'] ?? null;
if (is_string($version) !== true || $version === '') {
try {
// if plugin doesn't have version key in composer.json file
// try to get version from "vendor/composer/installed.php"
$version ??= InstalledVersions::getPrettyVersion($composerName);
} catch (Throwable) {
return null;
}
if (
is_string($version) !== true ||
$version === '' ||
Str::endsWith($version, '+no-version-set')
) {
return null;
}

View File

@@ -322,12 +322,11 @@ class System
// only return the actual license key if the
// current user has appropriate permissions
$user = $this->app->user();
if ($user && $user->isAdmin() === true) {
if ($this->app->user()?->isAdmin() === true) {
return $license['license'];
} else {
return true;
}
return true;
}
/**

View File

@@ -157,9 +157,9 @@ class User extends ModelWithContent
{
if ($relative === true) {
return 'users/' . $this->id();
} else {
return $this->kirby()->url('api') . '/users/' . $this->id();
}
return $this->kirby()->url('api') . '/users/' . $this->id();
}
/**

View File

@@ -308,12 +308,12 @@ trait UserActions
$path = $this->root() . '/index.php';
if (is_file($path) === true) {
$credentials = F::load($path);
$credentials = F::load($path, allowOutput: false);
return is_array($credentials) === false ? [] : $credentials;
} else {
return [];
}
return [];
}
/**

View File

@@ -148,7 +148,7 @@ class Users extends Collection
// get role information
$path = $root . '/' . $userDirectory . '/index.php';
if (is_file($path) === true) {
$credentials = F::load($path);
$credentials = F::load($path, allowOutput: false);
}
// create user model based on role

View File

@@ -61,7 +61,7 @@ class PHP extends Handler
throw new Exception('The file "' . $file . '" does not exist');
}
return (array)F::load($file, []);
return (array)F::load($file, [], allowOutput: false);
}
/**

View File

@@ -898,9 +898,8 @@ class Query
if (preg_match('!^findBy([a-z]+)!i', $method, $match)) {
$column = Str::lower($match[1]);
return $this->findBy($column, $arguments[0]);
} else {
throw new InvalidArgumentException('Invalid query method: ' . $method, static::ERROR_INVALID_QUERY_METHOD);
}
throw new InvalidArgumentException('Invalid query method: ' . $method, static::ERROR_INVALID_QUERY_METHOD);
}
/**
@@ -1027,8 +1026,8 @@ class Query
// attach the where clause
if (empty($current) === false) {
return $current . ' ' . $mode . ' ' . $result;
} else {
return $result;
}
return $result;
}
}

View File

@@ -684,9 +684,9 @@ abstract class Sql
}
return implode(', ', $result);
} else {
return $columns;
}
return $columns;
}
/**
@@ -833,9 +833,9 @@ abstract class Sql
if ($set === true) {
return $this->valueSet($table, $values, $separator, $enforceQualified);
} else {
return $this->valueList($table, $values, $separator, $enforceQualified);
}
return $this->valueList($table, $values, $separator, $enforceQualified);
}
/**

View File

@@ -5,6 +5,7 @@ namespace Kirby\Filesystem;
use Exception;
use IntlDateFormatter;
use Kirby\Cms\Helpers;
use Kirby\Http\Response;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
use Throwable;
@@ -347,8 +348,12 @@ class F
*
* @param array $data Optional array of variables to extract in the variable scope
*/
public static function load(string $file, $fallback = null, array $data = [])
{
public static function load(
string $file,
mixed $fallback = null,
array $data = [],
bool $allowOutput = true
) {
if (is_file($file) === false) {
return $fallback;
}
@@ -356,9 +361,21 @@ class F
// we use the loadIsolated() method here to prevent the included
// file from overwriting our $fallback in this variable scope; see
// https://www.php.net/manual/en/function.include.php#example-124
$result = static::loadIsolated($file, $data);
$callback = fn () => static::loadIsolated($file, $data);
if ($fallback !== null && gettype($result) !== gettype($fallback)) {
// if the loaded file should not produce any output,
// call the loaidIsolated method from the Response class
// which checks for unintended ouput and throws an error if detected
if ($allowOutput === false) {
$result = Response::guardAgainstOutput($callback);
} else {
$result = $callback();
}
if (
$fallback !== null &&
gettype($result) !== gettype($fallback)
) {
return $fallback;
}
@@ -369,24 +386,28 @@ class F
* A super simple class autoloader
* @since 3.7.0
*/
public static function loadClasses(array $classmap, string|null $base = null): void
{
public static function loadClasses(
array $classmap,
string|null $base = null
): void {
// convert all classnames to lowercase
$classmap = array_change_key_case($classmap);
spl_autoload_register(function ($class) use ($classmap, $base) {
$class = strtolower($class);
spl_autoload_register(
fn ($class) => Response::guardAgainstOutput(function () use ($class, $classmap, $base) {
$class = strtolower($class);
if (!isset($classmap[$class])) {
return false;
}
if (isset($classmap[$class]) === false) {
return false;
}
if ($base) {
include $base . '/' . $classmap[$class];
} else {
include $classmap[$class];
}
});
if ($base) {
include $base . '/' . $classmap[$class];
} else {
include $classmap[$class];
}
})
);
}
/**
@@ -408,13 +429,22 @@ class F
* Loads a file using `include_once()` and
* returns whether loading was successful
*/
public static function loadOnce(string $file): bool
{
public static function loadOnce(
string $file,
bool $allowOutput = true
): bool {
if (is_file($file) === false) {
return false;
}
include_once $file;
$callback = fn () => include_once $file;
if ($allowOutput === false) {
Response::guardAgainstOutput($callback);
} else {
$callback();
}
return true;
}

View File

@@ -73,12 +73,14 @@ class Cookie
if ($minutes > 1000000000) {
// absolute timestamp
return $minutes;
} elseif ($minutes > 0) {
}
if ($minutes > 0) {
// minutes from now
return time() + ($minutes * 60);
} else {
return 0;
}
return 0;
}
/**

View File

@@ -786,12 +786,20 @@ class Environment
// load the config for the host
if (empty($host) === false) {
$configHost = F::load($root . '/config.' . $host . '.php', []);
$configHost = F::load(
file: $root . '/config.' . $host . '.php',
fallback: [],
allowOutput: false
);
}
// load the config for the server IP
if (empty($addr) === false) {
$configAddr = F::load($root . '/config.' . $addr . '.php', []);
$configAddr = F::load(
file: $root . '/config.' . $addr . '.php',
fallback: [],
allowOutput: false
);
}
return array_replace_recursive($configHost, $configAddr);

View File

@@ -323,9 +323,9 @@ class Remote
{
if (is_object($data) || is_array($data)) {
return http_build_query($data);
} else {
return $data;
}
return $data;
}
/**

View File

@@ -2,7 +2,9 @@
namespace Kirby\Http;
use Closure;
use Exception;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\F;
use Throwable;
@@ -182,6 +184,23 @@ class Response
die(static::redirect($url, $code));
}
/**
* Ensures that the callback does not produce the first body output
* (used to show when loading a file creates side effects)
*/
public static function guardAgainstOutput(Closure $callback, ...$args): mixed
{
$before = headers_sent();
$result = $callback(...$args);
$after = headers_sent($file, $line);
if ($before === false && $after === true) {
throw new LogicException("Disallowed output from file $file:$line, possible accidental whitespace?");
}
return $result;
}
/**
* Getter for single headers
*

View File

@@ -76,7 +76,9 @@ class File extends Model
// for markdown notation or the UUID for Kirbytext (since
// Kirbytags support can resolve UUIDs directly)
if ($absolute === true) {
$url = $type === 'markdown' ? $this->model->permalink() : $this->model->uuid();
$url = $type === 'markdown' ? $this->model->permalink() : $this->model->uuid();
// if UUIDs are disabled, fall back to URL
$url ??= $this->model->url();
}

View File

@@ -286,7 +286,7 @@ abstract class Model
'link' => $this->url(true),
'sortable' => true,
'text' => $this->model->toSafeString($params['text'] ?? false),
'uuid' => $this->model->uuid()->toString(),
'uuid' => $this->model->uuid()?->toString() ?? $this->model->id(),
];
}

View File

@@ -49,10 +49,15 @@ class Page extends Model
$title = $this->model->title();
return match ($type) {
'markdown' => '[' . $title . '](' . $this->model->permalink() . ')',
default => '(link: ' . $this->model->uuid() . ' text: ' . $title . ')'
};
// type: markdown
if ($type === 'markdown') {
$url = $this->model->permalink() ?? $this->model->url();
return '[' . $title . '](' . $url . ')';
}
// type: kirbytext
$link = $this->model->uuid() ?? $this->model->uri();
return '(link: ' . $link . ' text: ' . $title . ')';
}
/**

View File

@@ -611,12 +611,15 @@ class Session
// now make sure that we have a valid timestamp
if (is_int($time)) {
return $time;
} else {
throw new InvalidArgumentException([
'data' => ['method' => 'Session::timeToTimestamp', 'argument' => '$time'],
'translate' => false
]);
}
throw new InvalidArgumentException([
'data' => [
'method' => 'Session::timeToTimestamp',
'argument' => '$time'
],
'translate' => false
]);
}
/**

View File

@@ -177,14 +177,19 @@ class SessionData
{
if (is_string($key)) {
return $this->data[$key] ?? $default;
} elseif ($key === null) {
return $this->data;
} else {
throw new InvalidArgumentException([
'data' => ['method' => 'SessionData::get', 'argument' => 'key'],
'translate' => false
]);
}
if ($key === null) {
return $this->data;
}
throw new InvalidArgumentException([
'data' => [
'method' => 'SessionData::get',
'argument' => 'key'
],
'translate' => false
]);
}
/**

View File

@@ -156,47 +156,65 @@ class A
/**
* Merges arrays recursively
*
* @param int $mode Behavior for elements with numeric keys;
* A::MERGE_APPEND: elements are appended, keys are reset;
* A::MERGE_OVERWRITE: elements are overwritten, keys are preserved
* A::MERGE_REPLACE: non-associative arrays are completely replaced
* If last argument is an integer, it defines the
* behavior for elements with numeric keys;
* - A::MERGE_OVERWRITE: elements are overwritten, keys are preserved
* - A::MERGE_APPEND: elements are appended, keys are reset;
* - A::MERGE_REPLACE: non-associative arrays are completely replaced
*/
public static function merge(array $array1, array $array2, int $mode = A::MERGE_APPEND): array
public static function merge(array|int ...$arrays): array
{
$merged = $array1;
// get mode from parameters
$last = A::last($arrays);
$mode = is_int($last) ? array_pop($arrays) : A::MERGE_APPEND;
// get the first two arrays that should be merged
$merged = array_shift($arrays);
$join = array_shift($arrays);
if (
static::isAssociative($array1) === false &&
static::isAssociative($merged) === false &&
$mode === static::MERGE_REPLACE
) {
return $array2;
}
$merged = $join;
} else {
foreach ($join as $key => $value) {
// append to the merged array, don't overwrite numeric keys
if (
is_int($key) === true &&
$mode === static::MERGE_APPEND
) {
$merged[] = $value;
foreach ($array2 as $key => $value) {
// append to the merged array, don't overwrite numeric keys
if (is_int($key) === true && $mode === static::MERGE_APPEND) {
$merged[] = $value;
// recursively merge the two array values
} elseif (
is_array($value) === true &&
isset($merged[$key]) === true &&
is_array($merged[$key]) === true
) {
$merged[$key] = static::merge($merged[$key], $value, $mode);
// recursively merge the two array values
} elseif (
is_array($value) === true &&
isset($merged[$key]) === true &&
is_array($merged[$key]) === true
) {
$merged[$key] = static::merge($merged[$key], $value, $mode);
// simply overwrite with the value from the second array
} else {
$merged[$key] = $value;
}
}
// simply overwrite with the value from the second array
} else {
$merged[$key] = $value;
if ($mode === static::MERGE_APPEND) {
// the keys don't make sense anymore, reset them
// array_merge() is the simplest way to renumber
// arrays that have both numeric and string keys;
// besides the keys, nothing changes here
$merged = array_merge($merged, []);
}
}
if ($mode === static::MERGE_APPEND) {
// the keys don't make sense anymore, reset them
// array_merge() is the simplest way to renumber
// arrays that have both numeric and string keys;
// besides the keys, nothing changes here
$merged = array_merge($merged, []);
// if more than two arrays need to be merged, add the result
// as first array and the mode to the end and call the method again
if (count($arrays) > 0) {
array_unshift($arrays, $merged);
array_push($arrays, $mode);
return static::merge(...$arrays);
}
return $merged;

View File

@@ -232,7 +232,7 @@ class Component
throw new Exception('Component definition ' . $definition . ' does not exist');
}
static::$types[$type] = $definition = F::load($definition);
static::$types[$type] = $definition = F::load($definition, allowOutput: false);
}
return $definition;
@@ -254,7 +254,11 @@ class Component
if (isset($definition['extends']) === true) {
// extend other definitions
$options = array_replace_recursive(static::defaults(), static::load($definition['extends']), $definition);
$options = array_replace_recursive(
static::defaults(),
static::load($definition['extends']),
$definition
);
} else {
// inject defaults
$options = array_replace_recursive(static::defaults(), $definition);
@@ -266,10 +270,14 @@ class Component
if (isset(static::$mixins[$mixin]) === true) {
if (is_string(static::$mixins[$mixin]) === true) {
// resolve a path to a mixin on demand
static::$mixins[$mixin] = include static::$mixins[$mixin];
static::$mixins[$mixin] = F::load(static::$mixins[$mixin], allowOutput: false);
}
$options = array_replace_recursive(static::$mixins[$mixin], $options);
$options = array_replace_recursive(
static::$mixins[$mixin],
$options
);
}
}
}

View File

@@ -281,10 +281,10 @@ class Html extends Xml
*/
public static function gist(string $url, string|null $file = null, array $attr = []): string
{
if ($file === null) {
$src = $url . '.js';
} else {
$src = $url . '.js?file=' . $file;
$src = $url . '.js';
if ($file !== null) {
$src .= '?file=' . $file;
}
return static::tag('script', '', array_merge($attr, ['src' => $src]));

View File

@@ -117,11 +117,13 @@ class Locale
}
return $convertedLocale;
} elseif (is_string($locale)) {
return [LC_ALL => $locale];
} else {
throw new InvalidArgumentException('Locale must be string or array');
}
if (is_string($locale) === true) {
return [LC_ALL => $locale];
}
throw new InvalidArgumentException('Locale must be string or array');
}
/**

View File

@@ -165,12 +165,12 @@ class Query
$args = array_map('self::parameter', $args);
return compact('method', 'args');
} else {
return [
'method' => $part,
'args' => []
];
}
return [
'method' => $part,
'args' => []
];
}
/**

View File

@@ -31,10 +31,10 @@ class Silo
{
if (is_array($key) === true) {
return static::$data = array_merge(static::$data, $key);
} else {
static::$data[$key] = $value;
return static::$data;
}
static::$data[$key] = $value;
return static::$data;
}
/**

View File

@@ -182,9 +182,9 @@ class Xml
if ($head === true) {
return '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL . $result;
} else {
return $result;
}
return $result;
}
/**
@@ -345,13 +345,12 @@ class Xml
// we didn't find any XML children above, only use the string value
$element = (string)$element;
if (count($array) > 0) {
$array['@value'] = $element;
return $array;
} else {
if (count($array) === 0) {
return $element;
}
$array['@value'] = $element;
return $array;
}
}

View File

@@ -17,5 +17,5 @@ namespace Kirby\Uuid;
interface Identifiable
{
public function id();
public function uuid(): Uuid;
public function uuid(): Uuid|null;
}

View File

@@ -63,6 +63,12 @@ class Uuid
Identifiable|null $model = null,
Collection|null $context = null
) {
// throw exception when globally disabled
if (Uuids::enabled() === false) {
throw new LogicException('UUIDs have been disabled via the `content.uuid` config option.');
}
$this->context = $context;
$this->model = $model;
@@ -143,7 +149,13 @@ class Uuid
final public static function for(
string|Identifiable $seed,
Collection|null $context = null
): static {
): static|null {
// if globally disabled, return null
if (Uuids::enabled() === false) {
return null;
}
// for UUID string
if (is_string($seed) === true) {
return match (Str::before($seed, '://')) {
'page' => new PageUuid(uuid: $seed, context: $context),
@@ -157,6 +169,7 @@ class Uuid
};
}
// for model object
return match (true) {
$seed instanceof Page
=> new PageUuid(model: $seed, context: $context),
@@ -229,6 +242,11 @@ class Uuid
string $string,
string|null $type = null
): bool {
// always return false when UUIDs have been disabled
if (Uuids::enabled() === false) {
return false;
}
$type ??= implode('|', Uri::$schemes);
$pattern = sprintf('!^(%s)://(.*)!', $type);

View File

@@ -5,6 +5,7 @@ namespace Kirby\Uuid;
use Closure;
use Kirby\Cache\Cache;
use Kirby\Cms\App;
use Kirby\Exception\LogicException;
/**
* Helper methods that deal with the entirety of UUIDs in the system
@@ -79,6 +80,11 @@ class Uuids
// }
}
public static function enabled(): bool
{
return App::instance()->option('content.uuid') !== false;
}
/**
* Generates UUID for all identifiable models of type
*
@@ -86,6 +92,10 @@ class Uuids
*/
public static function generate(string $type = 'all'): void
{
if (static::enabled() === false) {
throw new LogicException('UUIDs have been disabled via the `content.uuid` config option.');
}
static::each(
fn (Identifiable $model) => Uuid::for($model)->id(),
$type
@@ -100,6 +110,10 @@ class Uuids
*/
public static function populate(string $type = 'all'): void
{
if (static::enabled() === false) {
throw new LogicException('UUIDs have been disabled via the `content.uuid` config option.');
}
static::each(
fn (Identifiable $model) => Uuid::for($model)->populate(),
$type