chapter hgroup + calming decor
This commit is contained in:
parent
0545b131de
commit
94d14d70c1
370 changed files with 9583 additions and 1566 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
551
kirby/src/Cms/LazyCollection.php
Normal file
551
kirby/src/Cms/LazyCollection.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
159
kirby/src/Http/Range.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
151
kirby/src/Http/VolatileHeaders.php
Normal file
151
kirby/src/Http/VolatileHeaders.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class Page extends Model
|
|||
return ViewButtons::view($this)->defaults(
|
||||
'open',
|
||||
'preview',
|
||||
'-',
|
||||
'settings',
|
||||
'languages',
|
||||
'status'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class SmartyPants
|
|||
{
|
||||
return [
|
||||
'attr' => 1,
|
||||
'convert.quot' => true,
|
||||
'doublequote.open' => '“',
|
||||
'doublequote.close' => '”',
|
||||
'doublequote.low' => '„',
|
||||
|
|
@ -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('"', '"', $text);
|
||||
|
||||
// parse the text
|
||||
return $this->parser->transform($text);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// don’t 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 document’s
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue