Add blueprints and fake content

This commit is contained in:
Paul Nicoué 2021-11-18 17:44:47 +01:00
parent 1ff19bf38f
commit 8235816462
592 changed files with 22385 additions and 31535 deletions

View file

@ -0,0 +1,39 @@
<?php
namespace Kirby\Panel;
/**
* The Dialog response class handles Fiber
* requests to render the JSON object for
* Panel dialogs
* @since 3.6.0
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Dialog extends Json
{
protected static $key = '$dialog';
/**
* Renders dialogs
*
* @param mixed $data
* @param array $options
* @return \Kirby\Http\Response
*/
public static function response($data, array $options = [])
{
// interpret true as success
if ($data === true) {
$data = [
'code' => 200
];
}
return parent::response($data, $options);
}
}

View file

@ -0,0 +1,263 @@
<?php
namespace Kirby\Panel;
use Kirby\Exception\Exception;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Http\Response;
use Kirby\Http\Uri;
use Kirby\Toolkit\Tpl;
use Throwable;
/**
* The Document is used by the View class to render
* the full Panel HTML document in Fiber calls that
* should not return just JSON objects
* @since 3.6.0
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Document
{
/**
* Generates an array with all assets
* that need to be loaded for the panel (js, css, icons)
*
* @return array
*/
public static function assets(): array
{
$kirby = kirby();
$nonce = $kirby->nonce();
// get the assets from the Vite dev server in dev mode;
// dev mode = explicitly enabled in the config AND Vite is running
$dev = $kirby->option('panel.dev', false);
$isDev = $dev !== false && is_file($kirby->roots()->panel() . '/.vite-running') === true;
if ($isDev === true) {
// vite on explicitly configured base URL or port 3000
// of the current Kirby request
if (is_string($dev) === true) {
$url = $dev;
} else {
$url = rtrim($kirby->request()->url([
'port' => 3000,
'path' => null,
'params' => null,
'query' => null
])->toString(), '/');
}
} else {
// vite is not running, use production assets
$url = $kirby->url('media') . '/panel/' . $kirby->versionHash();
}
// fetch all plugins
$plugins = new Plugins();
$assets = [
'css' => [
'index' => $url . '/css/style.css',
'plugins' => $plugins->url('css'),
'custom' => static::customCss(),
],
'icons' => $kirby->option('panel.favicon', [
'apple-touch-icon' => [
'type' => 'image/png',
'url' => $url . '/apple-touch-icon.png',
],
'shortcut icon' => [
'type' => 'image/svg+xml',
'url' => $url . '/favicon.svg',
],
'alternate icon' => [
'type' => 'image/png',
'url' => $url . '/favicon.png',
]
]),
'js' => [
'vendor' => [
'nonce' => $nonce,
'src' => $url . '/js/vendor.js',
'type' => 'module'
],
'pluginloader' => [
'nonce' => $nonce,
'src' => $url . '/js/plugins.js',
'type' => 'module'
],
'plugins' => [
'nonce' => $nonce,
'src' => $plugins->url('js'),
'defer' => true
],
'custom' => [
'nonce' => $nonce,
'src' => static::customJs(),
'type' => 'module'
],
'index' => [
'nonce' => $nonce,
'src' => $url . '/js/index.js',
'type' => 'module'
],
]
];
// during dev mode, add vite client and adapt
// path to `index.js` - vendor and stylesheet
// don't need to be loaded in dev mode
if ($isDev === true) {
$assets['js']['vite'] = [
'nonce' => $nonce,
'src' => $url . '/@vite/client',
'type' => 'module'
];
$assets['js']['index'] = [
'nonce' => $nonce,
'src' => $url . '/src/index.js',
'type' => 'module'
];
unset($assets['css']['index'], $assets['js']['vendor']);
}
// remove missing files
$assets['css'] = array_filter($assets['css']);
$assets['js'] = array_filter($assets['js'], function ($js) {
return empty($js['src']) === false;
});
return $assets;
}
/**
* Check for a custom css file from the
* config (panel.css)
*
* @return string|null
*/
public static function customCss(): ?string
{
if ($css = kirby()->option('panel.css')) {
$asset = asset($css);
if ($asset->exists() === true) {
return $asset->url() . '?' . $asset->modified();
}
}
return null;
}
/**
* Check for a custom js file from the
* config (panel.js)
*
* @return string|null
*/
public static function customJs(): ?string
{
if ($js = kirby()->option('panel.js')) {
$asset = asset($js);
if ($asset->exists() === true) {
return $asset->url() . '?' . $asset->modified();
}
}
return null;
}
/**
* Load the SVG icon sprite
* This will be injected in the
* initial HTML document for the Panel
*
* @return string
*/
public static function icons(): string
{
return F::read(kirby()->root('kirby') . '/panel/dist/img/icons.svg');
}
/**
* Links all dist files in the media folder
* and returns the link to the requested asset
*
* @return bool
* @throws \Kirby\Exception\Exception If Panel assets could not be moved to the public directory
*/
public static function link(): bool
{
$kirby = kirby();
$mediaRoot = $kirby->root('media') . '/panel';
$panelRoot = $kirby->root('panel') . '/dist';
$versionHash = $kirby->versionHash();
$versionRoot = $mediaRoot . '/' . $versionHash;
// check if the version already exists
if (is_dir($versionRoot) === true) {
return false;
}
// delete the panel folder and all previous versions
Dir::remove($mediaRoot);
// recreate the panel folder
Dir::make($mediaRoot, true);
// copy assets to the dist folder
if (Dir::copy($panelRoot, $versionRoot) !== true) {
throw new Exception('Panel assets could not be linked');
}
return true;
}
/**
* Renders the panel document
*
* @param array $fiber
* @return \Kirby\Http\Response
*/
public static function response(array $fiber)
{
$kirby = kirby();
// Full HTML response
// @codeCoverageIgnoreStart
try {
if (static::link() === true) {
usleep(1);
go($kirby->url('index') . '/' . $kirby->path());
}
} catch (Throwable $e) {
die('The Panel assets cannot be installed properly. ' . $e->getMessage());
}
// @codeCoverageIgnoreEnd
// get the uri object for the panel url
$uri = new Uri($url = $kirby->url('panel'));
// proper response code
$code = $fiber['$view']['code'] ?? 200;
// load the main Panel view template
$body = Tpl::load($kirby->root('kirby') . '/views/panel.php', [
'assets' => static::assets(),
'icons' => static::icons(),
'nonce' => $kirby->nonce(),
'fiber' => $fiber,
'panelUrl' => $uri->path()->toString(true) . '/',
]);
return new Response($body, 'text/html', $code);
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace Kirby\Panel;
use Kirby\Cms\Find;
use Kirby\Exception\LogicException;
use Kirby\Http\Uri;
use Kirby\Toolkit\Str;
use Throwable;
/**
* The Dropdown response class handles Fiber
* requests to render the JSON object for
* dropdown menus
* @since 3.6.0
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Dropdown extends Json
{
protected static $key = '$dropdown';
/**
* Returns the options for the changes dropdown
*
* @return array
*/
public static function changes(): array
{
$kirby = kirby();
$multilang = $kirby->multilang();
$ids = Str::split(get('ids'));
$options = [];
foreach ($ids as $id) {
try {
// parse the given ID to extract
// the path and an optional query
$uri = new Uri($id);
$path = $uri->path()->toString();
$query = $uri->query();
$option = Find::parent($path)->panel()->dropdownOption();
// add the language to each option, if it is included in the query
// of the given ID and the language actually exists
if ($multilang && $query->language && $language = $kirby->language($query->language)) {
$option['text'] .= ' (' . $language->code() . ')';
$option['link'] .= '?language=' . $language->code();
}
$options[] = $option;
} catch (Throwable $e) {
continue;
}
}
// the given set of ids does not match any
// real models. This means that the stored ids
// in local storage are not correct and the changes
// store needs to be cleared
if (empty($options) === true) {
throw new LogicException('No changes for given models');
}
return $options;
}
/**
* Renders dropdowns
*
* @param mixed $data
* @param array $options
* @return \Kirby\Http\Response
*/
public static function response($data, array $options = [])
{
if (is_array($data) === true) {
$data = [
'options' => array_values($data)
];
}
return parent::response($data, $options);
}
}

272
kirby/src/Panel/Field.php Normal file
View file

@ -0,0 +1,272 @@
<?php
namespace Kirby\Panel;
use Kirby\Cms\File;
use Kirby\Cms\Page;
/**
* Provides common field prop definitions
* for dialogs and other places
* @since 3.6.0
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Field
{
/**
* A standard email field
*
* @param array $props
* @return array
*/
public static function email(array $props = []): array
{
return array_merge([
'label' => t('email'),
'type' => 'email',
'counter' => false,
], $props);
}
/**
* File position
*
* @param \Kirby\Cms\File
* @param array $props
* @return array
*/
public static function filePosition(File $file, array $props = []): array
{
$index = 0;
$options = [];
foreach ($file->siblings(false)->sorted() as $sibling) {
$index++;
$options[] = [
'value' => $index,
'text' => $index
];
$options[] = [
'value' => $sibling->id(),
'text' => $sibling->filename(),
'disabled' => true
];
}
$index++;
$options[] = [
'value' => $index,
'text' => $index
];
return array_merge([
'label' => t('file.sort'),
'type' => 'select',
'empty' => false,
'options' => $options
], $props);
}
/**
* @return array
*/
public static function hidden(): array
{
return ['type' => 'hidden'];
}
/**
* Page position
*
* @param \Kirby\Cms\Page
* @param array $props
* @return array
*/
public static function pagePosition(Page $page, array $props = []): array
{
$index = 0;
$options = [];
$siblings = $page->parentModel()->children()->listed()->not($page);
foreach ($siblings as $sibling) {
$index++;
$options[] = [
'value' => $index,
'text' => $index
];
$options[] = [
'value' => $sibling->id(),
'text' => $sibling->title()->value(),
'disabled' => true
];
}
$index++;
$options[] = [
'value' => $index,
'text' => $index
];
// if only one available option,
// hide field when not in debug mode
if (count($options) < 2) {
return static::hidden();
}
return array_merge([
'label' => t('page.changeStatus.position'),
'type' => 'select',
'empty' => false,
'options' => $options,
], $props);
}
/**
* A regular password field
*
* @param array $props
* @return array
*/
public static function password(array $props = []): array
{
return array_merge([
'label' => t('password'),
'type' => 'password'
], $props);
}
/**
* User role radio buttons
*
* @param array $props
* @return array
*/
public static function role(array $props = []): array
{
$kirby = kirby();
$user = $kirby->user();
$isAdmin = $user && $user->isAdmin();
$roles = [];
foreach ($kirby->roles() as $role) {
// exclude the admin role, if the user
// is not allowed to change role to admin
if ($role->name() === 'admin' && $isAdmin === false) {
continue;
}
$roles[] = [
'text' => $role->title(),
'info' => $role->description() ?? t('role.description.placeholder'),
'value' => $role->name()
];
}
return array_merge([
'label' => t('role'),
'type' => count($roles) <= 1 ? 'hidden' : 'radio',
'options' => $roles
], $props);
}
/**
* @param array $props
* @return array
*/
public static function slug(array $props = []): array
{
return array_merge([
'label' => t('slug'),
'type' => 'slug',
], $props);
}
/**
* @param array $blueprints
* @param array $props
* @return array
*/
public static function template(?array $blueprints = [], ?array $props = []): array
{
$options = [];
foreach ($blueprints as $blueprint) {
$options[] = [
'text' => $blueprint['title'] ?? $blueprint['text'] ?? null,
'value' => $blueprint['name'] ?? $blueprint['value'] ?? null,
];
}
return array_merge([
'label' => t('template'),
'type' => 'select',
'empty' => false,
'options' => $options,
'icon' => 'template',
'disabled' => count($options) <= 1
], $props);
}
/**
* @param array $props
* @return array
*/
public static function title(array $props = []): array
{
return array_merge([
'label' => t('title'),
'type' => 'text',
'icon' => 'title',
], $props);
}
/**
* Panel translation select box
*
* @param array $props
* @return array
*/
public static function translation(array $props = []): array
{
$translations = [];
foreach (kirby()->translations() as $translation) {
$translations[] = [
'text' => $translation->name(),
'value' => $translation->code()
];
}
return array_merge([
'label' => t('language'),
'type' => 'select',
'icon' => 'globe',
'options' => $translations,
'empty' => false
], $props);
}
/**
* @param array $props
* @return array
*/
public static function username(array $props = []): array
{
return array_merge([
'icon' => 'user',
'label' => t('name'),
'type' => 'text',
], $props);
}
}

471
kirby/src/Panel/File.php Normal file
View file

@ -0,0 +1,471 @@
<?php
namespace Kirby\Panel;
use Throwable;
/**
* Provides information about the file model for the Panel
* @since 3.6.0
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class File extends Model
{
/**
* Breadcrumb array
*
* @return array
*/
public function breadcrumb(): array
{
$breadcrumb = [];
$parent = $this->model->parent();
switch ($parent::CLASS_ALIAS) {
case 'user':
// The breadcrumb is not necessary
// on the account view
if ($parent->isLoggedIn() === false) {
$breadcrumb[] = [
'label' => $parent->username(),
'link' => $parent->panel()->url(true)
];
}
break;
case 'page':
$breadcrumb = $this->model->parents()->flip()->values(function ($parent) {
return [
'label' => $parent->title()->toString(),
'link' => $parent->panel()->url(true),
];
});
}
// add the file
$breadcrumb[] = [
'label' => $this->model->filename(),
'link' => $this->url(true),
];
return $breadcrumb;
}
/**
* Provides a kirbytag or markdown
* tag for the file, which will be
* used in the panel, when the file
* gets dragged onto a textarea
*
* @internal
* @param string|null $type (`auto`|`kirbytext`|`markdown`)
* @param bool $absolute
* @return string
*/
public function dragText(string $type = null, bool $absolute = false): string
{
$type = $this->dragTextType($type);
$url = $absolute ? $this->model->id() : $this->model->filename();
if ($dragTextFromCallback = $this->dragTextFromCallback($type, $url)) {
return $dragTextFromCallback;
}
if ($type === 'markdown') {
if ($this->model->type() === 'image') {
return '![' . $this->model->alt() . '](' . $url . ')';
}
return '[' . $this->model->filename() . '](' . $url . ')';
}
if ($this->model->type() === 'image') {
return '(image: ' . $url . ')';
}
if ($this->model->type() === 'video') {
return '(video: ' . $url . ')';
}
return '(file: ' . $url . ')';
}
/**
* Provides options for the file dropdown
*
* @param array $options
* @return array
*/
public function dropdown(array $options = []): array
{
$defaults = [
'view' => get('view'),
'update' => get('update'),
'delete' => get('delete')
];
$options = array_merge($defaults, $options);
$file = $this->model;
$permissions = $this->options(['preview']);
$view = $options['view'] ?? 'view';
$url = $this->url(true);
$result = [];
if ($view === 'list') {
$result[] = [
'link' => $file->previewUrl(),
'target' => '_blank',
'icon' => 'open',
'text' => t('open')
];
$result[] = '-';
}
$result[] = [
'dialog' => $url . '/changeName',
'icon' => 'title',
'text' => t('rename'),
'disabled' => $this->isDisabledDropdownOption('changeName', $options, $permissions)
];
$result[] = [
'click' => 'replace',
'icon' => 'upload',
'text' => t('replace'),
'disabled' => $this->isDisabledDropdownOption('replace', $options, $permissions)
];
if ($view === 'list') {
$result[] = '-';
$result[] = [
'dialog' => $url . '/changeSort',
'icon' => 'sort',
'text' => t('file.sort'),
'disabled' => $this->isDisabledDropdownOption('update', $options, $permissions)
];
}
$result[] = '-';
$result[] = [
'dialog' => $url . '/delete',
'icon' => 'trash',
'text' => t('delete'),
'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions)
];
return $result;
}
/**
* Returns the setup for a dropdown option
* which is used in the changes dropdown
* for example.
*
* @return array
*/
public function dropdownOption(): array
{
return [
'icon' => 'image',
'text' => $this->model->filename(),
] + parent::dropdownOption();
}
/**
* Returns the Panel icon color
*
* @return string
*/
protected function imageColor(): string
{
$types = [
'image' => 'orange-400',
'video' => 'yellow-400',
'document' => 'red-400',
'audio' => 'aqua-400',
'code' => 'blue-400',
'archive' => 'white'
];
$extensions = [
'indd' => 'purple-400',
'xls' => 'green-400',
'xlsx' => 'green-400',
'csv' => 'green-400',
'docx' => 'blue-400',
'doc' => 'blue-400',
'rtf' => 'blue-400'
];
return $extensions[$this->model->extension()] ??
$types[$this->model->type()] ??
parent::imageDefaults()['icon'];
}
/**
* Default settings for the file's Panel image
*
* @return array
*/
protected function imageDefaults(): array
{
return array_merge(parent::imageDefaults(), [
'color' => $this->imageColor(),
'icon' => $this->imageIcon(),
]);
}
/**
* Returns the Panel icon type
*
* @return string
*/
protected function imageIcon(): string
{
$types = [
'image' => 'file-image',
'video' => 'file-video',
'document' => 'file-document',
'audio' => 'file-audio',
'code' => 'file-code',
'archive' => 'file-zip'
];
$extensions = [
'xls' => 'file-spreadsheet',
'xlsx' => 'file-spreadsheet',
'csv' => 'file-spreadsheet',
'docx' => 'file-word',
'doc' => 'file-word',
'rtf' => 'file-word',
'mdown' => 'file-text',
'md' => 'file-text'
];
return $extensions[$this->model->extension()] ??
$types[$this->model->type()] ??
parent::imageDefaults()['color'];
}
/**
* Returns the image file object based on provided query
*
* @internal
* @param string|null $query
* @return \Kirby\Cms\File|\Kirby\Filesystem\Asset|null
*/
protected function imageSource(string $query = null)
{
if ($query === null && $this->model->isViewable()) {
return $this->model;
}
return parent::imageSource($query);
}
/**
* Returns an array of all actions
* that can be performed in the Panel
*
* @param array $unlock An array of options that will be force-unlocked
* @return array
*/
public function options(array $unlock = []): array
{
$options = parent::options($unlock);
try {
// check if the file type is allowed at all,
// otherwise it cannot be replaced
$this->model->match($this->model->blueprint()->accept());
} catch (Throwable $e) {
$options['replace'] = false;
}
return $options;
}
/**
* Returns the full path without leading slash
*
* @return string
*/
public function path(): string
{
return 'files/' . $this->model->filename();
}
/**
* Prepares the response data for file pickers
* and file fields
*
* @param array|null $params
* @return array
*/
public function pickerData(array $params = []): array
{
$id = $this->model->id();
$name = $this->model->filename();
if (empty($params['model']) === false) {
$parent = $this->model->parent();
$uuid = $parent === $params['model'] ? $name : $id;
$absolute = $parent !== $params['model'];
}
$params['text'] = $params['text'] ?? '{{ file.filename }}';
return array_merge(parent::pickerData($params), [
'filename' => $name,
'dragText' => $this->dragText('auto', $absolute ?? false),
'type' => $this->model->type(),
'url' => $this->model->url(),
'uuid' => $uuid ?? $id,
]);
}
/**
* Returns the data array for the
* view's component props
*
* @internal
*
* @return array
*/
public function props(): array
{
$file = $this->model;
$dimensions = $file->dimensions();
$siblings = $file->templateSiblings()->sortBy(
'sort',
'asc',
'filename',
'asc'
);
return array_merge(
parent::props(),
$this->prevNext(),
[
'blueprint' => $this->model->template() ?? 'default',
'model' => [
'content' => $this->content(),
'dimensions' => $dimensions->toArray(),
'extension' => $file->extension(),
'filename' => $file->filename(),
'link' => $this->url(true),
'mime' => $file->mime(),
'niceSize' => $file->niceSize(),
'id' => $id = $file->id(),
'parent' => $file->parent()->panel()->path(),
'template' => $file->template(),
'type' => $file->type(),
'url' => $file->url(),
],
'preview' => [
'image' => $this->image([
'back' => 'transparent',
'ratio' => '1/1'
], 'cards'),
'url' => $url = $file->previewUrl(),
'details' => [
[
'title' => t('template'),
'text' => $file->template() ?? '—'
],
[
'title' => t('mime'),
'text' => $file->mime()
],
[
'title' => t('url'),
'text' => $id,
'link' => $url
],
[
'title' => t('size'),
'text' => $file->niceSize()
],
[
'title' => t('dimensions'),
'text' => $file->type() === 'image' ? $file->dimensions() . ' ' . t('pixel') : '—'
],
[
'title' => t('orientation'),
'text' => $file->type() === 'image' ? t('orientation.' . $dimensions->orientation()) : '—'
],
]
]
]
);
}
/**
* Returns navigation array with
* previous and next file
*
* @internal
*
* @return array
*/
public function prevNext(): array
{
$file = $this->model;
$siblings = $file->templateSiblings()->sortBy(
'sort',
'asc',
'filename',
'asc'
);
return [
'next' => function () use ($file, $siblings): ?array {
$next = $siblings->nth($siblings->indexOf($file) + 1);
return $next ? $next->panel()->toLink('filename') : null;
},
'prev' => function () use ($file, $siblings): ?array {
$prev = $siblings->nth($siblings->indexOf($file) - 1);
return $prev ? $prev->panel()->toLink('filename') : null;
}
];
}
/**
* Returns the url to the editing view
* in the panel
*
* @param bool $relative
* @return string
*/
public function url(bool $relative = false): string
{
$parent = $this->model->parent()->panel()->url($relative);
return $parent . '/' . $this->path();
}
/**
* Returns the data array for
* this model's Panel view
*
* @internal
*
* @return array
*/
public function view(): array
{
$file = $this->model;
return [
'breadcrumb' => function () use ($file): array {
return $file->panel()->breadcrumb();
},
'component' => 'k-file-view',
'props' => $this->props(),
'search' => 'files',
'title' => $file->filename(),
];
}
}

261
kirby/src/Panel/Home.php Normal file
View file

@ -0,0 +1,261 @@
<?php
namespace Kirby\Panel;
use Kirby\Cms\User;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Http\Uri;
use Kirby\Toolkit\Str;
use Throwable;
/**
* The Home class creates the secure redirect
* URL after logins. The URL can either come
* from the session to remember the last view
* before the automatic logout, or from a user
* blueprint to redirect to custom views.
*
* The Home class also makes sure to check access
* before a redirect happens and avoids redirects
* to inaccessible views.
* @since 3.6.0
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Home
{
/**
* Returns an alternative URL if access
* to the first choice is blocked.
*
* It will go through the entire menu and
* take the first area which is not disabled
* or locked in other ways
*
* @param \Kirby\Cms\User $user
* @return string
*/
public static function alternative(User $user): string
{
$permissions = $user->role()->permissions();
// no access to the panel? The only good alternative is the main url
if ($permissions->for('access', 'panel') === false) {
return site()->url();
}
// needed to create a proper menu
$areas = Panel::areas();
$menu = View::menu($areas, $permissions->toArray());
// go through the menu and search for the first
// available view we can go to
foreach ($menu as $menuItem) {
// skip separators
if ($menuItem === '-') {
continue;
}
// skip disabled items
if (($menuItem['disabled'] ?? false) === true) {
continue;
}
// skip the logout button
if ($menuItem['id'] === 'logout') {
continue;
}
return Panel::url($menuItem['link']);
}
throw new NotFoundException('Theres no available Panel page to redirect to');
}
/**
* Checks if the user has access to the given
* panel path. This is quite tricky, because we
* need to call a trimmed down router to check
* for available routes and their firewall status.
*
* @param \Kirby\Cms\User
* @param string $path
* @return bool
*/
public static function hasAccess(User $user, string $path): bool
{
$areas = Panel::areas();
$routes = Panel::routes($areas);
// Remove fallback routes. Otherwise a route
// would be found even if the view does
// not exist at all.
foreach ($routes as $index => $route) {
if ($route['pattern'] === '(:all)') {
unset($routes[$index]);
}
}
// create a dummy router to check if we can access this route at all
try {
return router($path, 'GET', $routes, function ($route) use ($user) {
$auth = $route->attributes()['auth'] ?? true;
$areaId = $route->attributes()['area'] ?? null;
$type = $route->attributes()['type'] ?? 'view';
// only allow redirects to views
if ($type !== 'view') {
return false;
}
// if auth is not required the redirect is allowed
if ($auth === false) {
return true;
}
// check the firewall
return Panel::hasAccess($user, $areaId);
});
} catch (Throwable $e) {
return false;
}
}
/**
* Checks if the given Uri has the same domain
* as the index URL of the Kirby installation.
* This is used to block external URLs to third-party
* domains as redirect options.
*
* @param \Kirby\Http\Uri $uri
* @return bool
*/
public static function hasValidDomain(Uri $uri): bool
{
return $uri->domain() === (new Uri(site()->url()))->domain();
}
/**
* Checks if the given URL is a Panel Url.
*
* @param string $url
* @return bool
*/
public static function isPanelUrl(string $url): bool
{
return Str::startsWith($url, kirby()->url('panel'));
}
/**
* Returns the path after /panel/ which can then
* be used in the router or to find a matching view
*
* @param string $url
* @return string|null
*/
public static function panelPath(string $url): ?string
{
$after = Str::after($url, kirby()->url('panel'));
return trim($after, '/');
}
/**
* Returns the Url that has been stored in the session
* before the last logout. We take this Url if possible
* to redirect the user back to the last point where they
* left before they got logged out.
*
* @return string|null
*/
public static function remembered(): ?string
{
// check for a stored path after login
$remembered = kirby()->session()->pull('panel.path');
// convert the result to an absolute URL if available
return $remembered ? Panel::url($remembered) : null;
}
/**
* Tries to find the best possible Url to redirect
* the user to after the login.
*
* When the user got logged out, we try to send them back
* to the point where they left.
*
* If they have a custom redirect Url defined in their blueprint
* via the `home` option, we send them there if no Url is stored
* in the session.
*
* If none of the options above find any result, we try to send
* them to the site view.
*
* Before the redirect happens, the final Url is sanitized, the query
* and params are removed to avoid any attacks and the domain is compared
* to avoid redirects to external Urls.
*
* Afterwards, we also check for permissions before the redirect happens
* to avoid redirects to inaccessible Panel views. In such a case
* the next best accessible view is picked from the menu.
*
* @return string
*/
public static function url(): string
{
$user = kirby()->user();
// if there's no authenticated user, all internal
// redirects will be blocked and the user is redirected
// to the login instead
if (!$user) {
return Panel::url('login');
}
// get the last visited url from the session or the custom home
$url = static::remembered() ?? $user->panel()->home();
// inspect the given URL
$uri = new Uri($url);
// compare domains to avoid external redirects
if (static::hasValidDomain($uri) !== true) {
throw new InvalidArgumentException('External URLs are not allowed for Panel redirects');
}
// remove all params to avoid
// possible attack vectors
$uri->params = '';
$uri->query = '';
// get a clean version of the URL
$url = $uri->toString();
// Don't further inspect URLs outside of the Panel
if (static::isPanelUrl($url) === false) {
return $url;
}
// get the plain panel path
$path = static::panelPath($url);
// a redirect to login, logout or installation
// views would lead to an infinite redirect loop
if (in_array($path, ['', 'login', 'logout', 'installation'], true) === true) {
$path = 'site';
}
// Check if the user can access the URL
if (static::hasAccess($user, $path) === true) {
return Panel::url($path);
}
// Try to find an alternative
return static::alternative($user);
}
}

77
kirby/src/Panel/Json.php Normal file
View file

@ -0,0 +1,77 @@
<?php
namespace Kirby\Panel;
/**
* The Json abstract response class provides
* common framework for Fiber requests
* to render the JSON object for, e.g.
* Panel dialogs, dropdowns etc.
* @since 3.6.0
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
abstract class Json
{
protected static $key = '$response';
/**
* Renders the error response with the provided message
*
* @param string $message
* @param int $code
* @return array
*/
public static function error(string $message, int $code = 404)
{
return [
'code' => $code,
'error' => $message
];
}
/**
* Prepares the JSON response for the Panel
*
* @param mixed $data
* @param array $options
* @return mixed
*/
public static function response($data, array $options = [])
{
// handle redirects
if (is_a($data, 'Kirby\Panel\Redirect') === true) {
$data = [
'redirect' => $data->location(),
'code' => $data->code()
];
// handle Kirby exceptions
} elseif (is_a($data, 'Kirby\Exception\Exception') === true) {
$data = static::error($data->getMessage(), $data->getHttpCode());
// handle exceptions
} elseif (is_a($data, 'Throwable') === true) {
$data = static::error($data->getMessage(), 500);
// only expect arrays from here on
} elseif (is_array($data) === false) {
$data = static::error('Invalid response', 500);
}
if (empty($data) === true) {
$data = static::error('The response is empty', 404);
}
// always inject the response code
$data['code'] = $data['code'] ?? 200;
$data['path'] = $options['path'] ?? null;
$data['referrer'] = Panel::referrer();
return Panel::json([static::$key => $data], $data['code']);
}
}

