From 87958b7d2cbe9b8ff2f9c156a2c5f4cedeebcd65 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 25 Sep 2026 17:12:27 +0300 Subject: [PATCH] Server requirements dialog moved to Angular and API --- .htaccess | 2 +- INSTALL/includes/check.php | 172 ------------ INSTALL/includes/first_visit.php | 31 --- INSTALL/includes/first_visit_serverreqs.php | 71 ----- INSTALL/includes/index.php | 18 -- INSTALL/index.html | 0 LocalValetDriver.php | 179 ++++++++++++ _api/index.php | 1 + .../Setup/ServerRequirementsController.php | 28 ++ .../app/Setup/ServerRequirementsService.php | 257 ++++++++++++++++++ _api_app/bootstrap/requirements.php | 33 +++ _api_app/bootstrap/requirements_check.php | 45 +++ _api_app/bootstrap/requirements_guard.php | 27 ++ _api_app/composer.json | 6 + _api_app/composer.lock | 10 +- _api_app/routes/api.php | 2 + .../tests/Feature/ServerRequirementsTest.php | 106 ++++++++ editor/src/app/app.component.ts | 1 + editor/src/app/app.module.ts | 2 + editor/src/app/login/login.component.ts | 9 + .../src/app/render/template-render.service.ts | 2 +- .../server-requirements.component.spec.ts | 194 +++++++++++++ .../server-requirements.component.ts | 205 ++++++++++++++ .../server-requirements.interface.ts | 14 + .../server-requirements.service.spec.ts | 90 ++++++ .../server-requirements.service.ts | 56 ++++ .../src/app/setup/setup-wizard.component.ts | 9 +- editor/src/styles/_login.scss | 5 + editor/src/styles/_server-requirements.scss | 107 ++++++++ editor/src/styles/_variables.scss | 1 + editor/src/styles/styles.scss | 1 + engine/_classes/class.bertautils.php | 2 +- engine/inc.editor_redirect.php | 19 ++ engine/inc.engineprefs.php | 12 - engine/inc.page.php | 44 +-- engine/lang/en.php | 11 +- engine/lang/es.php | 13 +- engine/lang/fr.php | 29 +- engine/lang/lv.php | 28 +- engine/lang/nl.php | 31 +-- engine/lang/pl.php | 30 +- engine/lang/ru.php | 29 +- engine/loader.helper.php | 12 + 43 files changed, 1434 insertions(+), 510 deletions(-) delete mode 100644 INSTALL/includes/check.php delete mode 100644 INSTALL/includes/first_visit.php delete mode 100644 INSTALL/includes/first_visit_serverreqs.php delete mode 100644 INSTALL/includes/index.php delete mode 100644 INSTALL/index.html create mode 100644 LocalValetDriver.php create mode 100644 _api_app/app/Setup/ServerRequirementsController.php create mode 100644 _api_app/app/Setup/ServerRequirementsService.php create mode 100644 _api_app/bootstrap/requirements.php create mode 100644 _api_app/bootstrap/requirements_check.php create mode 100644 _api_app/bootstrap/requirements_guard.php create mode 100644 _api_app/tests/Feature/ServerRequirementsTest.php create mode 100644 editor/src/app/setup/server-requirements/server-requirements.component.spec.ts create mode 100644 editor/src/app/setup/server-requirements/server-requirements.component.ts create mode 100644 editor/src/app/setup/server-requirements/server-requirements.interface.ts create mode 100644 editor/src/app/setup/server-requirements/server-requirements.service.spec.ts create mode 100644 editor/src/app/setup/server-requirements/server-requirements.service.ts create mode 100644 editor/src/styles/_server-requirements.scss create mode 100644 engine/inc.editor_redirect.php diff --git a/.htaccess b/.htaccess index 875df67f4..1976ac7b0 100644 --- a/.htaccess +++ b/.htaccess @@ -55,7 +55,7 @@ RewriteRule ^ - [R=404,L] # If you have folders or files inside berta's folder you wish to access directly, # then modify and uncomment the following line # RewriteCond %{REQUEST_URI} ^/(my_folder_name1|my_folder_name1|my_file.html).*$ [OR] -RewriteCond %{REQUEST_URI} ^/(.well-known|_plugin_shop|engine|INSTALL|storage|_templates|_themes).*$ [OR] +RewriteCond %{REQUEST_URI} ^/(.well-known|_plugin_shop|engine|storage|_templates|_themes).*$ [OR] RewriteCond %{REQUEST_URI} ^/(index\.php|sitemap\.xml\.php|robots\.txt|crossdomain\.xml|favicon\.ico).*$ RewriteRule .* - [L] diff --git a/INSTALL/includes/check.php b/INSTALL/includes/check.php deleted file mode 100644 index 4e3a5fe1a..000000000 --- a/INSTALL/includes/check.php +++ /dev/null @@ -1,172 +0,0 @@ -
' . ($isOk ? 'YES' : 'NO') . '
' . $message . - (! $isOk && $failDesc ? ('
' . $failDesc . '
') : '') . - ''; - } - - $listOk = true; - $listHasErrors = false; - - $uriPath = explode('?', $_SERVER['REQUEST_URI'])[0]; - $redirectURL = strstr($uriPath, '/editor') ? $ENGINE_ROOT_URL . 'editor/' : $ENGINE_ROOT_URL; - - $testOutput = ''; - $testOutput .= '

