508 lines
11 KiB
PHP
508 lines
11 KiB
PHP
<?php
|
||
|
||
namespace Kirby\Cms;
|
||
|
||
use Kirby\Cms\System\UpdateStatus;
|
||
use Kirby\Exception\InvalidArgumentException;
|
||
use Kirby\Exception\PermissionException;
|
||
use Kirby\Filesystem\Dir;
|
||
use Kirby\Toolkit\A;
|
||
use Kirby\Toolkit\Str;
|
||
use Throwable;
|
||
|
||
/**
|
||
* The System class gathers all information
|
||
* about the server, PHP and other environment
|
||
* parameters and checks for a valid setup.
|
||
*
|
||
* This is mostly used by the panel installer
|
||
* to check if the panel can be installed at all.
|
||
*
|
||
* @package Kirby Cms
|
||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||
* @link https://getkirby.com
|
||
* @copyright Bastian Allgeier
|
||
* @license https://getkirby.com/license
|
||
*/
|
||
class System
|
||
{
|
||
// cache
|
||
protected License|null $license = null;
|
||
protected UpdateStatus|null $updateStatus = null;
|
||
|
||
public function __construct(protected App $app)
|
||
{
|
||
// try to create all folders that could be missing
|
||
$this->init();
|
||
}
|
||
|
||
/**
|
||
* Check for a writable accounts folder
|
||
*/
|
||
public function accounts(): bool
|
||
{
|
||
return is_writable($this->app->root('accounts')) === true;
|
||
}
|
||
|
||
/**
|
||
* Check for a writable content folder
|
||
*/
|
||
public function content(): bool
|
||
{
|
||
return is_writable($this->app->root('content')) === true;
|
||
}
|
||
|
||
/**
|
||
* Check for an existing curl extension
|
||
*/
|
||
public function curl(): bool
|
||
{
|
||
return extension_loaded('curl') === true;
|
||
}
|
||
|
||
/**
|
||
* Returns the URL to the file within a system folder
|
||
* if the file is located in the document
|
||
* root. Otherwise it will return null.
|
||
*
|
||
* @param string $folder 'git', 'content', 'site', 'kirby'
|
||
*/
|
||
public function exposedFileUrl(string $folder): string|null
|
||
{
|
||
if (!$url = $this->folderUrl($folder)) {
|
||
return null;
|
||
}
|
||
|
||
switch ($folder) {
|
||
case 'content':
|
||
return $url . '/' . basename($this->app->site()->storage()->contentFile(
|
||
'published',
|
||
'default'
|
||
));
|
||
case 'git':
|
||
return $url . '/config';
|
||
case 'kirby':
|
||
return $url . '/composer.json';
|
||
case 'site':
|
||
$root = $this->app->root('site');
|
||
$files = glob($root . '/blueprints/*.yml');
|
||
|
||
if (empty($files) === true) {
|
||
$files = glob($root . '/templates/*.*');
|
||
}
|
||
|
||
if (empty($files) === true) {
|
||
$files = glob($root . '/snippets/*.*');
|
||
}
|
||
|
||
if (empty($files) === true || empty($files[0]) === true) {
|
||
return $url;
|
||
}
|
||
|
||
$file = $files[0];
|
||
$file = basename(dirname($file)) . '/' . basename($file);
|
||
|
||
return $url . '/' . $file;
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns the URL to a system folder
|
||
* if the folder is located in the document
|
||
* root. Otherwise it will return null.
|
||
*
|
||
* @param string $folder 'git', 'content', 'site', 'kirby'
|
||
*/
|
||
public function folderUrl(string $folder): string|null
|
||
{
|
||
$index = $this->app->root('index');
|
||
$root = match ($folder) {
|
||
'git' => $index . '/.git',
|
||
default => $this->app->root($folder)
|
||
};
|
||
|
||
if (
|
||
$root === null ||
|
||
is_dir($root) === false ||
|
||
is_dir($index) === false
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
$root = realpath($root);
|
||
$index = realpath($index);
|
||
|
||
// windows
|
||
$root = str_replace('\\', '/', $root);
|
||
$index = str_replace('\\', '/', $index);
|
||
|
||
// the folder is not within the document root?
|
||
if (Str::startsWith($root, $index) === false) {
|
||
return null;
|
||
}
|
||
|
||
// get the path after the document root
|
||
$path = trim(Str::after($root, $index), '/');
|
||
|
||
// build the absolute URL to the folder
|
||
return Url::to($path);
|
||
}
|
||
|
||
/**
|
||
* Returns the app's human-readable
|
||
* index URL without scheme
|
||
*/
|
||
public function indexUrl(): string
|
||
{
|
||
return $this->app->url('index', true)
|
||
->setScheme(null)
|
||
->setSlash(false)
|
||
->toString();
|
||
}
|
||
|
||
/**
|
||
* Returns an array with relevant system information
|
||
* used for debugging
|
||
* @since 4.3.0
|
||
*/
|
||
public function info(): array
|
||
{
|
||
return [
|
||
'kirby' => $this->app->version(),
|
||
'php' => phpversion(),
|
||
'server' => $this->serverSoftware(),
|
||
'license' => $this->license()->label(),
|
||
'languages' => $this->app->languages()->values(
|
||
fn ($lang) => $lang->code()
|
||
)
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Create the most important folders
|
||
* if they don't exist yet
|
||
*
|
||
* @throws \Kirby\Exception\PermissionException
|
||
*/
|
||
public function init(): void
|
||
{
|
||
// init /site/accounts
|
||
try {
|
||
Dir::make($this->app->root('accounts'));
|
||
} catch (Throwable) {
|
||
throw new PermissionException('The accounts directory could not be created');
|
||
}
|
||
|
||
// init /site/sessions
|
||
try {
|
||
Dir::make($this->app->root('sessions'));
|
||
} catch (Throwable) {
|
||
throw new PermissionException('The sessions directory could not be created');
|
||
}
|
||
|
||
// init /content
|
||
try {
|
||
Dir::make($this->app->root('content'));
|
||
} catch (Throwable) {
|
||
throw new PermissionException('The content directory could not be created');
|
||
}
|
||
|
||
// init /media
|
||
try {
|
||
Dir::make($this->app->root('media'));
|
||
} catch (Throwable) {
|
||
throw new PermissionException('The media directory could not be created');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if the Panel has 2FA activated
|
||
*/
|
||
public function is2FA(): bool
|
||
{
|
||
return ($this->loginMethods()['password']['2fa'] ?? null) === true;
|
||
}
|
||
|
||
/**
|
||
* Check if the Panel has 2FA with TOTP activated
|
||
*/
|
||
public function is2FAWithTOTP(): bool
|
||
{
|
||
return
|
||
$this->is2FA() === true &&
|
||
in_array('totp', $this->app->auth()->enabledChallenges()) === true;
|
||
}
|
||
|
||
/**
|
||
* Check if the Panel is installable.
|
||
* On a public server the panel.install
|
||
* option must be explicitly set to true
|
||
* to get the installer up and running.
|
||
*/
|
||
public function isInstallable(): bool
|
||
{
|
||
return
|
||
$this->isLocal() === true ||
|
||
$this->app->option('panel.install', false) === true;
|
||
}
|
||
|
||
/**
|
||
* Check if Kirby is already installed
|
||
*/
|
||
public function isInstalled(): bool
|
||
{
|
||
return $this->app->users()->count() > 0;
|
||
}
|
||
|
||
/**
|
||
* Check if this is a local installation
|
||
*/
|
||
public function isLocal(): bool
|
||
{
|
||
return $this->app->environment()->isLocal();
|
||
}
|
||
|
||
/**
|
||
* Check if all tests pass
|
||
*/
|
||
public function isOk(): bool
|
||
{
|
||
return in_array(false, array_values($this->status()), true) === false;
|
||
}
|
||
|
||
/**
|
||
* Loads the license file and returns
|
||
* the license information if available
|
||
*/
|
||
public function license(): License
|
||
{
|
||
return $this->license ??= License::read();
|
||
}
|
||
|
||
/**
|
||
* Returns the configured UI modes for the login form
|
||
* with their respective options
|
||
*
|
||
* @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
|
||
*/
|
||
public function mbString(): bool
|
||
{
|
||
return extension_loaded('mbstring') === true;
|
||
}
|
||
|
||
/**
|
||
* Check for a writable media folder
|
||
*/
|
||
public function media(): bool
|
||
{
|
||
return is_writable($this->app->root('media')) === true;
|
||
}
|
||
|
||
/**
|
||
* Check for a valid PHP version
|
||
*/
|
||
public function php(): bool
|
||
{
|
||
return
|
||
version_compare(PHP_VERSION, '8.1.0', '>=') === true &&
|
||
version_compare(PHP_VERSION, '8.4.0', '<') === true;
|
||
}
|
||
|
||
/**
|
||
* Returns a sorted collection of all
|
||
* installed plugins
|
||
*/
|
||
public function plugins(): Collection
|
||
{
|
||
$plugins = new Collection($this->app->plugins());
|
||
return $plugins->sortBy('name', 'asc');
|
||
}
|
||
|
||
/**
|
||
* Validates the license key
|
||
* and adds it to the .license file in the config
|
||
* folder if possible.
|
||
*
|
||
* @throws \Kirby\Exception\Exception
|
||
* @throws \Kirby\Exception\InvalidArgumentException
|
||
*/
|
||
public function register(string $license = null, string $email = null): bool
|
||
{
|
||
$license = new License(
|
||
code: $license,
|
||
domain: $this->indexUrl(),
|
||
email: $email,
|
||
);
|
||
|
||
$this->license = $license->register();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Returns the detected server software
|
||
*/
|
||
public function serverSoftware(): string
|
||
{
|
||
return $this->app->environment()->get('SERVER_SOFTWARE', '–');
|
||
}
|
||
|
||
/**
|
||
* Check for a writable sessions folder
|
||
*/
|
||
public function sessions(): bool
|
||
{
|
||
return is_writable($this->app->root('sessions')) === true;
|
||
}
|
||
|
||
/**
|
||
* Get an status array of all checks
|
||
*/
|
||
public function status(): array
|
||
{
|
||
return [
|
||
'accounts' => $this->accounts(),
|
||
'content' => $this->content(),
|
||
'curl' => $this->curl(),
|
||
'sessions' => $this->sessions(),
|
||
'mbstring' => $this->mbstring(),
|
||
'media' => $this->media(),
|
||
'php' => $this->php()
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Returns the site's title as defined in the
|
||
* content file or `site.yml` blueprint
|
||
* @since 3.6.0
|
||
*/
|
||
public function title(): string
|
||
{
|
||
$site = $this->app->site();
|
||
|
||
if ($site->title()->isNotEmpty() === true) {
|
||
return $site->title()->value();
|
||
}
|
||
|
||
return $site->blueprint()->title();
|
||
}
|
||
|
||
public function toArray(): array
|
||
{
|
||
return $this->status();
|
||
}
|
||
|
||
/**
|
||
* Returns the update status object unless
|
||
* the update check for Kirby has been disabled
|
||
* @since 3.8.0
|
||
*
|
||
* @param array|null $data Custom override for the getkirby.com update data
|
||
*/
|
||
public function updateStatus(array|null $data = null): UpdateStatus|null
|
||
{
|
||
if ($this->updateStatus !== null) {
|
||
return $this->updateStatus;
|
||
}
|
||
|
||
$kirby = $this->app;
|
||
$option =
|
||
$kirby->option('updates.kirby') ??
|
||
$kirby->option('updates', true);
|
||
|
||
if ($option === false) {
|
||
return null;
|
||
}
|
||
|
||
return $this->updateStatus = new UpdateStatus(
|
||
$kirby,
|
||
$option === 'security',
|
||
$data
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Upgrade to the new folder separator
|
||
*/
|
||
public static function upgradeContent(string $root): void
|
||
{
|
||
$index = Dir::read($root);
|
||
|
||
foreach ($index as $dir) {
|
||
$oldRoot = $root . '/' . $dir;
|
||
$newRoot = preg_replace('!\/([0-9]+)\-!', '/$1_', $oldRoot);
|
||
|
||
if (is_dir($oldRoot) === true) {
|
||
Dir::move($oldRoot, $newRoot);
|
||
static::upgradeContent($newRoot);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Improved `var_dump` output
|
||
* @codeCoverageIgnore
|
||
*/
|
||
public function __debugInfo(): array
|
||
{
|
||
return $this->toArray();
|
||
}
|
||
}
|