415
kirby/src/Panel/Model.php Normal file
View file

@ -0,0 +1,415 @@
<?php
namespace Kirby\Panel;
use Kirby\Form\Form;
use Kirby\Toolkit\A;
/**
* Provides information about the model for the Panel
* @since 3.6.0
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
abstract class Model
{
/**
* @var \Kirby\Cms\ModelWithContent
*/
protected $model;
/**
* @param \Kirby\Cms\ModelWithContent $model
*/
public function __construct($model)
{
$this->model = $model;
}
/**
* Get the content values for the model
*
* @return array
*/
public function content(): array
{
return Form::for($this->model)->values();
}
/**
* Returns the drag text from a custom callback
* if the callback is defined in the config
* @internal
*
* @param string $type markdown or kirbytext
* @param mixed ...$args
* @return string|null
*/
public function dragTextFromCallback(string $type, ...$args): ?string
{
$option = 'panel.' . $type . '.' . $this->model::CLASS_ALIAS . 'DragText';
$callback = option($option);
if (
empty($callback) === false &&
is_a($callback, 'Closure') === true &&
($dragText = $callback($this->model, ...$args)) !== null
) {
return $dragText;
}
return null;
}
/**
* Returns the correct drag text type
* depending on the given type or the
* configuration
*
* @internal
*
* @param string|null $type (`auto`|`kirbytext`|`markdown`)
* @return string
*/
public function dragTextType(string $type = null): string
{
$type = $type ?? 'auto';
if ($type === 'auto') {
$type = option('panel.kirbytext', true) ? 'kirbytext' : 'markdown';
}
return $type === 'markdown' ? 'markdown' : 'kirbytext';
}
/**
* Returns the setup for a dropdown option
* which is used in the changes dropdown
* for example.
*
* @return array
*/
public function dropdownOption(): array
{
return [
'icon' => 'page',
'link' => $this->url(),
'text' => $this->model->id(),
];
}
/**
* Returns the Panel image definition
*
* @internal
*
* @param string|array|false|null $settings
* @return array|null
*/
public function image($settings = [], string $layout = 'list'): ?array
{
// completely switched off
if ($settings === false) {
return null;
}
// skip image thumbnail if option
// is explicitly set to show the icon
if ($settings === 'icon') {
$settings = [
'query' => false
];
} elseif (is_string($settings) === true) {
// convert string settings to proper array
$settings = [
'query' => $settings
];
}
// merge with defaults and blueprint option
$settings = array_merge(
$this->imageDefaults(),
$settings ?? [],
$this->model->blueprint()->image() ?? [],
);
if ($image = $this->imageSource($settings['query'] ?? null)) {
// main url
$settings['url'] = $image->url();
// only create srcsets for actual File objects
if (is_a($image, 'Kirby\Cms\File') === true) {
$settings['src'] = static::imagePlaceholder();
switch ($layout) {
case 'cards':
$sizes = [352, 864, 1408];
break;
case 'cardlets':
$sizes = [96, 192];
break;
case 'list':
default:
$sizes = [38, 76];
break;
}
if (($settings['cover'] ?? false) === false || $layout === 'cards') {
$settings['srcset'] = $image->srcset($sizes);
} else {
$settings['srcset'] = $image->srcset([
'1x' => [
'width' => $sizes[0],
'height' => $sizes[0],
'crop' => 'center'
],
'2x' => [
'width' => $sizes[1],
'height' => $sizes[1],
'crop' => 'center'
]
]);
}
}
}
if (isset($settings['query']) === true) {
unset($settings['query']);
}
// resolve remaining options defined as query
return A::map($settings, function ($option) {
if (is_string($option) === false) {
return $option;
}
return $this->model->toString($option);
});
}
/**
* Default settings for Panel image
*
* @return array
*/
protected function imageDefaults(): array
{
return [
'back' => 'pattern',
'color' => 'gray-500',
'cover' => false,
'icon' => 'page',
'ratio' => '3/2',
];
}
/**
* Data URI placeholder string for Panel image
*
* @internal
*
* @return string
*/
public static function imagePlaceholder(): string
{
return 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw';
}
/**
* Returns the image file object based on provided query
*
* @internal
*
* @param string|null $query
* @return \Kirby\Cms\File|\Kirby\Filesystem\Asset|null
*/
protected function imageSource(?string $query = null)
{
$image = $this->model->query($query ?? null);
// validate the query result
if (
is_a($image, 'Kirby\Cms\File') === true ||
is_a($image, 'Kirby\Filesystem\Asset') === true
) {
return $image;
}
return null;
}
/**
* Checks for disabled dropdown options according
* to the given permissions
*
* @param string $action
* @param array $options
* @param array $permissions
* @return bool
*/
public function isDisabledDropdownOption(string $action, array $options, array $permissions): bool
{
$option = $options[$action] ?? true;
return $permissions[$action] === false || $option === false || $option === 'false';
}
/**
* Returns lock info for the Panel
*
* @return array|false array with lock info,
* false if locking is not supported
*/
public function lock()
{
if ($lock = $this->model->lock()) {
if ($lock->isUnlocked() === true) {
return ['state' => 'unlock'];
}
if ($lock->isLocked() === true) {
return [
'state' => 'lock',
'data' => $lock->get()
];
}
return ['state' => null];
}
return false;
}
/**
* Returns an array of all actions
* that can be performed in the Panel
* This also checks for the lock status
*
* @param array $unlock An array of options that will be force-unlocked
* @return array
*/
public function options(array $unlock = []): array
{
$options = $this->model->permissions()->toArray();
if ($this->model->isLocked()) {
foreach ($options as $key => $value) {
if (in_array($key, $unlock)) {
continue;
}
$options[$key] = false;
}
}
return $options;
}
/**
* Returns the full path without leading slash
*
* @return string
*/
abstract public function path(): string;
/**
* Prepares the response data for page pickers
* and page fields
*
* @param array|null $params
* @return array
*/
public function pickerData(array $params = []): array
{
return [
'id' => $this->model->id(),
'image' => $this->image(
$params['image'] ?? [],
$params['layout'] ?? 'list'
),
'info' => $this->model->toSafeString($params['info'] ?? false),
'link' => $this->url(true),
'sortable' => true,
'text' => $this->model->toSafeString($params['text'] ?? false),
];
}
/**
* Returns the data array for the
* view's component props
*
* @internal
*
* @return array
*/
public function props(): array
{
$blueprint = $this->model->blueprint();
$tabs = $blueprint->tabs();
$tab = $blueprint->tab(get('tab')) ?? $tabs[0] ?? null;
$props = [
'lock' => $this->lock(),
'permissions' => $this->model->permissions()->toArray(),
'tabs' => $tabs,
];
// only send the tab if it exists
// this will let the vue component define
// a proper default value
if ($tab) {
$props['tab'] = $tab;
}
return $props;
}
/**
* Returns link url and tooltip
* for model (e.g. used for prev/next
* navigation)
*
* @internal
*
* @param string $tooltip
* @return array
*/
public function toLink(string $tooltip = 'title'): array
{
return [
'link' => $this->url(true),
'tooltip' => (string)$this->model->{$tooltip}()
];
}
/**
* Returns the url to the editing view
* in the Panel
*
* @internal
*
* @param bool $relative
* @return string
*/
public function url(bool $relative = false): string
{
if ($relative === true) {
return '/' . $this->path();
}
return $this->model->kirby()->url('panel') . '/' . $this->path();
}
/**
* Returns the data array for
* this model's Panel view
*
* @internal
*
* @return array
*/
abstract public function view(): array;
}

379
kirby/src/Panel/Page.php Normal file
View file

