Upgrade to rc5

This commit is contained in:
Bastian Allgeier
2020-12-10 11:24:42 +01:00
parent 3fec0d7c93
commit c378376bc9
257 changed files with 13009 additions and 1846 deletions

View File

@@ -56,28 +56,7 @@ class Api extends BaseApi
*/
public function fieldApi($model, string $name, string $path = null)
{
$form = Form::for($model);
$fieldNames = Str::split($name, '+');
$index = 0;
$count = count($fieldNames);
$field = null;
foreach ($fieldNames as $fieldName) {
$index++;
if ($field = $form->fields()->get($fieldName)) {
if ($count !== $index) {
$form = $field->form();
}
} else {
throw new NotFoundException('The field "' . $fieldName . '" could not be found');
}
}
// it can get this error only if $name is an empty string as $name = ''
if ($field === null) {
throw new NotFoundException('No field could be loaded');
}
$field = Form::for($model)->field($name);
$fieldApi = $this->clone([
'routes' => $field->api(),

View File

@@ -79,8 +79,9 @@ class App
* Creates a new App instance
*
* @param array $props
* @param bool $setInstance If false, the instance won't be set globally
*/
public function __construct(array $props = [])
public function __construct(array $props = [], bool $setInstance = true)
{
// register all roots to be able to load stuff afterwards
$this->bakeRoots($props['roots'] ?? []);
@@ -110,7 +111,9 @@ class App
]);
// set the singleton
Model::$kirby = static::$instance = $this;
if (static::$instance === null || $setInstance === true) {
Model::$kirby = static::$instance = $this;
}
// setup the I18n class with the translation loader
$this->i18n();
@@ -333,6 +336,24 @@ class App
return $router->call($path ?? $this->path(), $method ?? $this->request()->method());
}
/**
* Creates an instance with the same
* initial properties
*
* @param array $props
* @param bool $setInstance If false, the instance won't be set globally
* @return self
*/
public function clone(array $props = [], bool $setInstance = true)
{
$props = array_replace_recursive($this->propertyData, $props);
$clone = new static($props, $setInstance);
$clone->data = $this->data;
return $clone;
}
/**
* Returns a specific user-defined collection
* by name. All relevant dependencies are

View File

@@ -48,6 +48,7 @@ trait AppPlugins
// other plugin types
'api' => [],
'authChallenges' => [],
'blueprints' => [],
'cacheTypes' => [],
'collections' => [],
@@ -125,6 +126,17 @@ trait AppPlugins
}
}
/**
* Registers additional authentication challenges
*
* @param array $challenges
* @return array
*/
protected function extendAuthChallenges(array $challenges): array
{
return $this->extensions['authChallenges'] = Auth::$challenges = array_merge(Auth::$challenges, $challenges);
}
/**
* Registers additional blueprints
*
@@ -618,6 +630,7 @@ trait AppPlugins
// load static extensions only once
if (static::$systemExtensions === null) {
// Form Field Mixins
FormField::$mixins['datetime'] = include $root . '/config/fields/mixins/datetime.php';
FormField::$mixins['filepicker'] = include $root . '/config/fields/mixins/filepicker.php';
FormField::$mixins['min'] = include $root . '/config/fields/mixins/min.php';
FormField::$mixins['options'] = include $root . '/config/fields/mixins/options.php';
@@ -675,10 +688,17 @@ trait AppPlugins
'blueprints' => include $root . '/config/blueprints.php',
'fields' => include $root . '/config/fields.php',
'fieldMethods' => include $root . '/config/methods.php',
'tags' => include $root . '/config/tags.php'
'snippets' => include $root . '/config/snippets.php',
'tags' => include $root . '/config/tags.php',
'templates' => include $root . '/config/templates.php'
];
}
// default auth challenges
$this->extendAuthChallenges([
'email' => 'Kirby\Cms\Auth\EmailChallenge'
]);
// default cache types
$this->extendCacheTypes([
'apcu' => 'Kirby\Cache\ApcuCache',
@@ -691,7 +711,9 @@ trait AppPlugins
$this->extendBlueprints(static::$systemExtensions['blueprints']);
$this->extendFields(static::$systemExtensions['fields']);
$this->extendFieldMethods((static::$systemExtensions['fieldMethods'])($this));
$this->extendSnippets(static::$systemExtensions['snippets']);
$this->extendTags(static::$systemExtensions['tags']);
$this->extendTemplates(static::$systemExtensions['templates']);
}
/**

View File

@@ -2,10 +2,9 @@
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Data;
use Kirby\Toolkit\F;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Locale;
use Kirby\Toolkit\Str;
/**
@@ -36,11 +35,11 @@ trait AppTranslations
}
// inject translations from the current language
if ($this->multilang() === true && $language = $this->languages()->find($locale)) {
if (
$this->multilang() === true &&
$language = $this->languages()->find($locale)
) {
$data = array_merge($data, $language->translations());
// Add language slug rules to Str class
Str::$language = $language->rules();
}
@@ -65,25 +64,12 @@ trait AppTranslations
I18n::$translations = [];
// checks custom language definition for slugs
if ($slugsOption = $this->option('slugs')) {
// checks setting in two different ways
// add slug rules based on config option
if ($slugs = $this->option('slugs')) {
// two ways that the option can be defined:
// "slugs" => "de" or "slugs" => ["language" => "de"]
$slugsLanguage = is_string($slugsOption) === true ? $slugsOption : ($slugsOption['language'] ?? null);
// load custom slugs language if it's defined
if ($slugsLanguage !== null) {
$file = $this->root('i18n:rules') . '/' . $slugsLanguage . '.json';
if (F::exists($file) === true) {
try {
$data = Data::read($file);
} catch (Exception $e) {
$data = [];
}
Str::$language = $data;
}
if ($slugs = $slugs['language'] ?? $slugs ?? null) {
Str::$language = Language::loadRules($slugs);
}
}
}
@@ -99,7 +85,7 @@ trait AppTranslations
public function setCurrentLanguage(string $languageCode = null)
{
if ($this->multilang() === false) {
$this->setLocale($this->option('locale', 'en_US.utf-8'));
Locale::set($this->option('locale', 'en_US.utf-8'));
return $this->language = null;
}
@@ -110,9 +96,12 @@ trait AppTranslations
}
if ($this->language) {
$this->setLocale($this->language->locale());
Locale::set($this->language->locale());
}
// add language slug rules to Str class
Str::$language = $this->language->rules();
return $this->language;
}
@@ -131,18 +120,13 @@ trait AppTranslations
/**
* Set locale settings
*
* @internal
* @deprecated 3.5.0 Use \Kirby\Toolkit\Locale::set() instead
*
* @param string|array $locale
*/
public function setLocale($locale): void
{
if (is_array($locale) === true) {
foreach ($locale as $key => $value) {
setlocale($key, $value);
}
} else {
setlocale(LC_ALL, $locale);
}
Locale::set($locale);
}
/**

View File

@@ -35,9 +35,12 @@ trait AppUsers
}
/**
* Become any existing user
* Become any existing user or disable the current user
*
* @param string|null $who User ID or email address
* @param string|null $who User ID or email address,
* `null` to use the actual user again,
* `'kirby'` for a virtual admin user or
* `'nobody'` to disable the actual user
* @param Closure|null $callback Optional action function that will be run with
* the permissions of the impersonated user; the
* impersonation will be reset afterwards

View File

@@ -4,10 +4,12 @@ namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Exception\PermissionException;
use Kirby\Http\Idn;
use Kirby\Http\Request\Auth\BasicAuth;
use Kirby\Toolkit\A;
use Kirby\Toolkit\F;
use Throwable;
@@ -22,6 +24,14 @@ use Throwable;
*/
class Auth
{
/**
* Available auth challenge classes
* from the core and plugins
*
* @var array
*/
public static $challenges = [];
protected $impersonate;
protected $kirby;
protected $user = false;
@@ -36,6 +46,105 @@ class Auth
$this->kirby = $kirby;
}
/**
* Creates an authentication challenge
* (one-time auth code)
*
* @param string $email
* @param bool $long If `true`, a long session will be created
* @param string $mode Either 'login' or 'password-reset'
* @return string|null Name of the challenge that was created;
* `null` if the user does not exist or no
* challenge was available for the user
*
* @throws \Kirby\Exception\LogicException If there is no suitable authentication challenge (only in debug mode)
* @throws \Kirby\Exception\NotFoundException If the user does not exist (only in debug mode)
* @throws \Kirby\Exception\PermissionException If the rate limit is exceeded
*/
public function createChallenge(string $email, bool $long = false, string $mode = 'login'): ?string
{
// ensure that email addresses with IDN domains are in Unicode format
$email = Idn::decodeEmail($email);
if ($this->isBlocked($email) === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
if ($this->kirby->option('debug') === true) {
$message = 'Rate limit exceeded';
} else {
// avoid leaking security-relevant information
$message = ['key' => 'access.login'];
}
throw new PermissionException($message);
}
// rate-limit the number of challenges for DoS/DDoS protection
$this->track($email, false);
$session = $this->kirby->session([
'createMode' => 'cookie',
'long' => $long === true
]);
$challenge = null;
if ($user = $this->kirby->users()->find($email)) {
$timeout = $this->kirby->option('auth.challenge.timeout', 10 * 60);
$challenges = $this->kirby->option('auth.challenges', ['email']);
foreach (A::wrap($challenges) as $name) {
$class = static::$challenges[$name] ?? null;
if (
$class &&
class_exists($class) === true &&
is_subclass_of($class, 'Kirby\Cms\Auth\Challenge') === true &&
$class::isAvailable($user, $mode) === true
) {
$challenge = $name;
$code = $class::create($user, compact('mode', 'timeout'));
$session->set('kirby.challenge.type', $challenge);
if ($code !== null) {
$session->set('kirby.challenge.code', password_hash($code, PASSWORD_DEFAULT));
$session->set('kirby.challenge.timeout', time() + $timeout);
}
break;
}
}
// if no suitable challenge was found, `$challenge === null` at this point;
// only leak this in debug mode, otherwise `null` is returned below
if ($challenge === null && $this->kirby->option('debug') === true) {
throw new LogicException('Could not find a suitable authentication challenge');
}
} else {
$this->kirby->trigger('user.login:failed', compact('email'));
// only leak the non-existing user in debug mode
if ($this->kirby->option('debug') === true) {
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
}
}
// always set the email, even if the challenge won't be
// created to avoid leaking whether the user exists
$session->set('kirby.challenge.email', $email);
// sleep for a random amount of milliseconds
// to make automated attacks harder and to
// avoid leaking whether the user exists
usleep(random_int(1000, 300000));
return $challenge;
}
/**
* Returns the csrf token if it exists and if it is valid
*
@@ -73,6 +182,19 @@ class Auth
throw new PermissionException('Basic authentication is not activated');
}
// if logging in with password is disabled, basic auth cannot be possible either
$loginMethods = $this->kirby->system()->loginMethods();
if (isset($loginMethods['password']) !== true) {
throw new PermissionException('Login with password is not enabled');
}
// if any login method requires 2FA, basic auth without 2FA would be a weakness
foreach ($loginMethods as $method) {
if (isset($method['2fa']) === true && $method['2fa'] === true) {
throw new PermissionException('Basic authentication cannot be used with 2FA');
}
}
$request = $this->kirby->request();
$auth = $auth ?? $request->auth();
@@ -118,7 +240,7 @@ class Auth
$session = $this->kirby->session(['detect' => true]);
}
$id = $session->data()->get('user.id');
$id = $session->data()->get('kirby.userId');
if (is_string($id) !== true) {
return null;
@@ -135,9 +257,12 @@ class Auth
}
/**
* Become any existing user
* Become any existing user or disable the current user
*
* @param string|null $who User ID or email address
* @param string|null $who User ID or email address,
* `null` to use the actual user again,
* `'kirby'` for a virtual admin user or
* `'nobody'` to disable the actual user
* @return \Kirby\Cms\User|null
* @throws \Kirby\Exception\NotFoundException if the given user cannot be found
*/
@@ -152,6 +277,12 @@ class Auth
'id' => 'kirby',
'role' => 'admin',
]);
case 'nobody':
return $this->impersonate = new User([
'email' => 'nobody@getkirby.com',
'id' => 'nobody',
'role' => 'nobody',
]);
default:
if ($user = $this->kirby->users()->find($who)) {
return $this->impersonate = $user;
@@ -231,6 +362,25 @@ class Auth
return $user;
}
/**
* Login a user by email, password and auth challenge
*
* @param string $email
* @param string $password
* @param bool $long
* @return string|null Name of the challenge that was created;
* `null` if no challenge was available for the user
*
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occured with debug mode off
* @throws \Kirby\Exception\NotFoundException If the email was invalid
* @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`)
*/
public function login2fa(string $email, string $password, bool $long = false)
{
$this->validatePassword($email, $password);
return $this->createChallenge($email, $long, '2fa');
}
/**
* Sets a user object as the current user in the cache
* @internal
@@ -271,7 +421,7 @@ class Auth
$message = 'Rate limit exceeded';
} else {
// avoid leaking security-relevant information
$message = 'Invalid email or password';
$message = ['key' => 'access.login'];
}
throw new PermissionException($message);
@@ -304,7 +454,7 @@ class Auth
if ($this->kirby->option('debug') === true) {
throw $e;
} else {
throw new PermissionException('Invalid email or password');
throw new PermissionException(['key' => 'access.login']);
}
}
}
@@ -377,6 +527,13 @@ class Auth
if ($user = $this->user()) {
$user->logout();
}
// clear the pending challenge
$session = $this->kirby->session();
$session->remove('kirby.challenge.code');
$session->remove('kirby.challenge.email');
$session->remove('kirby.challenge.timeout');
$session->remove('kirby.challenge.type');
}
/**
@@ -394,12 +551,15 @@ class Auth
/**
* Tracks a login
*
* @param string $email
* @param string|null $email
* @param bool $triggerHook If `false`, no user.login:failed hook is triggered
* @return bool
*/
public function track(string $email): bool
public function track(?string $email, bool $triggerHook = true): bool
{
$this->kirby->trigger('user.login:failed', compact('email'));
if ($triggerHook === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
}
$ip = $this->ipHash();
$log = $this->log();
@@ -417,7 +577,7 @@ class Auth
];
}
if ($this->kirby->users()->find($email)) {
if ($email !== null && $this->kirby->users()->find($email)) {
if (isset($log['by-email'][$email]) === true) {
$log['by-email'][$email] = [
'time' => $time,
@@ -462,7 +622,7 @@ class Auth
* @param \Kirby\Session\Session|array|null $session
* @param bool $allowImpersonation If set to false, only the actually
* logged in user will be returned
* @return \Kirby\Cms\User
* @return \Kirby\Cms\User|null
*
* @throws \Throwable If an authentication error occured
*/
@@ -499,4 +659,89 @@ class Auth
throw $e;
}
}
/**
* Verifies an authentication code that was
* requested with the `createChallenge()` method;
* if successful, the user is automatically logged in
*
* @param string $code User-provided auth code to verify
* @return \Kirby\Cms\User User object of the logged-in user
*
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded, the challenge timed out, the code
* is incorrect or if any other error occured with debug mode off
* @throws \Kirby\Exception\NotFoundException If the user from the challenge doesn't exist
* @throws \Kirby\Exception\InvalidArgumentException If no authentication challenge is active
* @throws \Kirby\Exception\LogicException If the authentication challenge is invalid
*/
public function verifyChallenge(string $code)
{
try {
$session = $this->kirby->session();
// first check if we have an active challenge at all
$email = $session->get('kirby.challenge.email');
$challenge = $session->get('kirby.challenge.type');
if (is_string($email) !== true || is_string($challenge) !== true) {
throw new InvalidArgumentException('No authentication challenge is active');
}
$user = $this->kirby->users()->find($email);
if ($user === null) {
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
}
// rate-limiting
if ($this->isBlocked($email) === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
throw new PermissionException('Rate limit exceeded');
}
// time-limiting
$timeout = $session->get('kirby.challenge.timeout');
if ($timeout !== null && time() > $timeout) {
throw new PermissionException('Authentication challenge timeout');
}
if (
isset(static::$challenges[$challenge]) === true &&
class_exists(static::$challenges[$challenge]) === true &&
is_subclass_of(static::$challenges[$challenge], 'Kirby\Cms\Auth\Challenge') === true
) {
$class = static::$challenges[$challenge];
if ($class::verify($user, $code) === true) {
$this->logout();
$user->loginPasswordless();
return $user;
} else {
throw new PermissionException(['key' => 'access.code']);
}
}
throw new LogicException('Invalid authentication challenge: ' . $challenge);
} catch (Throwable $e) {
if ($e->getMessage() !== 'Rate limit exceeded') {
$this->track($email);
}
// sleep for a random amount of milliseconds
// to make automated attacks harder and to
// avoid leaking whether the user exists
usleep(random_int(1000, 2000000));
// keep throwing the original error in debug mode,
// otherwise hide it to avoid leaking security-relevant information
if ($this->kirby->option('debug') === true) {
throw $e;
} else {
throw new PermissionException(['key' => 'access.code']);
}
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Kirby\Cms\Auth;
use Kirby\Cms\User;
/**
* Template class for authentication challenges
* that create and verify one-time auth codes
*
* @package Kirby Cms
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
abstract class Challenge
{
/**
* Checks whether the challenge is available
* for the passed user and purpose
*
* @param \Kirby\Cms\User $user User the code will be generated for
* @param string $mode Purpose of the code ('login', 'reset' or '2fa')
* @return bool
*/
abstract public static function isAvailable(User $user, string $mode): bool;
/**
* Generates a random one-time auth code and returns that code
* for later verification
*
* @param \Kirby\Cms\User $user User to generate the code for
* @param array $options Details of the challenge request:
* - 'mode': Purpose of the code ('login', 'reset' or '2fa')
* - 'timeout': Number of seconds the code will be valid for
* @return string|null The generated and sent code or `null` in case
* there was no code to generate by this algorithm
*/
abstract public static function create(User $user, array $options): ?string;
/**
* Verifies the provided code against the created one;
* default implementation that checks the code that was
* returned from the `create()` method
*
* @param \Kirby\Cms\User $user User to check the code for
* @param string $code Code to verify
* @return bool
*/
public static function verify(User $user, string $code): bool
{
$hash = $user->kirby()->session()->get('kirby.challenge.code');
if (is_string($hash) !== true) {
return false;
}
// normalize the formatting in the user-provided code
$code = str_replace(' ', '', $code);
return password_verify($code, $hash);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Kirby\Cms\Auth;
use Kirby\Cms\User;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* Creates and verifies one-time auth codes
* that are sent via email
*
* @package Kirby Cms
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class EmailChallenge extends Challenge
{
/**
* Checks whether the challenge is available
* for the passed user and purpose
*
* @param \Kirby\Cms\User $user User the code will be generated for
* @param string $mode Purpose of the code ('login', 'reset' or '2fa')
* @return bool
*/
public static function isAvailable(User $user, string $mode): bool
{
return true;
}
/**
* Generates a random one-time auth code and returns that code
* for later verification
*
* @param \Kirby\Cms\User $user User to generate the code for
* @param array $options Details of the challenge request:
* - 'mode': Purpose of the code ('login', 'reset' or '2fa')
* - 'timeout': Number of seconds the code will be valid for
* @return string The generated and sent code
*/
public static function create(User $user, array $options): string
{
$code = Str::random(6, 'num');
// insert a space in the middle for easier readability
$formatted = substr($code, 0, 3) . ' ' . substr($code, 3, 3);
// use the login templates for 2FA
$mode = $options['mode'];
if ($mode === '2fa') {
$mode = 'login';
}
$kirby = $user->kirby();
$kirby->email([
'from' => $kirby->option('auth.challenge.email.from', 'noreply@' . $kirby->system()->indexUrl()),
'fromName' => $kirby->option('auth.challenge.email.fromName', $kirby->site()->title()),
'to' => $user,
'subject' => $kirby->option(
'auth.challenge.email.subject',
I18n::translate('login.email.' . $mode . '.subject')
),
'template' => 'auth/' . $mode,
'data' => [
'user' => $user,
'code' => $formatted,
'timeout' => round($options['timeout'] / 60)
]
]);
return $code;
}
}

217
kirby/src/Cms/Block.php Executable file
View File

@@ -0,0 +1,217 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Throwable;
/**
* Represents a single block
* which can be inspected further or
* converted to HTML
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Block extends Item
{
const ITEMS_CLASS = '\Kirby\Cms\Blocks';
/**
* @var \Kirby\Cms\Content
*/
protected $content;
/**
* @var bool
*/
protected $isHidden;
/**
* @var string
*/
protected $type;
/**
* Proxy for content fields
*
* @param string $method
* @param array $args
* @return \Kirby\Cms\Field
*/
public function __call(string $method, array $args = [])
{
return $this->content()->get($method);
}
/**
* Creates a new block object
*
* @param array $params
* @param \Kirby\Cms\Blocks $siblings
*/
public function __construct(array $params)
{
parent::__construct($params);
// import old builder format
$params = BlockConverter::builderBlock($params);
$params = BlockConverter::editorBlock($params);
if (isset($params['type']) === false) {
throw new InvalidArgumentException('The block type is missing');
}
$this->content = $params['content'] ?? [];
$this->isHidden = $params['isHidden'] ?? false;
$this->type = $params['type'] ?? null;
// create the content object
$this->content = new Content($this->content, $this->parent);
}
/**
* Converts the object to a string
*
* @return string
*/
public function __toString(): string
{
return $this->toHtml();
}
/**
* Deprecated method to return the block type
*
* @return string
*/
public function _key(): string
{
return $this->type();
}
/**
* Deprecated method to return the block id
*
* @return string
*/
public function _uid(): string
{
return $this->id();
}
/**
* Returns the content object
*
* @return \Kirby\Cms\Content
*/
public function content()
{
return $this->content;
}
/**
* Controller for the block snippet
*
* @return array
*/
public function controller(): array
{
return [
'block' => $this,
'content' => $this->content(),
// deprecated block data
'data' => $this,
'id' => $this->id(),
'prev' => $this->prev(),
'next' => $this->next()
];
}
/**
* Checks if the block is empty
*
* @return bool
*/
public function isEmpty(): bool
{
return empty($this->content()->toArray());
}
/**
* Checks if the block is hidden
* from being rendered in the frontend
*
* @return bool
*/
public function isHidden(): bool
{
return $this->isHidden;
}
/**
* Checks if the block is not empty
*
* @return bool
*/
public function isNotEmpty(): bool
{
return $this->isEmpty() === false;
}
/**
* Returns the block type
*
* @return string
*/
public function type(): string
{
return $this->type;
}
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{
return [
'content' => $this->content()->toArray(),
'id' => $this->id(),
'isHidden' => $this->isHidden(),
'type' => $this->type(),
];
}
/**
* Converts the block to html first
* and then places that inside a field
* object. This can be used further
* with all available field methods
*
* @return \Kirby\Cms\Field;
*/
public function toField()
{
return new Field($this->parent(), $this->id(), $this->toHtml());
}
/**
* Converts the block to HTML
*
* @return string
*/
public function toHtml(): string
{
try {
return (string)snippet('blocks/' . $this->type(), $this->controller(), true);
} catch (Throwable $e) {
return '<p>Block error: "' . $e->getMessage() . '" in block type: "' . $this->type() . '"</p>';
}
}
}

258
kirby/src/Cms/BlockConverter.php Executable file
View File

@@ -0,0 +1,258 @@
<?php
namespace Kirby\Cms;
class BlockConverter
{
public static function builderBlock(array $params): array
{
if (isset($params['_key']) === false) {
return $params;
}
$params['type'] = $params['_key'];
$params['content'] = $params;
unset($params['_uid']);
return $params;
}
public static function editorBlock(array $params): array
{
if (static::isEditorBlock($params) === false) {
return $params;
}
$method = 'editor' . $params['type'];
if (method_exists(static::class, $method) === true) {
$params = static::$method($params);
} else {
$params = static::editorCustom($params);
}
return $params;
}
public static function editorBlocks(array $blocks = []): array
{
if (empty($blocks) === true) {
return $blocks;
}
if (static::isEditorBlock($blocks[0]) === false) {
return $blocks;
}
$list = [];
$listStart = null;
foreach ($blocks as $index => $block) {
if (in_array($block['type'], ['ul', 'ol']) === true) {
$prev = $blocks[$index-1] ?? null;
$next = $blocks[$index+1] ?? null;
// new list starts here
if (!$prev || $prev['type'] !== $block['type']) {
$listStart = $index;
}
// add the block to the list
$list[] = $block;
// list ends here
if (!$next || $next['type'] !== $block['type']) {
$blocks[$listStart] = [
'content' => [
'text' =>
'<' . $block['type'] . '>' .
implode(array_map(function ($item) {
return '<li>' . $item['content'] . '</li>';
}, $list)) .
'</' . $block['type'] . '>',
],
'type' => 'list'
];
for ($x = $listStart+1; $x <= $listStart + count($list); $x++) {
$blocks[$x] = false;
}
$listStart = null;
$list = [];
}
} else {
$blocks[$index] = static::editorBlock($block);
}
}
return array_filter($blocks);
}
public static function editorBlockquote(array $params): array
{
return [
'content' => [
'text' => $params['content']
],
'type' => 'quote'
];
}
public static function editorCode(array $params): array
{
return [
'content' => [
'language' => $params['attrs']['language'] ?? null,
'code' => $params['content']
],
'type' => 'code'
];
}
public static function editorCustom(array $params): array
{
return [
'content' => array_merge(
$params['attrs'] ?? [],
[
'body' => $params['content'] ?? null
]
),
'type' => $params['type'] ?? 'unknown'
];
}
public static function editorH1(array $params): array
{
return static::editorHeading($params, 'h1');
}
public static function editorH2(array $params): array
{
return static::editorHeading($params, 'h2');
}
public static function editorH3(array $params): array
{
return static::editorHeading($params, 'h3');
}
public static function editorH4(array $params): array
{
return static::editorHeading($params, 'h4');
}
public static function editorH5(array $params): array
{
return static::editorHeading($params, 'h5');
}
public static function editorH6(array $params): array
{
return static::editorHeading($params, 'h6');
}
public static function editorHeading(array $params, string $level): array
{
return [
'content' => [
'level' => $level,
'text' => $params['content']
],
'type' => 'heading'
];
}
public static function editorImage(array $params): array
{
// internal image
if (isset($params['attrs']['id']) === true) {
return [
'content' => [
'alt' => $params['attrs']['alt'] ?? null,
'caption' => $params['attrs']['caption'] ?? null,
'image' => $params['attrs']['id'] ?? $params['attrs']['src'] ?? null,
'location' => 'kirby',
'ratio' => $params['attrs']['ratio'] ?? null,
],
'type' => 'image'
];
}
return [
'content' => [
'alt' => $params['attrs']['alt'] ?? null,
'caption' => $params['attrs']['caption'] ?? null,
'src' => $params['attrs']['src'] ?? null,
'location' => 'web',
'ratio' => $params['attrs']['ratio'] ?? null,
],
'type' => 'image'
];
}
public static function editorKirbytext(array $params): array
{
return [
'content' => [
'text' => $params['content']
],
'type' => 'markdown'
];
}
public static function editorOl(array $params): array
{
return [
'content' => [
'text' => $params['content']
],
'type' => 'list'
];
}
public static function editorParagraph(array $params): array
{
return [
'content' => [
'text' => $params['content']
],
'type' => 'text'
];
}
public static function editorUl(array $params): array
{
return [
'content' => [
'text' => $params['content']
],
'type' => 'list'
];
}
public static function editorVideo(array $params): array
{
return [
'content' => [
'caption' => $params['attrs']['caption'] ?? null,
'url' => $params['attrs']['src'] ?? null
],
'type' => 'video'
];
}
public static function isEditorBlock(array $params): bool
{
if (isset($params['attrs']) === true) {
return true;
}
if (is_string($params['content'] ?? null) === true) {
return true;
}
return false;
}
}

149
kirby/src/Cms/Blocks.php Executable file
View File

@@ -0,0 +1,149 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Json;
use Kirby\Data\Yaml;
use Kirby\Parsley\Parsley;
use Kirby\Parsley\Schema\Blocks as BlockSchema;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use Throwable;
/**
* A collection of blocks
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Blocks extends Items
{
const ITEM_CLASS = '\Kirby\Cms\Block';
/**
* Return HTML when the collection is
* converted to a string
*
* @return string
*/
public function __toString(): string
{
return $this->toHtml();
}
/**
* Converts the blocks to HTML and then
* uses the Str::excerpt method to create
* a non-formatted, shortened excerpt from it
*
* @param mixed ...$args
* @return string
*/
public function excerpt(...$args)
{
return Str::excerpt($this->toHtml(), ...$args);
}
/**
* Wrapper around the factory to
* catch blocks from layouts
*
* @param array $items
* @param array $params
* @return \Kirby\Cms\Blocks
*/
public static function factory(array $blocks = null, array $params = [])
{
$blocks = static::extractFromLayouts($blocks);
$blocks = BlockConverter::editorBlocks($blocks);
return parent::factory($blocks, $params);
}
/**
* Pull out blocks from layouts
*
* @param array $input
* @return array
*/
protected static function extractFromLayouts(array $input): array
{
if (empty($input) === true) {
return [];
}
// no layouts
if (array_key_exists('columns', $input[0]) === false) {
return $input;
}
$blocks = [];
foreach ($input as $layout) {
foreach (($layout['columns'] ?? []) as $column) {
foreach (($column['blocks'] ?? []) as $block) {
$blocks[] = $block;
}
}
}
return $blocks;
}
/**
* Parse and sanitize various block formats
*
* @param array|string $input
* @return array
*/
public static function parse($input): array
{
if (empty($input) === false && is_array($input) === false) {
try {
$input = Json::decode((string)$input);
} catch (Throwable $e) {
try {
// try to import the old YAML format
$yaml = Yaml::decode((string)$input);
$first = A::first($yaml);
// check for valid yaml
if (empty($yaml) === true || (isset($first['_key']) === false && isset($first['type']) === false)) {
throw new Exception('Invalid YAML');
} else {
$input = $yaml;
}
} catch (Throwable $e) {
$parser = new Parsley((string)$input, new BlockSchema());
$input = $parser->blocks();
}
}
}
if (empty($input) === true) {
return [];
}
return $input;
}
/**
* Convert all blocks to HTML
*
* @return string
*/
public function toHtml(): string
{
$html = [];
foreach ($this->data as $block) {
$html[] = $block->toHtml();
}
return implode($html);
}
}

View File

@@ -201,6 +201,7 @@ class Blueprint
try {
$mixin = static::find($extends);
$mixin = static::extend($mixin);
$props = A::merge($mixin, $props, A::MERGE_REPLACE);
} catch (Exception $e) {
// keep the props unextended if the snippet wasn't found

View File

@@ -3,7 +3,6 @@
namespace Kirby\Cms;
use Closure;
use Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Collection as BaseCollection;
use Kirby\Toolkit\Str;
@@ -89,7 +88,7 @@ class Collection extends BaseCollection
{
if (is_a($object, static::class) === true) {
$this->data = array_merge($this->data, $object->data);
} elseif (method_exists($object, 'id') === true) {
} elseif (is_object($object) === true && method_exists($object, 'id') === true) {
$this->__set($object->id(), $object);
} else {
$this->append($object);
@@ -121,45 +120,45 @@ class Collection extends BaseCollection
}
/**
* Groups the items by a given field. Returns a collection
* Groups the items by a given field or callback. Returns a collection
* with an item for each group and a collection for each group.
*
* @param string $field
* @param string|Closure $field
* @param bool $i Ignore upper/lowercase for group names
* @return \Kirby\Cms\Collection
* @throws \Kirby\Exception\Exception
*/
public function groupBy($field, bool $i = true)
public function group($field, bool $i = true)
{
if (is_string($field) === false) {
throw new Exception('Cannot group by non-string values. Did you mean to call group()?');
if (is_string($field) === true) {
$groups = new Collection([], $this->parent());
foreach ($this->data as $key => $item) {
$value = $this->getAttribute($item, $field);
// make sure that there's always a proper value to group by
if (!$value) {
throw new InvalidArgumentException('Invalid grouping value for key: ' . $key);
}
// ignore upper/lowercase for group names
if ($i) {
$value = Str::lower($value);
}
if (isset($groups->data[$value]) === false) {
// create a new entry for the group if it does not exist yet
$groups->data[$value] = new static([$key => $item]);
} else {
// add the item to an existing group
$groups->data[$value]->set($key, $item);
}
}
return $groups;
}
$groups = new Collection([], $this->parent());
foreach ($this->data as $key => $item) {
$value = $this->getAttribute($item, $field);
// make sure that there's always a proper value to group by
if (!$value) {
throw new InvalidArgumentException('Invalid grouping value for key: ' . $key);
}
// ignore upper/lowercase for group names
if ($i) {
$value = Str::lower($value);
}
if (isset($groups->data[$value]) === false) {
// create a new entry for the group if it does not exist yet
$groups->data[$value] = new static([$key => $item]);
} else {
// add the item to an existing group
$groups->data[$value]->set($key, $item);
}
}
return $groups;
return parent::group($field, $i);
}
/**
@@ -204,6 +203,7 @@ class Collection extends BaseCollection
public function not(...$keys)
{
$collection = $this->clone();
foreach ($keys as $key) {
if (is_array($key) === true) {
return $this->not(...$key);
@@ -212,8 +212,10 @@ class Collection extends BaseCollection
} elseif (is_object($key) === true) {
$key = $key->id();
}
unset($collection->$key);
unset($collection->{$key});
}
return $collection;
}
@@ -264,7 +266,7 @@ class Collection extends BaseCollection
}
/**
* Runs a combination of filterBy, sortBy, not
* Runs a combination of filter, sort, not,
* offset, limit, search and paginate on the collection.
* Any part of the query is optional.
*

View File

@@ -157,7 +157,7 @@ class Event
* @param \Closure $hook
* @return mixed
*/
public function call($bind = null, Closure $hook)
public function call(?object $bind, Closure $hook)
{
// collect the list of possible hook arguments
$data = $this->arguments();

View File

@@ -96,7 +96,7 @@ class Field
* @param string $key
* @param mixed $value
*/
public function __construct($parent = null, string $key, $value)
public function __construct(?object $parent, string $key, $value)
{
$this->key = $key;
$this->value = $value;

206
kirby/src/Cms/Fieldset.php Executable file
View File

@@ -0,0 +1,206 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* Represents a single Fieldset
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Fieldset extends Item
{
const ITEMS_CLASS = '\Kirby\Cms\Fieldsets';
protected $disabled;
protected $fields = [];
protected $icon;
protected $label;
protected $model;
protected $name;
protected $preview;
protected $tabs;
protected $translate;
protected $type;
protected $unset;
protected $wysiwyg;
/**
* Creates a new Fieldset object
*
* @param array $params
*/
public function __construct(array $params = [])
{
if (empty($params['type']) === true) {
throw new InvalidArgumentException('The fieldset type is missing');
}
$this->type = $params['id'] = $params['type'];
parent::__construct($params);
$this->disabled = $params['disabled'] ?? false;
$this->icon = $params['icon'] ?? null;
$this->model = $this->parent;
$this->kirby = $this->parent->kirby();
$this->name = $this->createName($params['name'] ?? Str::ucfirst($this->type));
$this->label = $this->createLabel($params['label'] ?? null);
$this->preview = $params['preview'] ?? null;
$this->tabs = $this->createTabs($params);
$this->translate = $params['translate'] ?? true;
$this->unset = $params['unset'] ?? false;
$this->wysiwyg = $params['wysiwyg'] ?? false;
if (
$this->translate === false &&
$this->kirby->multilang() === true &&
$this->kirby->language()->isDefault() === false
) {
// disable and unset the fieldset if it's not translatable
$this->unset = true;
$this->disabled = true;
}
}
protected function createFields(array $fields = []): array
{
$fields = Blueprint::fieldsProps($fields);
$fields = $this->form($fields)->fields()->toArray();
// collect all fields
$this->fields = array_merge($this->fields, $fields);
return $fields;
}
protected function createName($name): string
{
return I18n::translate($name, $name);
}
protected function createLabel($label = null): ?string
{
return I18n::translate($label, $label);
}
protected function createTabs(array $params = []): array
{
$tabs = $params['tabs'] ?? [];
// return a single tab if there are only fields
if (empty($tabs) === true) {
return [
'content' => [
'fields' => $this->createFields($params['fields'] ?? []),
]
];
}
// normalize tabs props
foreach ($tabs as $name => $tab) {
$tab = Blueprint::extend($tab);
$tab['fields'] = $this->createFields($tab['fields'] ?? []);
$tab['label'] = $this->createLabel($tab['label'] ?? Str::ucfirst($name));
$tab['name'] = $name;
$tabs[$name] = $tab;
}
return $tabs;
}
public function disabled(): bool
{
return $this->disabled;
}
public function fields(): array
{
return $this->fields;
}
/**
* Creates a form for the given fields
*
* @param array $fields
* @param array $input
* @return \Kirby\Cms\Form
*/
public function form(array $fields, array $input = [])
{
return new Form([
'fields' => $fields,
'model' => $this->model,
'strict' => true,
'values' => $input,
]);
}
public function icon(): ?string
{
return $this->icon;
}
public function label(): ?string
{
return $this->label;
}
public function model()
{
return $this->model;
}
public function name(): string
{
return $this->name;
}
public function tabs(): array
{
return $this->tabs;
}
public function translate(): bool
{
return $this->translate;
}
public function type(): string
{
return $this->type;
}
/**
* @return array
*/
public function toArray(): array
{
return [
'disabled' => $this->disabled,
'icon' => $this->icon,
'label' => $this->label,
'name' => $this->name,
'preview' => $this->preview,
'tabs' => $this->tabs,
'translate' => $this->translate,
'type' => $this->type,
'unset' => $this->unset,
'wysiwyg' => $this->wysiwyg,
];
}
public function unset(): bool
{
return $this->unset;
}
}

99
kirby/src/Cms/Fieldsets.php Executable file
View File

@@ -0,0 +1,99 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* A collection of fieldsets
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Fieldsets extends Items
{
const ITEM_CLASS = '\Kirby\Cms\Fieldset';
protected static function createFieldsets($params)
{
$fieldsets = [];
$groups = [];
foreach ($params as $type => $fieldset) {
if (is_int($type) === true && is_string($fieldset)) {
$type = $fieldset;
$fieldset = 'blocks/' . $type;
}
if ($fieldset === false) {
continue;
}
if ($fieldset === true) {
$fieldset = 'blocks/' . $type;
}
$fieldset = Blueprint::extend($fieldset);
// make sure the type is always set
$fieldset['type'] = $fieldset['type'] ?? $type;
// extract groups
if ($fieldset['type'] === 'group') {
$result = static::createFieldsets($fieldset['fieldsets'] ?? []);
$fieldsets = array_merge($fieldsets, $result['fieldsets']);
$label = $fieldset['label'] ?? Str::ucfirst($type);
$groups[$type] = [
'label' => I18n::translate($label, $label),
'name' => $type,
'open' => $fieldset['open'] ?? true,
'sets' => array_column($result['fieldsets'], 'type'),
];
} else {
$fieldsets[$fieldset['type']] = $fieldset;
}
}
return [
'fieldsets' => $fieldsets,
'groups' => $groups
];
}
public static function factory(array $fieldsets = null, array $options = [])
{
$fieldsets = $fieldsets ?? option('blocks.fieldsets', [
'code' => 'blocks/code',
'gallery' => 'blocks/gallery',
'heading' => 'blocks/heading',
'image' => 'blocks/image',
'list' => 'blocks/list',
'markdown' => 'blocks/markdown',
'quote' => 'blocks/quote',
'text' => 'blocks/text',
'video' => 'blocks/video',
]);
$result = static::createFieldsets($fieldsets);
return parent::factory($result['fieldsets'], ['groups' => $result['groups']] + $options);
}
public function groups(): array
{
return $this->options['groups'] ?? [];
}
public function toArray(?Closure $map = null): array
{
return array_map($map ?? function ($fieldset) {
return $fieldset->toArray();
}, $this->data);
}
}

View File

@@ -385,19 +385,6 @@ class File extends ModelWithContent
return $this->parent()->mediaUrl() . '/' . $this->mediaHash() . '/' . $this->filename();
}
/**
* @deprecated 3.0.0 Use `File::content()` instead
*
* @return \Kirby\Cms\Content
* @codeCoverageIgnore
*/
public function meta()
{
deprecated('$file->meta() is deprecated, use $file->content() instead. $file->meta() will be removed in Kirby 3.5.0.');
return $this->content();
}
/**
* Get the file's last modification time.
*
@@ -758,7 +745,7 @@ class File extends ModelWithContent
*/
public function templateSiblings(bool $self = true)
{
return $this->siblings($self)->filterBy('template', $this->template());
return $this->siblings($self)->filter('template', $this->template());
}
/**

View File

@@ -264,21 +264,6 @@ trait FileActions
return $this;
}
/**
* @deprecated 3.0.0 Use `File::changeName()` instead
*
* @param string $name
* @param bool $sanitize
* @return self
* @codeCoverageIgnore
*/
public function rename(string $name, bool $sanitize = true)
{
deprecated('$file->rename() is deprecated, use $file->changeName() instead. $file->rename() will be removed in Kirby 3.5.0.');
return $this->changeName($name, $sanitize);
}
/**
* Replaces the file. The source must
* be an absolute path to a file or a Url.

View File

@@ -2,6 +2,9 @@
namespace Kirby\Cms;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
/**
* Extension of the basic blueprint class
* to handle all blueprints for files.
@@ -14,6 +17,14 @@ namespace Kirby\Cms;
*/
class FileBlueprint extends Blueprint
{
/**
* `true` if the default accepted
* types are being used
*
* @var bool
*/
protected $defaultTypes = false;
public function __construct(array $props)
{
parent::__construct($props);
@@ -44,6 +55,64 @@ class FileBlueprint extends Blueprint
return $this->props['accept'];
}
/**
* Returns the list of all accepted MIME types for
* file upload or `*` if all MIME types are allowed
*
* @return string
*/
public function acceptMime(): string
{
// don't disclose the specific default types
if ($this->defaultTypes === true) {
return '*';
}
$accept = $this->accept();
$restrictions = [];
if (is_array($accept['mime']) === true) {
$restrictions[] = $accept['mime'];
} else {
// only fall back to the extension or type if
// no explicit MIME types were defined
// (allows to set custom MIME types for the frontend
// check but still restrict the extension and/or type)
if (is_array($accept['extension']) === true) {
// determine the main MIME type for each extension
$restrictions[] = array_map(['Kirby\Toolkit\Mime', 'fromExtension'], $accept['extension']);
}
if (is_array($accept['type']) === true) {
// determine the MIME types of each file type
$mimes = [];
foreach ($accept['type'] as $type) {
if ($extensions = F::typeToExtensions($type)) {
$mimes[] = array_map(['Kirby\Toolkit\Mime', 'fromExtension'], $extensions);
}
}
$restrictions[] = array_merge(...$mimes);
}
}
if ($restrictions !== []) {
if (count($restrictions) > 1) {
// only return the MIME types that are allowed by all restrictions
$mimes = array_intersect(...$restrictions);
} else {
$mimes = $restrictions[0];
}
// filter out empty MIME types and duplicates
return implode(', ', array_filter(array_unique($mimes)));
}
// no restrictions, accept everything
return '*';
}
/**
* @param mixed $accept
* @return array
@@ -54,16 +123,20 @@ class FileBlueprint extends Blueprint
$accept = [
'mime' => $accept
];
}
// accept anything
if (empty($accept) === true) {
return [];
} elseif ($accept === true) {
// explicitly no restrictions at all
$accept = [
'mime' => null
];
} elseif (empty($accept) === true) {
// no custom restrictions
$accept = [];
}
$accept = array_change_key_case($accept);
$defaults = [
'extension' => null,
'mime' => null,
'maxheight' => null,
'maxsize' => null,
@@ -71,9 +144,38 @@ class FileBlueprint extends Blueprint
'minheight' => null,
'minsize' => null,
'minwidth' => null,
'orientation' => null
'orientation' => null,
'type' => null
];
return array_merge($defaults, $accept);
// default type restriction if none are configured;
// this ensures that no unexpected files are uploaded
if (
array_key_exists('mime', $accept) === false &&
array_key_exists('extension', $accept) === false &&
array_key_exists('type', $accept) === false
) {
$defaults['type'] = ['image', 'document', 'archive', 'audio', 'video'];
$this->defaultTypes = true;
}
$accept = array_merge($defaults, $accept);
// normalize the MIME, extension and type from strings into arrays
if (is_string($accept['mime']) === true) {
$accept['mime'] = array_map(function ($mime) {
return $mime['value'];
}, Str::accepted($accept['mime']));
}
if (is_string($accept['extension']) === true) {
$accept['extension'] = array_map('trim', explode(',', $accept['extension']));
}
if (is_string($accept['type']) === true) {
$accept['type'] = array_map('trim', explode(',', $accept['type']));
}
return $accept;
}
}

View File

@@ -132,6 +132,14 @@ class Files extends Collection
return $this;
}
return $this->filterBy('template', is_array($template) ? 'in' : '==', $template);
if ($template === 'default') {
$template = ['default', ''];
}
return $this->filter(
'template',
is_array($template) ? 'in' : '==',
$template
);
}
}

View File

@@ -2,7 +2,9 @@
namespace Kirby\Cms;
use Kirby\Exception\NotFoundException;
use Kirby\Form\Form as BaseForm;
use Kirby\Toolkit\Str;
/**
* Extension of `Kirby\Form\Form` that introduces
@@ -49,6 +51,42 @@ class Form extends BaseForm
parent::__construct($props);
}
/**
* Get the field object by name
* and handle nested fields correctly
*
* @param string $name
* @throws \Kirby\Exception\NotFoundException
* @return \Kirby\Form\Field
*/
public function field(string $name)
{
$form = $this;
$fieldNames = Str::split($name, '+');
$index = 0;
$count = count($fieldNames);
$field = null;
foreach ($fieldNames as $fieldName) {
$index++;
if ($field = $form->fields()->get($fieldName)) {
if ($count !== $index) {
$form = $field->form();
}
} else {
throw new NotFoundException('The field "' . $fieldName . '" could not be found');
}
}
// it can get this error only if $name is an empty string as $name = ''
if ($field === null) {
throw new NotFoundException('No field could be loaded');
}
return $field;
}
/**
* @param \Kirby\Cms\Model $model
* @param array $props

View File

@@ -174,18 +174,6 @@ trait HasChildren
return $this->drafts()->count() > 0;
}
/**
* @deprecated 3.0.0 Use `Page::hasUnlistedChildren()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasInvisibleChildren(): bool
{
deprecated('$page->hasInvisibleChildren() is deprecated, use $page->hasUnlistedChildren() instead. $page->hasInvisibleChildren() will be removed in Kirby 3.5.0.');
return $this->hasUnlistedChildren();
}
/**
* Checks if the page has any listed children
*
@@ -206,18 +194,6 @@ trait HasChildren
return $this->children()->unlisted()->count() > 0;
}
/**
* @deprecated 3.0.0 Use `Page::hasListedChildren()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasVisibleChildren(): bool
{
deprecated('$page->hasVisibleChildren() is deprecated, use $page->hasListedChildren() instead. $page->hasVisibleChildren() will be removed in Kirby 3.5.0.');
return $this->hasListedChildren();
}
/**
* Creates a flat child index
*

View File

@@ -27,7 +27,7 @@ trait HasFiles
*/
public function audio()
{
return $this->files()->filterBy('type', '==', 'audio');
return $this->files()->filter('type', '==', 'audio');
}
/**
@@ -37,7 +37,7 @@ trait HasFiles
*/
public function code()
{
return $this->files()->filterBy('type', '==', 'code');
return $this->files()->filter('type', '==', 'code');
}
/**
@@ -74,7 +74,7 @@ trait HasFiles
*/
public function documents()
{
return $this->files()->filterBy('type', '==', 'document');
return $this->files()->filter('type', '==', 'document');
}
/**
@@ -196,7 +196,7 @@ trait HasFiles
*/
public function images()
{
return $this->files()->filterBy('type', '==', 'image');
return $this->files()->filter('type', '==', 'image');
}
/**
@@ -221,6 +221,6 @@ trait HasFiles
*/
public function videos()
{
return $this->files()->filterBy('type', '==', 'video');
return $this->files()->filter('type', '==', 'video');
}
}

130
kirby/src/Cms/Item.php Executable file
View File

@@ -0,0 +1,130 @@
<?php
namespace Kirby\Cms;
/**
* The Item class is the foundation
* for every object in context with
* other objects. I.e.
*
* - a Block in a collection of Blocks
* - a Layout in a collection of Layouts
* - a Column in a collection of Columns
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Item
{
const ITEMS_CLASS = '\Kirby\Cms\Items';
use HasSiblings;
/**
* @var string
*/
protected $id;
/**
* @var array
*/
protected $params;
/**
* @var \Kirby\Cms\Items
*/
protected $siblings;
/**
* Creates a new item
*
* @param array $params
*/
public function __construct(array $params = [])
{
$siblingsClass = static::ITEMS_CLASS;
$this->id = $params['id'] ?? uuid();
$this->params = $params;
$this->parent = $params['parent'] ?? site();
$this->siblings = $params['siblings'] ?? new $siblingsClass();
}
/**
* Static Item factory
*
* @param array $params
* @return \Kirby\Cms\Item
*/
public static function factory(array $params)
{
return new static($params);
}
/**
* Returns the unique item id (UUID v4)
*
* @return string
*/
public function id(): string
{
return $this->id;
}
/**
* Compares the item to another one
*
* @param \Kirby\Cms\Item $item
* @return bool
*/
public function is(Item $item): bool
{
return $this->id() === $item->id();
}
/**
* Returns the Kirby instance
*
* @return \Kirby\Cms\App
*/
public function kirby()
{
return $this->parent()->kirby();
}
/**
* Returns the parent model
*
* @return \Kirby\Cms\Page | \Kirby\Cms\Site | \Kirby\Cms\File | \Kirby\Cms\User
*/
public function parent()
{
return $this->parent;
}
/**
* Returns the sibling collection
* This is required by the HasSiblings trait
*
* @return \Kirby\Editor\Blocks
*/
protected function siblingsCollection()
{
return $this->siblings;
}
/**
* Converts the item to an array
*
* @return array
*/
public function toArray(): array
{
return [
'id' => $this->id(),
];
}
}

96
kirby/src/Cms/Items.php Executable file
View File

@@ -0,0 +1,96 @@
<?php
namespace Kirby\Cms;
use Closure;
use Exception;
/**
* A collection of items
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Items extends Collection
{
const ITEM_CLASS = '\Kirby\Cms\Item';
/**
* @var array
*/
protected $options;
/**
* @var \Kirby\Cms\ModelWithContent
*/
protected $parent;
/**
* Constructor
*
* @param array $objects
* @param array $options
*/
public function __construct($objects = [], array $options = [])
{
$this->options = $options;
$this->parent = $options['parent'] ?? site();
parent::__construct($objects, $this->parent);
}
/**
* Creates a new item collection from a
* an array of item props
*
* @param array $items
* @param array $params
* @return \Kirby\Cms\Items
*/
public static function factory(array $items = null, array $params = [])
{
$options = array_merge([
'options' => [],
'parent' => site(),
], $params);
if (empty($items) === true || is_array($items) === false) {
return new static();
}
if (is_array($options) === false) {
throw new Exception('Invalid item options');
}
// create a new collection of blocks
$collection = new static([], $options);
foreach ($items as $params) {
if (is_array($params) === false) {
continue;
}
$params['options'] = $options['options'];
$params['parent'] = $options['parent'];
$params['siblings'] = $collection;
$class = static::ITEM_CLASS;
$item = $class::factory($params);
$collection->append($item->id(), $item);
}
return $collection;
}
/**
* Convert the items to an array
*
* @return array
*/
public function toArray(Closure $map = null): array
{
return array_values(parent::toArray($map));
}
}

View File

@@ -27,7 +27,11 @@ class KirbyTag extends \Kirby\Text\KirbyTag
{
$parent = $this->parent();
if (method_exists($parent, 'file') === true && $file = $parent->file($path)) {
if (
is_object($parent) === true &&
method_exists($parent, 'file') === true &&
$file = $parent->file($path)
) {
return $file;
}

View File

@@ -4,9 +4,9 @@ namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Locale;
use Kirby\Toolkit\Str;
use Throwable;
@@ -328,6 +328,28 @@ class Language extends Model
return $this->code;
}
/**
* Loads the language rules for provided locale code
*
* @param string $code
*/
public static function loadRules(string $code)
{
$kirby = kirby();
$code = Str::contains($code, '.') ? Str::before($code, '.') : $code;
$file = $kirby->root('i18n:rules') . '/' . $code . '.json';
if (F::exists($file) === false) {
$file = $kirby->root('i18n:rules') . '/' . Str::before($code, '_') . '.json';
}
try {
return Data::read($file);
} catch (\Exception $e) {
return [];
}
}
/**
* Returns the PHP locale setting array
*
@@ -343,45 +365,6 @@ class Language extends Model
}
}
/**
* Returns the locale array but with the locale
* constants replaced with their string representations
*
* @return array
*/
protected function localeExport(): array
{
// list of all possible constant names
$constantNames = [
'LC_ALL', 'LC_COLLATE', 'LC_CTYPE', 'LC_MONETARY',
'LC_NUMERIC', 'LC_TIME', 'LC_MESSAGES'
];
// build an associative array with the locales
// that are actually supported on this system
$constants = [];
foreach ($constantNames as $name) {
if (defined($name) === true) {
$constants[constant($name)] = $name;
}
}
// replace the keys in the locale data array with the locale names
$return = [];
foreach ($this->locale() as $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;
}
/**
* Returns the human-readable name
* of the language
@@ -454,19 +437,7 @@ class Language extends Model
public function rules(): array
{
$code = $this->locale(LC_CTYPE);
$code = Str::contains($code, '.') ? Str::before($code, '.') : $code;
$file = $this->kirby()->root('i18n:rules') . '/' . $code . '.json';
if (F::exists($file) === false) {
$file = $this->kirby()->root('i18n:rules') . '/' . Str::before($code, '_') . '.json';
}
try {
$data = Data::read($file);
} catch (\Exception $e) {
$data = [];
}
$data = static::loadRules($code);
return array_merge($data, $this->slugs());
}
@@ -488,7 +459,7 @@ class Language extends Model
'code' => $this->code(),
'default' => $this->isDefault(),
'direction' => $this->direction(),
'locale' => $this->localeExport(),
'locale' => Locale::export($this->locale()),
'name' => $this->name(),
'translations' => $this->translations(),
'url' => $this->url,
@@ -539,24 +510,10 @@ class Language extends Model
*/
protected function setLocale($locale = null)
{
if (is_array($locale)) {
// replace string constant keys with the constant values
$convertedLocale = [];
foreach ($locale as $key => $value) {
if (is_string($key) === true && Str::startsWith($key, 'LC_') === true) {
$key = constant($key);
}
$convertedLocale[$key] = $value;
}
$this->locale = $convertedLocale;
} elseif (is_string($locale)) {
$this->locale = [LC_ALL => $locale];
} elseif ($locale === null) {
if ($locale === null) {
$this->locale = [LC_ALL => $this->code];
} else {
throw new InvalidArgumentException('Locale must be string or array');
$this->locale = Locale::normalize($locale);
}
return $this;

View File

@@ -116,7 +116,7 @@ class LanguageRoutes
'action' => function () use ($kirby) {
// find all languages with the same base url as the current installation
$languages = $kirby->languages()->filterBy('baseurl', $kirby->url());
$languages = $kirby->languages()->filter('baseurl', $kirby->url());
// if there's no language with a matching base url,
// redirect to the default language

View File

@@ -72,18 +72,6 @@ class Languages extends Collection
}
}
/**
* @deprecated 3.0.0 Use `Languages::default()` instead
* @return \Kirby\Cms\Language|null
* @codeCoverageIgnore
*/
public function findDefault()
{
deprecated('$languages->findDefault() is deprecated, use $languages->default() instead. $languages->findDefault() will be removed in Kirby 3.5.0.');
return $this->default();
}
/**
* Convert all defined languages to a collection
*

92
kirby/src/Cms/Layout.php Executable file
View File

@@ -0,0 +1,92 @@
<?php
namespace Kirby\Cms;
/**
* Represents a single Layout with
* multiple columns
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Layout extends Item
{
const ITEMS_CLASS = '\Kirby\Cms\Layouts';
/**
* @var \Kirby\Cms\Content
*/
protected $attrs;
/**
* @var \Kirby\Cms\LayoutColumns
*/
protected $columns;
/**
* Proxy for attrs
*
* @param string $method
* @param array $args
* @return \Kirby\Cms\Field
*/
public function __call(string $method, array $args = [])
{
return $this->attrs()->get($method);
}
/**
* Creates a new Layout object
*
* @param array $params
*/
public function __construct(array $params = [])
{
parent::__construct($params);
$this->columns = LayoutColumns::factory($params['columns'] ?? [], [
'parent' => $this->parent
]);
// create the attrs object
$this->attrs = new Content($params['attrs'] ?? [], $this->parent);
}
/**
* Returns the attrs object
*
* @return \Kirby\Cms\Content
*/
public function attrs()
{
return $this->attrs;
}
/**
* Returns the columns in this layout
*
* @return \Kirby\Cms\LayoutColumns
*/
public function columns()
{
return $this->columns;
}
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{
return [
'attrs' => $this->attrs()->toArray(),
'columns' => $this->columns()->toArray(),
'id' => $this->id(),
];
}
}

96
kirby/src/Cms/LayoutColumn.php Executable file
View File

@@ -0,0 +1,96 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Str;
/**
* Represents a single layout column with
* multiple blocks
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class LayoutColumn extends Item
{
const ITEMS_CLASS = '\Kirby\Cms\LayoutColumns';
/**
* @var \Kirby\Cms\Blocks
*/
protected $blocks;
/**
* @var string
*/
protected $width;
/**
* Creates a new LayoutColumn object
*
* @param array $params
*/
public function __construct(array $params = [])
{
parent::__construct($params);
$this->blocks = Blocks::factory($params['blocks'] ?? [], [
'parent' => $this->parent
]);
$this->width = $params['width'] ?? '1/1';
}
/**
* Returns the blocks collection
*
* @return \Kirby\Cms\Blocks
*/
public function blocks()
{
return $this->blocks;
}
/**
* Returns the number of columns this column spans
*
* @param int $columns
* @return int
*/
public function span(int $columns = 12): int
{
$fraction = Str::split($this->width, '/');
$a = $fraction[0] ?? 1;
$b = $fraction[1] ?? 1;
return $columns * $a / $b;
}
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{
return [
'blocks' => $this->blocks()->toArray(),
'id' => $this->id(),
'width' => $this->width(),
];
}
/**
* Returns the width of the column
*
* @return string
*/
public function width(): string
{
return $this->width;
}
}

17
kirby/src/Cms/LayoutColumns.php Executable file
View File

@@ -0,0 +1,17 @@
<?php
namespace Kirby\Cms;
/**
* A collection of layout columns
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class LayoutColumns extends Items
{
const ITEM_CLASS = '\Kirby\Cms\LayoutColumn';
}

39
kirby/src/Cms/Layouts.php Executable file
View File

@@ -0,0 +1,39 @@
<?php
namespace Kirby\Cms;
/**
* A collection of layouts
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Layouts extends Items
{
const ITEM_CLASS = '\Kirby\Cms\Layout';
public static function factory(array $layouts = null, array $options = [])
{
$first = $layouts[0] ?? [];
// if there are no wrapping layouts for blocks yet …
if (array_key_exists('content', $first) === true || array_key_exists('type', $first) === true) {
$layouts = [
[
'id' => uuid(),
'columns' => [
[
'width' => '1/1',
'blocks' => $layouts
]
]
]
];
}
return parent::factory($layouts, $options);
}
}

View File

@@ -294,9 +294,7 @@ abstract class ModelWithContent extends Model
$errors = [];
foreach ($this->blueprint()->sections() as $section) {
if (method_exists($section, 'errors') === true || isset($section->errors)) {
$errors = array_merge($errors, $section->errors());
}
$errors = array_merge($errors, $section->errors());
}
return $errors;

View File

@@ -753,18 +753,6 @@ class Page extends ModelWithContent
return $this->isHomePage() === true || $this->isErrorPage() === true;
}
/**
* @deprecated 3.0.0 Use `Page::isUnlisted()` instead
* @return bool
* @codeCoverageIgnore
*/
public function isInvisible(): bool
{
deprecated('$page->isInvisible() is deprecated, use $page->isUnlisted() instead. $page->isInvisible() will be removed in Kirby 3.5.0.');
return $this->isUnlisted();
}
/**
* Checks if the page has a sorting number
*
@@ -845,18 +833,6 @@ class Page extends ModelWithContent
return $this->isListed() === false;
}
/**
* @deprecated 3.0.0 Use `Page::isListed()` instead
* @return bool
* @codeCoverageIgnore
*/
public function isVisible(): bool
{
deprecated('$page->isVisible() is deprecated, use $page->isListed() instead. $page->isVisible() will be removed in Kirby 3.5.0.');
return $this->isListed();
}
/**
* Checks if the page access is verified.
* This is only used for drafts so far.

View File

@@ -23,9 +23,11 @@ use Kirby\Toolkit\Str;
trait PageActions
{
/**
* Changes the sorting number
* Changes the sorting number.
* The sorting number must already be correct
* when the method is called
* when the method is called.
* This only affects this page,
* siblings will not be resorted.
*
* @param int|null $num
* @return self
@@ -168,7 +170,9 @@ trait PageActions
/**
* Change the status of the current page
* to either draft, listed or unlisted
* to either draft, listed or unlisted.
* If changing to `listed`, you can pass a position for the
* page in the siblings collection. Siblings will be resorted.
*
* @param string $status "draft", "listed" or "unlisted"
* @param int|null $position Optional sorting number
@@ -203,7 +207,7 @@ trait PageActions
}
/**
* @param int $position
* @param int|null $position
* @return self
*/
protected function changeStatusToListed(int $position = null)
@@ -247,6 +251,19 @@ trait PageActions
return $page;
}
/**
* Change the position of the page in its siblings
* collection. Siblings will be resorted. If the page
* status isn't yet `listed`, it will be changed to it.
*
* @param int|null $position
* @return self
*/
public function changeSort(int $position = null)
{
return $this->changeStatus('listed', $position);
}
/**
* Changes the page template
*
@@ -545,7 +562,7 @@ trait PageActions
return $num;
default:
// get instance with default language
$app = $this->kirby()->clone();
$app = $this->kirby()->clone([], false);
$app->setCurrentLanguage();
$template = Str::template($mode, [
@@ -735,7 +752,7 @@ trait PageActions
}
$parent = $this->parentModel();
$parent->children = $parent->children()->sortBy('num', 'asc');
$parent->children = $parent->children()->sort('num', 'asc');
return true;
}
@@ -761,18 +778,22 @@ trait PageActions
$sibling->changeNum($index);
}
$parent->children = $siblings->sortBy('num', 'asc');
$parent->children = $siblings->sort('num', 'asc');
}
return true;
}
/**
* @deprecated 3.5.0 Use `Page::changeSort()` instead
*
* @param null $position
* @return self
*/
public function sort($position = null)
{
deprecated('$page->sort() is deprecated, use $page->changeSort() instead. $page->sort() will be removed in Kirby 3.6.0.');
return $this->changeStatus('listed', $position);
}

View File

@@ -162,7 +162,7 @@ class PagePicker extends Picker
}
// filter protected pages
$items = $items->filterBy('isReadable', true);
$items = $items->filter('isReadable', true);
// search
$items = $this->search($items);

View File

@@ -135,103 +135,6 @@ trait PageSiblings
*/
public function templateSiblings(bool $self = true)
{
return $this->siblings($self)->filterBy('intendedTemplate', $this->intendedTemplate()->name());
}
/**
* @deprecated 3.0.0 Use `Page::hasNextUnlisted()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasNextInvisible(): bool
{
deprecated('$page->hasNextInvisible() is deprecated, use $page->hasNextUnlisted() instead. $page->hasNextInvisible() will be removed in Kirby 3.5.0.');
return $this->hasNextUnlisted();
}
/**
* @deprecated 3.0.0 Use `Page::hasNextListed()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasNextVisible(): bool
{
deprecated('$page->hasNextVisible() is deprecated, use $page->hasNextListed() instead. $page->hasNextVisible() will be removed in Kirby 3.5.0.');
return $this->hasNextListed();
}
/**
* @deprecated 3.0.0 Use `Page::hasPrevUnlisted()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasPrevInvisible(): bool
{
deprecated('$page->hasPrevInvisible() is deprecated, use $page->hasPrevUnlisted() instead. $page->hasPrevInvisible() will be removed in Kirby 3.5.0.');
return $this->hasPrevUnlisted();
}
/**
* @deprecated 3.0.0 Use `Page::hasPrevListed()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasPrevVisible(): bool
{
deprecated('$page->hasPrevVisible() is deprecated, use $page->hasPrevListed() instead. $page->hasPrevVisible() will be removed in Kirby 3.5.0.');
return $this->hasPrevListed();
}
/**
* @deprecated 3.0.0 Use `Page::nextUnlisted()` instead
* @return self|null
* @codeCoverageIgnore
*/
public function nextInvisible()
{
deprecated('$page->nextInvisible() is deprecated, use $page->nextUnlisted() instead. $page->nextInvisible() will be removed in Kirby 3.5.0.');
return $this->nextUnlisted();
}
/**
* @deprecated 3.0.0 Use `Page::nextListed()` instead
* @return self|null
* @codeCoverageIgnore
*/
public function nextVisible()
{
deprecated('$page->nextVisible() is deprecated, use $page->nextListed() instead. $page->nextVisible() will be removed in Kirby 3.5.0.');
return $this->nextListed();
}
/**
* @deprecated 3.0.0 Use `Page::prevUnlisted()` instead
* @return self|null
* @codeCoverageIgnore
*/
public function prevInvisible()
{
deprecated('$page->prevInvisible() is deprecated, use $page->prevUnlisted() instead. $page->prevInvisible() will be removed in Kirby 3.5.0.');
return $this->prevUnlisted();
}
/**
* @deprecated 3.0.0 Use `Page::prevListed()` instead
* @return self|null
* @codeCoverageIgnore
*/
public function prevVisible()
{
deprecated('$page->prevVisible() is deprecated, use $page->prevListed() instead. $page->prevVisible() will be removed in Kirby 3.5.0.');
return $this->prevListed();
return $this->siblings($self)->filter('intendedTemplate', $this->intendedTemplate()->name());
}
}

View File

@@ -74,7 +74,7 @@ class Pages extends Collection
*/
public function audio()
{
return $this->files()->filterBy('type', 'audio');
return $this->files()->filter('type', 'audio');
}
/**
@@ -102,7 +102,7 @@ class Pages extends Collection
*/
public function code()
{
return $this->files()->filterBy('type', 'code');
return $this->files()->filter('type', 'code');
}
/**
@@ -112,7 +112,7 @@ class Pages extends Collection
*/
public function documents()
{
return $this->files()->filterBy('type', 'document');
return $this->files()->filter('type', 'document');
}
/**
@@ -319,7 +319,7 @@ class Pages extends Collection
*/
public function images()
{
return $this->files()->filterBy('type', 'image');
return $this->files()->filter('type', 'image');
}
/**
@@ -351,19 +351,6 @@ class Pages extends Collection
return $this->index;
}
/**
* @deprecated 3.0.0 Use `Pages::unlisted()` instead
*
* @return self
* @codeCoverageIgnore
*/
public function invisible()
{
deprecated('$pages->invisible() is deprecated, use $pages->unlisted() instead. $pages->invisible() will be removed in Kirby 3.5.0.');
return $this->unlisted();
}
/**
* Returns all listed pages in the collection
*
@@ -371,7 +358,7 @@ class Pages extends Collection
*/
public function listed()
{
return $this->filterBy('isListed', '==', true);
return $this->filter('isListed', '==', true);
}
/**
@@ -381,7 +368,7 @@ class Pages extends Collection
*/
public function unlisted()
{
return $this->filterBy('isUnlisted', '==', true);
return $this->filter('isUnlisted', '==', true);
}
/**
@@ -478,7 +465,7 @@ class Pages extends Collection
*/
public function published()
{
return $this->filterBy('isDraft', '==', false);
return $this->filter('isDraft', '==', false);
}
/**
@@ -509,19 +496,6 @@ class Pages extends Collection
*/
public function videos()
{
return $this->files()->filterBy('type', 'video');
}
/**
* @deprecated 3.0.0 Use `Pages::listed()` instead
*
* @return \Kirby\Cms\Pages
* @codeCoverageIgnore
*/
public function visible()
{
deprecated('$pages->visible() is deprecated, use $pages->listed() instead. $pages->visible() will be removed in Kirby 3.5.0.');
return $this->listed();
return $this->files()->filter('type', 'video');
}
}

View File

@@ -140,7 +140,7 @@ class Plugin extends Model
*/
protected function setName(string $name)
{
if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) == false) {
if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) !== 1) {
throw new InvalidArgumentException('The plugin name must follow the format "a-z0-9-/a-z0-9-"');
}