A green YES means that you don\'t even need to read what it is about - it\'s just all fine. A yellow NO means that ' . - 'Berta will still work, but the feature will not be available to you. The red NO is the bad one - if there is one, it will have a suggestion ' . - 'what can be done to correct the situation.

'; - - // if the test was ok without any warnings then redirect to the next step - if ($listOk && ! $listHasErrors) { - header('Location: .?_berta_install_step=2' . (! empty($options['MULTISITE']) ? '&site=' . $options['MULTISITE'] : '')); - exit; - } -} - -$version = $options['version']; - -?> - - - -<?php echo $berta->settings->get('texts', 'pageTitle') ?> / welcome - - - - -
-
-
-

Berta is already installed.
Please delete folder named INSTALL in your Berta's root folder!
 

-
-
-
- -
-
-
- Note: This check-list is displayed only once. To re-enable it you will need to manually edit settings.xml file in your storage folder and delete the row that looks like ' . htmlspecialchars('') . '.

'; - - if (! $listHasErrors) { - - // normally this is not visible because of a redirect earlier... - - echo '
'; - echo '

Welcome!

'; - echo '

Berta has completed a small test to see if it has everything it needs. It turns out that everything is just perfect.

'; - echo '

'; - echo '
'; - - echo '

Test results:

'; - echo $testOutput; - echo $bottomNote; - - } else { - - // some warnings... - - echo '
'; - echo '

Welcome...

'; - echo '

Berta has completed a small test to see if it has everything it needs.

'; - echo '

It appears that there are some issues with the server or the installation. You will be able to use Berta, although with limited functionality. Please take a look at the results below.

'; - echo '
'; - - echo $testOutput; - - echo '


If you like to, you can ignore the errors and:

'; - - echo $bottomNote; - } - - } else { - - // errors.. - - echo '
'; - echo '

Take action!

'; - echo '

Berta has completed a small test to see if it has everything it needs.

'; - echo '

It turns out that there are some problems with the server or with the installation. Please take a look at the results below and follow the suggestions for each error, and then come back again!

'; - echo '
'; - - echo $testOutput; - - echo '

'; - - } - - ?>
-
-
- diff --git a/INSTALL/includes/first_visit.php b/INSTALL/includes/first_visit.php deleted file mode 100644 index c3b68265c..000000000 --- a/INSTALL/includes/first_visit.php +++ /dev/null @@ -1,31 +0,0 @@ - - - - -<?php echo $berta->settings->get('texts', 'pageTitle') ?> / <?php echo I18n::_('welcome') ?> - - - - - -
-
-
- -
-
-
- - diff --git a/INSTALL/includes/first_visit_serverreqs.php b/INSTALL/includes/first_visit_serverreqs.php deleted file mode 100644 index d663883bf..000000000 --- a/INSTALL/includes/first_visit_serverreqs.php +++ /dev/null @@ -1,71 +0,0 @@ - - - - - berta / welcome - - - -
-
-
-