@ -0,0 +1,379 @@
<?php
namespace Kirby\Panel;
/**
* Provides information about the page model for the Panel
* @since 3.6.0
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Page extends Model
{
/**
* Breadcrumb array
*
* @return array
*/
public function breadcrumb(): array
{
$parents = $this->model->parents()->flip()->merge($this->model);
return $parents->values(function ($parent) {
return [
'label' => $parent->title()->toString(),
'link' => $parent->panel()->url(true),
];
});
}
/**
* Provides a kirbytag or markdown
* tag for the page, which will be
* used in the panel, when the page
* gets dragged onto a textarea
*
* @internal
* @param string|null $type (`auto`|`kirbytext`|`markdown`)
* @return string
*/
public function dragText(string $type = null): string
{
$type = $this->dragTextType($type);
if ($callback = $this->dragTextFromCallback($type)) {
return $callback;
}
if ($type === 'markdown') {
return '[' . $this->model->title() . '](' . $this->model->url() . ')';
}
return '(link: ' . $this->model->id() . ' text: ' . $this->model->title() . ')';
}
/**
* Provides options for the page dropdown
*
* @param array $options
* @return array
*/
public function dropdown(array $options = []): array
{
$defaults = [
'view' => get('view'),
'sort' => get('sort'),
'delete' => get('delete')
];
$options = array_merge($defaults, $options);
$page = $this->model;
$permissions = $this->options(['preview']);
$view = $options['view'] ?? 'view';
$url = $this->url(true);
$result = [];
if ($view === 'list') {
$result['preview'] = [
'link' => $page->previewUrl(),
'target' => '_blank',
'icon' => 'open',
'text' => t('open'),
'disabled' => $this->isDisabledDropdownOption('preview', $options, $permissions)
];
$result[] = '-';
}
$result['changeTitle'] = [
'dialog' => [
'url' => $url . '/changeTitle',
'query' => [
'select' => 'title'
]
],
'icon' => 'title',
'text' => t('rename'),
'disabled' => $this->isDisabledDropdownOption('changeTitle', $options, $permissions)
];
$result['duplicate'] = [
'dialog' => $url . '/duplicate',
'icon' => 'copy',
'text' => t('duplicate'),
'disabled' => $this->isDisabledDropdownOption('duplicate', $options, $permissions)
];
$result[] = '-';
$result['changeSlug'] = [
'dialog' => [
'url' => $url . '/changeTitle',
'query' => [
'select' => 'slug'
]
],
'icon' => 'url',
'text' => t('page.changeSlug'),
'disabled' => $this->isDisabledDropdownOption('changeSlug', $options, $permissions)
];
$result['changeStatus'] = [
'dialog' => $url . '/changeStatus',
'icon' => 'preview',
'text' => t('page.changeStatus'),
'disabled' => $this->isDisabledDropdownOption('changeStatus', $options, $permissions)
];
$siblings = $page->parentModel()->children()->listed()->not($page);
$result['changeSort'] = [
'dialog' => $url . '/changeSort',
'icon' => 'sort',
'text' => t('page.sort'),
'disabled' => $siblings->count() === 0 || $this->isDisabledDropdownOption('sort', $options, $permissions)
];
$result['changeTemplate'] = [
'dialog' => $url . '/changeTemplate',
'icon' => 'template',
'text' => t('page.changeTemplate'),
'disabled' => $this->isDisabledDropdownOption('changeTemplate', $options, $permissions)
];
$result[] = '-';
$result['delete'] = [
'dialog' => $url . '/delete',
'icon' => 'trash',
'text' => t('delete'),
'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions)
];
return $result;
}
/**
* Returns the setup for a dropdown option
* which is used in the changes dropdown
* for example.
*
* @return array
*/
public function dropdownOption(): array
{
return [
'text' => $this->model->title()->value(),
] + parent::dropdownOption();
}
/**
* Returns the escaped Id, which is
* used in the panel to make routing work properly
*
* @return string
*/
public function id(): string
{
return str_replace('/', '+', $this->model->id());
}
/**
* Default settings for the page's Panel image
*
* @return array
*/
protected function imageDefaults(): array
{
$defaults = [];
if ($icon = $this->model->blueprint()->icon()) {
$defaults['icon'] = $icon;
}
return array_merge(parent::imageDefaults(), $defaults);
}
/**
* Returns the image file object based on provided query
*
* @internal
* @param string|null $query
* @return \Kirby\Cms\File|\Kirby\Filesystem\Asset|null
*/
protected function imageSource(string $query = null)
{
if ($query === null) {
$query = 'page.image';
}
return parent::imageSource($query);
}
/**
* Returns the full path without leading slash
*
* @internal
* @return string
*/
public function path(): string
{
return 'pages/' . $this->id();
}
/**
* Prepares the response data for page pickers
* and page fields
*
* @param array|null $params
* @return array
*/
public function pickerData(array $params = []): array
{
$params['text'] = $params['text'] ?? '{{ page.title }}';
return array_merge(parent::pickerData($params), [
'dragText' => $this->dragText(),
'hasChildren' => $this->model->hasChildren(),
'url' => $this->model->url()
]);
}
/**
* The best applicable position for
* the position/status dialog
*
* @return int
*/
public function position(): int
{
return $this->model->num() ?? $this->model->parentModel()->children()->listed()->not($this->model)->count() + 1;
}
/**
* Returns navigation array with
* previous and next page
* based on blueprint definition
*
* @internal
*
* @return array
*/
public function prevNext(): array
{
$page = $this->model;
// create siblings collection based on
// blueprint navigation
$siblings = function (string $direction) use ($page) {
$navigation = $page->blueprint()->navigation();
$sortBy = $navigation['sortBy'] ?? null;
$status = $navigation['status'] ?? null;
$template = $navigation['template'] ?? null;
$direction = $direction === 'prev' ? 'prev' : 'next';
// if status is defined in navigation,
// all items in the collection are used
// (drafts, listed and unlisted) otherwise
// it depends on the status of the page
$siblings = $status !== null ? $page->parentModel()->childrenAndDrafts() : $page->siblings();
// sort the collection if custom sortBy
// defined in navigation otherwise
// default sorting will apply
if ($sortBy !== null) {
$siblings = $siblings->sort(...$siblings::sortArgs($sortBy));
}
$siblings = $page->{$direction . 'All'}($siblings);
if (empty($navigation) === false) {
$statuses = (array)($status ?? $page->status());
$templates = (array)($template ?? $page->intendedTemplate());
// do not filter if template navigation is all
if (in_array('all', $templates) === false) {
$siblings = $siblings->filter('intendedTemplate', 'in', $templates);
}
// do not filter if status navigation is all
if (in_array('all', $statuses) === false) {
$siblings = $siblings->filter('status', 'in', $statuses);
}
} else {
$siblings = $siblings
->filter('intendedTemplate', $page->intendedTemplate())
->filter('status', $page->status());
}
return $siblings->filter('isReadable', true);
};
return [
'next' => function () use ($siblings) {
$next = $siblings('next')->first();
return $next ? $next->panel()->toLink('title') : null;
},
'prev' => function () use ($siblings) {
$prev = $siblings('prev')->last();
return $prev ? $prev->panel()->toLink('title') : null;
}
];
}
/**
* Returns the data array for the
* view's component props
*
* @internal
*
* @return array
*/
public function props(): array
{
$page = $this->model;
return array_merge(
parent::props(),
$this->prevNext(),
[
'blueprint' => $this->model->intendedTemplate()->name(),
'model' => [
'content' => $this->content(),
'id' => $page->id(),
'link' => $this->url(true),
'parent' => $page->parentModel()->panel()->url(true),
'previewUrl' => $page->previewUrl(),
'status' => $page->status(),
'title' => $page->title()->toString(),
],
'status' => function () use ($page) {
if ($status = $page->status()) {
return $page->blueprint()->status()[$status] ?? null;
}
},
]
);
}
/**
* Returns the data array for
* this model's Panel view
*
* @internal
*
* @return array
*/
public function view(): array
{
$page = $this->model;
return [
'breadcrumb' => $page->panel()->breadcrumb(),
'component' => 'k-page-view',
'props' => $this->props(),
'title' => $page->title()->toString(),
];
}
}

608
kirby/src/Panel/Panel.php Normal file
View file

