chapter hgroup + calming decor

This commit is contained in:
Julie Blanc 2026-04-07 18:09:15 +02:00
parent 0545b131de
commit 94d14d70c1
370 changed files with 9583 additions and 1566 deletions

View file

@ -5,6 +5,7 @@ namespace Kirby\Api\Controller;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Content\Lock;
use Kirby\Exception\PermissionException;
use Kirby\Filesystem\F;
use Kirby\Form\Fields;
use Kirby\Form\Form;
@ -40,6 +41,12 @@ class Changes
*/
public static function discard(ModelWithContent $model): array
{
if ($model->permissions()->can('update') === false) {
throw new PermissionException(
key: 'version.discard.permission',
);
}
$model->version('changes')->delete('current');
// Removes the old .lock file when it is no longer needed
@ -56,6 +63,12 @@ class Changes
*/
public static function publish(ModelWithContent $model, array $input): array
{
if ($model->permissions()->can('update') === false) {
throw new PermissionException(
key: 'version.publish.permission',
);
}
// save the given changes first
static::save(
model: $model,
@ -91,6 +104,12 @@ class Changes
*/
public static function save(ModelWithContent $model, array $input): array
{
if ($model->permissions()->can('update') === false) {
throw new PermissionException(
key: 'version.save.permission',
);
}
// Removes the old .lock file when it is no longer needed
// @todo Remove in 6.0.0
static::cleanup($model);

View file

@ -36,6 +36,7 @@ use Kirby\Toolkit\LazyValue;
use Kirby\Toolkit\Locale;
use Kirby\Toolkit\Str;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
use Throwable;
/**
@ -106,6 +107,9 @@ class App
Snippet::$cache = [];
VersionCache::reset();
// reset the UUIDs option cache
Uuids::$enabled = null;
// register all roots to be able to load stuff afterwards
$this->bakeRoots($props['roots'] ?? []);
@ -421,6 +425,7 @@ class App
public function contentToken(object|null $model, string $value): string
{
$default = $this->root('content');
$default = realpath($default) ?: $default;
if ($model !== null && method_exists($model, 'id') === true) {
$default .= '/' . $model->id();
@ -1029,7 +1034,7 @@ class App
// load the main config options
$root = $this->root('config');
$options = F::load($root . '/config.php', [], allowOutput: false);
$options = F::load($root . '/config.php', [], allowOutput: false, cache: true);
// merge into one clean options array
return $this->options = array_replace_recursive(Config::$data, $options);
@ -1044,7 +1049,7 @@ class App
$root = $this->root('config');
// first load `config/env.php` to access its `url` option
$envOptions = F::load($root . '/env.php', [], allowOutput: false);
$envOptions = F::load($root . '/env.php', [], allowOutput: false, cache: true);
// use the option from the main `config.php`,
// but allow the `env.php` to override it

View file

@ -4,7 +4,6 @@ namespace Kirby\Cms;
use Closure;
use Kirby\Exception\Exception;
use Kirby\Filesystem\F;
use Kirby\Http\Response;
use Kirby\Toolkit\I18n;
use Throwable;
@ -38,6 +37,25 @@ trait AppErrors
*/
protected Whoops $whoops;
/**
* Replaces absolute file paths with placeholders such as
* {kirby_folder}, {site_folder} or {index_folder} to avoid
* exposing too many details about the filesystem and keeping
* error responses short and readable in debug mode.
*
* @since 5.3.0
*/
protected function disguiseFilePath(string $file): string
{
$disguise = [
$this->root('kirby') => '{kirby}',
$this->root('site') => '{site}',
$this->root('index') => '{index}'
];
return str_replace(array_keys($disguise), array_values($disguise), $file);
}
/**
* Registers the PHP error handler for CLI usage
*/
@ -150,10 +168,7 @@ trait AppErrors
'code' => $code,
'message' => $exception->getMessage(),
'details' => $details,
'file' => F::relativepath(
$exception->getFile(),
$this->environment()->get('DOCUMENT_ROOT', '')
),
'file' => $this->disguiseFilePath($exception->getFile()),
'line' => $exception->getLine(),
], $httpCode);
} else {

View file

@ -351,6 +351,7 @@ class Auth
/**
* Returns the hashed ip of the visitor
* which is used to track invalid logins
* @deprecated 5.3.0 Use `$visitor->ip(hash: true)` instead. Will be removed in Kirby 6.
*/
public function ipHash(): string
{
@ -365,7 +366,7 @@ class Auth
*/
public function isBlocked(string $email): bool
{
$ip = $this->ipHash();
$ip = $this->kirby->visitor()->ip(hash: true);
$log = $this->log();
$trials = $this->kirby->option('auth.trials', 10);
@ -669,7 +670,7 @@ class Auth
$this->kirby->trigger('user.login:failed', compact('email'));
}
$ip = $this->ipHash();
$ip = $this->kirby->visitor()->ip(hash: true);
$log = $this->log();
$time = time();

View file

@ -124,8 +124,14 @@ class Blueprint
continue;
}
$template = $section->template();
$templates = match ($section->type()) {
'files' => [...$templates, $section->template() ?? 'default'],
'files' => [
...$templates,
...($template
? [$template]
: App::instance()->blueprints('files'))
],
'fields' => [
...$templates,
...$this->acceptedFileTemplatesFromFields($section->fields())

View file

@ -167,7 +167,7 @@ class Collection extends BaseCollection
$groups = new self(parent: $this->parent());
if (is_string($field) === true) {
foreach ($this->data as $key => $item) {
foreach ($this as $key => $item) {
$value = $this->getAttribute($item, $field);
// make sure that there's always a proper value to group by

View file

@ -90,6 +90,8 @@ trait FileActions
$newFile->parent()->files()->remove($oldFile->id());
$newFile->parent()->files()->set($newFile->id(), $newFile);
$newFile->uuid()?->populate();
return $newFile;
});
}
@ -189,6 +191,7 @@ trait FileActions
// overwrite with new UUID (remove old, add new)
if (Uuids::enabled() === true) {
$copy = $copy->save(['uuid' => Uuid::generate()]);
$copy->uuid()->populate();
}
return $copy;
@ -292,6 +295,8 @@ trait FileActions
// store the content if necessary
$file->changeStorage($storage);
$file->uuid()?->populate();
// return a fresh clone
return $file->clone();
});

View file

@ -301,6 +301,14 @@ class Language implements Stringable
return file_exists($this->root());
}
/**
* Check if the language url is custom domain
*/
public function hasCustomDomain(): bool
{
return Url::isAbsolute($this->url);
}
/**
* Checks if the language is the same
* as the given language or language code
@ -547,13 +555,14 @@ class Language implements Stringable
public function toArray(): array
{
return [
'code' => $this->code(),
'default' => $this->isDefault(),
'direction' => $this->direction(),
'locale' => $this->locale(),
'name' => $this->name(),
'rules' => $this->rules(),
'url' => $this->url()
'code' => $this->code(),
'default' => $this->isDefault(),
'direction' => $this->direction(),
'hasCustomDomain' => $this->hasCustomDomain(),
'locale' => $this->locale(),
'name' => $this->name(),
'rules' => $this->rules(),
'url' => $this->url(),
];
}

View file

@ -0,0 +1,551 @@
<?php
namespace Kirby\Cms;
use Closure;
use Iterator;
use Kirby\Exception\LogicException;
/**
* The LazyCollection class is a variant of the CMS
* Collection that is only initialized with keys for
* each collection element or without any data.
* Collection elements and their values (= objects)
* are loaded and initialized lazily when they are
* first used.
*
* You can use LazyCollection in two ways:
* 1. Initialize with keys only (values are `null`),
* define `hydrateElement` method that initializes
* an element dynamically.
* 2. Option 1, but also don't initialize any keys,
* set `$initialized` prop to `false` and define
* `initialize` method that defines which keys
* are available.
*
* @package Kirby Cms
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @template TValue
* @extends \Kirby\Cms\Collection<TValue>
*/
abstract class LazyCollection extends Collection
{
/**
* Flag that tells whether hydration has been
* completed for all collection elements;
* this is used to increase performance
*/
protected bool $hydrated = false;
/**
* Flag that tells whether all possible collection
* items have been loaded (only relevant in lazy
* initialization mode)
*/
protected bool $initialized = true;
/**
* Temporary auto-hydration whenever a collection
* method is called; some methods may not need raw
* access to all collection data, so performance
* will be improved if methods call initialization
* or hydration themselves only if they need it
* @deprecated
* @todo Remove this in v6
*/
public function __call(string $key, $arguments)
{
$this->hydrate();
return parent::__call($key, $arguments);
}
/**
* Low-level getter for elements
*
* @return TValue|null
*/
public function __get(string $key)
{
$element = parent::__get($key);
// `$element === null` could mean "element does not exist"
// or "element found but not hydrated"
if (
$element === null &&
(array_key_exists($key, $this->data) || $this->initialized === false)
) {
return $this->hydrateElement($key);
}
return $element;
}
/**
* Low-level element remover
*/
public function __unset(string $key)
{
// first initialize, otherwise a later initialization
// might bring back the element that was unset
$this->initialize();
return parent::__unset($key);
}
/**
* Creates chunks of the same size.
* The last chunk may be smaller
*
* @param int $size Number of elements per chunk
* @return static A new collection with an element for each chunk and
* a sub collection in each chunk
*/
public function chunk(int $size): static
{
// chunking at least requires the collection structure
$this->initialize();
return parent::chunk($size);
}
/**
* Counts all elements
*/
public function count(): int
{
$this->initialize();
return parent::count();
}
/**
* Returns the current element
* @deprecated
* @todo Remove in v6
*
* @return TValue
*/
public function current(): mixed
{
$current = parent::current();
// `$current === null` could mean "empty collection"
// or "element found but not hydrated"
if ($current === null && $key = $this->key()) {
return $this->hydrateElement($key);
}
return $current;
}
/**
* Clone and remove all elements from the collection
*/
public function empty(): static
{
$empty = parent::empty();
// prevent new collection from initializing its
// elements into the now empty collection
// (relevant when emptying a collection that
// has not been (fully) initialized yet)
$empty->initialized = true;
return $empty;
}
/**
* Find one or multiple elements by id
*
* @param string ...$keys
* @return TValue|static
*/
public function find(...$keys)
{
$result = parent::find(...$keys);
// when the result is a cloned collection (multiple keys),
// mark it as initialized to prevent it from initializing
// all of its elements again after we filtered it above
// (relevant when finding elements in a collection that
// has not been (fully) initialized yet)
if ($result instanceof static && $result !== $this) {
$result->initialized = true;
}
return $result;
}
/**
* Returns the elements in reverse order
*/
public function flip(): static
{
// flipping at least requires the collection structure
$this->initialize();
return parent::flip();
}
/**
* Filters elements by one of the
* predefined filter methods, by a
* custom filter function or an array of filters
*/
public function filter(string|array|Closure $field, ...$args): static
{
// to filter through values, we need all values present
$this->hydrate();
return parent::filter($field, ...$args);
}
/**
* Returns the first element
*
* @return TValue
*/
public function first()
{
// returning a specific offset requires the collection structure
$this->initialize();
$first = parent::first();
// `$first === null` could mean "empty collection"
// or "element found but not hydrated"
if ($first === null && $key = array_key_first($this->data)) {
return $this->hydrateElement($key);
}
return $first;
}
/**
* Returns an iterator for the elements
* @return \Iterator<TKey, TValue>
*/
public function getIterator(): Iterator
{
// ensure we are looping over all possible elements
$this->initialize();
foreach ($this->data as $key => $value) {
if ($value === null) {
$value = $this->hydrateElement($key);
}
yield $key => $value;
}
}
/**
* Checks by key if an element is included
* @param TKey $key
*/
public function has(mixed $key): bool
{
$this->initialize();
return parent::has($key);
}
/**
* Ensures that all collection elements are loaded,
* essentially converting the lazy collection into a
* normal collection
*/
public function hydrate(): void
{
// first ensure all keys are initialized
$this->initialize();
// skip another hydration loop if no longer needed
if ($this->hydrated === true) {
return;
}
foreach ($this->data as $key => $value) {
if ($value === null) {
$this->hydrateElement($key);
}
}
$this->hydrated = true;
}
/**
* Loads a collection element, sets it in `$this->data[$key]`
* and returns the hydrated object value (or `null` if the
* element does not exist in the collection); to be
* implemented in each specific collection
*/
abstract protected function hydrateElement(string $key): object|null;
/**
* Ensures that the keys for all valid collection elements
* are loaded in the `$data` array and sets `$initialized`
* to `true` afterwards; to be implemented in each collection
* that wants to use lazy initialization; be sure to keep
* existing `$data` values and not overwrite the entire array
*/
public function initialize(): void
{
if ($this->initialized === true) {
return;
}
throw new LogicException(static::class . ' class does not implement `initialize()` method that is required for lazy initialization'); // @codeCoverageIgnore
}
/**
* Returns an array of all keys
*/
public function keys(): array
{
// ensure we are returning all possible keys
$this->initialize();
return parent::keys();
}
/**
* Tries to find the key for the given element
*
* @param TValue $needle the element to search for
* @return int|string|false the name of the key or false
*/
public function keyOf(mixed $needle): int|string|false
{
// quick lookup without having to hydrate the collection
// (keys in CMS collections are the object IDs)
if (
is_object($needle) === true &&
method_exists($needle, 'id') === true
) {
return $needle->id();
}
$this->hydrate();
return parent::keyOf($needle);
}
/**
* Returns the last element
*
* @return TValue
*/
public function last()
{
// returning a specific offset requires the collection structure
$this->initialize();
$last = parent::last();
// `$last === null` could mean "empty collection"
// or "element found but not hydrated"
if ($last === null && $key = array_key_last($this->data)) {
return $this->hydrateElement($key);
}
return $last;
}
/**
* Map a function to each element
*
* @return $this
*/
public function map(callable $callback): static
{
// to map a function, we need all values present
$this->hydrate();
return parent::map($callback);
}
/**
* Moves the cursor to the next element
* and returns it
* @deprecated
* @todo Remove in v6
*
* @return TValue
*/
public function next(): mixed
{
$this->initialize();
$next = parent::next();
// `$next === null` could mean "empty collection"
// or "element found but not hydrated"
if ($next === null && $key = $this->key()) {
return $this->hydrateElement($key);
}
return $next;
}
/**
* Returns the nth element from the collection
*
* @return TValue|null
*/
public function nth(int $n)
{
// returning a specific offset requires the collection structure
$this->initialize();
$nth = parent::nth($n);
// `$nth === null` could mean "empty collection"
// or "element found but not hydrated"
if ($nth === null) {
$key = array_keys($this->data)[$n] ?? null;
if (is_string($key) === true) {
return $this->hydrateElement($key);
}
}
return $nth;
}
/**
* Prepends an element to the data array
*
* ```php
* $collection->prepend('key', $value);
* $collection->prepend($value);
* ```
*
* @param string|TValue ...$args
* @return $this
*/
public function prepend(...$args): static
{
// prepending to an uninitialized collection would
// destroy the order on later initialization
$this->initialize();
return parent::prepend(...$args);
}
/**
* Moves the cursor to the previous element
* and returns it
* @deprecated
* @todo Remove in v6
*
* @return TValue
*/
public function prev(): mixed
{
$this->initialize();
$prev = parent::prev();
// `$prev === null` could mean "empty collection"
// or "element found but not hydrated"
if ($prev === null && $key = $this->key()) {
return $this->hydrateElement($key);
}
return $prev;
}
/**
* Returns a new collection consisting of random elements,
* from the original collection, shuffled or ordered
*/
public function random(int $count = 1, bool $shuffle = false): static
{
// picking random elements at least requires the collection structure
$this->initialize();
return parent::random($count, $shuffle);
}
/**
* Shuffle all elements
*/
public function shuffle(): static
{
// shuffling at least requires the collection structure
$this->initialize();
return parent::shuffle();
}
/**
* Returns a slice of the object
*
* @param int $offset The optional index to start the slice from
* @param int|null $limit The optional number of elements to return
* @return $this|static
* @psalm-return ($offset is 0 && $limit is null ? $this : static)
*/
public function slice(
int $offset = 0,
int|null $limit = null
): static {
// returning a specific subset requires the collection structure
$this->initialize();
return parent::slice($offset, $limit);
}
/**
* Sorts the elements by any number of fields
*
* ```php
* $collection->sort('fieldName');
* $collection->sort('fieldName', 'desc');
* $collection->sort('fieldName', 'asc', SORT_REGULAR);
* $collection->sort(fn ($a) => ...);
* ```
*
* @param string|callable $field Field name or value callback to sort by
* @param string|null $direction asc or desc
* @param int|null $method The sort flag, SORT_REGULAR, SORT_NUMERIC etc.
* @return $this|static
*/
public function sort(...$args): static
{
// to sort through values, we need all values present
$this->hydrate();
return parent::sort(...$args);
}
/**
* Converts all objects in the collection
* to an array. This can also take a callback
* function to further modify the array result.
*/
public function toArray(Closure|null $map = null): array
{
// to export an array, we need all values present
$this->hydrate();
return parent::toArray($map);
}
/**
* Returns a non-associative array
* with all values. If a mapping Closure is passed,
* all values are processed by the Closure.
*/
public function values(Closure|null $map = null): array
{
// to export an array, we need all values present
$this->hydrate();
return parent::values($map);
}
}

View file

@ -7,6 +7,7 @@ use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Http\Response;
use Kirby\Toolkit\Str;
use Throwable;
@ -53,7 +54,7 @@ class Media
}
// send the file to the browser
return Response::file($file->publish()->mediaRoot());
return Response::file($file->publish()->root());
}
// try to generate a thumb for the file