Thank you for choosing Berta.me!

-

This server does not meet Berta's requirements.
- Berta needs PHP >= 8.4 support on server.

-
-
-
- - diff --git a/INSTALL/includes/index.php b/INSTALL/includes/index.php deleted file mode 100644 index 74beedc37..000000000 --- a/INSTALL/includes/index.php +++ /dev/null @@ -1,18 +0,0 @@ -resolve($sitePath, $uri); + + return $type === self::STATIC ? $target : false; + } + + public function frontControllerPath(string $sitePath, string $siteName, string $uri): ?string + { + [$type, $target] = $this->resolve($sitePath, $uri); + + switch ($type) { + case self::PHP: + $scriptName = substr($target, strlen($sitePath)); + $_SERVER['SCRIPT_FILENAME'] = $target; + $_SERVER['SCRIPT_NAME'] = $scriptName; + $_SERVER['PHP_SELF'] = $scriptName; + $_SERVER['DOCUMENT_ROOT'] = $sitePath; + + return $target; + + case self::STATIC: + // `.php` URIs skip isStaticFile(), e.g. /engine/index.php + $this->serveStaticFile($target, $sitePath, $siteName, $uri); + exit; + + case self::REDIRECT: + $query = $_SERVER['QUERY_STRING'] ?? ''; + header('Location: ' . $target . ($query !== '' ? '?' . $query : ''), true, 301); + exit; + + case self::FORBIDDEN: + http_response_code(403); + exit('Forbidden'); + + default: + http_response_code(404); + exit('Not Found'); + } + } + + /** + * @return array{0: string, 1: ?string} Route type and its target (file path or redirect URL) + */ + private function resolve(string $sitePath, string $uri): array + { + $path = $sitePath . $uri; + + // _api/.htaccess + if ($uri === '/_api' || $this->startsWith($uri, '/_api/')) { + if ($this->endsWith($uri, '/') && ! is_dir($path)) { + return [self::REDIRECT, rtrim($uri, '/')]; + } + + if ($this->isActualFile($path)) { + return $this->fileRoute($path); + } + + return [self::PHP, $sitePath . '/_api/index.php']; + } + + // Root .htaccess, in the same order + if ($this->endsWith($uri, 'engine/hosting')) { + return [self::FORBIDDEN, null]; + } + + if ($uri === '/engine/index.php') { + return [self::STATIC, $sitePath . '/engine/dist/index.html']; + } + + if ($this->startsWith($uri, '/engine') && ! $this->startsWith($uri, '/engine/editor') && ! $this->isActualFile($path)) { + return [self::STATIC, $sitePath . '/engine/dist/index.html']; + } + + if (preg_match('#^/storage.*\.(xml|sqlite|db|log|php)$#', $uri) || $uri === '/storage/' || $uri === '/_templates/') { + return [self::NOT_FOUND, null]; + } + + if (preg_match(self::PASSTHROUGH_PATTERN, $uri)) { + return $this->passthroughRoute($path, $uri); + } + + if (! $this->isActualFile($path) && ! $this->endsWith($uri, 'index.php') && ! $this->endsWith($uri, '/')) { + return [self::REDIRECT, $uri . '/']; + } + + $this->addRewriteFlag(); + + return [self::PHP, $sitePath . '/index.php']; + } + + /** + * Served as is, with Apache's DirectoryIndex and `Options -Indexes` behaviour for folders + */ + private function passthroughRoute(string $path, string $uri): array + { + if ($this->isActualFile($path)) { + return $this->fileRoute($path); + } + + if (is_dir($path)) { + if (! $this->endsWith($uri, '/')) { + return [self::REDIRECT, $uri . '/']; + } + + foreach (['index.php', 'index.html'] as $index) { + if ($this->isActualFile($path . $index)) { + return $this->fileRoute($path . $index); + } + } + } + + return [self::NOT_FOUND, null]; + } + + private function fileRoute(string $path): array + { + return [$this->endsWith($path, '.php') ? self::PHP : self::STATIC, $path]; + } + + /** + * `index.php?__rewrite=1 [QSA]`, the engine enables clean URLs with this flag + */ + private function addRewriteFlag(): void + { + $_GET['__rewrite'] = '1'; + $_REQUEST['__rewrite'] = '1'; + $query = $_SERVER['QUERY_STRING'] ?? ''; + $_SERVER['QUERY_STRING'] = ($query !== '' ? $query . '&' : '') . '__rewrite=1'; + } + + private function startsWith(string $haystack, string $needle): bool + { + return strncmp($haystack, $needle, strlen($needle)) === 0; + } + + private function endsWith(string $haystack, string $needle): bool + { + return $needle === '' || substr($haystack, -strlen($needle)) === $needle; + } +} diff --git a/_api/index.php b/_api/index.php index 8060b9fa7..1fcf0ab1e 100644 --- a/_api/index.php +++ b/_api/index.php @@ -2,5 +2,6 @@ use Illuminate\Http\Request; +require __DIR__ . '/../_api_app/bootstrap/requirements_guard.php'; require_once __DIR__ . '/../_api_app/bootstrap/load_app.php'; (require_once __DIR__ . '/../_api_app/bootstrap/app.php')->handleRequest(Request::capture()); diff --git a/_api_app/app/Setup/ServerRequirementsController.php b/_api_app/app/Setup/ServerRequirementsController.php new file mode 100644 index 000000000..f9b1dfd7e --- /dev/null +++ b/_api_app/app/Setup/ServerRequirementsController.php @@ -0,0 +1,28 @@ +isInstalled()) { + return response()->json([ + 'installed' => true, + 'requirements' => [], + ]); + } + + return response()->json([ + 'installed' => false, + 'requirements' => $service->check(), + ]); + } +} diff --git a/_api_app/app/Setup/ServerRequirementsService.php b/_api_app/app/Setup/ServerRequirementsService.php new file mode 100644 index 000000000..f007a1400 --- /dev/null +++ b/_api_app/app/Setup/ServerRequirementsService.php @@ -0,0 +1,257 @@ +requirements = require base_path('bootstrap/requirements.php'); + } + + /** + * Reads only the `berta/installed` flag of the main site settings. SiteSettingsDataService + * is not used on purpose, it depends on auth, engine settings and templates which may not + * work on a server that fails the requirements. + */ + public function isInstalled(): bool + { + $settingsFile = $this->bertaStoragePath() . '/settings.xml'; + + if (! File::isReadable($settingsFile)) { + return false; + } + + $document = new DOMDocument; + + if (! @$document->loadXML(File::get($settingsFile))) { + return false; + } + + $installed = (new DOMXPath($document))->evaluate('string(/settings/berta/installed)'); + + return (bool) (int) trim($installed); + } + + /** + * @return array + */ + public function check(): array + { + return [ + ...$this->serverChecks(), + ...$this->installationChecks(), + ]; + } + + /** + * Parse php.ini byte values like `128M` or `2G`. + * + * @return int|null Bytes, null for an unlimited value (-1) + */ + public static function parseIniBytes(string $value): ?int + { + $bytes = @ini_parse_quantity($value); + + return $bytes < 0 ? null : $bytes; + } + + private function serverChecks(): array + { + $checks = []; + + $checks[] = $this->row( + 'php', + 'server', + 'Supported PHP version (' . $this->requirements['php'] . ' or newer)', + version_compare(PHP_VERSION, $this->requirements['php'], '>='), + 'Berta needs PHP ' . $this->requirements['php'] . ' or newer. Ask your server administrator to enable a supported PHP version.', + ); + + foreach ($this->requirements['extensions'] as $extension => $label) { + $isOk = extension_loaded($extension); + + if ($extension === 'mbstring') { + $isOk = $isOk && function_exists('mb_ereg_replace'); + } + + $checks[] = $this->row( + 'ext_' . $extension, + 'server', + 'PHP extension: ' . $label, + $isOk, + 'Berta can not work without the PHP "' . $extension . '" extension. Ask your server administrator to enable it.', + ); + } + + if (extension_loaded('gd')) { + $gdInfo = gd_info(); + + $checks[] = $this->row( + 'gd_jpeg', + 'server', + 'JPEG image support', + ! empty($gdInfo['JPEG Support']), + 'You won\'t be able to upload JPEG images and Berta won\'t be able to make thumbnails. Ask your server administrator for GD JPEG support.', + ); + $checks[] = $this->row( + 'gd_png', + 'server', + 'PNG image support', + ! empty($gdInfo['PNG Support']), + 'You won\'t be able to upload PNG images.', + false, + ); + $checks[] = $this->row( + 'gd_gif', + 'server', + 'GIF image support', + ! empty($gdInfo['GIF Read Support']) && ! empty($gdInfo['GIF Create Support']), + 'You won\'t be able to upload GIF images.', + false, + ); + } + + // Takes both `upload_max_filesize` and `post_max_size` into account + $checks[] = $this->row( + 'upload_size', + 'server', + 'Large file uploads (videos) allowed', + UploadedFile::getMaxFilesize() >= self::MIN_UPLOAD_SIZE, + 'Uploads are limited to ' . $this->formatBytes(UploadedFile::getMaxFilesize()) . '. Ask your server administrator to set PHP "upload_max_filesize" and "post_max_size" to at least 256M for larger videos.', + false, + ); + + $memoryLimit = self::parseIniBytes((string) ini_get('memory_limit')); + $checks[] = $this->row( + 'memory_limit', + 'server', + 'Enough memory for image processing', + $memoryLimit === null || $memoryLimit >= self::MIN_MEMORY_LIMIT, + 'PHP "memory_limit" is ' . ini_get('memory_limit') . ', resizing large images may fail. Ask your server administrator to set it to at least 128M.', + false, + ); + + return $checks; + } + + private function installationChecks(): array + { + $storagePath = $this->bertaStoragePath(); + $isStorageWritable = File::isDirectory($storagePath) && File::isWritable($storagePath); + + $checks = []; + + $checks[] = $this->row( + 'storage', + 'installation', + 'Folder "storage" exists and is writable', + $isStorageWritable, + 'Make sure the folder "storage" in your Berta installation exists and is writable by the web server. Use your FTP client to set the permissions.', + ); + + $mediaFolders = array_map(fn ($folder) => $storagePath . '/' . $folder, ['media', 'cache']); + $checks[] = $this->row( + 'storage_media', + 'installation', + 'Folders "storage/media" and "storage/cache" are writable', + collect($mediaFolders)->every( + fn ($path) => File::isDirectory($path) ? File::isWritable($path) : $isStorageWritable, + ), + 'Make sure the folders "storage/media" and "storage/cache" are writable by the web server, or delete them so Berta can create them.', + ); + + $apiStoragePaths = [ + storage_path(), + storage_path('framework/cache'), + storage_path('framework/sessions'), + storage_path('framework/views'), + storage_path('logs'), + ]; + $checks[] = $this->row( + 'api_storage', + 'installation', + 'Folder "_api_app/storage" is writable', + collect($apiStoragePaths)->every(fn ($path) => File::isDirectory($path) && File::isWritable($path)), + 'Make sure the folder "_api_app/storage" and all its subfolders exist and are writable by the web server.', + ); + + $bootstrapCachePath = base_path('bootstrap/cache'); + $checks[] = $this->row( + 'api_bootstrap_cache', + 'installation', + 'Folder "_api_app/bootstrap/cache" is writable', + File::isDirectory($bootstrapCachePath) && File::isWritable($bootstrapCachePath), + 'Make sure the folder "_api_app/bootstrap/cache" exists and is writable by the web server.', + ); + + $checks[] = $this->row( + 'app_key', + 'installation', + 'Application key is set', + $this->hasAppKey(), + 'Berta could not create the "_api_app/.env" file. Make sure the "_api_app" folder is writable by the web server, or copy "_api_app/.env.example" to "_api_app/.env".', + ); + + return $checks; + } + + /** + * Reads the .env file directly: SetupMiddleware generates APP_KEY during this + * same request, after the config was already loaded. + */ + private function hasAppKey(): bool + { + $envPath = base_path('.env'); + + if (! File::exists($envPath)) { + return false; + } + + $env = Dotenv::parse(File::get($envPath)); + + return ! in_array($env['APP_KEY'] ?? '', self::ENV_PLACEHOLDERS, true); + } + + private function bertaStoragePath(): string + { + return config('app.old_berta_root') . '/storage'; + } + + private function formatBytes(int|float $bytes): string + { + return round($bytes / 1024 / 1024) . 'M'; + } + + private function row(string $key, string $group, string $label, bool $isOk, string $message, bool $isFatal = true): array + { + return [ + 'key' => $key, + 'group' => $group, + 'label' => $label, + 'ok' => $isOk, + 'fatal' => $isFatal, + 'message' => $isOk ? '' : $message, + ]; + } +} diff --git a/_api_app/bootstrap/requirements.php b/_api_app/bootstrap/requirements.php new file mode 100644 index 000000000..f9ccc0735 --- /dev/null +++ b/_api_app/bootstrap/requirements.php @@ -0,0 +1,33 @@ + '8.4.1', + + // Extensions Berta uses and that can be missing on a server: not built by + // default (mbstring, gd, openssl, curl) or often packaged separately / disabled + // by hosts (dom, fileinfo, session, tokenizer, filter). + // Left out on purpose: always compiled in (hash, json, pcre), implied by dom + // (libxml), polyfilled (ctype), unused by Berta (pdo, xml), although Laravel + // lists them. `mbstring` is required despite symfony/polyfill-mbstring, the + // polyfill does not provide `mb_ereg_replace`. + 'extensions' => [ + 'mbstring' => 'Multibyte strings (international characters)', + 'gd' => 'GD graphics library (image resizing and thumbnails)', + 'dom' => 'DOM (XML content storage)', + 'fileinfo' => 'Fileinfo (uploaded file type detection)', + 'openssl' => 'OpenSSL', + 'session' => 'Session', + 'tokenizer' => 'Tokenizer', + 'filter' => 'Filter', + 'curl' => 'cURL', + ], +]; diff --git a/_api_app/bootstrap/requirements_check.php b/_api_app/bootstrap/requirements_check.php new file mode 100644 index 000000000..176fd2f90 --- /dev/null +++ b/_api_app/bootstrap/requirements_check.php @@ -0,0 +1,45 @@ + 'php', + 'group' => 'server', + 'label' => 'Supported PHP version (' . $bertaRequirements['php'] . ' or newer)', + 'ok' => false, + 'fatal' => true, + 'message' => 'Berta needs PHP ' . $bertaRequirements['php'] . ' or newer, this server runs PHP ' . PHP_VERSION . '. Ask your server administrator to enable a supported PHP version.', + ]; +} + +if (! file_exists(__DIR__ . '/../vendor/autoload.php')) { + $bertaFailedRequirements[] = [ + 'key' => 'vendor', + 'group' => 'installation', + 'label' => 'Dependencies installed', + 'ok' => false, + 'fatal' => true, + 'message' => 'The "_api_app/vendor" folder is missing. Upload the complete Berta package or run "composer install" in the "_api_app" folder.', + ]; +} + +// Required into the engine's global scope, don't leave anything behind +unset($bertaRequirements); + +return $bertaFailedRequirements; diff --git a/_api_app/bootstrap/requirements_guard.php b/_api_app/bootstrap/requirements_guard.php new file mode 100644 index 000000000..c22ef79c0 --- /dev/null +++ b/_api_app/bootstrap/requirements_guard.php @@ -0,0 +1,27 @@ + null, + 'requirements' => $bertaFailedRequirements, + ]); + exit; +} + +unset($bertaFailedRequirements); diff --git a/_api_app/composer.json b/_api_app/composer.json index 3a661f5d3..1ca997422 100644 --- a/_api_app/composer.json +++ b/_api_app/composer.json @@ -12,6 +12,12 @@ "license": "MIT", "require": { "php": "^8.4", + "ext-curl": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-mbstring": "*", + "ext-openssl": "*", "firebase/php-jwt": "^7.0", "intervention/image": "^3.11", "laravel/ai": "^0.3.0", diff --git a/_api_app/composer.lock b/_api_app/composer.lock index 29a073722..f51c4371f 100644 --- a/_api_app/composer.lock +++ b/_api_app/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fc73b1292e31663a20bafb6cbda7c225", + "content-hash": "e43790057f21f165924c93d4e14c8ce0", "packages": [ { "name": "brick/math", @@ -10723,7 +10723,13 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.4" + "php": "^8.4", + "ext-curl": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-mbstring": "*", + "ext-openssl": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/_api_app/routes/api.php b/_api_app/routes/api.php index 2884a53a5..1d1e25040 100644 --- a/_api_app/routes/api.php +++ b/_api_app/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\StateController; use App\Http\Middleware\Authenticate; use App\Http\Middleware\SetupMiddleware; +use App\Setup\ServerRequirementsController; use App\Sites\Sections\Entries\SectionEntriesController; use App\Sites\Sections\SiteSectionsController; use App\Sites\Sections\Tags\SectionTagsController; @@ -20,6 +21,7 @@ Route::get('v1/meta', [StateController::class, 'getMeta'])->name('meta'); Route::get('v1/sentry-dsn', [StateController::class, 'getSentryDSN'])->name('sentry'); + Route::get('v1/requirements', [ServerRequirementsController::class, 'index'])->name('requirements'); }); Route::middleware([SetupMiddleware::class, Authenticate::class])->prefix('v1')->group(function () { diff --git a/_api_app/tests/Feature/ServerRequirementsTest.php b/_api_app/tests/Feature/ServerRequirementsTest.php new file mode 100644 index 000000000..91a939c26 --- /dev/null +++ b/_api_app/tests/Feature/ServerRequirementsTest.php @@ -0,0 +1,106 @@ +bertaRoot = sys_get_temp_dir() . '/berta_requirements_' . uniqid(); + File::ensureDirectoryExists($this->bertaRoot . '/storage'); + config(['app.old_berta_root' => $this->bertaRoot]); +}); + +afterEach(function () { + @chmod($this->bertaRoot . '/storage', 0777); + File::deleteDirectory($this->bertaRoot); +}); + +function writeInstalledFlag(string $bertaRoot, int $installed): void +{ + file_put_contents( + $bertaRoot . '/storage/settings.xml', + '', + ); +} + +function requirementRow(array $requirements, string $key): ?array +{ + return collect($requirements)->firstWhere('key', $key); +} + +it('does not expose requirements once Berta is installed', function () { + writeInstalledFlag($this->bertaRoot, 1); + + get(route('requirements')) + ->assertOk() + ->assertExactJson([ + 'installed' => true, + 'requirements' => [], + ]); +}); + +it('returns requirements while Berta is not installed', function () { + writeInstalledFlag($this->bertaRoot, 0); + + $response = get(route('requirements')) + ->assertOk() + ->assertJsonPath('installed', false) + ->assertJsonStructure([ + 'requirements' => [ + '*' => ['key', 'group', 'label', 'ok', 'fatal', 'message'], + ], + ]); + + $requirements = $response->json('requirements'); + $keys = collect($requirements)->pluck('key'); + + expect($keys)->toContain('php', 'ext_mbstring', 'ext_gd', 'ext_dom', 'gd_jpeg', 'upload_size', 'storage', 'api_storage', 'app_key'); + expect(requirementRow($requirements, 'php')['ok'])->toBeTrue(); + expect(requirementRow($requirements, 'storage')['ok'])->toBeTrue(); + expect(requirementRow($requirements, 'upload_size')['fatal'])->toBeFalse(); +}); + +it('treats a missing settings file as not installed', function () { + get(route('requirements')) + ->assertOk() + ->assertJsonPath('installed', false); +}); + +it('reports a missing storage folder as a fatal failure', function () { + File::deleteDirectory($this->bertaRoot . '/storage'); + + $requirements = get(route('requirements')) + ->assertOk() + ->assertJsonPath('installed', false) + ->json('requirements'); + + $storage = requirementRow($requirements, 'storage'); + expect($storage['ok'])->toBeFalse(); + expect($storage['fatal'])->toBeTrue(); + expect($storage['message'])->not->toBeEmpty(); + expect($storage['message'])->not->toContain($this->bertaRoot); + expect(requirementRow($requirements, 'storage_media')['ok'])->toBeFalse(); +}); + +it('reports a read only storage folder as a fatal failure', function () { + chmod($this->bertaRoot . '/storage', 0555); + + if (is_writable($this->bertaRoot . '/storage')) { + $this->markTestSkipped('Running as a user that can write to read only folders.'); + } + + $requirements = (new ServerRequirementsService)->check(); + + expect(requirementRow($requirements, 'storage')['ok'])->toBeFalse(); +}); + +it('parses php.ini byte values', function (string $value, ?int $expected) { + expect(ServerRequirementsService::parseIniBytes($value))->toBe($expected); +})->with([ + ['2G', 2 * 1024 * 1024 * 1024], + ['300M', 300 * 1024 * 1024], + ['512K', 512 * 1024], + ['1048576', 1048576], + ['-1', null], +]); diff --git a/editor/src/app/app.component.ts b/editor/src/app/app.component.ts index 840e7ddd8..09073055d 100644 --- a/editor/src/app/app.component.ts +++ b/editor/src/app/app.component.ts @@ -56,6 +56,7 @@ import { AppStateService } from './app-state/app-state.service'; > + `, styles: [ ` diff --git a/editor/src/app/app.module.ts b/editor/src/app/app.module.ts index 64e5e9ee0..70cbcce07 100644 --- a/editor/src/app/app.module.ts +++ b/editor/src/app/app.module.ts @@ -30,6 +30,7 @@ import { InlineEditOverlayComponent } from './preview/inline-edit/inline-edit-ov import { InlineEditRichTextOverlayComponent } from './preview/inline-edit/inline-edit-rich-text-overlay.component'; import { PopupComponent } from './popup/popup.component'; import { SetupWizardComponent } from './setup/setup-wizard.component'; +import { ServerRequirementsComponent } from './setup/server-requirements/server-requirements.component'; import { ErrorState } from './error-state/error.state'; import { SharedModule } from './shared/shared.module'; import { StyleService } from './preview/style.service'; @@ -63,6 +64,7 @@ import { sentryInitFactory } from './sentry/sentry-init.factory'; ThemesComponent, PopupComponent, SetupWizardComponent, + ServerRequirementsComponent, ], bootstrap: [AppComponent], imports: [ diff --git a/editor/src/app/login/login.component.ts b/editor/src/app/login/login.component.ts index 542df3f3f..72932cb4b 100644 --- a/editor/src/app/login/login.component.ts +++ b/editor/src/app/login/login.component.ts @@ -14,6 +14,7 @@ import { AppState } from '../app-state/app.state'; import { PopupService } from '../popup/popup.service'; import { UserLoginAction } from '../user/user.actions'; import { UserState } from '../user/user.state'; +import { ServerRequirementsService } from '../setup/server-requirements/server-requirements.service'; @Component({ selector: 'berta-login', @@ -22,6 +23,11 @@ import { UserState } from '../user/user.state'; @if (!(isLoggedIn$ | async)) {