@ -0,0 +1,608 @@
<?php
namespace Kirby\Panel;
use Kirby\Cms\User;
use Kirby\Exception\Exception;
use Kirby\Exception\NotFoundException;
use Kirby\Exception\PermissionException;
use Kirby\Http\Response;
use Kirby\Http\Url;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\Tpl;
use Throwable;
/**
* The Panel class is only responsible to create
* a working panel view with all the right URLs
* and other panel options. The view template is
* located in `kirby/views/panel.php`
* @since 3.6.0
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Panel
{
/**
* Normalize a panel area
*
* @param string $id
* @param array|string $area
* @return array
*/
public static function area(string $id, $area): array
{
$area['id'] = $id;
$area['label'] = $area['label'] ?? $id;
$area['breadcrumb'] = $area['breadcrumb'] ?? [];
$area['breadcrumbLabel'] = $area['breadcrumbLabel'] ?? $area['label'];
$area['title'] = $area['label'];
$area['menu'] = $area['menu'] ?? false;
$area['link'] = $area['link'] ?? $id;
$area['search'] = $area['search'] ?? null;
return $area;
}
/**
* Collect all registered areas
*
* @return array
*/
public static function areas(): array
{
$kirby = kirby();
$system = $kirby->system();
$user = $kirby->user();
$areas = $kirby->load()->areas();
// the system is not ready
if ($system->isOk() === false || $system->isInstalled() === false) {
return [
'installation' => static::area('installation', $areas['installation']),
];
}
// not yet authenticated
if (!$user) {
return [
'login' => static::area('login', $areas['login']),
];
}
unset($areas['installation'], $areas['login']);
// Disable the language area for single-language installations
// This does not check for installed languages. Otherwise you'd
// not be able to add the first language through the view
if (!$kirby->option('languages')) {
unset($areas['languages']);
}
$menu = $kirby->option('panel.menu', [
'site',
'languages',
'users',
'system',
]);
$result = [];
// add the sorted areas
foreach ($menu as $id) {
if ($area = ($areas[$id] ?? null)) {
$result[$id] = static::area($id, $area);
unset($areas[$id]);
}
}
// add the remaining areas
foreach ($areas as $id => $area) {
$result[$id] = static::area($id, $area);
}
return $result;
}
/**
* Check for access permissions
*
* @param \Kirby\Cms\User|null $user
* @param string|null $areaId
* @return bool
*/
public static function firewall(?User $user = null, ?string $areaId = null): bool
{
// a user has to be logged in
if ($user === null) {
throw new PermissionException(['key' => 'access.panel']);
}
// get all access permissions for the user role
$permissions = $user->role()->permissions()->toArray()['access'];
// check for general panel access
if (($permissions['panel'] ?? true) !== true) {
throw new PermissionException(['key' => 'access.panel']);
}
// don't check if the area is not defined
if (empty($areaId) === true) {
return true;
}
// undefined area permissions means access
if (isset($permissions[$areaId]) === false) {
return true;
}
// no access
if ($permissions[$areaId] !== true) {
throw new PermissionException(['key' => 'access.view']);
}
return true;
}
/**
* Redirect to a Panel url
*
* @param string|null $path
* @param int $code
* @throws \Kirby\Panel\Redirect
* @return void
* @codeCoverageIgnore
*/
public static function go(?string $url = null, int $code = 302): void
{
throw new Redirect(static::url($url), $code);
}
/**
* Check if the given user has access to the panel
* or to a given area
*
* @param \Kirby\Cms\User|null $user
* @param string|null $area
* @return bool
*/
public static function hasAccess(?User $user = null, string $area = null): bool
{
try {
static::firewall($user, $area);
return true;
} catch (Throwable $e) {
return false;
}
}
/**
* Checks for a Fiber request
* via get parameters or headers
*
* @return bool
*/
public static function isFiberRequest(): bool
{
$request = kirby()->request();
if ($request->method() === 'GET') {
return (bool)($request->get('_json') ?? $request->header('X-Fiber'));
}
return false;
}
/**
* Returns a JSON response
* for Fiber calls
*
* @param array $data
* @param int $code
* @return \Kirby\Http\Response
*/
public static function json(array $data, int $code = 200)
{
return Response::json($data, $code, get('_pretty'), [
'X-Fiber' => 'true',
'Cache-Control' => 'no-store'
]);
}
/**
* Checks for a multilanguage installation
*
* @return bool
*/
public static function multilang(): bool
{
// multilang setup check
$kirby = kirby();
return $kirby->option('languages') || $kirby->multilang();
}
/**
* Returns the referrer path if present
*
* @return string|null
*/
public static function referrer(): ?string
{
$referrer = kirby()->request()->header('X-Fiber-Referrer') ?? get('_referrer');
return '/' . trim($referrer, '/');
}
/**
* Creates a Response object from the result of
* a Panel route call
*
* @params mixed $result
* @params array $options
* @return \Kirby\Http\Response
*/
public static function response($result, array $options = [])
{
// pass responses directly down to the Kirby router
if (is_a($result, 'Kirby\Http\Response') === true) {
return $result;
}
// interpret missing/empty results as not found
if ($result === null || $result === false) {
$result = new NotFoundException('The data could not be found');
// interpret strings as errors
} elseif (is_string($result) === true) {
$result = new Exception($result);
}
// handle different response types (view, dialog, ...)
switch ($options['type'] ?? null) {
case 'dialog':
return Dialog::response($result, $options);
case 'dropdown':
return Dropdown::response($result, $options);
case 'search':
return Search::response($result, $options);
default:
return View::response($result, $options);
}
}
/**
* Router for the Panel views
*
* @param string $path
* @return \Kirby\Http\Response|false
*/
public static function router(string $path = null)
{
$kirby = kirby();
if ($kirby->option('panel') === false) {
return null;
}
// set the translation for Panel UI before
// gathering areas and routes, so that the
// `t()` helper can already be used
static::setTranslation();
// set the language in multi-lang installations
static::setLanguage();
$areas = static::areas();
$routes = static::routes($areas);
// create a micro-router for the Panel
return router($path, $method = $kirby->request()->method(), $routes, function ($route) use ($areas, $kirby, $method, $path) {
// trigger hook
$route = $kirby->apply('panel.route:before', compact('route', 'path', 'method'), 'route');
// route needs authentication?
$auth = $route->attributes()['auth'] ?? true;
$areaId = $route->attributes()['area'] ?? null;
$type = $route->attributes()['type'] ?? 'view';
$area = $areas[$areaId] ?? null;
// call the route action to check the result
try {
// check for access before executing area routes
if ($auth !== false) {
static::firewall($kirby->user(), $areaId);
}
$result = $route->action()->call($route, ...$route->arguments());
} catch (Throwable $e) {
$result = $e;
}
$response = static::response($result, [
'area' => $area,
'areas' => $areas,
'path' => $path,
'type' => $type
]);
return $kirby->apply('panel.route:after', compact('route', 'path', 'method', 'response'), 'response');
});
}
/**
* Extract the routes from the given array
* of active areas.
*
* @return array
*/
public static function routes(array $areas): array
{
$kirby = kirby();
// the browser incompatibility
// warning is always needed
$routes = [
[
'pattern' => 'browser',
'auth' => false,
'action' => function () use ($kirby) {
return new Response(
Tpl::load($kirby->root('kirby') . '/views/browser.php')
);
},
]
];
// register all routes from areas
foreach ($areas as $areaId => $area) {
$routes = array_merge(
$routes,
static::routesForViews($areaId, $area),
static::routesForSearches($areaId, $area),
static::routesForDialogs($areaId, $area),
static::routesForDropdowns($areaId, $area),
);
}
// if the Panel is already installed and/or the
// user is authenticated, those areas won't be
// included, which is why we add redirect routes
// to main Panel view as fallbacks
$routes[] = [
'pattern' => [
'/',
'installation',
'login',
],
'action' => function () {
Panel::go(Home::url());
}
];
// catch all route
$routes[] = [
'pattern' => '(:all)',
'action' => function () {
return 'The view could not be found';
}
];
return $routes;
}
/**
* Extract all routes from an area
*
* @param string $areaId
* @param array $area
* @return array
*/
public static function routesForDialogs(string $areaId, array $area): array
{
$dialogs = $area['dialogs'] ?? [];
$routes = [];
foreach ($dialogs as $key => $dialog) {
// create the full pattern with dialogs prefix
$pattern = 'dialogs/' . trim(($dialog['pattern'] ?? $key), '/');
// load event
$routes[] = [
'pattern' => $pattern,
'type' => 'dialog',
'area' => $areaId,
'action' => $dialog['load'] ?? function () {
return 'The load handler for your dialog is missing';
},
];
// submit event
$routes[] = [
'pattern' => $pattern,
'type' => 'dialog',
'area' => $areaId,
'method' => 'POST',
'action' => $dialog['submit'] ?? function () {
return 'Your dialog does not define a submit handler';
}
];
}
return $routes;
}
/**
* Extract all routes for dropdowns
*
* @param string $areaId
* @param array $area
* @return array
*/
public static function routesForDropdowns(string $areaId, array $area): array
{
$dropdowns = $area['dropdowns'] ?? [];
$routes = [];
foreach ($dropdowns as $name => $dropdown) {
// create the full pattern with dropdowns prefix
$pattern = 'dropdowns/' . trim(($dropdown['pattern'] ?? $name), '/');
// load event
$routes[] = [
'pattern' => $pattern,
'type' => 'dropdown',
'area' => $areaId,
'method' => 'GET|POST',
'action' => $dropdown['options'] ?? $dropdown['action']
];
}
return $routes;
}
/**
* Extract all routes for searches
*
* @param string $areaId
* @param array $area
* @return array
*/
public static function routesForSearches(string $areaId, array $area): array
{
$searches = $area['searches'] ?? [];
$routes = [];
foreach ($searches as $name => $params) {
// create the full routing pattern
$pattern = 'search/' . $name;
// load event
$routes[] = [
'pattern' => $pattern,
'type' => 'search',
'area' => $areaId,
'action' => function () use ($params) {
return $params['query'](get('query'));
}
];
}
return $routes;
}
/**
* Extract all views from an area
*
* @param string $areaId
* @param array $area
* @return array
*/
public static function routesForViews(string $areaId, array $area): array
{
$views = $area['views'] ?? [];
$routes = [];
foreach ($views as $view) {
$view['area'] = $areaId;
$view['type'] = 'view';
$routes[] = $view;
}
return $routes;
}
/**
* Set the current language in multi-lang
* installations based on the session or the
* query language query parameter
*
* @return string|null
*/
public static function setLanguage(): ?string
{
$kirby = kirby();
// language switcher
if (static::multilang()) {
$fallback = 'en';
if ($defaultLanguage = $kirby->defaultLanguage()) {
$fallback = $defaultLanguage->code();
}
$session = $kirby->session();
$sessionLanguage = $session->get('panel.language', $fallback);
$language = get('language') ?? $sessionLanguage;
// keep the language for the next visit
if ($language !== $sessionLanguage) {
$session->set('panel.language', $language);
}
// activate the current language in Kirby
$kirby->setCurrentLanguage($language);
return $language;
}
return null;
}
/**
* Set the currently active Panel translation
* based on the current user or config
*
* @return string
*/
public static function setTranslation(): string
{
$kirby = kirby();
if ($user = $kirby->user()) {
// use the user language for the default translation
$translation = $user->language();
} else {
// fall back to the language from the config
$translation = $kirby->panelLanguage();
}
$kirby->setCurrentTranslation($translation);
return $translation;
}
/**
* Creates an absolute Panel URL
* independent of the Panel slug config
*
* @param string|null $url
* @return string
*/
public static function url(?string $url = null): string
{
$slug = kirby()->option('panel.slug', 'panel');
// only touch relative paths
if (Url::isAbsolute($url) === false) {
$path = trim($url, '/');
// add the panel slug prefix if it it's not
// included in the path yet
if (Str::startsWith($path, $slug . '/') === false) {
$path = $slug . '/' . $path;
}
// create an absolute URL
$url = url($path);
}
return $url;
}
}

109
kirby/src/Panel/Plugins.php Normal file
View file