View file

@ -119,7 +119,7 @@ trait PageActions
]);
// clear UUID cache recursively (for children and files as well)
$oldPage->uuid()?->clear(true);
$oldPage->uuid()?->clear(recursive: true);
if ($oldPage->exists() === true) {
// actually move stuff on disk
@ -142,6 +142,8 @@ trait PageActions
Dir::remove($oldPage->mediaRoot());
}
$newPage->uuid()?->populate(recursive: true);
return $newPage;
});
}
@ -422,6 +424,8 @@ trait PageActions
parent: $parentModel
);
$copy->uuid()?->populate(recursive: true);
return $copy;
}
@ -487,6 +491,8 @@ trait PageActions
$page = $page->changeStatus('listed', $props['num']);
}
$page->uuid()?->populate();
return $page;
}
@ -583,7 +589,7 @@ trait PageActions
$page->changeStorage(ImmutableMemoryStorage::class);
// clear UUID cache
$page->uuid()?->clear();
$page->uuid()?->clear(recursive: true);
// Explanation: The two while loops below are only
// necessary because our property caches result in
@ -697,6 +703,8 @@ trait PageActions
);
}
$newPage->uuid()?->populate(recursive: true);
return $newPage;
});
}

View file

@ -5,7 +5,7 @@ namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Filesystem\Mime;
use Kirby\Http\Response as HttpResponse;
use Kirby\Toolkit\A;
use Kirby\Http\VolatileHeaders;
use Kirby\Toolkit\Str;
use Stringable;
@ -66,10 +66,9 @@ class Responder implements Stringable
protected array $usesCookies = [];
/**
* Tracks headers that depend on the request
* and must not be persisted in the cache
* Volatile headers manager
*/
protected array $volatileHeaders = [];
protected VolatileHeaders|null $volatileHeaders = null;
/**
* Creates and sends the response
@ -244,7 +243,7 @@ class Responder implements Stringable
$this->type($response['type'] ?? null);
$this->usesAuth($response['usesAuth'] ?? null);
$this->usesCookies($response['usesCookies'] ?? null);
$this->volatileHeaders = $response['volatileHeaders'] ?? [];
$this->volatileHeaders($response['volatileHeaders'] ?? null);
}
/**
@ -325,7 +324,7 @@ class Responder implements Stringable
}
$this->headers = $headers;
$this->volatileHeaders = [];
$this->volatileHeaders([]);
return $this;
}
@ -405,13 +404,13 @@ class Responder implements Stringable
public function toCacheArray(): array
{
$response = $this->toArray();
$volatile = $this->collectVolatileHeaders();
$volatile = $this->volatileHeaders()->collect();
if ($volatile === []) {
return $response;
}
$response['headers'] = $this->stripVolatileHeaders($response['headers'], $volatile);
$response['headers'] = $this->volatileHeaders()->strip($response['headers'], $volatile);
return $response;
}
@ -465,114 +464,32 @@ class Responder implements Stringable
* can be subtracted before caching a response snapshot
*
* @since 5.2.0
* @deprecated 5.3.0 Use `::volatileHeaders()->mark($name, $values)` instead. Will be removed in Kirby 6.
*/
public function markVolatileHeader(string $name, array|null $values = null): void
{
$this->appendVolatileHeader($this->volatileHeaders, $name, $values);
$this->volatileHeaders()->mark($name, $values);
}
/**
* Collects volatile headers from both manual configuration
* and automatically injected CORS headers
* Setter and getter for the volatile headers manager
* @since 5.3.0
*/
protected function collectVolatileHeaders(): array
public function volatileHeaders(VolatileHeaders|array|null $headers = null): VolatileHeaders
{
$volatile = $this->volatileHeaders;
$corsHeaders = Cors::headers();
if ($corsHeaders === []) {
return $volatile;
if ($headers === null) {
return $this->volatileHeaders ??= new VolatileHeaders();
}
foreach ($corsHeaders as $name => $value) {
if ($name === 'Vary') {
$corsVaryValues = array_map('trim', explode(',', $value));
$this->appendVolatileHeader($volatile, 'Vary', $corsVaryValues);
continue;
}
$this->appendVolatileHeader($volatile, $name);
if ($headers instanceof VolatileHeaders) {
return $this->volatileHeaders = $headers;
}
return $volatile;
}
/**
* Strips request-dependent headers for safe caching
*/
protected function stripVolatileHeaders(array $headers, array $volatile): array
{
foreach ($volatile as $name => $values) {
if ($name === 'Vary' && is_array($values) === true) {
if (isset($headers['Vary']) === false) {
continue;
}
$current = $this->normalizeVaryValues($headers['Vary']);
$remaining = $this->removeVaryValues($current, $values);
if ($remaining === []) {
unset($headers['Vary']);
} else {
$headers['Vary'] = implode(', ', $remaining);
}
continue;
}
unset($headers[$name]);
$volatileHeaders = new VolatileHeaders();
foreach ($headers as $name => $values) {
$volatileHeaders->mark($name, $values);
}
return $headers;
}
/**
* Adds (parts of) a header to the provided volatile header list
*/
protected function appendVolatileHeader(array &$target, string $name, array|null $values = null): void
{
if ($values === null) {
$target[$name] = null;
return;
}
if (array_key_exists($name, $target) === true && $target[$name] === null) {
return;
}
$values = A::map($values, static fn ($value) => strtolower(trim($value)));
$values = A::filter($values, static fn ($value) => $value !== '');
if ($values === []) {
return;
}
$existingValues = $target[$name] ?? [];
$target[$name] = array_values(array_unique([...$existingValues, ...$values]));
}
/**
* Normalizes a comma-separated list of Vary values
* into a unique array without empty entries
*/
protected function normalizeVaryValues(string $value): array
{
$values = A::map(explode(',', $value), 'trim');
$values = A::filter($values, static fn ($entry) => $entry !== '');
return array_values(array_unique($values));
}
/**
* Returns the Vary values with the provided entries removed
*/
protected function removeVaryValues(array $values, array $remove): array
{
$removeLower = A::map($remove, 'strtolower');
return array_values(A::filter(
$values,
static fn ($value) => in_array(strtolower($value), $removeLower, true) === false
));
return $this->volatileHeaders = $volatileHeaders;
}
}

View file

@ -471,9 +471,15 @@ class Site extends ModelWithContent
string|Page $page,
string|null $languageCode = null
): Page {
// always set the locale; in single-language mode this
// applies the locale from config, in multi-language mode
// it falls back to the default language when `null` is passed
$this->kirby()->setCurrentLanguage($languageCode);
// only set translation when explicitly passed;
// otherwise it would always fall back to 'en'
if ($languageCode !== null) {
$this->kirby()->setCurrentTranslation($languageCode);
$this->kirby()->setCurrentLanguage($languageCode);
}
// convert ids to a Page object

View file

@ -82,6 +82,10 @@ class User extends ModelWithContent
$this->password = $props['password'] ?? null;
$this->role = $set('role', fn ($role) => Str::lower(trim($role)));
if (isset($props['credentials'])) {
$this->credentials = $props['credentials'];
}
// Set blueprint before setting content
// or translations in the parent constructor.
// Otherwise, the blueprint definition cannot be
@ -229,13 +233,23 @@ class User extends ModelWithContent
#[SensitiveParameter]
string|null $password = null
): string|null {
if ($password !== null) {
if ($password !== null && $password !== '') {
$password = password_hash($password, PASSWORD_DEFAULT);
}
return $password;
}
/**
* Checks if the user has a stored password
* @since 5.3.0
*/
public function hasPassword(): bool
{
$password = $this->password();
return $password !== '' && $password !== null;
}
/**
* Returns the user id
*/
@ -699,7 +713,7 @@ class User extends ModelWithContent
#[SensitiveParameter]
string|null $password = null
): bool {
if (empty($this->password()) === true) {
if ($this->hasPassword() === false) {
throw new NotFoundException(
key: 'user.password.undefined'
);

View file

@ -3,6 +3,7 @@
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Str;
@ -21,12 +22,29 @@ use Kirby\Uuid\HasUuids;
* @license https://getkirby.com/license
*
* @template TUser of \Kirby\Cms\User
* @extends \Kirby\Cms\Collection<TUser>
* @extends \Kirby\Cms\LazyCollection<TUser>
*/
class Users extends Collection
class Users extends LazyCollection
{
use HasUuids;
/**
* Creates a new Collection with the given objects
*
* @param iterable<TUser> $objects
* @param string|null $root Directory to dynamically load user
* objects from during hydration
* @param array $inject Props to inject into hydrated user objects
*/
public function __construct(
iterable $objects = [],
protected object|null $parent = null,
protected string|null $root = null,
protected array $inject = []
) {
parent::__construct($objects, $parent);
}
/**
* All registered users methods
*/
@ -98,7 +116,7 @@ class Users extends Collection
{
$files = new Files([], $this->parent);
foreach ($this->data as $user) {
foreach ($this as $user) {
foreach ($user->files() as $fileKey => $file) {
$files->data[$fileKey] = $file;
}
@ -126,32 +144,86 @@ class Users extends Collection
}
/**
* Loads a user from disk by passing the absolute path (root)
* Loads a user object, sets it in `$this->data[$key]`
* and returns the hydrated user object
*/
public static function load(string $root, array $inject = []): static
protected function hydrateElement(string $key): User|null
{
$users = new static();
if ($this->root === null) {
throw new LogicException('Cannot hydrate user "' . $key . '" with missing root'); // @codeCoverageIgnore
}
foreach (Dir::read($root) as $userDirectory) {
if (is_dir($root . '/' . $userDirectory) === false) {
// ignore empty keys to avoid matching the `accounts` root
// directory itself (e.g. from `false` values coerced to `""`)
if ($key === '') {
return null;
}
// check if the user directory exists if not all keys have been
// populated in the collection, otherwise we can assume that
// this method will only be called on "unhydrated" user IDs
$root = $this->root . '/' . $key;
if ($this->initialized === false && is_dir($root) === false) {
return null;
}
// get role information
$path = $root . '/index.php';
if (is_file($path) === true) {
$credentials = F::load($path, allowOutput: false);
}
// create user model based on role
$user = User::factory([
'id' => $key,
'model' => $credentials['role'] ?? null,
'credentials' => is_array($credentials ?? null) ? $credentials : null
] + $this->inject);
return $this->data[$key] = $user;
}
/**
* Ensures that the IDs for all valid users are loaded in the
* `$data` array and sets `$initialized` to `true` afterwards
*/
public function initialize(): void
{
// skip another initialization if it already has been initialized
if ($this->initialized === true) {
return;
}
if ($this->root === null) {
throw new LogicException('Cannot initialize users with missing root'); // @codeCoverageIgnore
}
// ensure the order matches the filesystem, even if
// individual users have been hydrated/added before
$existing = $this->data;
$this->data = [];
foreach (Dir::read($this->root) as $userDirectory) {
if (is_dir($this->root . '/' . $userDirectory) === false) {
continue;
}
// get role information
$path = $root . '/' . $userDirectory . '/index.php';
if (is_file($path) === true) {
$credentials = F::load($path, allowOutput: false);
}
// create user model based on role
$user = User::factory([
'id' => $userDirectory,
'model' => $credentials['role'] ?? null
] + $inject);
$users->set($user->id(), $user);
$this->data[$userDirectory] = null;
}
$this->data = [...$this->data, ...$existing];
$this->initialized = true;
}
/**
* Loads users from disk by passing the absolute directory path (root)
*/
public static function load(string $root, array $inject = []): static
{
$users = new static(root: $root, inject: $inject);
$users->initialized = false;
return $users;
}

View file

@ -118,6 +118,10 @@ class Changes
{
/**
* @var \Kirby\Cms\Pages $pages
*
* Always pass at least two arguments even if the
* data is empty so that `$site->find()` always
* returns a collection, not a single page
*/
$pages = $this->kirby->site()->find(
false,
@ -185,6 +189,10 @@ class Changes
{
/**
* @var \Kirby\Cms\Users $users
*
* Always pass at least two arguments even if the
* data is empty so that `$users->find()` always
* returns a collection, not a single user
*/
$users = $this->kirby->users()->find(
false,

View file

@ -81,7 +81,7 @@ class FieldOptions
public function render(ModelWithContent $model): array
{
return $this->resolve($model)->render($model);
return $this->resolve($model)->render($model, $this->safeMode);
}
public function resolve(ModelWithContent $model): Options

View file

@ -122,6 +122,11 @@ class F
'YB'
];
/**
* Cache for loaded files when using `load()` with `cache: true`
*/
public static array $loadCache = [];
/**
* Appends new content to an existing file
*
@ -358,8 +363,14 @@ class F
string $file,
mixed $fallback = null,
array $data = [],
bool $allowOutput = true
bool $allowOutput = true,
bool $cache = false
) {
// return cached result if available
if ($cache === true && array_key_exists($file, static::$loadCache)) {
return static::$loadCache[$file];
}
if (is_file($file) === false) {
return $fallback;
}
@ -384,6 +395,11 @@ class F
return $fallback;
}
// cache the result if requested
if ($cache === true) {
static::$loadCache[$file] = $result;
}
return $result;
}
@ -599,6 +615,52 @@ class F
return $size . ' ' . static::$units[$unit];
}
/**
* Reads a specific byte range from a file
* @since 5.3.0
*
* @param string $file The path to the file
* @param int $offset The byte offset to start reading from
* @param int|null $length The number of bytes to read (null = read to end)
*/
public static function range(
string $file,
int $offset = 0,
int|null $length = null
): string|false {
if (str_contains($file, '://') === true) {
return false;
}
// exit early on empty paths that would trigger a PHP `ValueError`
if ($file === '') {
return false;
}
return Helpers::handleErrors(
function () use ($file, $offset, $length): string|false {
$handle = fopen($file, 'rb');
if ($handle === false) {
return false; // @codeCoverageIgnore
}
if ($offset > 0) {
fseek($handle, $offset);
}
$content = $length !== null
? fread($handle, $length)
: fread($handle, filesize($file) - $offset);
fclose($handle);
return $content;
},
fn (int $errno, string $errstr): bool => str_contains($errstr, 'No such file'),
false
);
}
/**
* Reads the content of a file or requests the
* contents of a remote HTTP or HTTPS URL
@ -616,9 +678,9 @@ class F
return false;
}
// to increase performance, directly try to load the file without checking
// if it exists; fall back to a `false` return value if it doesn't exist
// while letting other warnings through
// to increase performance, directly try to load the file
// without checking if it exists; fall back to return `false`
// if it doesn't exist while letting other warnings through
return Helpers::handleErrors(
fn (): string|false => file_get_contents($file),
fn (int $errno, string $errstr): bool => str_contains($errstr, 'No such file'),

View file

@ -809,7 +809,8 @@ class Environment
$configCli = F::load(
file: $root . '/config.cli.php',
fallback: [],
allowOutput: false
allowOutput: false,
cache: true
);
}
@ -821,7 +822,8 @@ class Environment
$configHost = F::load(
file: $path,
fallback: [],
allowOutput: false
allowOutput: false,
cache: true
);
}
@ -833,7 +835,8 @@ class Environment
$configAddr = F::load(
file: $path,
fallback: [],
allowOutput: false
allowOutput: false,
cache: true
);
}

159
kirby/src/Http/Range.php Normal file
View file

@ -0,0 +1,159 @@
<?php
namespace Kirby\Http;
use Kirby\Filesystem\F;
/**
* Handles HTTP Range requests (RFC 7233)
* for partial content delivery, primarily
* used for video streaming in browsers
*
* @package Kirby Http
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
* @since 5.3.0
*/
class Range
{
/**
* Parses the Range header and returns start and end byte positions
*
* @return array{int, int}|false Array of [start, end] or false if invalid
*/
public static function parse(
string $range,
int $size
): array|false {
if ($size <= 0) {
return false;
}
$range = trim($range);
// only support byte ranges (not other units)
if (strncasecmp($range, 'bytes=', 6) !== 0) {
return false;
}
// extract the range part after "bytes="
$range = substr($range, 6);
// support only single ranges (not multiple ranges like "0-100,200-300")
if (str_contains($range, ',') === true) {
return false;
}
// split start and end
$parts = explode('-', $range, 2);
if (count($parts) !== 2) {
return false;
}
[$startStr, $endStr] = $parts;
$startStr = trim($startStr);
$endStr = trim($endStr);
// handle "bytes=-500" (last 500 bytes)
if ($startStr === '') {
if (is_numeric($endStr) === false) {
return false;
}
$suffix = (int)$endStr;
if ($suffix <= 0) {
return false;
}
if ($suffix > $size) {
$suffix = $size;
}
$start = $size - $suffix;
$end = $size - 1;
return [$start, $end];
}
// validate that start is numeric
if (is_numeric($startStr) === false) {
return false;
}
$start = (int)$startStr;
// handle "bytes=1024-" (from byte 1024 to end)
if ($endStr === '') {
$end = $size - 1;
} elseif (is_numeric($endStr) === false) {
return false;
} else {
$end = (int)$endStr;
// clamp end to file size if a client overshoots
if ($end >= $size) {
$end = $size - 1;
}
}
// validate the range
if (
$start < 0 ||
$start >= $size ||
$end < $start
) {
return false;
}
return [$start, $end];
}
/**
* Creates a response for a partial file request (byte-range)
*/
public static function response(
string $file,
string $range,
array $props = []
): Response {
// parse the Range header (e.g., "bytes=0-1" or "bytes=1024-")
$size = filesize($file);
$parsed = static::parse($range, $size);
// if the range is invalid, return 416 Range Not Satisfiable
if ($parsed === false) {
return new Response(
code: 416,
body: 'Requested Range Not Satisfiable',
headers: [
'Content-Range' => 'bytes */' . $size
]
);
}
[$start, $end] = $parsed;
$length = $end - $start + 1;
// read only the requested byte range from the file
$body = F::range($file, offset: $start, length: $length);
$props = Response::ensureSafeMimeType([
'body' => $body,
'code' => 206, // Partial Content
'type' => F::extensionToMime(F::extension($file)),
'headers' => [
'Accept-Ranges' => 'bytes',
'Content-Range' => 'bytes ' . $start . '-' . $end . '/' . $size,
'Content-Length' => $length,
...$props['headers'] ?? []
],
...$props
]);
return new Response($props);
}
}

View file

@ -310,7 +310,18 @@ class Remote
*/
public function json(bool $array = true): array|stdClass|null
{
return json_decode($this->content(), $array);
if ($content = $this->content()) {
$json = json_decode($content, $array);
if (
is_array($json) === true ||
$json instanceof stdClass === true
) {
return $json;
}
}
return null;
}
/**

View file

@ -4,6 +4,7 @@ namespace Kirby\Http;
use Closure;
use Exception;
use Kirby\Cms\App;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\F;
use Stringable;
@ -160,6 +161,23 @@ class Response implements Stringable
return new static($props);
}
/**
* Ensures safe MIME type handling by forcing plain text
* for files without recognizable MIME types to harden
* against attacks from malicious file uploads
* @since 5.3.0
* @internal
*/
public static function ensureSafeMimeType(array $props): array
{
if ($props['type'] === null) {
$props['type'] = 'text/plain';
$props['headers']['X-Content-Type-Options'] = 'nosniff';
}
return $props;
}
/**
* Creates a response for a file and
* sends the file content to the browser
@ -168,23 +186,24 @@ class Response implements Stringable
*/
public static function file(string $file, array $props = []): static
{
$props = [
$request = App::instance(lazy: true)?->request();
// handle byte-range requests (e.g., for video streaming in Safari)
if ($range = $request?->header('Range')) {
return Range::response($file, $range, $props);
}
// always indicate that byte-range requests are supported
$props['headers'] = [
'Accept-Ranges' => 'bytes',
...$props['headers'] ?? []
];
$props = static::ensureSafeMimeType([
'body' => F::read($file),
'type' => F::extensionToMime(F::extension($file)),
...$props
];
// if we couldn't serve a correct MIME type, force
// the browser to display the file as plain text to
// harden against attacks from malicious file uploads
if ($props['type'] === null) {
if (isset($props['headers']) !== true) {
$props['headers'] = [];
}
$props['type'] = 'text/plain';
$props['headers']['X-Content-Type-Options'] = 'nosniff';
}
]);
return new static($props);
}

View file

@ -3,6 +3,7 @@
namespace Kirby\Http;
use Kirby\Toolkit\Str;
use Whoops\Handler\PrettyPageHandler;
/**
* Static URL tools
@ -63,6 +64,23 @@ class Url
return dirname(static::current());
}
/**
* Use Whoops to create an editor URL to open
* a file at the given line number
* @since 5.3.0
*/
public static function editor(string|false $editor, string|null $file, int $line = 0): string|null
{
if ($editor === false || $file === null) {
return null;
}
$handler = new PrettyPageHandler();
$handler->setEditor($editor);
return $handler->getEditorHref($file, $line);
}
/**
* Tries to fix a broken url without protocol
* @psalm-return ($url is null ? string|null : string)
@ -223,6 +241,7 @@ class Url
/**
* Smart resolver for internal and external urls
* @deprecated 5.3.0 Use `Kirby\Cms\Url::to()` instead
*/
public static function to(
string|null $path = null,

View file

@ -157,6 +157,31 @@ class Visitor
return Mime::isAccepted($mimeType, $this->acceptedMimeType);
}
/**
* Sets the ip address if provided
* or returns the ip of the current
* visitor otherwise
*
* @return $this|string|null
*/
public function ip(
string|null $ip = null,
bool $hash = false
): static|string|null {
if ($ip === null) {
if ($hash === true) {
// only use the first 50 chars to ensure privacy
$hash = hash('sha256', $this->ip);
return substr($hash, 0, 50);
}
return $this->ip;
}
$this->ip = $ip;
return $this;
}
/**
* Returns the MIME type from the provided list that
* is most accepted (= preferred) by the visitor
@ -195,23 +220,6 @@ class Visitor
return $preferred === 'application/json';
}
/**
* Sets the ip address if provided
* or returns the ip of the current
* visitor otherwise
*
* @return $this|string|null
*/
public function ip(string|null $ip = null): static|string|null
{
if ($ip === null) {
return $this->ip;
}
$this->ip = $ip;
return $this;
}
/**
* Sets the user agent if provided
* or returns the user agent string of

View file

@ -0,0 +1,151 @@
<?php
namespace Kirby\Http;
use Kirby\Cms\Cors;
use Kirby\Toolkit\A;
/**
* Manages request-dependent headers that must not be
* persisted in cached responses
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.3.0
*/
class VolatileHeaders
{
/**
* Stored volatile header configurations
*/
protected array $headers = [];
/**
* Adds (parts of) a header to the volatile list
*/
protected function append(
string $name,
array|null $values = null,
array|null &$target = null
): void {
if ($values === null) {
$target[$name] = null;
return;
}
if (array_key_exists($name, $target) === true && $target[$name] === null) {
return;
}
$values = A::map($values, static fn ($value) => strtolower(trim($value)));
$values = A::filter($values, static fn ($value) => $value !== '');
if ($values === []) {
return;
}
$existingValues = $target[$name] ?? [];
$target[$name] = array_values(array_unique([...$existingValues, ...$values]));
}
/**
* Collects all volatile headers including CORS headers
*/
public function collect(): array
{
$volatile = $this->headers;
$corsHeaders = Cors::headers();
if ($corsHeaders === []) {
return $volatile;
}
foreach ($corsHeaders as $name => $value) {
if ($name === 'Vary') {
$corsVaryValues = array_map('trim', explode(',', $value));
$this->append($name, $corsVaryValues, $volatile);
continue;
}
$this->append($name, null, $volatile);
}
return $volatile;
}
/**
* Marks headers (or header parts) as request-dependent
*/
public function mark(string $name, array|null $values = null): void
{
$this->append($name, $values, $this->headers);
}
/**
* Normalizes a comma-separated list of Vary values
* into a unique array without empty entries
*/
protected function normalizeVaryValues(string $value): array
{
$values = A::map(explode(',', $value), 'trim');
$values = A::filter($values, static fn ($entry) => $entry !== '');
return array_values(array_unique($values));
}
/**
* Returns the Vary values with the provided entries removed
*/
protected function removeVaryValues(array $values, array $remove): array
{
$removeLower = A::map($remove, 'strtolower');
return array_values(A::filter(
$values,
static fn ($value) => in_array(strtolower($value), $removeLower, true) === false
));
}
/**
* Strips volatile headers from the provided header array
*/
public function strip(array $headers, array|null $volatile = null): array
{
$volatile ??= $this->collect();
foreach ($volatile as $name => $values) {
if ($name === 'Vary' && is_array($values) === true) {
$headers = $this->stripVaryHeader($headers, $values);
continue;
}
unset($headers[$name]);
}
return $headers;
}
/**
* Strips Vary header values from the headers array
*/
protected function stripVaryHeader(array $headers, array $values): array
{
if (isset($headers['Vary']) === false) {
return $headers;
}
$current = $this->normalizeVaryValues($headers['Vary']);
$remaining = $this->removeVaryValues($current, $values);
if ($remaining === []) {
unset($headers['Vary']);
} else {
$headers['Vary'] = implode(', ', $remaining);
}
return $headers;
}
}

View file

@ -188,8 +188,10 @@ class ImageMagick extends Darkroom
*/
protected function save(string $file, array $options): string
{
// use the format: prefix to output in the specified format
// while writing to the original path
if ($options['format'] !== null) {
$file = pathinfo($file, PATHINFO_DIRNAME) . '/' . pathinfo($file, PATHINFO_FILENAME) . '.' . $options['format'];
return escapeshellarg($options['format'] . ':' . $file);
}
return escapeshellarg($file);

View file

@ -195,8 +195,10 @@ class Imagick extends Darkroom
*/
protected function save(Image $image, string $file, array $options): bool
{
// set the output format explicitly if specified;
// writing to the original path
if ($options['format'] !== null) {
$file = pathinfo($file, PATHINFO_DIRNAME) . '/' . pathinfo($file, PATHINFO_FILENAME) . '.' . $options['format'];
$image->setImageFormat($options['format']);
}
return $image->writeImages($file, true);

View file

@ -65,16 +65,19 @@ class Option
/**
* Renders all data for the option
*/
public function render(ModelWithContent $model): array
{
public function render(
ModelWithContent $model,
bool $safeMode = true
): array {
$info = I18n::translate($this->info, $this->info);
$text = I18n::translate($this->text, $this->text);
$method = $safeMode === true ? 'toSafeString' : 'toString';
return [
'disabled' => $this->disabled,
'icon' => $this->icon,
'info' => $info ? $model->toSafeString($info) : $info,
'text' => $text ? $model->toSafeString($text) : $text,
'info' => $info ? $model->$method($info) : $info,
'text' => $text ? $model->$method($text) : $text,
'value' => $this->value
];
}

View file

@ -63,12 +63,12 @@ class Options extends Collection
return $collection;
}
public function render(ModelWithContent $model): array
public function render(ModelWithContent $model, bool $safeMode = true): array
{
$options = [];
foreach ($this->data as $key => $option) {
$options[$key] = $option->render($model);
$options[$key] = $option->render($model, $safeMode);
}
return array_values($options);

View file

@ -10,11 +10,12 @@ use Kirby\Cms\StructureObject;
use Kirby\Cms\User;
use Kirby\Content\Field;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Collection;
use Kirby\Toolkit\Obj;
/**
* Options derrived from running a query against
* Options derived from running a query against
* pages, files, users or structures to create
* options out of them.
*
@ -38,11 +39,14 @@ class OptionsQuery extends OptionsProvider
protected function collection(array $array): Collection
{
$isAssociative = A::isAssociative($array);
foreach ($array as $key => $value) {
if (is_scalar($value) === true) {
$array[$key] = new Obj([
'key' => new Field(null, 'key', $key),
'value' => new Field(null, 'value', $value),
'key' => new Field(null, 'key', $key),
'value' => new Field(null, 'value', $value),
'hasStringKey' => $isAssociative,
]);
}
}
@ -72,6 +76,12 @@ class OptionsQuery extends OptionsProvider
protected function itemToDefaults(array|object $item): array
{
return match (true) {
$item instanceof Obj && $item->hasStringKey === true => [
'arrayItem',
'{{ item.value }}',
'{{ item.key }}'
],
is_array($item),
$item instanceof Obj => [
'arrayItem',

View file

@ -50,6 +50,7 @@ class Page extends Model
return ViewButtons::view($this)->defaults(
'open',
'preview',
'-',
'settings',
'languages',
'status'

View file

@ -273,18 +273,17 @@ class PageCreateDialog
}
$props = [
'slug' => '__new__',
'slug' => $this->slug ?? '__new__',
'template' => $this->template,
'model' => $this->template,
'parent' => $this->parent instanceof Page ? $this->parent : null
'parent' => $this->parent instanceof Page ? $this->parent : null,
'content' => ['title' => $this->title],
];
// make sure that a UUID gets generated
// and added to content right away
if (Uuids::enabled() === true) {
$props['content'] = [
'uuid' => $this->uuid = Uuid::generate()
];
$props['content']['uuid'] = $this->uuid = Uuid::generate();
}
$this->model = Page::factory($props);

View file

@ -69,6 +69,12 @@ class ViewButtons
$buttons = [];
foreach ($this->buttons ?? [] as $name => $button) {
// separator, keep as is in array
if ($button === '-') {
$buttons[] = '-';
continue;
}
$buttons[] = ViewButton::factory(
button: $button,
name: $name,

View file

@ -100,7 +100,7 @@ class User extends Model
$result[] = [
'dialog' => $url . '/changePassword',
'icon' => 'key',
'text' => I18n::translate('user.changePassword'),
'text' => I18n::translate('user.' . ($this->model->hasPassword() === true ? 'changePassword' : 'setPassword')),
'disabled' => $this->isDisabledDropdownOption('changePassword', $options, $permissions)
];

View file

@ -37,6 +37,7 @@ class SmartyPants
{
return [
'attr' => 1,
'convert.quot' => true,
'doublequote.open' => '&#8220;',
'doublequote.close' => '&#8221;',
'doublequote.low' => '&#8222;',
@ -76,6 +77,7 @@ class SmartyPants
$this->parser = new SmartyPantsTypographer($this->options['attr']);
// configuration
$this->parser->convert_quot = $this->options['convert.quot'];
$this->parser->smart_doublequote_open = $this->options['doublequote.open'];
$this->parser->smart_doublequote_close = $this->options['doublequote.close'];
$this->parser->smart_singlequote_open = $this->options['singlequote.open'];
@ -111,7 +113,6 @@ class SmartyPants
{
// prepare the text
$text ??= '';
$text = str_replace('&quot;', '"', $text);
// parse the text
return $this->parser->transform($text);

View file

@ -289,6 +289,23 @@ class A
$keys = explode('.', $key);
$firstKey = array_shift($keys);
// prefer a dotted prefix key if it exists
// (e.g. plugin namespaces).
for ($i = count($keys); $i > 0; $i--) {
$prefix = $firstKey . '.' . implode('.', array_slice($keys, 0, $i));
if (
isset($array[$prefix]) === true &&
is_array($array[$prefix]) === true
) {
return static::get(
$array[$prefix],
implode('.', array_slice($keys, $i)),
$default
);
}
}
// if the input array also uses dot notation,
// try to find a subset of the $keys
if (isset($array[$firstKey]) === false) {

View file

@ -391,7 +391,7 @@ class Collection extends Iterator implements Stringable
*/
public function findBy(string $attribute, $value)
{
foreach ($this->data as $item) {
foreach ($this as $item) {
if ($this->getAttribute($item, $attribute) == $value) {
return $item;
}
@ -516,12 +516,12 @@ class Collection extends Iterator implements Stringable
if (is_callable($field) === true) {
$groups = [];
foreach ($this->data as $key => $item) {
foreach ($this as $key => $item) {
// get the value to group by
$value = $field($item);
// make sure that there's always a proper value to group by
if (!$value) {
if ($value === null || $value === false) {
throw new Exception(
message: 'Invalid grouping value for key: ' . $key
);
@ -689,7 +689,7 @@ class Collection extends Iterator implements Stringable
$collection = clone $this;
foreach ($keys as $key) {
unset($collection->data[$key]);
unset($collection->{$key});
}
return $collection;
@ -742,7 +742,7 @@ class Collection extends Iterator implements Stringable
): array {
$result = [];
foreach ($this->data as $item) {
foreach ($this as $item) {
$row = $this->getAttribute($item, $field);
if ($split !== null) {

View file

@ -51,30 +51,54 @@ class Dom
$this->doc = new DOMDocument();
$this->type = strtoupper($type);
// switch to "user error handling"
// Switch libxml into internal error handling mode so warnings
// dont leak into output or interrupt parsing
$errors = libxml_use_internal_errors(true);
if ($this->type === 'HTML') {
// ensure proper parsing for HTML snippets
// If this is an HTML fragment (no <html> or <body> root),
// wrap it in <body> so DOMDocument has a valid container.
if (preg_match('/<(html|body)[> ]/i', $code) !== 1) {
$code = '<body>' . $code . '</body>';
}
// the loadHTML() method expects ISO-8859-1 by default;
// force parsing as UTF-8 by injecting an XML declaration
// DOMDocument::loadHTML() historically assumes ISO-8859-1 input.
// To force UTF-8 parsing, Kirby injects an XML declaration.
// The random ID allows us to reliably identify *our* injected node
// later and remove it again.
$xml = 'encoding="UTF-8" id="' . Str::random(10) . '"';
$load = $this->doc->loadHTML('<?xml ' . $xml . '>' . $code);
// remove the injected XML declaration again
$pis = $this->query('//processing-instruction()');
foreach (iterator_to_array($pis, false) as $pi) {
if ($pi->data === $xml) {
static::remove($pi);
// Newer libxml2 versions may not attach the injected XML node
// inside <html>. Instead, they may convert it into a top-level
// comment node that sits before <html>:
// <!--?xml encoding="UTF-8" id="XYZ"--><html>...
//
// XPath queries like //comment() or //processing-instruction()
// often operate relative to the document element (<html>) and
// therefore miss this node entirely.
// To fix this, we must also inspect and clean up the documents
// top-level child nodes explicitly.
//
// Walk all top-level nodes of the document and remove
// any node that matches the injected XML marker
for ($node = $this->doc->firstChild; $node !== null; $node = $next) {
$next = $node->nextSibling;
if (
// Case 1: libxml preserved it as a processing instruction
($node->nodeType === XML_PI_NODE && $node->data === $xml) ||
// Case 2: libxml converted it into a comment node
// (<!--?xml encoding="UTF-8" id="..."-->)
($node->nodeType === XML_COMMENT_NODE && strpos($node->data, $xml) !== false)
) {
static::remove($node);
break;
}
}
// remove the default doctype
// Remove the default doctype
if (Str::contains($code, '<!DOCTYPE ', true) === false) {
static::remove($this->doc->doctype);
}
@ -655,6 +679,17 @@ class Dom
static::remove($metaTag);
$html = str_replace($this->doc->saveHTML($metaTag), '', $html);
// if the original input contained an HTML doctype, some libxml
// implementations expand it to the long HTML4 transitional doctype
// when saving. Normalize it back to the short `<!DOCTYPE html>`
// to keep behavior consistent across environments.
if (
Str::contains($this->code, '<!DOCTYPE ', true) === true &&
preg_match('/<!doctype\s+html/i', $this->code) === 1
) {
$html = preg_replace('/^<!DOCTYPE[^>]*>\s*/i', '<!DOCTYPE html>' . "\n", $html, 1);
}
return trim($html);
}

View file

@ -4,6 +4,7 @@ namespace Kirby\Toolkit;
use ArrayIterator;
use Countable;
use Iterator as PhpIterator;
use IteratorAggregate;
/**
@ -34,16 +35,18 @@ class Iterator implements Countable, IteratorAggregate
}
/**
* Get an iterator for the items.
* Returns an iterator for the elements
* @return \ArrayIterator<TKey, TValue>
*/
public function getIterator(): ArrayIterator
public function getIterator(): PhpIterator
{
return new ArrayIterator($this->data);
}
/**
* Returns the current key
* @deprecated
* @todo Remove in v6
*/
public function key(): int|string|null
{
@ -60,6 +63,8 @@ class Iterator implements Countable, IteratorAggregate
/**
* Returns the current element
* @deprecated
* @todo Remove in v6
* @return TValue
*/
public function current(): mixed
@ -70,6 +75,8 @@ class Iterator implements Countable, IteratorAggregate
/**
* Moves the cursor to the previous element
* and returns it
* @deprecated
* @todo Remove in v6
* @return TValue
*/
public function prev(): mixed
@ -80,6 +87,8 @@ class Iterator implements Countable, IteratorAggregate
/**
* Moves the cursor to the next element
* and returns it
* @deprecated
* @todo Remove in v6
* @return TValue
*/
public function next(): mixed
@ -89,6 +98,8 @@ class Iterator implements Countable, IteratorAggregate
/**
* Moves the cursor to the first element
* @deprecated
* @todo Remove in v6
*/
public function rewind(): void
{
@ -97,6 +108,8 @@ class Iterator implements Countable, IteratorAggregate
/**
* Checks if the current element is valid
* @deprecated
* @todo Remove in v6
*/
public function valid(): bool
{
@ -139,7 +152,7 @@ class Iterator implements Countable, IteratorAggregate
*/
public function has(mixed $key): bool
{
return isset($this->data[$key]) === true;
return array_key_exists($key, $this->data) === true;
}
/**

View file

@ -651,10 +651,17 @@ class Str
}
/**
* Safe ltrim alternative
* Trims away a fixed sequence at the beginning of the string.
* For character list trimming, use PHP's native `ltrim()` function.
*
* ```php
* Str::ltrim('abababaC', 'ab'); // 'aC'
* ```
*/
public static function ltrim(string $string, string $trim = ' '): string
{
public static function ltrim(
string $string,
string $trim = ' '
): string {
return preg_replace('!^(' . preg_quote($trim) . ')+!', '', $string);
}
@ -997,10 +1004,17 @@ class Str
}
/**
* Safe rtrim alternative
* Trims away a fixed sequence at the end of the string.
* For character list trimming, use PHP's native `rtrim()` function.
*
* ```php
* Str::rtrim('Cabababa', 'ba'); // 'Ca'
* ```
*/
public static function rtrim(string $string, string $trim = ' '): string
{
public static function rtrim(
string $string,
string $trim = ' '
): string {
return preg_replace('!(' . preg_quote($trim) . ')+$!', '', $string);
}
@ -1342,8 +1356,8 @@ class Str
array $data = [],
array $options = []
): string {
$start = $options['start'] ?? '{{1,2}';
$end = $options['end'] ?? '}{1,2}';
$start = $options['start'] ?? '(?:{{|{<|{)';
$end = $options['end'] ?? '(?:}}|>}|})';
$fallback = $options['fallback'] ?? null;
$callback = $options['callback'] ?? null;
@ -1425,11 +1439,20 @@ class Str
}
/**
* Safe trim alternative
* Trims away a fixed sequence at the beginning and end of the string.
* For character list trimming, use PHP's native `trim()` function.
*
* ```php
* Str::trim('ababaCbabab', 'ab'); // 'aCb'
* ```
*/
public static function trim(string $string, string $trim = ' '): string
{
return static::rtrim(static::ltrim($string, $trim), $trim);
public static function trim(
string $string,
string $trim = ' '
): string {
$string = static::ltrim($string, $trim);
$string = static::rtrim($string, $trim);
return $string;
}
/**

View file

@ -25,6 +25,25 @@ class PageUuid extends ModelUuid
*/
public Identifiable|null $model = null;
/**
* Removes the current UUID from cache,
* recursively including all children if needed
*/
public function clear(bool $recursive = false): bool
{
/**
* If $recursive, also clear UUIDs from cache for all children
* @var \Kirby\Cms\Page $model
*/
if ($recursive === true && $model = $this->model()) {
foreach ($model->children() as $child) {
$child->uuid()->clear(true);
}
}
return parent::clear();
}
/**
* Looks up UUID in cache and resolves
* to page object
@ -55,6 +74,27 @@ class PageUuid extends ModelUuid
}
}
/**
* Feeds the UUID for the page (and optionally
* its children) into the cache
*/
public function populate(
bool $force = false,
bool $recursive = false
): bool {
/**
* If $recursive, also populate UUIDs for all children
* @var \Kirby\Cms\Page $model
*/
if ($recursive === true && $model = $this->model()) {
foreach ($model->children() as $child) {
$child->uuid()->populate($force, true);
}
}
return parent::populate($force);
}
/**
* Returns permalink url
*/

View file

@ -100,18 +100,8 @@ abstract class Uuid implements Stringable
* Removes the current UUID from cache,
* recursively including all children if needed
*/
public function clear(bool $recursive = false): bool
public function clear(): bool
{
// For all models with children: if $recursive,
// also clear UUIDs from cache for all children
if ($recursive === true && $model = $this->model()) {
if (method_exists($model, 'children') === true) {
foreach ($model->children() as $child) {
$child->uuid()->clear(true);
}
}
}
if ($key = $this->key()) {
return Uuids::cache()->remove($key);
}

View file

@ -19,6 +19,11 @@ use Kirby\Exception\LogicException;
*/
class Uuids
{
/**
* Cache for the uuid option state
*/
public static bool|null $enabled = null;
/**
* Returns the instance for the lookup cache
*/
@ -82,7 +87,7 @@ class Uuids
public static function enabled(): bool
{
return App::instance()->option('content.uuid') !== false;
return static::$enabled ??= App::instance()->option('content.uuid') !== false;
}
/**