diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 52591c673..0a2bf0a98 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,7 +35,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" cache: "npm" cache-dependency-path: editor/package-lock.json @@ -56,7 +56,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" cache: "npm" cache-dependency-path: package-lock.json 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 @@ -settings = new Settings($settingsDefinition); - -include_once $ENGINE_ROOT_PATH . '_classes/class.bertaeditor.php'; - -$version = $options['version']; - -$uriPath = explode('?', $_SERVER['REQUEST_URI'])[0]; - -?> - - - -<?php echo $berta->settings->get('texts', 'pageTitle') ?> / welcome - - - - - -
-
-
-

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

-
-
-
- -
-
-
-
-

- -

- -

1.

-

- - settings->getDefinition('siteTexts', 'siteHeading'), - $berta->settings->get('siteTexts', 'siteHeading', false), - ['xCaption' => str_replace(' ', '+', $berta->settings->getDefinitionParam('siteTexts', 'siteHeading', 'title'))], - 'p', - $basePath . '/siteTexts/siteHeading' - ); - ?> - -

2.

-

- - settings->getDefinition('texts', 'ownerName'), - $berta->settings->get('texts', 'ownerName', false), - ['xCaption' => str_replace(' ', '+', $berta->settings->getDefinitionParam('texts', 'ownerName', 'title'))], - 'p', - $basePath . '/texts/ownerName' - ); - ?> - -

3.

-

-

- - settings->getDefinition('texts', 'metaDescription'), - $berta->settings->get('texts', 'metaDescription', false), - ['xCaption' => str_replace(' ', '+', $berta->settings->getDefinitionParam('texts', 'metaDescription', 'title'))], - 'p', - $basePath . '/texts/metaDescription' - ); - ?> - -

-
-
-
-
- - - - - diff --git a/INSTALL/index.html b/INSTALL/index.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/LocalValetDriver.php b/LocalValetDriver.php new file mode 100644 index 000000000..ca00951b7 --- /dev/null +++ b/LocalValetDriver.php @@ -0,0 +1,179 @@ +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/app/Shop/shoppingCart.twig b/_api_app/app/Shop/shoppingCart.twig index 810b5e676..e3063483d 100644 --- a/_api_app/app/Shop/shoppingCart.twig +++ b/_api_app/app/Shop/shoppingCart.twig @@ -18,7 +18,7 @@
{% if isEditMode %} - {{ tableHeadTitle.content }} + {% if tableHeadTitle.content %}{{ tableHeadTitle.content }}{% else %} title {% endif %}
{% else %} {{ tableHeadTitle.content }} @@ -30,7 +30,7 @@
{% if isEditMode %} - {{ tableHeadQuantity.content }} + {% if tableHeadQuantity.content %}{{ tableHeadQuantity.content }}{% else %} quantity {% endif %}
{% else %} {{ tableHeadQuantity.content }} @@ -39,7 +39,7 @@
{% if isEditMode %} - {{ tableHeadPrice.content }} + {% if tableHeadPrice.content %}{{ tableHeadPrice.content }}{% else %} price {% endif %}
{% else %} {{ tableHeadPrice.content }} @@ -48,7 +48,7 @@
{% if isEditMode %} - {{ tableHeadSum.content }} + {% if tableHeadSum.content %}{{ tableHeadSum.content }}{% else %} sum {% endif %}
{% else %} {{ tableHeadSum.content }} @@ -78,7 +78,7 @@
{% if isEditMode %} - {{ promoCode.content }} + {% if promoCode.content %}{{ promoCode.content }}{% else %} Promo code {% endif %}
{% else %} {{ promoCode.content }} @@ -87,7 +87,7 @@ {% if isEditMode %} - {{ promoButton.content }} + {% if promoButton.content %}{{ promoButton.content }}{% else %} OK {% endif %} {% else %} @@ -131,7 +131,7 @@ {% if isEditMode %} - {{ discount.content }} + {% if discount.content %}{{ discount.content }}{% else %} discount {% endif %} {% else %} {{ discount.content }} @@ -146,7 +146,7 @@
{% if isEditMode %} - {{ total.content }} + {% if total.content %}{{ total.content }}{% else %} total {% endif %}
{% else %} {{ total.content }} @@ -154,7 +154,7 @@ {% if isEditMode %} - {{ includedVat.content }} + {% if includedVat.content %}{{ includedVat.content }}{% else %} incl vat {% endif %} {% endif %} @@ -177,7 +177,7 @@

{% if isEditMode %} - {{ legalPerson.content }} + {% if legalPerson.content %}{{ legalPerson.content }}{% else %} legal person {% endif %} {% else %} @@ -188,7 +188,7 @@

@@ -318,7 +318,7 @@

- {{ shippingAddressHeader.content }} + {% if shippingAddressHeader.content %}{{ shippingAddressHeader.content }}{% else %} shipping address {% endif %}
@@ -326,7 +326,7 @@
{% else %} {{ nameSurname.content }} @@ -341,7 +341,7 @@
{% else %} {{ address.content }} @@ -356,7 +356,7 @@