@ -0,0 +1,109 @@
<?php
namespace Kirby\Panel;
use Kirby\Cms\App;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Str;
/**
* The Plugins class takes care of collecting
* js and css plugin files for the panel and caches
* them in the media folder
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Plugins
{
/**
* Cache of all collected plugin files
*
* @var array
*/
public $files;
/**
* Collects and returns the plugin files for all plugins
*
* @return array
*/
public function files(): array
{
if ($this->files !== null) {
return $this->files;
}
$this->files = [];
foreach (App::instance()->plugins() as $plugin) {
$this->files[] = $plugin->root() . '/index.css';
$this->files[] = $plugin->root() . '/index.js';
}
return $this->files;
}
/**
* Returns the last modification
* of the collected plugin files
*
* @return int
*/
public function modified(): int
{
$files = $this->files();
$modified = [0];
foreach ($files as $file) {
$modified[] = F::modified($file);
}
return max($modified);
}
/**
* Read the files from all plugins and concatenate them
*
* @param string $type
* @return string
*/
public function read(string $type): string
{
$dist = [];
foreach ($this->files() as $file) {
if (F::extension($file) === $type) {
if ($content = F::read($file)) {
if ($type === 'js') {
$content = trim($content);
// make sure that each plugin is ended correctly
if (Str::endsWith($content, ';') === false) {
$content .= ';';
}
}
$dist[] = $content;
}
}
}
return implode(PHP_EOL . PHP_EOL, $dist);
}
/**
* Absolute url to the cache file
* This is used by the panel to link the plugins
*
* @param string $type
* @return string
*/
public function url(string $type): string
{
return App::instance()->url('media') . '/plugins/index.' . $type . '?' . $this->modified();
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace Kirby\Panel;
use Exception;
/**
* The Redirect exception can be thrown in all Fiber
* routes to send a redirect response. It is
* primarily used in `Panel::go($location)`
* @since 3.6.0
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Redirect extends Exception
{
/**
* Returns the HTTP code for the redirect
*
* @return int
*/
public function code(): int
{
$codes = [301, 302, 303, 307, 308];
if (in_array($this->getCode(), $codes) === true) {
return $this->getCode();
}
return 302;
}
/**
* Returns the URL for the redirect
*
* @return string
*/
public function location(): string
{
return $this->getMessage();
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace Kirby\Panel;
/**
* The Search response class handles Fiber
* requests to render the JSON object for
* search queries
* @since 3.6.0
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Search extends Json
{
protected static $key = '$search';
/**
* @param mixed $data
* @param array $options
* @return \Kirby\Http\Response
*/
public static function response($data, array $options = [])
{
if (is_array($data) === true) {
$data = [
'results' => $data
];
}
return parent::response($data, $options);
}
}

94
kirby/src/Panel/Site.php Normal file
View file

@ -0,0 +1,94 @@
<?php
namespace Kirby\Panel;
/**
* Provides information about the site model for the Panel
* @since 3.6.0
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Site extends Model
{
/**
* Returns the setup for a dropdown option
* which is used in the changes dropdown
* for example.
*
* @return array
*/
public function dropdownOption(): array
{
return [
'icon' => 'home',
'text' => $this->model->title()->value(),
] + parent::dropdownOption();
}
/**
* Returns the image file object based on provided query
*
* @internal
* @param string|null $query
* @return \Kirby\Cms\File|\Kirby\Filesystem\Asset|null
*/
protected function imageSource(string $query = null)
{
if ($query === null) {
$query = 'site.image';
}
return parent::imageSource($query);
}
/**
* Returns the full path without leading slash
*
* @return string
*/
public function path(): string
{
return 'site';
}
/**
* Returns the data array for the
* view's component props
*
* @internal
*
* @return array
*/
public function props(): array
{
return array_merge(parent::props(), [
'blueprint' => 'site',
'model' => [
'content' => $this->content(),
'link' => $this->url(true),
'previewUrl' => $this->model->previewUrl(),
'title' => $this->model->title()->toString(),
]
]);
}
/**
* Returns the data array for
* this model's Panel view
*
* @internal
*
* @return array
*/
public function view(): array
{
return [
'component' => 'k-site-view',
'props' => $this->props()
];
}
}

272
kirby/src/Panel/User.php Normal file
View file

@ -0,0 +1,272 @@
<?php
namespace Kirby\Panel;
/**
* Provides information about the user model for the Panel
* @since 3.6.0
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class User extends Model
{
/**
* Breadcrumb array
*
* @return array
*/
public function breadcrumb(): array
{
return [
[
'label' => $this->model->username(),
'link' => $this->url(true),
]
];
}
/**
* Provides options for the user dropdown
*
* @param array $options
* @return array
*/
public function dropdown(array $options = []): array
{
$account = $this->model->isLoggedIn();
$i18nPrefix = $account ? 'account' : 'user';
$permissions = $this->options(['preview']);
$url = $this->url(true);
$result = [];
$result[] = [
'dialog' => $url . '/changeName',
'icon' => 'title',
'text' => t($i18nPrefix . '.changeName'),
'disabled' => $this->isDisabledDropdownOption('changeName', $options, $permissions)
];
$result[] = '-';
$result[] = [
'dialog' => $url . '/changeEmail',
'icon' => 'email',
'text' => t('user.changeEmail'),
'disabled' => $this->isDisabledDropdownOption('changeEmail', $options, $permissions)
];
$result[] = [
'dialog' => $url . '/changeRole',
'icon' => 'bolt',
'text' => t('user.changeRole'),
'disabled' => $this->isDisabledDropdownOption('changeRole', $options, $permissions)
];
$result[] = [
'dialog' => $url . '/changePassword',
'icon' => 'key',
'text' => t('user.changePassword'),
'disabled' => $this->isDisabledDropdownOption('changePassword', $options, $permissions)
];
$result[] = [
'dialog' => $url . '/changeLanguage',
'icon' => 'globe',
'text' => t('user.changeLanguage'),
'disabled' => $this->isDisabledDropdownOption('changeLanguage', $options, $permissions)
];
$result[] = '-';
$result[] = [
'dialog' => $url . '/delete',
'icon' => 'trash',
'text' => t($i18nPrefix . '.delete'),
'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions)
];
return $result;
}
/**
* Returns the setup for a dropdown option
* which is used in the changes dropdown
* for example.
*
* @return array
*/
public function dropdownOption(): array
{
return [
'icon' => 'user',
'text' => $this->model->username(),
] + parent::dropdownOption();
}
/**
* @return string|null
*/
public function home(): ?string
{
if ($home = ($this->model->blueprint()->home() ?? null)) {
$url = $this->model->toString($home);
return url($url);
}
return Panel::url('site');
}
/**
* Default settings for the user's Panel image
*
* @return array
*/
protected function imageDefaults(): array
{
return array_merge(parent::imageDefaults(), [
'back' => 'black',
'icon' => 'user',
'ratio' => '1/1',
]);
}
/**
* Returns the image file object based on provided query
*
* @param string|null $query
* @return \Kirby\Cms\File|\Kirby\Filesystem\Asset|null
*/
protected function imageSource(string $query = null)
{
if ($query === null) {
return $this->model->avatar();
}
return parent::imageSource($query);
}
/**
* Returns the full path without leading slash
*
* @return string
*/
public function path(): string
{
// path to your own account
if ($this->model->isLoggedIn() === true) {
return 'account';
}
return 'users/' . $this->model->id();
}
/**
* Returns prepared data for the panel user picker
*
* @param array|null $params
* @return array
*/
public function pickerData(array $params = null): array
{
$params['text'] = $params['text'] ?? '{{ user.username }}';
return array_merge(parent::pickerData($params), [
'email' => $this->model->email(),
'username' => $this->model->username(),
]);
}
/**
* Returns navigation array with
* previous and next user
*
* @internal
*
* @return array
*/
public function prevNext(): array
{
$user = $this->model;
return [
'next' => function () use ($user) {
$next = $user->next();
return $next ? $next->panel()->toLink('username') : null;
},
'prev' => function () use ($user) {
$prev = $user->prev();
return $prev ? $prev->panel()->toLink('username') : null;
}
];
}
/**
* Returns the data array for the
* view's component props
*
* @internal
*
* @return array
*/
public function props(): array
{
$user = $this->model;
$account = $user->isLoggedIn();
$avatar = $user->avatar();
return array_merge(
parent::props(),
$account ? [] : $this->prevNext(),
[
'blueprint' => $this->model->role()->name(),
'model' => [
'account' => $account,
'avatar' => $avatar ? $avatar->url() : null,
'content' => $this->content(),
'email' => $user->email(),
'id' => $user->id(),
'language' => $this->translation()->name(),
'link' => $this->url(true),
'name' => $user->name()->toString(),
'role' => $user->role()->title(),
'username' => $user->username(),
]
]
);
}
/**
* Returns the Translation object
* for the selected Panel language
*
* @return \Kirby\Cms\Translation
*/
public function translation()
{
$kirby = $this->model->kirby();
$lang = $this->model->language();
return $kirby->translation($lang);
}
/**
* Returns the data array for
* this model's Panel view
*
* @internal
*
* @return array
*/
public function view(): array
{
return [
'breadcrumb' => $this->breadcrumb(),
'component' => 'k-user-view',
'props' => $this->props(),
'title' => $this->model->username(),
];
}
}

463
kirby/src/Panel/View.php Normal file
View file