View File

@@ -87,7 +87,7 @@ class Roles extends Collection
}
// return the collection sorted by name
return $collection->sortBy('name', 'asc');
return $collection->sort('name', 'asc');
}
/**
@@ -134,6 +134,6 @@ class Roles extends Collection
}
// return the collection sorted by name
return $roles->sortBy('name', 'asc');
return $roles->sort('name', 'asc');
}
}

View File

@@ -51,6 +51,15 @@ class Section extends Component
parent::__construct($type, $attrs);
}
public function errors(): array
{
if (array_key_exists('errors', $this->methods) === true) {
return $this->methods['errors']->call($this);
}
return $this->errors ?? [];
}
/**
* @return \Kirby\Cms\App
*/

View File

@@ -340,6 +340,68 @@ class System
}
}
/**
* Returns the configured UI modes for the login form
* with their respective options
*
* @return array
*
* @throws \Kirby\Exception\InvalidArgumentException If the configuration is invalid
* (only in debug mode)
*/
public function loginMethods(): array
{
$default = ['password' => []];
$methods = A::wrap($this->app->option('auth.methods', $default));
// normalize the syntax variants
$normalized = [];
$uses2fa = false;
foreach ($methods as $key => $value) {
if (is_int($key) === true) {
// ['password']
$normalized[$value] = [];
} elseif ($value === true) {
// ['password' => true]
$normalized[$key] = [];
} else {
// ['password' => [...]]
$normalized[$key] = $value;
if (isset($value['2fa']) === true && $value['2fa'] === true) {
$uses2fa = true;
}
}
}
// 2FA must not be circumvented by code-based modes
foreach (['code', 'password-reset'] as $method) {
if ($uses2fa === true && isset($normalized[$method]) === true) {
unset($normalized[$method]);
if ($this->app->option('debug') === true) {
$message = 'The "' . $method . '" login method cannot be enabled when 2FA is required';
throw new InvalidArgumentException($message);
}
}
}
// only one code-based mode can be active at once
if (
isset($normalized['code']) === true &&
isset($normalized['password-reset']) === true
) {
unset($normalized['code']);
if ($this->app->option('debug') === true) {
$message = 'The "code" and "password-reset" login methods cannot be enabled together';
throw new InvalidArgumentException($message);
}
}
return $normalized;
}
/**
* Check for an existing mbstring extension
*
@@ -367,7 +429,9 @@ class System
*/
public function php(): bool
{
return version_compare(phpversion(), '7.1.0', '>=');
return
version_compare(PHP_VERSION, '7.3.0', '>=') === true &&
version_compare(PHP_VERSION, '8.1.0', '<') === true;
}
/**

View File

@@ -368,7 +368,8 @@ class User extends ModelWithContent
*/
public function isLastAdmin(): bool
{
return $this->role()->isAdmin() === true && $this->kirby()->users()->filterBy('role', 'admin')->count() <= 1;
return $this->role()->isAdmin() === true &&
$this->kirby()->users()->filter('role', 'admin')->count() <= 1;
}
/**
@@ -381,6 +382,17 @@ class User extends ModelWithContent
return $this->kirby()->users()->count() === 1;
}
/**
* Checks if the current user is the virtual
* Nobody user
*
* @return bool
*/
public function isNobody(): bool
{
return $this->email() === 'nobody@getkirby.com';
}
/**
* Returns the user language
*
@@ -421,7 +433,7 @@ class User extends ModelWithContent
$kirby->trigger('user.login:before', ['user' => $this, 'session' => $session]);
$session->regenerateToken(); // privilege change
$session->data()->set('user.id', $this->id());
$session->data()->set('kirby.userId', $this->id());
$this->kirby()->auth()->setUser($this);
$kirby->trigger('user.login:after', ['user' => $this, 'session' => $session]);
@@ -441,7 +453,7 @@ class User extends ModelWithContent
$kirby->trigger('user.logout:before', ['user' => $this, 'session' => $session]);
// remove the user from the session for future requests
$session->data()->remove('user.id');
$session->data()->remove('kirby.userId');
// clear the cached user object from the app state of the current request
$this->kirby()->auth()->flush();
@@ -538,6 +550,18 @@ class User extends ModelWithContent
return $this->name = new Field($this, 'name', $this->credentials()['name'] ?? null);
}
/**
* Returns the user's name or,
* if empty, the email address
*
* @return \Kirby\Cms\Field
*/
public function nameOrEmail()
{
$name = $this->name();
return $name->isNotEmpty() ? $name : new Field($this, 'email', $this->email());
}
/**
* Create a dummy nobody
*
@@ -688,7 +712,7 @@ class User extends ModelWithContent
$roles = $kirby->roles();
// a collection with just the one role of the user
$myRole = $roles->filterBy('id', $this->role()->id());
$myRole = $roles->filter('id', $this->role()->id());
// if there's an authenticated user …
if ($user = $kirby->user()) {

View File

@@ -140,7 +140,7 @@ trait UserActions
* @return mixed
* @throws \Kirby\Exception\PermissionException
*/
protected function commit(string $action, array $arguments = [], Closure $callback)
protected function commit(string $action, array $arguments, Closure $callback)
{
if ($this->isKirby() === true) {
throw new PermissionException('The Kirby user cannot be changed');

View File

@@ -50,7 +50,7 @@ class UserPermissions extends ModelPermissions
}
// users who are not admins cannot create admins
if ($this->model->isAdmin() === false) {
if ($this->model->isAdmin() === true) {
return false;
}

View File

@@ -61,7 +61,7 @@ class UserPicker extends Picker
$users = $this->search($users);
// sort
$users = $users->sortBy('username', 'asc');
$users = $users->sort('username', 'asc');
// paginate
return $this->paginate($users);

View File

@@ -165,6 +165,12 @@ class UserRules
static::validEmail($user, $user->email(), true);
static::validLanguage($user, $user->language());
// the first user must have a password
if ($user->kirby()->users()->count() === 0 && empty($props['password'])) {
// trigger invalid password error
static::validPassword($user, ' ');
}
if (empty($props['password']) === false) {
static::validPassword($user, $props['password']);
}

View File

@@ -128,13 +128,13 @@ class Users extends Collection
}
/**
* Shortcut for `$users->filterBy('role', 'admin')`
* Shortcut for `$users->filter('role', 'admin')`
*
* @param string $role
* @return self
*/
public function role(string $role)
{
return $this->filterBy('role', $role);
return $this->filter('role', $role);
}
}