1
0

downgrade to kirby v3

This commit is contained in:
Philip Wagner
2024-09-01 10:47:15 +02:00
parent a4b2aece7b
commit af86acb7a1
1085 changed files with 54743 additions and 65042 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -30,9 +30,6 @@ class Collection extends Iterator implements Countable
* Whether the collection keys should be
* treated as case-sensitive
*
* @todo 5.0 Check if case-sensitive can become the
* default mode, see https://github.com/getkirby/kirby/pull/5635
*
* @var bool
*/
protected $caseSensitive = false;
@@ -70,7 +67,8 @@ class Collection extends Iterator implements Countable
/**
* Improve var_dump() output
* @codeCoverageIgnore
*
* @return array
*/
public function __debugInfo(): array
{
@@ -101,11 +99,11 @@ class Collection extends Iterator implements Countable
*/
public function __set(string $key, $value): void
{
if ($this->caseSensitive !== true) {
$key = strtolower($key);
if ($this->caseSensitive === true) {
$this->data[$key] = $value;
} else {
$this->data[strtolower($key)] = $value;
}
$this->data[$key] = $value;
}
/**
@@ -125,10 +123,6 @@ class Collection extends Iterator implements Countable
*/
public function __unset($key)
{
if ($this->caseSensitive !== true) {
$key = strtolower($key);
}
unset($this->data[$key]);
}
@@ -386,11 +380,11 @@ class Collection extends Iterator implements Countable
public function find(...$keys)
{
if (count($keys) === 1) {
if (is_array($keys[0]) === false) {
if (is_array($keys[0]) === true) {
$keys = $keys[0];
} else {
return $this->findByKey($keys[0]);
}
$keys = $keys[0];
}
$result = [];
@@ -523,24 +517,21 @@ class Collection extends Iterator implements Countable
* Groups the elements by a given field or callback function
*
* @param string|Closure $field
* @param bool $i
* @return \Kirby\Toolkit\Collection A new collection with an element for
* each group and a subcollection in
* each group
* @throws \Exception if $field is not a string nor a callback function
*/
public function group($field, bool $caseInsensitive = true)
public function group($field, bool $i = true)
{
// group by field name
if (is_string($field) === true) {
return $this->group(function ($item) use ($field, $caseInsensitive) {
return $this->group(function ($item) use ($field, $i) {
$value = $this->getAttribute($item, $field);
// ignore upper/lowercase for group names
if ($caseInsensitive === true) {
return Str::lower($value);
}
return (string)$value;
return $i === true ? Str::lower($value) : $value;
});
}
@@ -560,14 +551,12 @@ class Collection extends Iterator implements Countable
// make sure we have a proper key for each group
if (is_array($value) === true) {
throw new Exception('You cannot group by arrays or objects');
}
if (is_object($value) === true) {
} elseif (is_object($value) === true) {
if (method_exists($value, '__toString') === false) {
throw new Exception('You cannot group by arrays or objects');
} else {
$value = (string)$value;
}
$value = (string)$value;
}
if (isset($groups[$value]) === false) {
@@ -745,17 +734,14 @@ class Collection extends Iterator implements Countable
* Add pagination
*
* @param array ...$arguments
* @return $this|static a sliced set of data
* @return static a sliced set of data
*/
public function paginate(...$arguments)
{
$this->pagination = Pagination::for($this, ...$arguments);
// slice and clone the collection according to the pagination
return $this->slice(
$this->pagination->offset(),
$this->pagination->limit()
);
return $this->slice($this->pagination->offset(), $this->pagination->limit());
}
/**
@@ -1189,7 +1175,11 @@ class Collection extends Iterator implements Countable
return $callback->call($this, $condition);
}
return $fallback?->call($this, $condition) ?? $this;
if ($fallback !== null) {
return $fallback->call($this, $condition);
}
return $this;
}
/**
@@ -1258,58 +1248,74 @@ Collection::$filters['!='] = function ($collection, $field, $test, $split = fals
* In Filter
*/
Collection::$filters['in'] = [
'validator' => fn ($value, $test) => in_array($value, $test) === true,
'strict' => false
'validator' => function ($value, $test) {
return in_array($value, $test) === true;
},
'strict' => false
];
/**
* Not In Filter
*/
Collection::$filters['not in'] = [
'validator' => fn ($value, $test) => in_array($value, $test) === false
'validator' => function ($value, $test) {
return in_array($value, $test) === false;
},
];
/**
* Contains Filter
*/
Collection::$filters['*='] = [
'validator' => fn ($value, $test) => strpos($value, $test) !== false,
'strict' => false
'validator' => function ($value, $test) {
return strpos($value, $test) !== false;
},
'strict' => false
];
/**
* Not Contains Filter
*/
Collection::$filters['!*='] = [
'validator' => fn ($value, $test) => strpos($value, $test) === false
'validator' => function ($value, $test) {
return strpos($value, $test) === false;
},
];
/**
* More Filter
*/
Collection::$filters['>'] = [
'validator' => fn ($value, $test) => $value > $test
'validator' => function ($value, $test) {
return $value > $test;
}
];
/**
* Min Filter
*/
Collection::$filters['>='] = [
'validator' => fn ($value, $test) => $value >= $test
'validator' => function ($value, $test) {
return $value >= $test;
}
];
/**
* Less Filter
*/
Collection::$filters['<'] = [
'validator' => fn ($value, $test) => $value < $test
'validator' => function ($value, $test) {
return $value < $test;
}
];
/**
* Max Filter
*/
Collection::$filters['<='] = [
'validator' => fn ($value, $test) => $value <= $test
'validator' => function ($value, $test) {
return $value <= $test;
}
];
/**
@@ -1324,7 +1330,9 @@ Collection::$filters['$='] = [
* Not Ends With Filter
*/
Collection::$filters['!$='] = [
'validator' => fn ($value, $test) => V::endsWith($value, $test) === false
'validator' => function ($value, $test) {
return V::endsWith($value, $test) === false;
}
];
/**
@@ -1339,15 +1347,19 @@ Collection::$filters['^='] = [
* Not Starts With Filter
*/
Collection::$filters['!^='] = [
'validator' => fn ($value, $test) => V::startsWith($value, $test) === false
'validator' => function ($value, $test) {
return V::startsWith($value, $test) === false;
}
];
/**
* Between Filter
*/
Collection::$filters['between'] = Collection::$filters['..'] = [
'validator' => fn ($value, $test) => V::between($value, ...$test) === true,
'strict' => false
'validator' => function ($value, $test) {
return V::between($value, ...$test) === true;
},
'strict' => false
];
/**
@@ -1362,7 +1374,9 @@ Collection::$filters['*'] = [
* Not Match Filter
*/
Collection::$filters['!*'] = [
'validator' => fn ($value, $test) => V::match($value, $test) === false
'validator' => function ($value, $test) {
return V::match($value, $test) === false;
}
];
/**
@@ -1397,49 +1411,62 @@ Collection::$filters['minwords'] = [
* Date Equals Filter
*/
Collection::$filters['date =='] = [
'validator' => fn ($value, $test) => V::date($value, '==', $test)
'validator' => function ($value, $test) {
return V::date($value, '==', $test);
}
];
/**
* Date Not Equals Filter
*/
Collection::$filters['date !='] = [
'validator' => fn ($value, $test) => V::date($value, '!=', $test)
'validator' => function ($value, $test) {
return V::date($value, '!=', $test);
}
];
/**
* Date More Filter
*/
Collection::$filters['date >'] = [
'validator' => fn ($value, $test) => V::date($value, '>', $test)
'validator' => function ($value, $test) {
return V::date($value, '>', $test);
}
];
/**
* Date Min Filter
*/
Collection::$filters['date >='] = [
'validator' => fn ($value, $test) => V::date($value, '>=', $test)
'validator' => function ($value, $test) {
return V::date($value, '>=', $test);
}
];
/**
* Date Less Filter
*/
Collection::$filters['date <'] = [
'validator' => fn ($value, $test) => V::date($value, '<', $test)
'validator' => function ($value, $test) {
return V::date($value, '<', $test);
}
];
/**
* Date Max Filter
*/
Collection::$filters['date <='] = [
'validator' => fn ($value, $test) => V::date($value, '<=', $test)
'validator' => function ($value, $test) {
return V::date($value, '<=', $test);
}
];
/**
* Date Between Filter
*/
Collection::$filters['date between'] = Collection::$filters['date ..'] = [
'validator' => fn ($value, $test) =>
V::date($value, '>=', $test[0]) &&
V::date($value, '<=', $test[1])
'validator' => function ($value, $test) {
return V::date($value, '>=', $test[0]) &&
V::date($value, '<=', $test[1]);
}
];

View File

@@ -2,9 +2,7 @@
namespace Kirby\Toolkit;
use AllowDynamicProperties;
use ArgumentCountError;
use Closure;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Filesystem\F;
@@ -18,56 +16,95 @@ use TypeError;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* @todo remove the following psalm suppress when PHP >= 8.2 required
* @psalm-suppress UndefinedAttributeClass
*/
#[AllowDynamicProperties]
class Component
{
/**
* Registry for all component mixins
*
* @var array
*/
public static array $mixins = [];
public static $mixins = [];
/**
* Registry for all component types
*
* @var array
*/
public static array $types = [];
public static $types = [];
/**
* An array of all passed attributes
*
* @var array
*/
protected array $attrs = [];
protected $attrs = [];
/**
* An array of all computed properties
*
* @var array
*/
protected array $computed = [];
protected $computed = [];
/**
* An array of all registered methods
*
* @var array
*/
protected array $methods = [];
protected $methods = [];
/**
* An array of all component options
* from the component definition
*
* @var array
*/
protected array|string $options = [];
protected $options = [];
/**
* An array of all resolved props
*
* @var array
*/
protected array $props = [];
protected $props = [];
/**
* The component type
*
* @var string
*/
protected string $type;
protected $type;
/**
* Magic caller for defined methods and properties
*
* @param string $name
* @param array $arguments
* @return mixed
*/
public function __call(string $name, array $arguments = [])
{
if (array_key_exists($name, $this->computed) === true) {
return $this->computed[$name];
}
if (array_key_exists($name, $this->props) === true) {
return $this->props[$name];
}
if (array_key_exists($name, $this->methods) === true) {
return $this->methods[$name]->call($this, ...$arguments);
}
return $this->$name;
}
/**
* Creates a new component for the given type
*
* @param string $type
* @param array $attrs
*/
public function __construct(string $type, array $attrs = [])
{
@@ -76,7 +113,7 @@ class Component
}
$this->attrs = $attrs;
$this->options = $options = static::setup($type);
$this->options = $options = $this->setup($type);
$this->methods = $methods = $options['methods'] ?? [];
foreach ($attrs as $attrName => $attrValue) {
@@ -97,29 +134,10 @@ class Component
$this->type = $type;
}
/**
* Magic caller for defined methods and properties
*/
public function __call(string $name, array $arguments = [])
{
if (array_key_exists($name, $this->computed) === true) {
return $this->computed[$name];
}
if (array_key_exists($name, $this->props) === true) {
return $this->props[$name];
}
if (array_key_exists($name, $this->methods) === true) {
return $this->methods[$name]->call($this, ...$arguments);
}
return $this->$name;
}
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*
* @return array
*/
public function __debugInfo(): array
{
@@ -129,6 +147,9 @@ class Component
/**
* Fallback for missing properties to return
* null instead of an error
*
* @param string $attr
* @return null
*/
public function __get(string $attr)
{
@@ -140,6 +161,8 @@ class Component
* This can be overwritten by extended classes
* to define basic options that should always
* be applied.
*
* @return array
*/
public static function defaults(): array
{
@@ -149,50 +172,54 @@ class Component
/**
* Register all defined props and apply the
* passed values.
*
* @param array $props
* @return void
*/
protected function applyProps(array $props): void
{
foreach ($props as $name => $function) {
if ($function instanceof Closure) {
if (isset($this->attrs[$name]) === true) {
foreach ($props as $propName => $propFunction) {
if (is_a($propFunction, 'Closure') === true) {
if (isset($this->attrs[$propName]) === true) {
try {
$this->$name = $this->props[$name] = $function->call(
$this,
$this->attrs[$name]
);
continue;
} catch (TypeError) {
throw new TypeError('Invalid value for "' . $name . '"');
$this->$propName = $this->props[$propName] = $propFunction->call($this, $this->attrs[$propName]);
} catch (TypeError $e) {
throw new TypeError('Invalid value for "' . $propName . '"');
}
} else {
try {
$this->$propName = $this->props[$propName] = $propFunction->call($this);
} catch (ArgumentCountError $e) {
throw new ArgumentCountError('Please provide a value for "' . $propName . '"');
}
}
try {
$this->$name = $this->props[$name] = $function->call($this);
continue;
} catch (ArgumentCountError) {
throw new ArgumentCountError('Please provide a value for "' . $name . '"');
}
} else {
$this->$propName = $this->props[$propName] = $propFunction;
}
$this->$name = $this->props[$name] = $function;
}
}
/**
* Register all computed properties and calculate their values.
* This must happen after all props are registered.
*
* @param array $computed
* @return void
*/
protected function applyComputed(array $computed): void
{
foreach ($computed as $name => $function) {
if ($function instanceof Closure) {
$this->$name = $this->computed[$name] = $function->call($this);
foreach ($computed as $computedName => $computedFunction) {
if (is_a($computedFunction, 'Closure') === true) {
$this->$computedName = $this->computed[$computedName] = $computedFunction->call($this);
}
}
}
/**
* Load a component definition by type
*
* @param string $type
* @return array
*/
public static function load(string $type): array
{
@@ -204,10 +231,7 @@ class Component
throw new Exception('Component definition ' . $definition . ' does not exist');
}
static::$types[$type] = $definition = F::load(
$definition,
allowOutput: false
);
static::$types[$type] = $definition = F::load($definition);
}
return $definition;
@@ -218,6 +242,9 @@ class Component
* mixes in the defaults from the defaults method and
* then injects all additional mixins, defined in the
* component options.
*
* @param string $type
* @return array
*/
public static function setup(string $type): array
{
@@ -226,32 +253,23 @@ class Component
if (isset($definition['extends']) === true) {
// extend other definitions
$options = array_replace_recursive(
static::defaults(),
static::load($definition['extends']),
$definition
);
$options = array_replace_recursive(static::defaults(), static::load($definition['extends']), $definition);
} else {
// inject defaults
$options = array_replace_recursive(static::defaults(), $definition);
}
// inject mixins
foreach ($options['mixins'] ?? [] as $mixin) {
if (isset(static::$mixins[$mixin]) === true) {
if (is_string(static::$mixins[$mixin]) === true) {
// resolve a path to a mixin on demand
if (isset($options['mixins']) === true) {
foreach ($options['mixins'] as $mixin) {
if (isset(static::$mixins[$mixin]) === true) {
if (is_string(static::$mixins[$mixin]) === true) {
// resolve a path to a mixin on demand
static::$mixins[$mixin] = include static::$mixins[$mixin];
}
static::$mixins[$mixin] = F::load(
static::$mixins[$mixin],
allowOutput: false
);
$options = array_replace_recursive(static::$mixins[$mixin], $options);
}
$options = array_replace_recursive(
static::$mixins[$mixin],
$options
);
}
}
@@ -260,13 +278,13 @@ class Component
/**
* Converts all props and computed props to an array
*
* @return array
*/
public function toArray(): array
{
$closure = $this->options['toArray'] ?? null;
if ($closure instanceof Closure) {
return $closure->call($this);
if (is_a($this->options['toArray'] ?? null, 'Closure') === true) {
return $this->options['toArray']->call($this);
}
$array = array_merge($this->attrs, $this->props, $this->computed);

View File

@@ -14,5 +14,8 @@ namespace Kirby\Toolkit;
*/
class Config extends Silo
{
public static array $data = [];
/**
* @var array
*/
public static $data = [];
}

View File

@@ -19,8 +19,11 @@ use ReflectionFunction;
*/
class Controller
{
public function __construct(protected Closure $function)
protected $function;
public function __construct(Closure $function)
{
$this->function = $function;
}
public function arguments(array $data = []): array
@@ -28,20 +31,9 @@ class Controller
$info = new ReflectionFunction($this->function);
$args = [];
foreach ($info->getParameters() as $param) {
$name = $param->getName();
if ($param->isVariadic() === true) {
// variadic ... argument collects all remaining values
$args += $data;
} elseif (isset($data[$name]) === true) {
// use provided argument value if available
$args[$name] = $data[$name];
} elseif ($param->isDefaultValueAvailable() === false) {
// use null for any other arguments that don't define
// a default value for themselves
$args[$name] = null;
}
foreach ($info->getParameters() as $parameter) {
$name = $parameter->getName();
$args[] = $data[$name] ?? null;
}
return $args;
@@ -49,18 +41,16 @@ class Controller
public function call($bind = null, $data = [])
{
// unwrap lazy values in arguments
$args = $this->arguments($data);
$args = LazyValue::unwrap($args);
if ($bind === null) {
return ($this->function)(...$args);
return call_user_func($this->function, ...$args);
}
return $this->function->call($bind, ...$args);
}
public static function load(string $file): static|null
public static function load(string $file)
{
if (is_file($file) === false) {
return null;
@@ -68,7 +58,7 @@ class Controller
$function = F::load($file);
if ($function instanceof Closure === false) {
if (is_a($function, 'Closure') === false) {
return null;
}

View File

@@ -2,12 +2,9 @@
namespace Kirby\Toolkit;
use DateInterval;
use DateTime;
use DateTimeInterface;
use DateTimeZone;
use Exception;
use IntlDateFormatter;
use Kirby\Exception\InvalidArgumentException;
/**
@@ -29,15 +26,13 @@ class Date extends DateTime
* @param string|int|\DateTimeInterface $datetime Datetime string, UNIX timestamp or object
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
*/
public function __construct(
string|int|DateTimeInterface $datetime = 'now',
DateTimeZone|null $timezone = null
) {
public function __construct($datetime = 'now', ?DateTimeZone $timezone = null)
{
if (is_int($datetime) === true) {
$datetime = date('r', $datetime);
}
if ($datetime instanceof DateTimeInterface) {
if (is_a($datetime, 'DateTimeInterface') === true) {
$datetime = $datetime->format('r');
}
@@ -46,6 +41,8 @@ class Date extends DateTime
/**
* Returns the datetime in `YYYY-MM-DD hh:mm:ss` format with timezone
*
* @return string
*/
public function __toString(): string
{
@@ -60,7 +57,7 @@ class Date extends DateTime
*
* @throws \Kirby\Exception\InvalidArgumentException If the unit name is invalid
*/
public function ceil(string $unit): static
public function ceil(string $unit)
{
static::validateUnit($unit);
@@ -72,19 +69,22 @@ class Date extends DateTime
/**
* Returns the interval between the provided and the object's datetime
*
* @param string|int|\DateTimeInterface $datetime
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
* @return \DateInterval
*/
public function compare(
string|int|DateTimeInterface $datetime = 'now',
DateTimeZone|null $timezone = null
): DateInterval {
public function compare($datetime = 'now', ?DateTimeZone $timezone = null)
{
return $this->diff(new static($datetime, $timezone));
}
/**
* Gets or sets the day value
*
* @param int|null $day
* @return int
*/
public function day(int|null $day = null): int
public function day(?int $day = null): int
{
if ($day === null) {
return (int)$this->format('d');
@@ -102,42 +102,31 @@ class Date extends DateTime
*
* @throws \Kirby\Exception\InvalidArgumentException If the unit name is invalid
*/
public function floor(string $unit): static
public function floor(string $unit)
{
static::validateUnit($unit);
$formats = [
'year' => 'Y-01-01',
'month' => 'Y-m-01',
'day' => 'Y-m-d',
'hour' => 'Y-m-d H:00:00',
'minute' => 'Y-m-d H:i:00',
'second' => 'Y-m-d H:i:s'
'year' => 'Y-01-01P',
'month' => 'Y-m-01P',
'day' => 'Y-m-dP',
'hour' => 'Y-m-d H:00:00P',
'minute' => 'Y-m-d H:i:00P',
'second' => 'Y-m-d H:i:sP'
];
$flooredDate = $this->format($formats[$unit]);
$this->set($flooredDate, $this->timezone());
$flooredDate = date($formats[$unit], $this->timestamp());
$this->set($flooredDate);
return $this;
}
/**
* Formats the datetime value with a custom handler
* or with the globally configured one
*
* @param 'date'|'intl'|'strftime'|null $handler Custom date handler or `null`
* for the globally configured one
*/
public function formatWithHandler(
string|IntlDateFormatter|null $format = null,
string|null $handler = null
): string|int|false {
return Str::date($this->timestamp(), $format, $handler);
}
/**
* Gets or sets the hour value
*
* @param int|null $hour
* @return int
*/
public function hour(int|null $hour = null): int
public function hour(?int $hour = null): int
{
if ($hour === null) {
return (int)$this->format('H');
@@ -150,93 +139,102 @@ class Date extends DateTime
/**
* Checks if the object's datetime is the same as the given datetime
*
* @param string|int|\DateTimeInterface $datetime
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
* @return bool
*/
public function is(
string|int|DateTimeInterface $datetime = 'now',
DateTimeZone|null $timezone = null
): bool {
public function is($datetime = 'now', ?DateTimeZone $timezone = null): bool
{
return $this == new static($datetime, $timezone);
}
/**
* Checks if the object's datetime is after the given datetime
*
* @param string|int|\DateTimeInterface $datetime
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
* @return bool
*/
public function isAfter(
string|int|DateTimeInterface $datetime = 'now',
DateTimeZone|null $timezone = null
): bool {
public function isAfter($datetime = 'now', ?DateTimeZone $timezone = null): bool
{
return $this > new static($datetime, $timezone);
}
/**
* Checks if the object's datetime is before the given datetime
*
* @param string|int|\DateTimeInterface $datetime
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
* @return bool
*/
public function isBefore(
string|int|DateTimeInterface $datetime = 'now',
DateTimeZone|null $timezone = null
): bool {
public function isBefore($datetime = 'now', ?DateTimeZone $timezone = null): bool
{
return $this < new static($datetime, $timezone);
}
/**
* Checks if the object's datetime is between the given datetimes
*
* @param string|int|\DateTimeInterface $min
* @param string|int|\DateTimeInterface $max
* @return bool
*/
public function isBetween(
string|int|DateTimeInterface $min,
string|int|DateTimeInterface $max
): bool {
public function isBetween($min, $max): bool
{
return $this->isMin($min) === true && $this->isMax($max) === true;
}
/**
* Checks if the object's datetime is at or before the given datetime
*
* @param string|int|\DateTimeInterface $datetime
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
* @return bool
*/
public function isMax(
string|int|DateTimeInterface $datetime = 'now',
DateTimeZone|null $timezone = null
): bool {
public function isMax($datetime = 'now', ?DateTimeZone $timezone = null): bool
{
return $this <= new static($datetime, $timezone);
}
/**
* Checks if the object's datetime is at or after the given datetime
*
* @param string|int|\DateTimeInterface $datetime
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
* @return bool
*/
public function isMin(
string|int|DateTimeInterface $datetime = 'now',
DateTimeZone|null $timezone = null
): bool {
public function isMin($datetime = 'now', ?DateTimeZone $timezone = null): bool
{
return $this >= new static($datetime, $timezone);
}
/**
* Gets the microsecond value
*
* @return int
*/
public function microsecond(): int
{
return (int)$this->format('u');
return $this->format('u');
}
/**
* Gets the millisecond value
*
* @return int
*/
public function millisecond(): int
{
return (int)$this->format('v');
return $this->format('v');
}
/**
* Gets or sets the minute value
*
* @param int|null $minute
* @return int
*/
public function minute(int|null $minute = null): int
public function minute(?int $minute = null): int
{
if ($minute === null) {
return (int)$this->format('i');
@@ -248,8 +246,11 @@ class Date extends DateTime
/**
* Gets or sets the month value
*
* @param int|null $month
* @return int
*/
public function month(int|null $month = null): int
public function month(?int $month = null): int
{
if ($month === null) {
return (int)$this->format('m');
@@ -263,10 +264,10 @@ class Date extends DateTime
* Returns the datetime which is nearest to the object's datetime
*
* @param string|int|\DateTimeInterface ...$datetime Datetime strings, UNIX timestamps or objects
* @return string|int|\DateTimeInterface
*/
public function nearest(
string|int|DateTimeInterface ...$datetime
): string|int|DateTimeInterface {
public function nearest(...$datetime)
{
$timestamp = $this->timestamp();
$minDiff = PHP_INT_MAX;
$nearest = null;
@@ -289,8 +290,9 @@ class Date extends DateTime
* Returns an instance of the current datetime
*
* @param \DateTimeZone|null $timezone
* @return static
*/
public static function now(DateTimeZone|null $timezone = null): static
public static function now(?DateTimeZone $timezone = null)
{
return new static('now', $timezone);
}
@@ -298,18 +300,20 @@ class Date extends DateTime
/**
* Tries to create an instance from the given string
* or fails silently by returning `null` on error
*
* @param string|null $datetime
* @param \DateTimeZone|null $timezone
* @return static|null
*/
public static function optional(
string|null $datetime = null,
DateTimeZone|null $timezone = null
): static|null {
public static function optional(?string $datetime = null, ?DateTimeZone $timezone = null)
{
if (empty($datetime) === true) {
return null;
}
try {
return new static($datetime, $timezone);
} catch (Exception) {
} catch (Exception $e) {
return null;
}
}
@@ -323,7 +327,7 @@ class Date extends DateTime
*
* @throws \Kirby\Exception\InvalidArgumentException If the unit name or size is invalid
*/
public function round(string $unit, int $size = 1): static
public function round(string $unit, int $size = 1)
{
static::validateUnit($unit);
@@ -360,12 +364,12 @@ class Date extends DateTime
* by the defined step
* @since 3.7.0
*
* @param string|null $date
* @param int|array|null $step array of `unit` and `size` to round to nearest
* @return int|null
*/
public static function roundedTimestamp(
string|null $date = null,
int|array|null $step = null
): int|null {
public static function roundedTimestamp(?string $date = null, $step = null): ?int
{
if ($date = static::optional($date)) {
if ($step !== null) {
$step = static::stepConfig($step, [
@@ -383,8 +387,11 @@ class Date extends DateTime
/**
* Gets or sets the second value
*
* @param int|null $second
* @return int
*/
public function second(int|null $second = null): int
public function second(?int $second = null): int
{
if ($second === null) {
return (int)$this->format('s');
@@ -400,10 +407,8 @@ class Date extends DateTime
* @param string|int|\DateTimeInterface $datetime Datetime string, UNIX timestamp or object
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
*/
public function set(
string|int|DateTimeInterface $datetime,
DateTimeZone|null $timezone = null
): void {
public function set($datetime, ?DateTimeZone $timezone = null)
{
$datetime = new static($datetime, $timezone);
$this->setTimestamp($datetime->timestamp());
}
@@ -414,12 +419,15 @@ class Date extends DateTime
* @param array|string|int|null $input Full array with `size` and/or `unit` keys, `unit`
* string, `size` int or `null` for the default
* @param array|null $default Default values to use if one or both values are not provided
* @return array
*/
public static function stepConfig(
// no type hint to use InvalidArgumentException at the end
$input = null,
array|null $default = ['size' => 1, 'unit' => 'day']
): array {
public static function stepConfig($input = null, ?array $default = null): array
{
$default ??= [
'size' => 1,
'unit' => 'day'
];
if ($input === null) {
return $default;
}
@@ -443,6 +451,8 @@ class Date extends DateTime
/**
* Returns the time in `hh:mm:ss` format
*
* @return string
*/
public function time(): string
{
@@ -451,6 +461,8 @@ class Date extends DateTime
/**
* Returns the UNIX timestamp
*
* @return int
*/
public function timestamp(): int
{
@@ -459,16 +471,21 @@ class Date extends DateTime
/**
* Returns the timezone object
*
* @return \DateTimeZone
*/
public function timezone(): DateTimeZone|false
public function timezone()
{
return $this->getTimezone();
}
/**
* Returns an instance of the beginning of the current day
*
* @param \DateTimeZone|null $timezone
* @return static
*/
public static function today(DateTimeZone|null $timezone = null): static
public static function today(?DateTimeZone $timezone = null)
{
return new static('today', $timezone);
}
@@ -479,19 +496,25 @@ class Date extends DateTime
*
* @param string $mode `date`, `time` or `datetime`
* @param bool $timezone Whether the timezone is printed as well
* @return string
*
* @throws \Kirby\Exception\InvalidArgumentException If the mode is invalid
*/
public function toString(
string $mode = 'datetime',
bool $timezone = true
): string {
$format = match ($mode) {
'date' => 'Y-m-d',
'time' => 'H:i:s',
'datetime' => 'Y-m-d H:i:s',
default => throw new InvalidArgumentException('Invalid mode')
};
public function toString(string $mode = 'datetime', bool $timezone = true): string
{
switch ($mode) {
case 'date':
$format = 'Y-m-d';
break;
case 'time':
$format = 'H:i:s';
break;
case 'datetime':
$format = 'Y-m-d H:i:s';
break;
default:
throw new InvalidArgumentException('Invalid mode');
}
if ($timezone === true) {
$format .= 'P';
@@ -502,8 +525,11 @@ class Date extends DateTime
/**
* Gets or sets the year value
*
* @param int|null $year
* @return int
*/
public function year(int|null $year = null): int
public function year(?int $year = null): int
{
if ($year === null) {
return (int)$this->format('Y');
@@ -516,6 +542,9 @@ class Date extends DateTime
/**
* Ensures that the provided string is a valid unit name
*
* @param string $unit
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException
*/
protected static function validateUnit(string $unit): void

View File

@@ -8,11 +8,10 @@ use DOMDocument;
use DOMDocumentType;
use DOMElement;
use DOMNode;
use DOMNodeList;
use DOMProcessingInstruction;
use DOMText;
use DOMXPath;
use Kirby\Cms\App;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
/**
@@ -69,6 +68,12 @@ class Dom
$this->doc = new DOMDocument();
$loaderSetting = null;
if (\PHP_VERSION_ID < 80000) {
// prevent loading external entities to protect against XXE attacks;
// only needed for PHP versions before 8.0 (the function was deprecated
// as the disabled state is the new default in PHP 8.0+)
$loaderSetting = libxml_disable_entity_loader(true);
}
// switch to "user error handling"
$intErrorsSetting = libxml_use_internal_errors(true);
@@ -101,6 +106,12 @@ class Dom
$load = $this->doc->loadXML($code);
}
if (\PHP_VERSION_ID < 80000) {
// ensure that we don't alter global state by
// resetting the original value
libxml_disable_entity_loader($loaderSetting);
}
// get one error for use below and reset the global state
$error = libxml_get_last_error();
libxml_clear_errors();
@@ -122,16 +133,20 @@ class Dom
/**
* Returns the HTML body if one exists
*
* @return \DOMElement|null
*/
public function body(): DOMElement|null
public function body()
{
return $this->body ??= $this->query('/html/body')[0] ?? null;
}
/**
* Returns the document object
*
* @return \DOMDocument
*/
public function document(): DOMDocument
public function document()
{
return $this->doc;
}
@@ -139,6 +154,9 @@ class Dom
/**
* Extracts all URLs wrapped in a url() wrapper. E.g. for style attributes.
* @internal
*
* @param string $value
* @return array
*/
public static function extractUrls(string $value): array
{
@@ -163,14 +181,12 @@ class Dom
* Checks for allowed attributes according to the allowlist
* @internal
*
* @param \DOMAttr $attr
* @param array $options
* @return true|string If not allowed, an error message is returned
*/
public static function isAllowedAttr(
DOMAttr $attr,
array $options
): bool|string {
$options = static::normalizeSanitizeOptions($options);
public static function isAllowedAttr(DOMAttr $attr, array $options)
{
$allowedTags = $options['allowedTags'];
// check if the attribute is in the list of global allowed attributes
@@ -213,14 +229,12 @@ class Dom
* Checks for allowed attributes according to the global allowlist
* @internal
*
* @param \DOMAttr $attr
* @param array $options
* @return true|string If not allowed, an error message is returned
*/
public static function isAllowedGlobalAttr(
DOMAttr $attr,
array $options
): bool|string {
$options = static::normalizeSanitizeOptions($options);
public static function isAllowedGlobalAttr(DOMAttr $attr, array $options)
{
$allowedAttrs = $options['allowedAttrs'];
if ($allowedAttrs === true) {
@@ -253,14 +267,12 @@ class Dom
* Checks if the URL is acceptable for URL attributes
* @internal
*
* @param string $url
* @param array $options
* @return true|string If not allowed, an error message is returned
*/
public static function isAllowedUrl(
string $url,
array $options
): bool|string {
$options = static::normalizeSanitizeOptions($options);
public static function isAllowedUrl(string $url, array $options)
{
$url = Str::lower($url);
// allow empty URL values
@@ -280,7 +292,7 @@ class Dom
// allow site-internal URLs that didn't match the
// protocol-relative check above
if (mb_substr($url, 0, 1) === '/' && $options['allowHostRelativeUrls'] !== true) {
if (mb_substr($url, 0, 1) === '/') {
// if a CMS instance is active, only allow the URL
// if it doesn't point outside of the index URL
if ($kirby = App::instance(null, true)) {
@@ -392,6 +404,8 @@ class Dom
* Otherwise DOMDocument won't be available and the Dom cannot
* work at all.
*
* @return bool
*
* @codeCoverageIgnore
*/
public static function isSupported(): bool
@@ -401,6 +415,9 @@ class Dom
/**
* Returns the XML or HTML markup contained in the node
*
* @param \DOMNode $node
* @return string
*/
public function innerMarkup(DOMNode $node): string
{
@@ -419,21 +436,20 @@ class Dom
* the allowed namespaces
* @internal
*
* @param array $list
* @param \DOMNode $node
* @param array $options See `Dom::sanitize()`
* @param \Closure|null Comparison callback that returns whether the expected and real name match
* @return string|false Matched name in the list or `false`
*/
public static function listContainsName(
array $list,
DOMNode $node,
array $options,
Closure|null $compare = null
): string|false {
$options = static::normalizeSanitizeOptions($options);
public static function listContainsName(array $list, DOMNode $node, array $options, ?Closure $compare = null)
{
$allowedNamespaces = $options['allowedNamespaces'];
$localName = $node->localName;
$compare ??= fn ($expected, $real): bool => $expected === $real;
if ($compare === null) {
$compare = fn ($expected, $real): bool => $expected === $real;
}
// if the configuration does not define namespace URIs or if the
// currently checked node is from the special `xml:` namespace
@@ -464,7 +480,7 @@ class Dom
$namespaceUri = null;
$itemLocal = $item;
if (Str::contains($item, ':') === true) {
[$namespaceName, $itemLocal] = explode(':', $item);
list($namespaceName, $itemLocal) = explode(':', $item);
$namespaceUri = $allowedNamespaces[$namespaceName] ?? null;
} else {
// list items without namespace are from the default namespace
@@ -472,19 +488,13 @@ class Dom
}
// try if we can find an exact namespaced match
if (
$namespaceUri === $node->namespaceURI &&
$compare($itemLocal, $localName) === true
) {
if ($namespaceUri === $node->namespaceURI && $compare($itemLocal, $localName) === true) {
return $item;
}
// also try to match the fully-qualified name
// if the document doesn't define the namespace
if (
$node->namespaceURI === null &&
$compare($item, $node->nodeName) === true
) {
if ($node->namespaceURI === null && $compare($item, $node->nodeName) === true) {
return $item;
}
}
@@ -494,6 +504,9 @@ class Dom
/**
* Removes a node from the document
*
* @param \DOMNode $node
* @return void
*/
public static function remove(DOMNode $node): void
{
@@ -503,12 +516,12 @@ class Dom
/**
* Executes an XPath query in the document
*
* @param string $query
* @param \DOMNode|null $node Optional context node for relative queries
* @return \DOMNodeList|false
*/
public function query(
string $query,
DOMNode|null $node = null
): DOMNodeList|false {
public function query(string $query, ?DOMNode $node = null)
{
return (new DOMXPath($this->doc))->query($query, $node);
}
@@ -525,9 +538,6 @@ class Dom
* or `true` for any
* - `allowedDomains`: Allowed hostnames for HTTP(S) URLs in `urlAttrs`
* and inside `url()` wrappers or `true` for any
* - `allowHostRelativeUrls`: Whether URLs that begin with `/` should be
* allowed even if the site index URL is in a subfolder (useful when using
* the HTML `<base>` element where the sanitized code will be rendered)
* - `allowedNamespaces`: Associative array of all allowed namespace URIs;
* the array keys are reference names that can be referred to from the
* `allowedAttrPrefixes`, `allowedAttrs`, `allowedTags`, `disallowedTags`
@@ -558,7 +568,20 @@ class Dom
*/
public function sanitize(array $options): array
{
$options = static::normalizeSanitizeOptions($options);
$options = array_merge([
'allowedAttrPrefixes' => [],
'allowedAttrs' => true,
'allowedDataUris' => true,
'allowedDomains' => true,
'allowedNamespaces' => true,
'allowedPIs' => true,
'allowedTags' => true,
'attrCallback' => null,
'disallowedTags' => [],
'doctypeCallback' => null,
'elementCallback' => null,
'urlAttrs' => ['href', 'src', 'xlink:href'],
], $options);
$errors = [];
@@ -566,7 +589,7 @@ class Dom
// convert the `DOMNodeList` to an array first, otherwise removing
// nodes would shift the list and make subsequent operations fail
foreach (iterator_to_array($this->doc->childNodes, false) as $child) {
if ($child instanceof DOMDocumentType) {
if (is_a($child, 'DOMDocumentType') === true) {
$this->sanitizeDoctype($child, $options, $errors);
}
}
@@ -593,6 +616,7 @@ class Dom
* is exported with an XML declaration/
* full HTML markup even if the input
* didn't have them
* @return string
*/
public function toString(bool $normalize = false): string
{
@@ -613,13 +637,16 @@ class Dom
/**
* Removes a node from the document but keeps its children
* by moving them one level up
*
* @param \DOMNode $node
* @return void
*/
public static function unwrap(DOMNode $node): void
{
foreach ($node->childNodes as $childNode) {
// discard text nodes as they can be unexpected
// directly in the parent element
if ($childNode instanceof DOMText) {
if (is_a($childNode, 'DOMText') === true) {
continue;
}
@@ -635,6 +662,7 @@ class Dom
* @param bool $normalize If set to `true`, the document
* is exported with full HTML markup
* even if the input didn't have it
* @return string
*/
protected function exportHtml(bool $normalize = false): string
{
@@ -643,7 +671,7 @@ class Dom
$metaTag = $this->doc->createElement('meta');
$metaTag->setAttribute('http-equiv', 'Content-Type');
$metaTag->setAttribute('content', 'text/html; charset=utf-8');
$metaTag->setAttribute('id', Str::random(10));
$metaTag->setAttribute('id', $metaId = Str::random(10));
$this->doc->insertBefore($metaTag, $this->doc->documentElement);
if (
@@ -674,13 +702,11 @@ class Dom
* @param bool $normalize If set to `true`, the document
* is exported with an XML declaration
* even if the input didn't have it
* @return string
*/
protected function exportXml(bool $normalize = false): string
{
if (
Str::contains($this->code, '<?xml ', true) === false &&
$normalize === false
) {
if (Str::contains($this->code, '<?xml ', true) === false && $normalize === false) {
// the input didn't contain an XML declaration;
// only return child nodes, which omits it
$result = [];
@@ -694,52 +720,23 @@ class Dom
// ensure that the document is encoded as UTF-8
// unless a different encoding was specified in
// the input or before exporting
$this->doc->encoding ??= 'UTF-8';
if ($this->doc->encoding === null) {
$this->doc->encoding = 'UTF-8';
}
return trim($this->doc->saveXML());
}
/**
* Ensures that all options are set in the user-provided
* options array (otherwise setting the default option)
*/
protected static function normalizeSanitizeOptions(array $options): array
{
// increase performance for already normalized option arrays
if (($options['_normalized'] ?? false) === true) {
return $options;
}
return [
'allowedAttrPrefixes' => [],
'allowedAttrs' => true,
'allowedDataUris' => true,
'allowedDomains' => true,
'allowHostRelativeUrls' => true,
'allowedNamespaces' => true,
'allowedPIs' => true,
'allowedTags' => true,
'attrCallback' => null,
'disallowedTags' => [],
'doctypeCallback' => null,
'elementCallback' => null,
'urlAttrs' => ['href', 'src', 'xlink:href'],
...$options,
'_normalized' => true
];
}
/**
* Sanitizes an attribute
*
* @param \DOMAttr $attr
* @param array $options See `Dom::sanitize()`
* @param array $errors Array to store additional errors in by reference
* @return void
*/
protected function sanitizeAttr(
DOMAttr $attr,
array $options,
array &$errors
): void {
protected function sanitizeAttr(DOMAttr $attr, array $options, array &$errors): void
{
$element = $attr->ownerElement;
$name = $attr->nodeName;
$value = $attr->value;
@@ -781,14 +778,13 @@ class Dom
/**
* Sanitizes the doctype
*
* @param \DOMDocumentType $doctype
* @param array $options See `Dom::sanitize()`
* @param array $errors Array to store additional errors in by reference
* @return void
*/
protected function sanitizeDoctype(
DOMDocumentType $doctype,
array $options,
array &$errors
): void {
protected function sanitizeDoctype(DOMDocumentType $doctype, array $options, array &$errors): void
{
try {
$this->validateDoctype($doctype, $options);
} catch (InvalidArgumentException $e) {
@@ -800,14 +796,13 @@ class Dom
/**
* Sanitizes a single DOM element and its attribute
*
* @param \DOMElement $element
* @param array $options See `Dom::sanitize()`
* @param array $errors Array to store additional errors in by reference
* @return void
*/
protected function sanitizeElement(
DOMElement $element,
array $options,
array &$errors
): void {
protected function sanitizeElement(DOMElement $element, array $options, array &$errors): void
{
$name = $element->nodeName;
// check defined namespaces (`xmlns` attributes);
@@ -869,28 +864,27 @@ class Dom
// custom check (if the attribute is still in the document)
if ($attr->ownerElement !== null && $options['attrCallback']) {
$errors = array_merge($errors, $options['attrCallback']($attr, $options) ?? []);
$errors = array_merge($errors, $options['attrCallback']($attr) ?? []);
}
}
}
// custom check
if ($options['elementCallback']) {
$errors = array_merge($errors, $options['elementCallback']($element, $options) ?? []);
$errors = array_merge($errors, $options['elementCallback']($element) ?? []);
}
}
/**
* Sanitizes a single XML processing instruction
*
* @param \DOMProcessingInstruction $pi
* @param array $options See `Dom::sanitize()`
* @param array $errors Array to store additional errors in by reference
* @return void
*/
protected function sanitizePI(
DOMProcessingInstruction $pi,
array $options,
array &$errors
): void {
protected function sanitizePI(DOMProcessingInstruction $pi, array $options, array &$errors): void
{
$name = $pi->nodeName;
// check for allow-listed processing instructions
@@ -906,18 +900,15 @@ class Dom
/**
* Validates the document type
*
* @param \DOMDocumentType $doctype
* @param array $options See `Dom::sanitize()`
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If the doctype is not valid
*/
protected function validateDoctype(
DOMDocumentType $doctype,
array $options
): void {
if (
empty($doctype->publicId) === false ||
empty($doctype->systemId) === false
) {
protected function validateDoctype(DOMDocumentType $doctype, array $options): void
{
if (empty($doctype->publicId) === false || empty($doctype->systemId) === false) {
throw new InvalidArgumentException('The doctype must not reference external files');
}
@@ -926,7 +917,7 @@ class Dom
}
if ($options['doctypeCallback']) {
$options['doctypeCallback']($doctype, $options);
$options['doctypeCallback']($doctype);
}
}
}

View File

@@ -25,8 +25,10 @@ class Escape
{
/**
* The internal singleton escaper instance
*
* @var \Laminas\Escaper\Escaper
*/
protected static Escaper|null $escaper = null;
protected static $escaper;
/**
* Escape common HTML attributes data
@@ -42,8 +44,11 @@ class Escape
* <div attr=...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...>content</div>
* <div attr='...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...'>content</div>
* <div attr="...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...">content</div>
*
* @param string $string
* @return string
*/
public static function attr(string $string): string
public static function attr($string)
{
return static::escaper()->escapeHtmlAttr($string);
}
@@ -60,16 +65,21 @@ class Escape
* <style>selector { property : ...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...; } </style>
* <style>selector { property : "...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE..."; } </style>
* <span style="property : ...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...">text</span>
*
* @param string $string
* @return string
*/
public static function css(string $string): string
public static function css($string)
{
return static::escaper()->escapeCss($string);
}
/**
* Get the escaper instance (and create if needed)
*
* @return \Laminas\Escaper\Escaper
*/
protected static function escaper(): Escaper
protected static function escaper()
{
return static::$escaper ??= new Escaper('utf-8');
}
@@ -85,8 +95,11 @@ class Escape
*
* <body>...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...</body>
* <div>...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...</div>
*
* @param string $string
* @return string
*/
public static function html(string $string): string
public static function html($string)
{
return static::escaper()->escapeHtml($string);
}
@@ -100,8 +113,11 @@ class Escape
* <script>alert('...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...')</script>
* <script>x='...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...'</script>
* <div onmouseover="x='...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...'"</div>
*
* @param string $string
* @return string
*/
public static function js(string $string): string
public static function js($string)
{
return static::escaper()->escapeJs($string);
}
@@ -113,8 +129,11 @@ class Escape
* This should not be used to escape an entire URI.
*
* <a href="http://www.somesite.com?test=...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...">link</a>
*
* @param string $string
* @return string
*/
public static function url(string $string): string
public static function url($string)
{
return rawurlencode($string);
}
@@ -132,8 +151,11 @@ class Escape
* & is replaced with &amp;
* < is replaced with &lt;
* > is replaced with &gt;
*
* @param string $string
* @return string
*/
public static function xml(string $string): string
public static function xml($string)
{
return htmlspecialchars($string, ENT_QUOTES | ENT_XML1, 'UTF-8');
}

View File

@@ -17,11 +17,17 @@ abstract class Facade
/**
* Returns the instance that should be
* available statically
*
* @return mixed
*/
abstract public static function instance();
/**
* Proxy for all public instance calls
*
* @param string $method
* @param array $args
* @return mixed
*/
public static function __callStatic(string $method, array $args = null)
{

View File

@@ -19,13 +19,17 @@ class Html extends Xml
{
/**
* An internal store for an HTML entities translation table
*
* @var array
*/
public static array|null $entities = null;
public static $entities;
/**
* List of HTML tags that can be used inline
*
* @var array
*/
public static array $inlineList = [
public static $inlineList = [
'b',
'i',
'small',
@@ -90,11 +94,10 @@ class Html extends Xml
*
* @param string $tag Tag name
* @param array $arguments Further arguments for the Html::tag() method
* @return string
*/
public static function __callStatic(
string $tag,
array $arguments = []
): string {
public static function __callStatic(string $tag, array $arguments = []): string
{
if (static::isVoid($tag) === true) {
return static::tag($tag, null, ...$arguments);
}
@@ -134,22 +137,13 @@ class Html extends Xml
* @param string|null $after An optional string that will be appended if the result is not empty
* @return string|null The generated HTML attributes string
*/
public static function attr(
string|array $name,
$value = null,
string|null $before = null,
string|null $after = null
): string|null {
public static function attr($name, $value = null, ?string $before = null, ?string $after = null): ?string
{
// HTML supports boolean attributes without values
if (is_array($name) === false && is_bool($value) === true) {
return $value === true ? strtolower($name) : null;
}
// HTML attribute names are case-insensitive
if (is_string($name) === true) {
$name = strtolower($name);
}
// all other cases can share the XML variant
$attr = parent::attr($name, $value);
@@ -172,6 +166,9 @@ class Html extends Xml
/**
* Converts lines in a string into HTML breaks
*
* @param string $string
* @return string
*/
public static function breaks(string $string): string
{
@@ -186,11 +183,8 @@ class Html extends Xml
* @param array $attr Additional attributes for the tag
* @return string The generated HTML
*/
public static function email(
string $email,
string|array|null $text = null,
array $attr = []
): string {
public static function email(string $email, $text = null, array $attr = []): string
{
if (empty($email) === true) {
return '';
}
@@ -219,15 +213,14 @@ class Html extends Xml
/**
* Converts a string to an HTML-safe string
*
* @param string|null $string
* @param bool $keepTags If true, existing tags won't be escaped
* @return string The HTML string
*
* @psalm-suppress ParamNameMismatch
*/
public static function encode(
string|null $string,
bool $keepTags = false
): string {
public static function encode(?string $string, bool $keepTags = false): string
{
if ($string === null) {
return '';
}
@@ -247,6 +240,8 @@ class Html extends Xml
/**
* Returns the entity translation table
*
* @return array
*/
public static function entities(): array
{
@@ -261,11 +256,8 @@ class Html extends Xml
* @param array $attr Additional attributes for the `<figure>` tag
* @return string The generated HTML
*/
public static function figure(
string|array $content,
string|array|null $caption = '',
array $attr = []
): string {
public static function figure($content, $caption = '', array $attr = []): string
{
if ($caption) {
$figcaption = static::tag('figcaption', $caption);
@@ -287,15 +279,12 @@ class Html extends Xml
* @param array $attr Additional attributes for the `<script>` tag
* @return string The generated HTML
*/
public static function gist(
string $url,
string|null $file = null,
array $attr = []
): string {
$src = $url . '.js';
if ($file !== null) {
$src .= '?file=' . $file;
public static function gist(string $url, ?string $file = null, array $attr = []): string
{
if ($file === null) {
$src = $url . '.js';
} else {
$src = $url . '.js?file=' . $file;
}
return static::tag('script', '', array_merge($attr, ['src' => $src]));
@@ -304,6 +293,7 @@ class Html extends Xml
/**
* Creates an `<iframe>`
*
* @param string $src
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string The generated HTML
*/
@@ -323,7 +313,7 @@ class Html extends Xml
{
$attr = array_merge([
'src' => $src,
'alt' => ''
'alt' => ' '
], $attr);
return static::tag('img', '', $attr);
@@ -331,6 +321,9 @@ class Html extends Xml
/**
* Checks if a tag is self-closing
*
* @param string $tag
* @return bool
*/
public static function isVoid(string $tag): bool
{
@@ -345,11 +338,8 @@ class Html extends Xml
* @param array $attr Additional attributes for the tag
* @return string The generated HTML
*/
public static function link(
string $href,
string|array|null $text = null,
array $attr = []
): string {
public static function link(string $href, $text = null, array $attr = []): string
{
$attr = array_merge(['href' => $href], $attr);
if (empty($text) === true) {
@@ -367,16 +357,14 @@ class Html extends Xml
}
/**
* Add noreferrer to rels when target is `_blank`
* Add noopener & noreferrer to rels when target is `_blank`
*
* @param string|null $rel Current `rel` value
* @param string|null $target Current `target` value
* @return string|null New `rel` value or `null` if not needed
*/
public static function rel(
string|null $rel = null,
string|null $target = null
): string|null {
public static function rel(?string $rel = null, ?string $target = null): ?string
{
$rel = trim($rel ?? '');
if ($target === '_blank') {
@@ -384,10 +372,10 @@ class Html extends Xml
return $rel;
}
return trim($rel . ' noreferrer', ' ');
return trim($rel . ' noopener noreferrer', ' ');
}
return $rel ?: null;
return $rel;
}
/**
@@ -401,16 +389,13 @@ class Html extends Xml
* @param int $level Indentation level
* @return string The generated HTML
*/
public static function tag(
string $name,
array|string|null $content = '',
array $attr = [],
string $indent = null,
int $level = 0
): string {
public static function tag(string $name, $content = '', array $attr = null, string $indent = null, int $level = 0): string
{
// treat an explicit `null` value as an empty tag
// as void tags are already covered below
$content ??= '';
if ($content === null) {
$content = '';
}
// force void elements to be self-closing
if (static::isVoid($name) === true) {
@@ -428,11 +413,8 @@ class Html extends Xml
* @param array $attr Additional attributes for the tag
* @return string The generated HTML
*/
public static function tel(
string $tel,
string|array|null $text = null,
array $attr = []
): string {
public static function tel(string $tel, $text = null, array $attr = []): string
{
$number = preg_replace('![^0-9\+]+!', '', $tel);
if (empty($text) === true) {
@@ -444,8 +426,11 @@ class Html extends Xml
/**
* Properly encodes tag contents
*
* @param mixed $value
* @return string|null
*/
public static function value($value): string|null
public static function value($value): ?string
{
if ($value === true) {
return 'true';
@@ -477,11 +462,8 @@ class Html extends Xml
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string|null The generated HTML
*/
public static function video(
string $url,
array $options = [],
array $attr = []
): string|null {
public static function video(string $url, array $options = [], array $attr = []): ?string
{
// YouTube video
if (Str::contains($url, 'youtu', true) === true) {
return static::youtube($url, $options['youtube'] ?? [], $attr);
@@ -513,6 +495,9 @@ class Html extends Xml
/**
* Generates a list of attributes
* for video iframes
*
* @param array $attr
* @return array
*/
public static function videoAttr(array $attr = []): array
{
@@ -537,20 +522,22 @@ class Html extends Xml
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string|null The generated HTML
*/
public static function vimeo(
string $url,
array $options = [],
array $attr = []
): string|null {
public static function vimeo(string $url, array $options = [], array $attr = []): ?string
{
$uri = new Uri($url);
$path = $uri->path();
$query = $uri->query();
$id = null;
$id = match ($uri->host()) {
'vimeo.com', 'www.vimeo.com' => $path->last(),
'player.vimeo.com' => $path->nth(1),
default => null
};
switch ($uri->host()) {
case 'vimeo.com':
case 'www.vimeo.com':
$id = $path->last();
break;
case 'player.vimeo.com':
$id = $path->nth(1);
break;
}
if (empty($id) === true || preg_match('!^[0-9]*$!', $id) !== 1) {
return null;
@@ -575,11 +562,8 @@ class Html extends Xml
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string|null The generated HTML
*/
public static function youtube(
string $url,
array $options = [],
array $attr = []
): string|null {
public static function youtube(string $url, array $options = [], array $attr = []): ?string
{
if (preg_match('!youtu!i', $url) !== 1) {
return null;
}
@@ -592,12 +576,12 @@ class Html extends Xml
$host = 'https://' . $uri->host() . '/embed';
$src = null;
$isYoutubeId = function (string|null $id = null): bool {
$isYoutubeId = function (?string $id = null): bool {
if (empty($id) === true) {
return false;
}
return preg_match('!^[a-zA-Z0-9_-]+$!', $id) === 1;
return preg_match('!^[a-zA-Z0-9_-]+$!', $id);
};
switch ($path->toString()) {
@@ -623,19 +607,14 @@ class Html extends Xml
default:
// short URLs
if (
Str::contains($uri->host(), 'youtu.be') === true &&
$isYoutubeId($first) === true
) {
if (Str::contains($uri->host(), 'youtu.be') === true && $isYoutubeId($first) === true) {
$src = 'https://www.youtube.com/embed/' . $first;
$query->start = $query->t;
unset($query->t);
} elseif (
in_array($first, ['embed', 'shorts']) === true &&
$isYoutubeId($second) === true
) {
// embedded and shorts video URLs
// embedded video URLs
} elseif ($first === 'embed' && $isYoutubeId($second) === true) {
$src = $host . '/' . $second;
}
}
@@ -650,7 +629,7 @@ class Html extends Xml
}
// build the full video src URL
$src .= $query->toString(true);
$src = $src . $query->toString(true);
// render the iframe
return static::iframe($src, static::videoAttr($attr));

View File

@@ -18,53 +18,68 @@ class I18n
{
/**
* Custom loader function
*
* @var Closure
*/
public static Closure|null $load = null;
public static $load = null;
/**
* Current locale
*
* @var string|\Closure
*/
public static string|Closure|null $locale = 'en';
public static $locale = 'en';
/**
* All registered translations
*
* @var array
*/
public static array $translations = [];
public static $translations = [];
/**
* The fallback locale or a list of fallback locales
* The fallback locale or a
* list of fallback locales
*
* @var string|array|\Closure
*/
public static string|array|Closure|null $fallback = ['en'];
public static $fallback = ['en'];
/**
* Cache of `NumberFormatter` objects by locale
*
* @var array
*/
protected static array $decimalsFormatters = [];
protected static $decimalsFormatters = [];
/**
* Returns the list of fallback locales
*
* @return array
*/
public static function fallbacks(): array
{
if (is_callable(static::$fallback) === true) {
static::$fallback = (static::$fallback)();
}
if (is_array(static::$fallback) === true) {
return static::$fallback;
}
if (is_string(static::$fallback) === true) {
if (
is_array(static::$fallback) === true ||
is_string(static::$fallback) === true
) {
return A::wrap(static::$fallback);
}
if (is_callable(static::$fallback) === true) {
return static::$fallback = A::wrap((static::$fallback)());
}
return static::$fallback = ['en'];
}
/**
* Returns singular or plural depending on the given number
* Returns singular or plural
* depending on the given number
*
* @param int $count
* @param bool $none If true, 'none' will be returned if the count is 0
* @return string
*/
public static function form(int $count, bool $none = false): string
{
@@ -77,41 +92,105 @@ class I18n
/**
* Formats a number
*
* @param int|float $number
* @param string $locale
* @return string
*/
public static function formatNumber(int|float $number, string $locale = null): string
public static function formatNumber($number, string $locale = null): string
{
$locale ??= static::locale();
$locale ??= static::locale();
$formatter = static::decimalNumberFormatter($locale);
$number = $formatter?->format($number) ?? $number;
if ($formatter !== null) {
$number = $formatter->format($number);
}
return (string)$number;
}
/**
* Returns the current locale code
* Returns the locale code
*
* @return string
*/
public static function locale(): string
{
if (is_callable(static::$locale) === true) {
static::$locale = (static::$locale)();
}
if (is_string(static::$locale) === true) {
return static::$locale;
}
if (is_callable(static::$locale) === true) {
return static::$locale = (static::$locale)();
}
return static::$locale = 'en';
}
/**
* Translates a given message
* according to the currently set locale
*
* @param string|array $key
* @param string|array|null $fallback
* @param string|null $locale
* @return string|array|null
*/
public static function translate($key, $fallback = null, string $locale = null)
{
$locale ??= static::locale();
if (is_array($key) === true) {
// try to use actual locale
if (isset($key[$locale])) {
return $key[$locale];
}
// try to use language code, e.g. `es` when locale is `es_ES`
$lang = Str::before($locale, '_');
if (isset($key[$lang])) {
return $key[$lang];
}
// use fallback
if (is_array($fallback)) {
return $fallback[$locale] ?? $fallback['en'] ?? reset($fallback);
}
return $fallback;
}
if ($translation = static::translation($locale)[$key] ?? null) {
return $translation;
}
if ($fallback !== null) {
return $fallback;
}
foreach (static::fallbacks() as $fallback) {
// skip locales we have already tried
if ($locale === $fallback) {
continue;
}
if ($translation = static::translation($fallback)[$key] ?? null) {
return $translation;
}
}
return null;
}
/**
* Translate by key and then replace
* placeholders in the text
*
* @param string $key
* @param string|array|null $fallback
* @param array|null $replace
* @param string|null $locale
* @return string
*/
public static function template(
string $key,
string|array $fallback = null,
array|null $replace = null,
string|null $locale = null
): string {
public static function template(string $key, $fallback = null, ?array $replace = null, ?string $locale = null): string
{
if (is_array($fallback) === true) {
$replace = $fallback;
$fallback = null;
@@ -119,144 +198,37 @@ class I18n
}
$template = static::translate($key, $fallback, $locale);
return Str::template($template, $replace, ['fallback' => '-']);
}
/**
* Translates either a given i18n key from global translations
* or chooses correct entry from array of translations
* according to the currently set locale
*/
public static function translate(
string|array|null $key,
string|array $fallback = null,
string $locale = null
): string|array|Closure|null {
// use current locale if no specific is passed
$locale ??= static::locale();
// create shorter locale code, e.g. `es` for `es_ES` locale
$shortLocale = Str::before($locale, '_');
// There are two main use cases that we will treat separately:
// (1) with a string representing an i18n key to be looked up
// (2) an array with entries per locale
//
// Both with various ways of handling fallbacks, provided
// explicitly via the parameter and/or from global defaults.
// (1) string $key: look up i18n string from global translations
if (is_string($key) === true) {
// look up locale in global translations list,
if ($result = static::translation($locale)[$key] ?? null) {
return $result;
}
// prefer any direct provided $fallback
// over further fallback alternatives
if ($fallback !== null) {
if (is_array($fallback) === true) {
return static::translate($fallback, null, $locale);
}
return $fallback;
}
// last resort: try using the fallback locales
foreach (static::fallbacks() as $fallback) {
// skip locale if we have already tried to save performance
if ($locale === $fallback) {
continue;
}
if ($result = static::translation($fallback)[$key] ?? null) {
return $result;
}
}
return null;
}
// --------
// (2) array|null $key with entries per locale
// try entry for long and short locale
if ($result = $key[$locale] ?? null) {
return $result;
}
if ($result = $key[$shortLocale] ?? null) {
return $result;
}
// if the array as a global wildcard entry,
// use this one as i18n key and try to resolve
// this via part (1) of this method
if ($wildcard = $key['*'] ?? null) {
if ($result = static::translate($wildcard, $wildcard, $locale)) {
return $result;
}
}
// if the $fallback parameter is an array, we can assume
// that it's also an array with entries per locale:
// check with long and short locale if we find a matching entry
if ($result = $fallback[$locale] ?? null) {
return $result;
}
if ($result = $fallback[$shortLocale] ?? null) {
return $result;
}
// all options for long/short actual locale have been exhausted,
// revert to the list of fallback locales and try with each of them
foreach (static::fallbacks() as $locale) {
// first on the original input
if ($result = $key[$locale] ?? null) {
return $result;
}
// then on the fallback
if ($result = $fallback[$locale] ?? null) {
return $result;
}
}
// if a string was provided as fallback, use that one
if (is_string($fallback) === true) {
return $fallback;
}
// otherwise the first array element of the input
// or the first array element of the fallback
if (is_array($key) === true) {
return reset($key);
}
if (is_array($fallback) === true) {
return reset($fallback);
}
return null;
return Str::template($template, $replace, [
'fallback' => '-',
'start' => '{',
'end' => '}'
]);
}
/**
* Returns the current or any other translation
* by locale. If the translation does not exist
* yet, the loader will try to load it, if defined.
*
* @param string|null $locale
* @return array
*/
public static function translation(string $locale = null): array
{
$locale ??= static::locale();
if ($translation = static::$translations[$locale] ?? null) {
return $translation;
if (isset(static::$translations[$locale]) === true) {
return static::$translations[$locale];
}
if (static::$load instanceof Closure) {
if (is_a(static::$load, 'Closure') === true) {
return static::$translations[$locale] = (static::$load)($locale);
}
// try to use language code, e.g. `es` when locale is `es_ES`
if ($translation = static::$translations[Str::before($locale, '_')] ?? null) {
return $translation;
$lang = Str::before($locale, '_');
if (isset(static::$translations[$lang]) === true) {
return static::$translations[$lang];
}
return static::$translations[$locale] = [];
@@ -264,6 +236,8 @@ class I18n
/**
* Returns all loaded or defined translations
*
* @return array
*/
public static function translations(): array
{
@@ -272,17 +246,16 @@ class I18n
/**
* Returns (and creates) a decimal number formatter for a given locale
*
* @return \NumberFormatter|null
*/
protected static function decimalNumberFormatter(string $locale): NumberFormatter|null
protected static function decimalNumberFormatter(string $locale): ?NumberFormatter
{
if ($formatter = static::$decimalsFormatters[$locale] ?? null) {
return $formatter;
if (isset(static::$decimalsFormatters[$locale])) {
return static::$decimalsFormatters[$locale];
}
if (
extension_loaded('intl') !== true ||
class_exists('NumberFormatter') !== true
) {
if (extension_loaded('intl') !== true || class_exists('NumberFormatter') !== true) {
return null; // @codeCoverageIgnore
}
@@ -299,14 +272,14 @@ class I18n
* defined, the template that is defined last in the translation array is used
* - Translation is a callback with a `$count` argument: Returns the callback return value
*
* @param string $key
* @param int $count
* @param string|null $locale
* @param bool $formatNumber If set to `false`, the count is not formatted
* @return mixed
*/
public static function translateCount(
string $key,
int $count,
string $locale = null,
bool $formatNumber = true
) {
public static function translateCount(string $key, int $count, string $locale = null, bool $formatNumber = true)
{
$locale ??= static::locale();
$translation = static::translate($key, null, $locale);
@@ -314,20 +287,22 @@ class I18n
return null;
}
if ($translation instanceof Closure) {
if (is_a($translation, 'Closure') === true) {
return $translation($count);
}
$message = match (true) {
is_string($translation) => $translation,
isset($translation[$count]) => $translation[$count],
default => end($translation)
};
if (is_string($translation) === true) {
$message = $translation;
} elseif (isset($translation[$count]) === true) {
$message = $translation[$count];
} else {
$message = end($translation);
}
if ($formatNumber === true) {
$count = static::formatNumber($count, $locale);
}
return Str::template($message, compact('count'));
return str_replace('{{ count }}', $count, $message);
}
}

View File

@@ -15,16 +15,21 @@ use IteratorAggregate;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* @psalm-suppress MissingTemplateParam Implementing template params
* in this class would require
* implementing them throughout
* the code base: https://github.com/getkirby/kirby/pull/4886#pullrequestreview-1203577545
*/
class Iterator implements IteratorAggregate
{
public array $data = [];
/**
* The data array
*
* @var array
*/
public $data = [];
/**
* Constructor
*
* @param array $data
*/
public function __construct(array $data = [])
{
$this->data = $data;
@@ -32,6 +37,8 @@ class Iterator implements IteratorAggregate
/**
* Get an iterator for the items.
*
* @return \ArrayIterator
*/
public function getIterator(): ArrayIterator
{
@@ -40,14 +47,18 @@ class Iterator implements IteratorAggregate
/**
* Returns the current key
*
* @return string
*/
public function key(): int|string|null
public function key()
{
return key($this->data);
}
/**
* Returns an array of all keys
*
* @return array
*/
public function keys(): array
{
@@ -56,8 +67,10 @@ class Iterator implements IteratorAggregate
/**
* Returns the current element
*
* @return mixed
*/
public function current(): mixed
public function current()
{
return current($this->data);
}
@@ -65,8 +78,10 @@ class Iterator implements IteratorAggregate
/**
* Moves the cursor to the previous element
* and returns it
*
* @return mixed
*/
public function prev(): mixed
public function prev()
{
return prev($this->data);
}
@@ -74,8 +89,10 @@ class Iterator implements IteratorAggregate
/**
* Moves the cursor to the next element
* and returns it
*
* @return mixed
*/
public function next(): mixed
public function next()
{
return next($this->data);
}
@@ -83,13 +100,15 @@ class Iterator implements IteratorAggregate
/**
* Moves the cursor to the first element
*/
public function rewind(): void
public function rewind()
{
reset($this->data);
}
/**
* Checks if the current element is valid
*
* @return bool
*/
public function valid(): bool
{
@@ -98,6 +117,8 @@ class Iterator implements IteratorAggregate
/**
* Counts all elements
*
* @return int
*/
public function count(): int
{
@@ -110,7 +131,7 @@ class Iterator implements IteratorAggregate
* @param mixed $needle the element to search for
* @return int|false the index (int) of the element or false
*/
public function indexOf(mixed $needle): int|false
public function indexOf($needle)
{
return array_search($needle, array_values($this->data));
}
@@ -119,32 +140,39 @@ class Iterator implements IteratorAggregate
* Tries to find the key for the given element
*
* @param mixed $needle the element to search for
* @return int|string|false the name of the key or false
* @return string|false the name of the key or false
*/
public function keyOf(mixed $needle): int|string|false
public function keyOf($needle)
{
return array_search($needle, $this->data);
}
/**
* Checks by key if an element is included
*
* @param mixed $key
* @return bool
*/
public function has(mixed $key): bool
public function has($key): bool
{
return isset($this->data[$key]) === true;
return isset($this->data[$key]);
}
/**
* Checks if the current key is set
*
* @param mixed $key the key to check
* @return bool
*/
public function __isset(mixed $key): bool
public function __isset($key): bool
{
return $this->has($key);
}
/**
* Simplified var_dump output
* @codeCoverageIgnore
*
* @return array
*/
public function __debugInfo(): array
{

View File

@@ -1,48 +0,0 @@
<?php
namespace Kirby\Toolkit;
use Closure;
/**
* Store a lazy value (safe from processing inside a closure)
* in this class wrapper to also protect it from being unwrapped
* by normal `Closure`/`is_callable()` checks
*
* @package Kirby Toolkit
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class LazyValue
{
public function __construct(
protected Closure $value
) {
}
/**
* Resolve the lazy value to its actual value
*/
public function resolve(mixed ...$args): mixed
{
return call_user_func_array($this->value, $args);
}
/**
* Unwrap a single value or an array of values
*/
public static function unwrap(mixed $data, mixed ...$args): mixed
{
if (is_array($data) === true) {
return A::map($data, fn ($value) => static::unwrap($value, $args));
}
if ($data instanceof static) {
return $data->resolve(...$args);
}
return $data;
}
}

View File

@@ -21,28 +21,32 @@ class Locale
* List of all locale constants supported by PHP
*/
public const LOCALE_CONSTANTS = [
'LC_COLLATE',
'LC_CTYPE',
'LC_MONETARY',
'LC_NUMERIC',
'LC_TIME',
'LC_MESSAGES'
'LC_COLLATE', 'LC_CTYPE', 'LC_MONETARY',
'LC_NUMERIC', 'LC_TIME', 'LC_MESSAGES'
];
/**
* Converts a normalized locale array to an array with the
* locale constants replaced with their string representations
*
* @param array $locale
* @return array
*/
public static function export(array $locale): array
{
$return = [];
$constants = static::supportedConstants(true);
// replace the keys in the locale data array with the locale names
$return = [];
foreach ($locale as $key => $value) {
// use string representation for key
// if it is a valid constant
$return[$constants[$key] ?? $key] = $value;
if (isset($constants[$key]) === true) {
// the key is a valid constant,
// replace it with its string representation
$return[$constants[$key]] = $value;
} else {
// not found, keep it as-is
$return[$key] = $value;
}
}
return $return;
@@ -59,7 +63,7 @@ class Locale
* @throws \Kirby\Exception\Exception If the locale cannot be determined
* @throws \Kirby\Exception\InvalidArgumentException If the provided locale category is invalid
*/
public static function get(int|string $category = LC_ALL): array|string
public static function get($category = LC_ALL)
{
$normalizedCategory = static::normalizeConstant($category);
@@ -101,10 +105,11 @@ class Locale
* string keys to a normalized constant => value array
*
* @param array|string $locale
* @return array
*/
public static function normalize($locale): array
{
if (is_array($locale) === true) {
if (is_array($locale)) {
// replace string constant keys with the constant values
$convertedLocale = [];
foreach ($locale as $key => $value) {
@@ -112,20 +117,21 @@ class Locale
}
return $convertedLocale;
}
if (is_string($locale) === true) {
} elseif (is_string($locale)) {
return [LC_ALL => $locale];
} else {
throw new InvalidArgumentException('Locale must be string or array');
}
throw new InvalidArgumentException('Locale must be string or array');
}
/**
* Sets the PHP locale with a locale string or
* an array with constant or string keys
*
* @param array|string $locale
* @return void
*/
public static function set(array|string $locale): void
public static function set($locale): void
{
$locale = static::normalize($locale);
@@ -146,13 +152,13 @@ class Locale
/**
* Tries to convert an `LC_*` constant name
* to its constant value
*
* @param int|string $constant
* @return int|string
*/
protected static function normalizeConstant(int|string $constant): int|string
protected static function normalizeConstant($constant)
{
if (
is_string($constant) === true &&
Str::startsWith($constant, 'LC_') === true
) {
if (is_string($constant) === true && Str::startsWith($constant, 'LC_') === true) {
return constant($constant);
}
@@ -165,6 +171,7 @@ class Locale
* that are actually supported on this system
*
* @param bool $withAll If set to `true`, `LC_ALL` is returned as well
* @return array
*/
protected static function supportedConstants(bool $withAll = false): array
{

View File

@@ -17,6 +17,11 @@ use stdClass;
*/
class Obj extends stdClass
{
/**
* Constructor
*
* @param array $data
*/
public function __construct(array $data = [])
{
foreach ($data as $key => $val) {
@@ -26,6 +31,10 @@ class Obj extends stdClass
/**
* Magic getter
*
* @param string $property
* @param array $arguments
* @return mixed
*/
public function __call(string $property, array $arguments)
{
@@ -34,7 +43,8 @@ class Obj extends stdClass
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*
* @return array
*/
public function __debugInfo(): array
{
@@ -43,6 +53,9 @@ class Obj extends stdClass
/**
* Magic property getter
*
* @param string $property
* @return mixed
*/
public function __get(string $property)
{
@@ -52,15 +65,19 @@ class Obj extends stdClass
/**
* Gets one or multiple properties of the object
*
* @param string|array $property
* @param mixed $fallback If multiple properties are requested:
* Associative array of fallback values per key
* @return mixed
*/
public function get(string|array $property, $fallback = null)
public function get($property, $fallback = null)
{
if (is_array($property)) {
$fallback ??= [];
if ($fallback === null) {
$fallback = [];
}
if (is_array($fallback) === false) {
if (!is_array($fallback)) {
throw new InvalidArgumentException('The fallback value must be an array when getting multiple properties');
}
@@ -76,16 +93,15 @@ class Obj extends stdClass
/**
* Converts the object to an array
*
* @return array
*/
public function toArray(): array
{
$result = [];
foreach ((array)$this as $key => $value) {
if (
is_object($value) === true &&
method_exists($value, 'toArray')
) {
if (is_object($value) === true && method_exists($value, 'toArray')) {
$result[$key] = $value->toArray();
} else {
$result[$key] = $value;
@@ -97,6 +113,9 @@ class Obj extends stdClass
/**
* Converts the object to a json string
*
* @param mixed ...$arguments
* @return string
*/
public function toJson(...$arguments): string
{

View File

@@ -16,105 +16,100 @@ use Kirby\Exception\Exception;
*/
class Pagination
{
use Properties {
setProperties as protected baseSetProperties;
}
/**
* The current page
*
* @var int
*/
protected int $page = 1;
protected $page;
/**
* Total number of items
*
* @var int
*/
protected int $total = 0;
protected $total = 0;
/**
* The number of items per page
*
* @var int
*/
protected int $limit = 20;
protected $limit = 20;
/**
* Whether validation of the pagination page
* is enabled; will throw Exceptions if true
*
* @var bool
*/
public static bool $validate = true;
public static $validate = true;
/**
* Creates a new pagination object
* with the given parameters
*
* @param array $props
*/
public function __construct(array $props = [])
{
$this->setLimit($props['limit'] ?? 20);
$this->setPage($props['page'] ?? null);
$this->setTotal($props['total'] ?? 0);
// ensure that page is set to something, otherwise
// generate "default page" based on other params
$this->page ??= $this->firstPage();
// allow a page value of 1 even if there are no pages;
// otherwise the exception will get thrown for this pretty common case
$min = $this->firstPage();
$max = $this->pages();
if ($this->page === 1 && $max === 0) {
$this->page = 0;
}
// validate page based on all params if validation is enabled,
// otherwise limit the page number to the bounds
if ($this->page < $min || $this->page > $max) {
if (static::$validate === true) {
throw new ErrorPageException('Pagination page ' . $this->page . ' does not exist, expected ' . $min . '-' . $max);
}
$this->page = max(min($this->page, $max), $min);
}
}
/**
* Creates a new instance while
* merging initial and new properties
*/
public function clone(array $props = []): static
{
return new static(array_replace_recursive([
'page' => $this->page,
'limit' => $this->limit,
'total' => $this->total
], $props));
$this->setProperties($props);
}
/**
* Creates a pagination instance for the given
* collection with a flexible argument api
*
* @param \Kirby\Toolkit\Collection $collection
* @param mixed ...$arguments
* @return static
*/
public static function for(Collection $collection, ...$arguments): static
public static function for(Collection $collection, ...$arguments)
{
$a = $arguments[0] ?? null;
$b = $arguments[1] ?? null;
$params = [];
// First argument is a pagination object
if ($a instanceof static) {
if (is_a($a, static::class) === true) {
/**
* First argument is a pagination/self object
*/
return $a;
}
if (is_array($a) === true) {
// First argument is an option array
// $collection->paginate([...])
} elseif (is_array($a) === true) {
/**
* First argument is an option array
*
* $collection->paginate([...])
*/
$params = $a;
} elseif (is_int($a) === true && $b === null) {
// First argument is the limit
// $collection->paginate(10)
/**
* First argument is the limit
*
* $collection->paginate(10)
*/
$params['limit'] = $a;
} elseif (is_int($a) === true && is_int($b) === true) {
// First argument is the limit, second argument is the page
// $collection->paginate(10, 2)
/**
* First argument is the limit,
* second argument is the page
*
* $collection->paginate(10, 2)
*/
$params['limit'] = $a;
$params['page'] = $b;
} elseif (is_int($a) === true && is_array($b) === true) {
// First argument is the limit, second argument are options
// $collection->paginate(10, [...])
/**
* First argument is the limit,
* second argument are options
*
* $collection->paginate(10, [...])
*/
$params = $b;
$params['limit'] = $a;
}
@@ -131,6 +126,8 @@ class Pagination
/**
* Getter for the current page
*
* @return int
*/
public function page(): int
{
@@ -139,6 +136,8 @@ class Pagination
/**
* Getter for the total number of items
*
* @return int
*/
public function total(): int
{
@@ -147,6 +146,8 @@ class Pagination
/**
* Getter for the number of items per page
*
* @return int
*/
public function limit(): int
{
@@ -155,24 +156,40 @@ class Pagination
/**
* Returns the index of the first item on the page
*
* @return int
*/
public function start(): int
{
$index = max(0, $this->page() - 1);
$index = $this->page() - 1;
if ($index < 0) {
$index = 0;
}
return $index * $this->limit() + 1;
}
/**
* Returns the index of the last item on the page
*
* @return int
*/
public function end(): int
{
$value = min($this->total(), ($this->start() - 1) + $this->limit());
return $value;
$value = ($this->start() - 1) + $this->limit();
if ($value <= $this->total()) {
return $value;
}
return $this->total();
}
/**
* Returns the total number of pages
*
* @return int
*/
public function pages(): int
{
@@ -185,6 +202,8 @@ class Pagination
/**
* Returns the first page
*
* @return int
*/
public function firstPage(): int
{
@@ -193,6 +212,8 @@ class Pagination
/**
* Returns the last page
*
* @return int
*/
public function lastPage(): int
{
@@ -201,6 +222,8 @@ class Pagination
/**
* Returns the offset (i.e. for db queries)
*
* @return int
*/
public function offset(): int
{
@@ -209,6 +232,9 @@ class Pagination
/**
* Checks if the given page exists
*
* @param int $page
* @return bool
*/
public function hasPage(int $page): bool
{
@@ -225,6 +251,8 @@ class Pagination
/**
* Checks if there are any pages at all
*
* @return bool
*/
public function hasPages(): bool
{
@@ -233,6 +261,8 @@ class Pagination
/**
* Checks if there's a previous page
*
* @return bool
*/
public function hasPrevPage(): bool
{
@@ -241,14 +271,18 @@ class Pagination
/**
* Returns the previous page
*
* @return int|null
*/
public function prevPage(): int|null
public function prevPage()
{
return $this->hasPrevPage() ? $this->page() - 1 : null;
}
/**
* Checks if there's a next page
*
* @return bool
*/
public function hasNextPage(): bool
{
@@ -257,14 +291,18 @@ class Pagination
/**
* Returns the next page
*
* @return int|null
*/
public function nextPage(): int|null
public function nextPage()
{
return $this->hasNextPage() ? $this->page() + 1 : null;
}
/**
* Checks if the current page is the first page
*
* @return bool
*/
public function isFirstPage(): bool
{
@@ -273,6 +311,8 @@ class Pagination
/**
* Checks if the current page is the last page
*
* @return bool
*/
public function isLastPage(): bool
{
@@ -281,6 +321,9 @@ class Pagination
/**
* Creates a range of page numbers for Google-like pagination
*
* @param int $range
* @return array
*/
public function range(int $range = 5): array
{
@@ -293,7 +336,7 @@ class Pagination
return range($start, $end);
}
$middle = (int)floor($range / 2);
$middle = (int)floor($range/2);
$start = $page - $middle + ($range % 2 === 0);
$end = $start + $range - 1;
@@ -312,6 +355,9 @@ class Pagination
/**
* Returns the first page of the created range
*
* @param int $range
* @return int
*/
public function rangeStart(int $range = 5): int
{
@@ -320,6 +366,9 @@ class Pagination
/**
* Returns the last page of the created range
*
* @param int $range
* @return int
*/
public function rangeEnd(int $range = 5): int
{
@@ -328,11 +377,50 @@ class Pagination
}
/**
* Sets the number of items per page
* Sets the properties limit, total and page
* and validates that the properties match
*
* @param array $props Array with keys limit, total and/or page
* @return $this
*/
protected function setLimit(int $limit = 20): static
protected function setProperties(array $props)
{
$this->baseSetProperties($props);
// ensure that page is set to something, otherwise
// generate "default page" based on other params
if ($this->page === null) {
$this->page = $this->firstPage();
}
// allow a page value of 1 even if there are no pages;
// otherwise the exception will get thrown for this pretty common case
$min = $this->firstPage();
$max = $this->pages();
if ($this->page === 1 && $max === 0) {
$this->page = 0;
}
// validate page based on all params if validation is enabled,
// otherwise limit the page number to the bounds
if ($this->page < $min || $this->page > $max) {
if (static::$validate === true) {
throw new ErrorPageException('Pagination page ' . $this->page . ' does not exist, expected ' . $min . '-' . $max);
} else {
$this->page = max(min($this->page, $max), $min);
}
}
return $this;
}
/**
* Sets the number of items per page
*
* @param int $limit
* @return $this
*/
protected function setLimit(int $limit = 20)
{
if ($limit < 1) {
throw new Exception('Invalid pagination limit: ' . $limit);
@@ -345,9 +433,10 @@ class Pagination
/**
* Sets the total number of items
*
* @param int $total
* @return $this
*/
protected function setTotal(int $total = 0): static
protected function setTotal(int $total = 0)
{
if ($total < 0) {
throw new Exception('Invalid total number of items: ' . $total);
@@ -364,7 +453,7 @@ class Pagination
* automatically determined if null
* @return $this
*/
protected function setPage(int|string|null $page = null): static
protected function setPage($page = null)
{
// if $page is null, it is set to a default in the setProperties() method
if ($page !== null) {
@@ -380,6 +469,8 @@ class Pagination
/**
* Returns an array with all properties
*
* @return array
*/
public function toArray(): array
{

View File

@@ -7,8 +7,6 @@ use ReflectionMethod;
/**
* Properties
* @deprecated 4.0.0 Will be remove in Kirby 5
* @codeCoverageIgnore
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>

244
kirby/src/Toolkit/Query.php Normal file
View File

@@ -0,0 +1,244 @@
<?php
namespace Kirby\Toolkit;
use Kirby\Exception\BadMethodCallException;
use Kirby\Exception\InvalidArgumentException;
/**
* The Query class can be used to
* query arrays and objects, including their
* methods with a very simple string-based syntax.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Query
{
public const PARTS = '!\.|(\(([^()]+|(?1))*+\))(*SKIP)(*FAIL)!'; // split by dot, but not inside (nested) parens
public const PARAMETERS = '!,|' . self::SKIP . '!'; // split by comma, but not inside skip groups
public const NO_PNTH = '\([^(]+\)(*SKIP)(*FAIL)';
public const NO_SQBR = '\[[^]]+\](*SKIP)(*FAIL)';
public const NO_DLQU = '\"(?:[^"\\\\]|\\\\.)*\"(*SKIP)(*FAIL)'; // allow \" escaping inside string
public const NO_SLQU = '\'(?:[^\'\\\\]|\\\\.)*\'(*SKIP)(*FAIL)'; // allow \' escaping inside string
public const SKIP = self::NO_PNTH . '|' . self::NO_SQBR . '|' .
self::NO_DLQU . '|' . self::NO_SLQU;
/**
* The query string
*
* @var string
*/
protected $query;
/**
* Queryable data
*
* @var array
*/
protected $data;
/**
* Creates a new Query object
*
* @param string|null $query
* @param array|object $data
*/
public function __construct(?string $query = null, $data = [])
{
$this->query = $query;
$this->data = $data;
}
/**
* Returns the query result if anything
* can be found, otherwise returns null
*
* @return mixed
*/
public function result()
{
if (empty($this->query) === true) {
return $this->data;
}
return $this->resolve($this->query);
}
/**
* Resolves the query if anything
* can be found, otherwise returns null
*
* @param string $query
* @return mixed
*
* @throws \Kirby\Exception\BadMethodCallException If an invalid method is accessed by the query
*/
protected function resolve(string $query)
{
// direct key access in arrays
if (is_array($this->data) === true && array_key_exists($query, $this->data) === true) {
return $this->data[$query];
}
$parts = $this->parts($query);
$data = $this->data;
$value = null;
foreach ($parts as $part) {
$info = $this->part($part);
$method = $info['method'];
$args = $info['args'];
if (is_array($data)) {
if (array_key_exists($method, $data) === true) {
$value = $data[$method];
if (is_a($value, 'Closure') === true) {
$value = $value(...$args);
} elseif ($args !== []) {
throw new InvalidArgumentException('Cannot access array element ' . $method . ' with arguments');
}
} else {
static::accessError($data, $method, 'property');
}
} elseif (is_object($data)) {
if (
method_exists($data, $method) === true ||
method_exists($data, '__call') === true
) {
$value = $data->$method(...$args);
} elseif (
$args === [] && (
property_exists($data, $method) === true ||
method_exists($data, '__get') === true
)
) {
$value = $data->$method;
} else {
$label = ($args === []) ? 'method/property' : 'method';
static::accessError($data, $method, $label);
}
} else {
// further parts on a scalar/null value
static::accessError($data, $method, 'method/property');
}
// continue with the current value for the next part
$data = $value;
}
return $value;
}
/**
* Breaks the query string down into its components
*
* @param string $query
* @return array
*/
protected function parts(string $query): array
{
return preg_split(self::PARTS, trim($query), -1, PREG_SPLIT_NO_EMPTY);
}
/**
* Analyzes each part of the query string and
* extracts methods and method arguments
*
* @param string $part
* @return array
*/
protected function part(string $part): array
{
if (Str::endsWith($part, ')') === true) {
$method = Str::before($part, '(');
// the args are everything inside the *outer* parentheses
$args = Str::substr($part, Str::position($part, '(') + 1, -1);
$args = preg_split(self::PARAMETERS, $args);
$args = array_map('self::parameter', $args);
return compact('method', 'args');
} else {
return [
'method' => $part,
'args' => []
];
}
}
/**
* Converts a parameter of a query to
* its proper native PHP type
*
* @param string $arg
* @return mixed
*/
protected function parameter(string $arg)
{
$arg = trim($arg);
// string with double quotes
if (substr($arg, 0, 1) === '"' && substr($arg, -1) === '"') {
return str_replace('\"', '"', substr($arg, 1, -1));
}
// string with single quotes
if (substr($arg, 0, 1) === "'" && substr($arg, -1) === "'") {
return str_replace("\'", "'", substr($arg, 1, -1));
}
// boolean or null
switch ($arg) {
case 'null':
return null;
case 'false':
return false;
case 'true':
return true;
}
// numeric
if (is_numeric($arg) === true) {
return (float)$arg;
}
// array: split and recursive sanitizing
if (substr($arg, 0, 1) === '[' && substr($arg, -1) === ']') {
$arg = substr($arg, 1, -1);
$arg = preg_split(self::PARAMETERS, $arg);
return array_map('self::parameter', $arg);
}
// resolve parameter for objects and methods itself
return $this->resolve($arg);
}
/**
* Throws an exception for an access to an invalid method
*
* @param mixed $data Variable on which the access was tried
* @param string $name Name of the method/property that was accessed
* @param string $label Type of the name (`method`, `property` or `method/property`)
* @return void
*
* @throws \Kirby\Exception\BadMethodCallException
*/
protected static function accessError($data, string $name, string $label): void
{
$type = strtolower(gettype($data));
if ($type === 'double') {
$type = 'float';
}
$nonExisting = in_array($type, ['array', 'object']) ? 'non-existing ' : '';
$error = 'Access to ' . $nonExisting . $label . ' ' . $name . ' on ' . $type;
throw new BadMethodCallException($error);
}
}

View File

@@ -15,22 +15,34 @@ namespace Kirby\Toolkit;
*/
class Silo
{
public static array $data = [];
/**
* @var array
*/
public static $data = [];
/**
* Setter for new data
* Setter for new data.
*
* @param string|array $key
* @param mixed $value
* @return array
*/
public static function set(string|array $key, $value = null): array
public static function set($key, $value = null): array
{
if (is_array($key) === true) {
return static::$data = array_merge(static::$data, $key);
} else {
static::$data[$key] = $value;
return static::$data;
}
static::$data[$key] = $value;
return static::$data;
}
public static function get(string|array $key = null, $default = null)
/**
* @param string|array $key
* @param mixed $default
* @return mixed
*/
public static function get($key = null, $default = null)
{
if ($key === null) {
return static::$data;
@@ -41,6 +53,9 @@ class Silo
/**
* Removes an item from the data array
*
* @param string|null $key
* @return array
*/
public static function remove(string $key = null): array
{

File diff suppressed because it is too large Load Diff

View File

@@ -1,216 +0,0 @@
<?php
namespace Kirby\Toolkit;
use Kirby\Data\Json;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use SensitiveParameter;
/**
* User-friendly and safe abstraction for symmetric
* authenticated encryption and decryption using the
* PHP `sodium` extension
* @since 3.9.8
*
* @package Kirby Toolkit
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class SymmetricCrypto
{
/**
* Cache for secret keys derived from the password
* indexed by the used salt and limits
*/
protected array $secretKeysByOptions = [];
/**
* Initializes the keys used for crypto, both optional
*
* @param string|null $password Password to be derived into a `$secretKey`
* @param string|null $secretKey 256-bit key, alternatively a `$password` can be used
*/
public function __construct(
#[SensitiveParameter]
protected string|null $password = null,
#[SensitiveParameter]
protected string|null $secretKey = null,
) {
if ($password !== null && $secretKey !== null) {
throw new InvalidArgumentException('Passing both a secret key and a password is not supported');
}
if ($secretKey !== null && strlen($secretKey) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new InvalidArgumentException('Invalid secret key length, expected ' . SODIUM_CRYPTO_SECRETBOX_KEYBYTES . ' bytes');
}
}
/**
* Hide values of secrets when printing the object
*/
public function __debugInfo(): array
{
return [
'hasPassword' => isset($this->password),
'hasSecretKey' => isset($this->secretKey),
];
}
/**
* Wipes the secrets from memory when they are no longer needed
*/
public function __destruct()
{
$this->memzero($this->password);
$this->memzero($this->secretKey);
foreach ($this->secretKeysByOptions as $key => &$value) {
$this->memzero($value);
unset($this->secretKeysByOptions[$key]);
}
}
/**
* Decrypts JSON data encrypted by `SymmetricCrypto::encrypt()` using the secret key or password
*
* <code>
* // decryption with a password
* $crypto = new SymmetricCrypto(password: 'super secure');
* $plaintext = $crypto->decrypt('a very confidential string');
*
* // decryption with a previously generated key
* $crypto = new SymmetricCrypto(secretKey: $secretKey);
* $plaintext = $crypto->decrypt('{"mode":"secretbox"...}');
* </code>
*/
public function decrypt(string $json): string
{
$props = Json::decode($json);
if (($props['mode'] ?? null) !== 'secretbox') {
throw new InvalidArgumentException('Unsupported encryption mode "' . ($props['mode'] ?? '') . '"');
}
if (
isset($props['data']) !== true ||
isset($props['nonce']) !== true ||
isset($props['salt']) !== true ||
isset($props['limits']) !== true
) {
throw new InvalidArgumentException('Input data does not contain all required props');
}
$data = base64_decode($props['data']);
$nonce = base64_decode($props['nonce']);
$salt = base64_decode($props['salt']);
$limits = $props['limits'];
$plaintext = sodium_crypto_secretbox_open($data, $nonce, $this->secretKey($salt, $limits));
if (is_string($plaintext) !== true) {
throw new LogicException('Encrypted string was tampered with');
}
return $plaintext;
}
/**
* Encrypts a string using the secret key or password
*
* <code>
* // encryption with a password
* $crypto = new SymmetricCrypto(password: 'super secure');
* $ciphertext = $crypto->encrypt('a very confidential string');
*
* // encryption with a random key
* $crypto = new SymmetricCrypto();
* $ciphertext = $crypto->encrypt('a very confidential string');
* $secretKey = $crypto->secretKey();
* </code>
*/
public function encrypt(
#[SensitiveParameter]
string $string
): string {
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
$limits = [SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE];
$key = $this->secretKey($salt, $limits);
$ciphertext = sodium_crypto_secretbox($string, $nonce, $key);
// bundle all necessary information in a JSON object;
// always include the salt and limits to hide whether a key or password was used
return Json::encode([
'mode' => 'secretbox',
'data' => base64_encode($ciphertext),
'nonce' => base64_encode($nonce),
'salt' => base64_encode($salt),
'limits' => $limits,
]);
}
/**
* Checks if the required PHP `sodium` extension is available
*/
public static function isAvailable(): bool
{
return defined('SODIUM_LIBRARY_MAJOR_VERSION') === true && SODIUM_LIBRARY_MAJOR_VERSION >= 10;
}
/**
* Returns the binary secret key, optionally derived from the password
* or randomly generated
*
* @param string|null $salt Salt for password-based key derivation
* @param array|null $limits Processing limits for password-based key derivation
*/
public function secretKey(
#[SensitiveParameter]
string|null $salt = null,
array|null $limits = null
): string {
if (isset($this->secretKey) === true) {
return $this->secretKey;
}
// derive from password
if (isset($this->password) === true) {
if ($salt === null || $limits === null) {
throw new InvalidArgumentException('Salt and limits are required when deriving a secret key from a password');
}
// access from cache
$options = $salt . ':' . implode(',', $limits);
if (isset($this->secretKeysByOptions[$options]) === true) {
return $this->secretKeysByOptions[$options];
}
return $this->secretKeysByOptions[$options] = sodium_crypto_pwhash(
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
$this->password,
$salt,
$limits[0],
$limits[1],
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13
);
}
// generate a random key
return $this->secretKey = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
}
/**
* Wipes a variable from memory if it is a string
*/
protected function memzero(mixed &$value): void
{
if (is_string($value) === true) {
sodium_memzero($value);
$value = '';
}
}
}

View File

@@ -1,144 +0,0 @@
<?php
namespace Kirby\Toolkit;
use Base32\Base32;
use Kirby\Exception\InvalidArgumentException;
use SensitiveParameter;
/**
* The TOTP class handles the generation and verification
* of time-based one-time passwords according to RFC6238
* with the SHA1 algorithm, 30 second intervals and 6 digits
* @since 4.0.0
*
* @package Kirby Toolkit
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Totp
{
/**
* Binary secret
*/
protected string $secret;
/**
* Class constructor
*
* @param string|null $secret Existing secret in Base32 format
* or `null` to generate a new one
* @param bool $force Whether to skip the secret length validation;
* WARNING: Only ever set this to `true` when
* generating codes for third-party services
*/
public function __construct(
#[SensitiveParameter]
string|null $secret = null,
bool $force = false
) {
// if provided, decode the existing secret into binary
if ($secret !== null) {
$this->secret = Base32::decode($secret);
}
// otherwise generate a new one;
// 20 bytes are the length of the SHA1 HMAC
$this->secret ??= random_bytes(20);
// safety check to avoid accidental insecure secrets
if ($force === false && strlen($this->secret) !== 20) {
throw new InvalidArgumentException('TOTP secrets should be 32 Base32 digits (= 20 bytes)');
}
}
/**
* Generates the current TOTP code
*
* @param int $offset Optional counter offset to generate
* previous or upcoming codes
*/
public function generate(int $offset = 0): string
{
// generate a new code every 30 seconds
$counter = floor(time() / 30) + $offset;
// pack the number into a binary 64-bit unsigned int
$binaryCounter = pack('J', $counter);
// on 32-bit systems, we need to pack into a binary 32-bit
// unsigned int and prepend 4 null bytes to get a 64-bit value
// @codeCoverageIgnoreStart
if (PHP_INT_SIZE < 8) {
$binaryCounter = "\0\0\0\0" . pack('N', $counter);
}
// @codeCoverageIgnoreEnd
// create a binary HMAC from the binary counter and the binary secret
$binaryHmac = hash_hmac('sha1', $binaryCounter, $this->secret, true);
// convert the HMAC into an array of byte values (from 0-255)
$bytes = unpack('C*', $binaryHmac);
// perform dynamic truncation to four bytes according to RFC6238 & RFC4226
$byteOffset = (end($bytes) & 0xF);
$code = (($bytes[$byteOffset + 1] & 0x7F) << 24) |
($bytes[$byteOffset + 2] << 16) |
($bytes[$byteOffset + 3] << 8) |
$bytes[$byteOffset + 4];
// truncate the resulting number to at max six digits
$code %= 1000000;
// format as a six-digit string, left-padded with zeros
return sprintf('%06d', $code);
}
/**
* Returns the secret in human-readable Base32 format
*/
public function secret(): string
{
return Base32::encode($this->secret);
}
/**
* Returns a `otpauth://` URI for use in a setup QR code or link
*
* @param string $issuer Name of the site the code is valid for
* @param string $label Account name the code is valid for
*/
public function uri(string $issuer, string $label): string
{
$query = http_build_query([
'secret' => $this->secret(),
'issuer' => $issuer
], '', '&', PHP_QUERY_RFC3986);
return 'otpauth://totp/' . rawurlencode($issuer) .
':' . rawurlencode($label) . '?' . $query;
}
/**
* Securely checks the provided TOTP code against the
* current, the direct previous and following codes
*/
public function verify(string $totp): bool
{
// strip out any non-numeric character (e.g. spaces)
// from user input to increase UX
$totp = preg_replace('/[^0-9]/', '', $totp);
// also allow the previous and upcoming codes
// to account for time sync issues
foreach ([0, -1, 1] as $offset) {
if (hash_equals($this->generate($offset), $totp) === true) {
return true;
}
}
return false;
}
}

View File

@@ -19,12 +19,13 @@ class Tpl
/**
* Renders the template
*
* @param string|null $file
* @param array $data
* @return string
* @throws Throwable
*/
public static function load(
string|null $file = null,
array $data = []
): string {
public static function load(?string $file = null, array $data = []): string
{
if ($file === null || is_file($file) === false) {
return '';
}
@@ -41,10 +42,10 @@ class Tpl
$content = ob_get_contents();
ob_end_clean();
if ($exception !== null) {
throw $exception;
if ($exception === null) {
return $content;
}
return $content;
throw $exception;
}
}

View File

@@ -2,12 +2,9 @@
namespace Kirby\Toolkit;
use Countable;
use Exception;
use Kirby\Content\Field;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Http\Idn;
use Kirby\Uuid\Uuid;
use ReflectionFunction;
use Throwable;
@@ -24,19 +21,23 @@ class V
{
/**
* An array with all installed validators
*
* @var array
*/
public static array $validators = [];
public static $validators = [];
/**
* Validates the given input with all passed rules
* and returns an array with all error messages.
* The array will be empty if the input is valid
*
* @param mixed $input
* @param array $rules
* @param array $messages
* @return array
*/
public static function errors(
$input,
array $rules,
array $messages = []
): array {
public static function errors($input, array $rules, $messages = []): array
{
$errors = static::value($input, $rules, $messages, false);
return $errors === true ? [] : $errors;
@@ -46,12 +47,14 @@ class V
* Runs a number of validators on a set of data and
* checks if the data is invalid
* @since 3.7.0
*
* @param array $data
* @param array $rules
* @param array $messages
* @return array
*/
public static function invalid(
array $data = [],
array $rules = [],
array $messages = []
): array {
public static function invalid(array $data = [], array $rules = [], array $messages = []): array
{
$errors = [];
foreach ($rules as $field => $validations) {
@@ -113,11 +116,13 @@ class V
* Creates a useful error message for the given validator
* and the arguments. This is used mainly internally
* to create error messages
*
* @param string $validatorName
* @param mixed ...$params
* @return string|null
*/
public static function message(
string $validatorName,
...$params
): string|null {
public static function message(string $validatorName, ...$params): ?string
{
$validatorName = strtolower($validatorName);
$translationKey = 'error.validation.' . $validatorName;
$validators = array_change_key_case(static::$validators);
@@ -134,13 +139,16 @@ class V
$value = $params[$index] ?? null;
if (is_array($value) === true) {
foreach ($value as $key => $item) {
if (is_array($item) === true) {
$value[$key] = A::implode($item, '|');
try {
foreach ($value as $key => $item) {
if (is_array($item) === true) {
$value[$key] = implode('|', $item);
}
}
$value = implode(', ', $value);
} catch (Throwable $e) {
$value = '-';
}
$value = implode(', ', $value);
}
$arguments[$parameter->getName()] = $value;
@@ -151,6 +159,8 @@ class V
/**
* Return the list of all validators
*
* @return array
*/
public static function validators(): array
{
@@ -161,13 +171,15 @@ class V
* Validate a single value against
* a set of rules, using all registered
* validators
*
* @param mixed $value
* @param array $rules
* @param array $messages
* @param bool $fail
* @return bool|array
*/
public static function value(
$value,
array $rules,
array $messages = [],
bool $fail = true
): bool|array {
public static function value($value, array $rules, array $messages = [], bool $fail = true)
{
$errors = [];
foreach ($rules as $validatorName => $validatorOptions) {
@@ -199,6 +211,10 @@ class V
* Validate an input array against
* a set of rules, using all registered
* validators
*
* @param array $input
* @param array $rules
* @return bool
*/
public static function input(array $input, array $rules): bool
{
@@ -233,6 +249,10 @@ class V
/**
* Calls an installed validator and passes all arguments
*
* @param string $method
* @param array $arguments
* @return bool
*/
public static function __callStatic(string $method, array $arguments): bool
{
@@ -278,17 +298,8 @@ V::$validators = [
* Checks for numbers within the given range
*/
'between' => function ($value, $min, $max): bool {
return
V::min($value, $min) === true &&
V::max($value, $max) === true;
},
/**
* Checks with the callback sent by the user
* It's ideal for one-time custom validations
*/
'callback' => function ($value, callable $callback): bool {
return $callback($value);
return V::min($value, $min) === true &&
V::max($value, $max) === true;
},
/**
@@ -306,7 +317,7 @@ V::$validators = [
* Pass an operator as second argument and another date as
* third argument to compare them.
*/
'date' => function (string|null $value, string $operator = null, string $test = null): bool {
'date' => function (?string $value, string $operator = null, string $test = null): bool {
// make sure $value is a string
$value ??= '';
@@ -327,16 +338,22 @@ V::$validators = [
return false;
}
return match ($operator) {
'!=' => $value !== $test,
'<' => $value < $test,
'>' => $value > $test,
'<=' => $value <= $test,
'>=' => $value >= $test,
'==' => $value === $test,
switch ($operator) {
case '!=':
return $value !== $test;
case '<':
return $value < $test;
case '>':
return $value > $test;
case '<=':
return $value <= $test;
case '>=':
return $value >= $test;
case '==':
return $value === $test;
}
default => throw new InvalidArgumentException('Invalid date comparison operator: "' . $operator . '". Allowed operators: "==", "!=", "<", "<=", ">", ">="')
};
throw new InvalidArgumentException('Invalid date comparison operator: "' . $operator . '". Allowed operators: "==", "!=", "<", "<=", ">", ">="');
},
/**
@@ -363,7 +380,7 @@ V::$validators = [
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
try {
$email = Idn::encodeEmail($value);
} catch (Throwable) {
} catch (Throwable $e) {
return false;
}
@@ -401,9 +418,8 @@ V::$validators = [
* Checks for a valid filename
*/
'filename' => function ($value): bool {
return
V::match($value, '/^[a-z0-9@._-]+$/i') === true &&
V::min($value, 2) === true;
return V::match($value, '/^[a-z0-9@._-]+$/i') === true &&
V::min($value, 2) === true;
},
/**
@@ -454,7 +470,7 @@ V::$validators = [
* Checks if the value matches the given regular expression
*/
'match' => function ($value, string $pattern): bool {
return preg_match($pattern, (string)$value) === 1;
return preg_match($pattern, $value) !== 0;
},
/**
@@ -563,7 +579,7 @@ V::$validators = [
'size' => function ($value, $size, $operator = '=='): bool {
// if value is field object, first convert it to a readable value
// it is important to check at the beginning as the value can be string or numeric
if ($value instanceof Field) {
if (is_a($value, '\Kirby\Cms\Field') === true) {
$value = $value->value();
}
@@ -574,7 +590,7 @@ V::$validators = [
} elseif (is_array($value) === true) {
$count = count($value);
} elseif (is_object($value) === true) {
if ($value instanceof Countable) {
if ($value instanceof \Countable) {
$count = count($value);
} elseif (method_exists($value, 'count') === true) {
$count = $value->count();
@@ -585,13 +601,18 @@ V::$validators = [
throw new Exception('$value is of type without size');
}
return match ($operator) {
'<' => $count < $size,
'>' => $count > $size,
'<=' => $count <= $size,
'>=' => $count >= $size,
default => $count == $size
};
switch ($operator) {
case '<':
return $count < $size;
case '>':
return $count > $size;
case '<=':
return $count <= $size;
case '>=':
return $count >= $size;
default:
return $count == $size;
}
},
/**
@@ -601,13 +622,6 @@ V::$validators = [
return Str::startsWith($value, $start);
},
/**
* Checks for a valid unformatted telephone number
*/
'tel' => function ($value): bool {
return V::match($value, '!^[+]{0,1}[0-9]+$!');
},
/**
* Checks for valid time
*/
@@ -623,12 +637,5 @@ V::$validators = [
// Added localhost support and removed 127.*.*.* ip restriction
$regex = '_^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:localhost)|(?:(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)(?:\.(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)*(?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$_iu';
return preg_match($regex, $value ?? '') !== 0;
},
/**
* Checks for a valid Uuid, optionally for specific model type
*/
'uuid' => function (string $value, string $type = null): bool {
return Uuid::is($value, $type);
}
];

View File

@@ -18,17 +18,36 @@ use Throwable;
class View
{
/**
* Creates a new view object
* The absolute path to the view file
*
* @var string
*/
public function __construct(
// The absolute path to the view file
protected string $file,
protected array $data = []
) {
protected $file;
/**
* The view data
*
* @var array
*/
protected $data = [];
/**
* Creates a new view object
*
* @param string $file
* @param array $data
*/
public function __construct(string $file, array $data = [])
{
$this->file = $file;
$this->data = $data;
}
/**
* Returns the view's data array without globals
* Returns the view's data array
* without globals.
*
* @return array
*/
public function data(): array
{
@@ -37,6 +56,8 @@ class View
/**
* Checks if the template file exists
*
* @return bool
*/
public function exists(): bool
{
@@ -45,14 +66,18 @@ class View
/**
* Returns the view file
*
* @return string|false
*/
public function file(): string
public function file()
{
return $this->file;
}
/**
* Creates an error message for the missing view exception
*
* @return string
*/
protected function missingViewMessage(): string
{
@@ -61,6 +86,8 @@ class View
/**
* Renders the view
*
* @return string
*/
public function render(): string
{
@@ -70,6 +97,7 @@ class View
ob_start();
$exception = null;
try {
F::load($this->file(), null, $this->data());
} catch (Throwable $e) {
@@ -79,15 +107,17 @@ class View
$content = ob_get_contents();
ob_end_clean();
if (($exception ?? null) !== null) {
throw $exception;
if ($exception === null) {
return $content;
}
return $content;
throw $exception;
}
/**
* @see ::render()
* Alias for View::render()
*
* @return string
*/
public function toString(): string
{
@@ -98,7 +128,7 @@ class View
* Magic string converter to enable
* converting view objects to string
*
* @see ::render()
* @return string
*/
public function __toString(): string
{

View File

@@ -2,7 +2,6 @@
namespace Kirby\Toolkit;
use Kirby\Cms\Helpers;
use SimpleXMLElement;
/**
@@ -18,8 +17,10 @@ class Xml
{
/**
* HTML to XML conversion table for entities
*
* @var array
*/
public static array|null $entities = [
public static $entities = [
'&nbsp;' => '&#160;', '&iexcl;' => '&#161;', '&cent;' => '&#162;', '&pound;' => '&#163;', '&curren;' => '&#164;', '&yen;' => '&#165;', '&brvbar;' => '&#166;', '&sect;' => '&#167;',
'&uml;' => '&#168;', '&copy;' => '&#169;', '&ordf;' => '&#170;', '&laquo;' => '&#171;', '&not;' => '&#172;', '&shy;' => '&#173;', '&reg;' => '&#174;', '&macr;' => '&#175;',
'&deg;' => '&#176;', '&plusmn;' => '&#177;', '&sup2;' => '&#178;', '&sup3;' => '&#179;', '&acute;' => '&#180;', '&micro;' => '&#181;', '&para;' => '&#182;', '&middot;' => '&#183;',
@@ -70,10 +71,8 @@ class Xml
* If used with a `$name` array, this can be set to `false` to disable attribute sorting.
* @return string|null The generated XML attributes string
*/
public static function attr(
string|array $name,
$value = null
): string|null {
public static function attr($name, $value = null): ?string
{
if (is_array($name) === true) {
if ($value !== false) {
ksort($name);
@@ -81,34 +80,26 @@ class Xml
$attributes = [];
foreach ($name as $key => $val) {
if (is_int($key) === true) {
$key = $val;
$val = true;
}
$a = static::attr($key, $val);
if ($attribute = static::attr($key, $val)) {
$attributes[] = $attribute;
if ($a) {
$attributes[] = $a;
}
}
return implode(' ', $attributes);
}
if ($value === null || $value === false || $value === []) {
if ($value === null || $value === '' || $value === []) {
return null;
}
// TODO: In 5.0, remove this block to render space as space
// @codeCoverageIgnoreStart
if ($value === ' ') {
Helpers::deprecated('Passing a single space as value to `Xml::attr()` has been deprecated. In a future version, passing a single space won\'t render an empty value anymore but a single space. To render an empty value, please pass an empty string.', 'xml-attr-single-space');
return $name . '=""';
return strtolower($name) . '=""';
}
// @codeCoverageIgnoreEnd
if ($value === true) {
return $name . '="' . $name . '"';
if (is_bool($value) === true) {
return $value === true ? strtolower($name) . '="' . strtolower($name) . '"' : null;
}
if (is_array($value) === true) {
@@ -124,7 +115,7 @@ class Xml
$value = static::encode($value);
}
return $name . '="' . $value . '"';
return strtolower($name) . '="' . $value . '"';
}
/**
@@ -142,13 +133,8 @@ class Xml
* @param int $level The indentation level (used internally)
* @return string The XML string
*/
public static function create(
array|string $props,
string $name = 'root',
bool $head = true,
string $indent = ' ',
int $level = 0
): string {
public static function create($props, string $name = 'root', bool $head = true, string $indent = ' ', int $level = 0): string
{
if (is_array($props) === true) {
if (A::isAssociative($props) === true) {
// a tag with attributes or named children
@@ -191,14 +177,14 @@ class Xml
} else {
// scalar value
$result = static::tag($name, $props, [], $indent, $level);
$result = static::tag($name, $props, null, $indent, $level);
}
if ($head === true) {
return '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL . $result;
} else {
return $result;
}
return $result;
}
/**
@@ -208,10 +194,17 @@ class Xml
* echo Xml::decode('some &uuml;ber <em>crazy</em> stuff');
* // output: some über crazy stuff
* ```
*
* @param string|null $string
* @return string
*/
public static function decode(string|null $string): string
public static function decode(?string $string): string
{
$string = strip_tags($string ?? '');
if ($string === null) {
$string = '';
}
$string = strip_tags($string);
return html_entity_decode($string, ENT_COMPAT, 'utf-8');
}
@@ -226,12 +219,12 @@ class Xml
* // output: some &#252;ber crazy stuff
* ```
*
* @param string|null $string
* @param bool $html True = Convert to HTML-safe first
* @return string
*/
public static function encode(
string|null $string,
bool $html = true
): string {
public static function encode(?string $string, bool $html = true): string
{
if ($string === null) {
return '';
}
@@ -249,6 +242,8 @@ class Xml
/**
* Returns the HTML-to-XML entity translation table
*
* @return array
*/
public static function entities(): array
{
@@ -258,12 +253,27 @@ class Xml
/**
* Parses an XML string and returns an array
*
* @param string $xml
* @return array|null Parsed array or `null` on error
*/
public static function parse(string $xml): array|null
public static function parse(string $xml): ?array
{
$loaderSetting = null;
if (\PHP_VERSION_ID < 80000) {
// prevent loading external entities to protect against XXE attacks;
// only needed for PHP versions before 8.0 (the function was deprecated
// as the disabled state is the new default in PHP 8.0+)
$loaderSetting = libxml_disable_entity_loader(true);
}
$xml = @simplexml_load_string($xml);
if (\PHP_VERSION_ID < 80000) {
// ensure that we don't alter global state by
// resetting the original value
libxml_disable_entity_loader($loaderSetting);
}
if (is_object($xml) !== true) {
return null;
}
@@ -275,12 +285,12 @@ class Xml
* Breaks a SimpleXMLElement down into a simpler tree
* structure of arrays and strings
*
* @param \SimpleXMLElement $element
* @param bool $collectName Whether the element name should be collected (for the root element)
* @return array|string
*/
public static function simplify(
SimpleXMLElement $element,
bool $collectName = true
): array|string {
public static function simplify(SimpleXMLElement $element, bool $collectName = true)
{
// get all XML namespaces of the whole document to iterate over later;
// we don't need the global namespace (empty string) in the list
$usedNamespaces = $element->getNamespaces(true);
@@ -345,17 +355,18 @@ class Xml
}
return $array;
} else {
// we didn't find any XML children above, only use the string value
$element = (string)$element;
if (count($array) > 0) {
$array['@value'] = $element;
return $array;
} else {
return $element;
}
}
// we didn't find any XML children above, only use the string value
$element = (string)$element;
if (count($array) === 0) {
return $element;
}
$array['@value'] = $element;
return $array;
}
/**
@@ -369,13 +380,8 @@ class Xml
* @param int $level Indentation level
* @return string The generated XML
*/
public static function tag(
string $name,
array|string|null $content = '',
array $attr = [],
string $indent = null,
int $level = 0
): string {
public static function tag(string $name, $content = '', array $attr = null, ?string $indent = null, int $level = 0): string
{
$attr = static::attr($attr);
$start = '<' . $name . ($attr ? ' ' . $attr : '') . '>';
$startShort = '<' . $name . ($attr ? ' ' . $attr : '') . static::$void;
@@ -403,8 +409,11 @@ class Xml
/**
* Properly encodes tag contents
*
* @param mixed $value
* @return string|null
*/
public static function value($value): string|null
public static function value($value): ?string
{
if ($value === true) {
return 'true';