@ -0,0 +1,463 @@
<?php
namespace Kirby\Panel;
use Kirby\Http\Response;
use Kirby\Http\Url;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* The View response class handles Fiber
* requests to render either a JSON object
* or a full HTML document for Panel views
* @since 3.6.0
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class View
{
/**
* Filters the data array based on headers or
* query parameters. Requests can return only
* certain data fields that way or globals can
* be injected on demand.
*
* @param array $data
* @return array
*/
public static function apply(array $data): array
{
$request = kirby()->request();
$only = $request->header('X-Fiber-Only') ?? get('_only');
if (empty($only) === false) {
return static::applyOnly($data, $only);
}
$globals = $request->header('X-Fiber-Globals') ?? get('_globals');
if (empty($globals) === false) {
return static::applyGlobals($data, $globals);
}
return A::apply($data);
}
/**
* Checks if globals should be included in a JSON Fiber request. They are normally
* only loaded with the full document request, but sometimes need to be updated.
*
* A global request can be activated with the `X-Fiber-Globals` header or the
* `_globals` query parameter.
*
* @param array $data
* @param string|null $globals
* @return array
*/
public static function applyGlobals(array $data, ?string $globals = null): array
{
// split globals string into an array of fields
$globalKeys = Str::split($globals, ',');
// add requested globals
if (empty($globalKeys) === true) {
return $data;
}
$globals = static::globals();
foreach ($globalKeys as $globalKey) {
if (isset($globals[$globalKey]) === true) {
$data[$globalKey] = $globals[$globalKey];
}
}
// merge with shared data
return A::apply($data);
}
/**
* Checks if the request should only return a limited
* set of data. This can be activated with the `X-Fiber-Only`
* header or the `_only` query parameter in a request.
*
* Such requests can fetch shared data or globals.
* Globals will be loaded on demand.
*
* @param array $data
* @param string|null $only
* @return array
*/
public static function applyOnly(array $data, ?string $only = null): array
{
// split include string into an array of fields
$onlyKeys = Str::split($only, ',');
// if a full request is made, return all data
if (empty($onlyKeys) === true) {
return $data;
}
// otherwise filter data based on
// dot notation, e.g. `$props.tab.columns`
$result = [];
// check if globals are requested and need to be merged
if (Str::contains($only, '$')) {
$data = array_merge_recursive(static::globals(), $data);
}
// make sure the data is already resolved to make
// nested data fetching work
$data = A::apply($data);
// build a new array with all requested data
foreach ($onlyKeys as $onlyKey) {
$result[$onlyKey] = A::get($data, $onlyKey);
}
// Nest dotted keys in array but ignore $translation
return A::nest($result, [
'$translation'
]);
}
/**
* Creates the shared data array for the individual views
* The full shared data is always sent on every JSON and
* full document request unless the `X-Fiber-Only` header or
* the `_only` query parameter is set.
*
* @param array $view
* @param array $options
* @return array
*/
public static function data(array $view = [], array $options = []): array
{
$kirby = kirby();
// multilang setup check
$multilang = Panel::multilang();
// get the authenticated user
$user = $kirby->user();
// user permissions
$permissions = $user ? $user->role()->permissions()->toArray() : [];
// current content language
$language = $kirby->language();
// shared data for all requests
return [
'$direction' => function () use ($kirby, $multilang, $language, $user) {
if ($multilang === true && $language && $user) {
$isDefault = $language->direction() === $kirby->defaultLanguage()->direction();
$isFromUser = $language->code() === $user->language();
if ($isDefault === false && $isFromUser === false) {
return $language->direction();
}
}
},
'$language' => function () use ($kirby, $multilang, $language) {
if ($multilang === true && $language) {
return [
'code' => $language->code(),
'default' => $language->isDefault(),
'direction' => $language->direction(),
'name' => $language->name(),
'rules' => $language->rules(),
];
}
},
'$languages' => function () use ($kirby, $multilang): array {
if ($multilang === true) {
return $kirby->languages()->values(function ($language) {
return [
'code' => $language->code(),
'default' => $language->isDefault(),
'direction' => $language->direction(),
'name' => $language->name(),
'rules' => $language->rules(),
];
});
}
return [];
},
'$menu' => function () use ($options, $permissions) {
return static::menu($options['areas'] ?? [], $permissions, $options['area']['id'] ?? null);
},
'$permissions' => $permissions,
'$license' => (bool)$kirby->system()->license(),
'$multilang' => $multilang,
'$searches' => static::searches($options['areas'] ?? [], $permissions),
'$url' => Url::current(),
'$user' => function () use ($user) {
if ($user) {
return [
'email' => $user->email(),
'id' => $user->id(),
'language' => $user->language(),
'role' => $user->role()->id(),
'username' => $user->username(),
];
}
return null;
},
'$view' => function () use ($kirby, $options, $view) {
$defaults = [
'breadcrumb' => [],
'code' => 200,
'path' => Str::after($kirby->path(), '/'),
'timestamp' => (int)(microtime(true) * 1000),
'props' => [],
'search' => $kirby->option('panel.search.type', 'pages')
];
$view = array_replace_recursive($defaults, $options['area'] ?? [], $view);
// make sure that views and dialogs are gone
unset(
$view['dialogs'],
$view['dropdowns'],
$view['searches'],
$view['views']
);
// resolve all callbacks in the view array
return A::apply($view);
}
];
}
/**
* Renders the error view with provided message
*
* @param string $message
* @param int $code
* @return array
*/
public static function error(string $message, int $code = 404)
{
return [
'code' => $code,
'component' => 'k-error-view',
'error' => $message,
'props' => [
'error' => $message,
'layout' => Panel::hasAccess(kirby()->user()) ? 'inside' : 'outside'
],
'title' => 'Error'
];
}
/**
* Creates global data for the Panel.
* This will be injected in the full Panel
* view via the script tag. Global data
* is only requested once on the first page load.
* It can be loaded partially later if needed,
* but is otherwise not included in Fiber calls.
*
* @return array
*/
public static function globals(): array
{
$kirby = kirby();
return [
'$config' => function () use ($kirby) {
return [
'debug' => $kirby->option('debug', false),
'kirbytext' => $kirby->option('panel.kirbytext', true),
'search' => [
'limit' => $kirby->option('panel.search.limit', 10),
'type' => $kirby->option('panel.search.type', 'pages')
],
'translation' => $kirby->option('panel.language', 'en'),
];
},
'$system' => function () use ($kirby) {
$locales = [];
foreach ($kirby->translations() as $translation) {
$locales[$translation->code()] = $translation->locale();
}
return [
'ascii' => Str::$ascii,
'csrf' => $kirby->auth()->csrfFromSession(),
'isLocal' => $kirby->system()->isLocal(),
'locales' => $locales,
'slugs' => Str::$language,
'title' => $kirby->site()->title()->or('Kirby Panel')->toString()
];
},
'$translation' => function () use ($kirby) {
if ($user = $kirby->user()) {
$translation = $kirby->translation($user->language());
} else {
$translation = $kirby->translation($kirby->panelLanguage());
}
return [
'code' => $translation->code(),
'data' => $translation->dataWithFallback(),
'direction' => $translation->direction(),
'name' => $translation->name(),
];
},
'$urls' => function () use ($kirby) {
return [
'api' => $kirby->url('api'),
'site' => $kirby->url('index')
];
}
];
}
/**
* Creates the menu for the topbar
*
* @param array $areas
* @param array $permissions
* @param string|null $current
* @return array
*/
public static function menu(?array $areas = [], ?array $permissions = [], ?string $current = null): array
{
$menu = [];
// areas
foreach ($areas as $areaId => $area) {
$access = $permissions['access'][$areaId] ?? true;
// areas without access permissions get skipped entirely
if ($access === false) {
continue;
}
// fetch custom menu settings from the area definition
$menuSetting = $area['menu'] ?? false;
// menu settings can be a callback that can return true, false or disabled
if (is_a($menuSetting, 'Closure') === true) {
$menuSetting = $menuSetting($areas, $permissions, $current);
}
// false will remove the area entirely just like with
// disabled permissions
if ($menuSetting === false) {
continue;
}
$menu[] = [
'current' => $areaId === $current,
'disabled' => $menuSetting === 'disabled',
'icon' => $area['icon'],
'id' => $areaId,
'link' => $area['link'],
'text' => $area['label'],
];
}
$menu[] = '-';
$menu[] = [
'current' => $current === 'account',
'icon' => 'account',
'id' => 'account',
'link' => 'account',
'disabled' => ($permissions['access']['account'] ?? false) === false,
'text' => t('view.account'),
];
$menu[] = '-';
// logout
$menu[] = [
'icon' => 'logout',
'id' => 'logout',
'link' => 'logout',
'text' => t('logout')
];
return $menu;
}
/**
* Renders the main panel view either as
* JSON response or full HTML document based
* on the request header or query params
*
* @param mixed $data
* @param array $options
* @return \Kirby\Http\Response
*/
public static function response($data, array $options = [])
{
$kirby = kirby();
$area = $options['area'] ?? [];
$areas = $options['areas'] ?? [];
// handle redirects
if (is_a($data, 'Kirby\Panel\Redirect') === true) {
return Response::redirect($data->location(), $data->code());
// handle Kirby exceptions
} elseif (is_a($data, 'Kirby\Exception\Exception') === true) {
$data = static::error($data->getMessage(), $data->getHttpCode());
// handle regular exceptions
} elseif (is_a($data, 'Throwable') === true) {
$data = static::error($data->getMessage(), 500);
// only expect arrays from here on
} elseif (is_array($data) === false) {
$data = static::error('Invalid Panel response', 500);
}
// get all data for the request
$fiber = static::data($data, $options);
// if requested, send $fiber data as JSON
if (Panel::isFiberRequest() === true) {
// filter data, if only or globals headers or
// query parameters are set
$fiber = static::apply($fiber);
return Panel::json($fiber, $fiber['$view']['code'] ?? 200);
}
// load globals for the full document response
$globals = static::globals();
// resolve and merge globals and shared data
$fiber = array_merge_recursive(A::apply($globals), A::apply($fiber));
// render the full HTML document
return Document::response($fiber);
}
public static function searches(array $areas, array $permissions)
{
$searches = [];
foreach ($areas as $area) {
foreach ($area['searches'] ?? [] as $id => $params) {
$searches[$id] = [
'icon' => $params['icon'] ?? 'search',
'label' => $params['label'] ?? Str::ucfirst($id),
'id' => $id
];
}
}
return $searches;
}
}