diff --git a/.metadata b/.metadata
index f155ec1..ff38257 100644
Binary files a/.metadata and b/.metadata differ
diff --git a/.recipes/wondercms_php8/README.md b/.recipes/wondercms_php8/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/.recipes/wondercms_php8/docker-compose.yml b/.recipes/wondercms_php8/docker-compose.yml
new file mode 100644
index 0000000..298bab9
--- /dev/null
+++ b/.recipes/wondercms_php8/docker-compose.yml
@@ -0,0 +1,46 @@
+# WonderCMS Flat File application with official Apache 2.4.x and PHP 8.x.
+#
+# This recipe doesn't extend the official image, it keeps all necessary
+# modifications in the container. The application itself must be added
+# with a persistent volume.
+#
+# Based on https://github.com/robiso/docker-wondercms/
+#
+services:
+ # https://github.com/WonderCMS/wondercms
+ # https://github.com/robiso/docker-wondercms/
+ # https://hub.docker.com/_/php
+ wondercms:
+ image: php:8-apache
+ restart: unless-stopped
+ # Take a look the possible public port collision.
+ ports:
+ - 8201:80
+ volumes:
+ - /etc/localtime:/etc/localtime:ro
+ # Needs R/W for UID 33 (www-data).
+ - ./storage/volumes/wonder_html/:/var/www/html/
+ environment:
+ TZ: Europe/Budapest
+ # We don't want to extend the official image to maintain
+ # watchtower's monitoring for updates. So we use CMD to
+ # make all the necessary changes. Unfortunately this will
+ # slightly prolong the start of the service.
+ command:
+ - /bin/bash
+ - -c
+ - |
+ DEBIAN_FRONTEND=noninteractive apt update
+ apt install -y libzip-dev zip
+ apt clean
+ rm -rf /var/lib/apt/lists/*
+ docker-php-ext-configure zip
+ docker-php-ext-install zip
+ a2enmod rewrite
+ cp -p /usr/local/etc/php/php.ini-production /usr/local/etc/php/conf.d/php.ini
+ apache2-foreground
+
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ labels:
+ com.centurylinklabs.watchtower.enable: true
diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/.htaccess b/.recipes/wondercms_php8/storage/volumes/wonder_html/.htaccess
new file mode 100644
index 0000000..f452eea
--- /dev/null
+++ b/.recipes/wondercms_php8/storage/volumes/wonder_html/.htaccess
@@ -0,0 +1,8 @@
+Options -Indexes
+ServerSignature Off
+RewriteEngine on
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteRule ^(.+)$ index.php?page=$1 [QSA,L]
+RewriteRule database.js - [F]
+RewriteRule cache.json - [F]
diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/index.php b/.recipes/wondercms_php8/storage/volumes/wonder_html/index.php
new file mode 100644
index 0000000..1fa5736
--- /dev/null
+++ b/.recipes/wondercms_php8/storage/volumes/wonder_html/index.php
@@ -0,0 +1,3068 @@
+init();
+ $Wcms->render();
+}
+
+class Wcms
+{
+ private const MODULES_JSON_VERSION = 1;
+ private const THEMES_DIR = 'themes';
+ private const PLUGINS_DIR = 'plugins';
+ private const VALID_DIRS = [self::THEMES_DIR, self::PLUGINS_DIR];
+ private const THEME_PLUGINS_TYPES = [
+ 'installs' => 'install',
+ 'updates' => 'update',
+ 'exists' => 'exist',
+ ];
+
+ /** Database main keys */
+ public const DB_CONFIG = 'config';
+ public const DB_MENU_ITEMS = 'menuItems';
+ public const DB_MENU_ITEMS_SUBPAGE = 'subpages';
+ public const DB_PAGES_KEY = 'pages';
+ public const DB_PAGES_SUBPAGE_KEY = 'subpages';
+
+ /** @var int MIN_PASSWORD_LENGTH minimum number of characters */
+ public const MIN_PASSWORD_LENGTH = 8;
+
+ /** @var string WCMS_REPO - repo URL */
+ public const WCMS_REPO = 'https://raw.githubusercontent.com/WonderCMS/wondercms/main/';
+
+ /** @var string WCMS_CDN_REPO - CDN repo URL */
+ public const WCMS_CDN_REPO = 'https://raw.githubusercontent.com/WonderCMS/wondercms-cdn-files/main/';
+
+ /** @var string $currentPage - current page */
+ public $currentPage = '';
+
+ /** @var array $currentPageTree - Tree hierarchy of the current page */
+ public $currentPageTree = [];
+
+ /** @var array $installedPlugins - Currently installed plugins */
+ public $installedPlugins = [];
+
+ /** @var bool $currentPageExists - check if current page exists */
+ public $currentPageExists = false;
+
+ /** @var object $db - content of database.js */
+ protected $db;
+
+ /** @var bool $loggedIn - check if admin is logged in */
+ public $loggedIn = false;
+
+ /** @var array $listeners for hooks */
+ public $listeners = [];
+
+ /** @var string $dataPath path to data folder */
+ public $dataPath;
+
+ /** @var string $modulesCachePath path to cached json file with Themes/Plugins data */
+ protected $modulesCachePath;
+
+ /** @var string $securityCachePath path to security json file with force https caching data */
+ protected $securityCachePath;
+
+ /** @var string $dbPath path to database.js */
+ protected $dbPath;
+
+ /** @var string $filesPath path to uploaded files */
+ public $filesPath;
+
+ /** @var string $rootDir root dir of the install (where index.php is) */
+ public $rootDir;
+
+ /** @var bool $headerResponseDefault read default header response */
+ public $headerResponseDefault = true;
+
+ /** @var string $headerResponse header status */
+ public $headerResponse = 'HTTP/1.0 200 OK';
+
+ /**
+ * Constructor
+ *
+ * @param string $dataFolder
+ * @param string $filesFolder
+ * @param string $dbName
+ * @param string $rootDir
+ * @throws Exception
+ */
+ public function __construct(
+ string $dataFolder = 'data',
+ string $filesFolder = 'files',
+ string $dbName = 'database.js',
+ string $rootDir = __DIR__
+ ) {
+ $this->rootDir = $rootDir;
+ $this->setPaths($dataFolder, $filesFolder, $dbName);
+ $this->db = $this->getDb();
+ }
+
+ /**
+ * Setting default paths
+ *
+ * @param string $dataFolder
+ * @param string $filesFolder
+ * @param string $dbName
+ */
+ public function setPaths(
+ string $dataFolder = 'data',
+ string $filesFolder = 'files',
+ string $dbName = 'database.js'
+ ): void {
+ $this->dataPath = sprintf('%s/%s', $this->rootDir, $dataFolder);
+ $this->dbPath = sprintf('%s/%s', $this->dataPath, $dbName);
+ $this->filesPath = sprintf('%s/%s', $this->dataPath, $filesFolder);
+ $this->modulesCachePath = sprintf('%s/%s', $this->dataPath, 'cache.json');
+ $this->securityCachePath = sprintf('%s/%s', $this->dataPath, 'security.json');
+ }
+
+ /**
+ * Init function called on each page load
+ *
+ * @return void
+ * @throws Exception
+ */
+ public function init(): void
+ {
+ $this->forceSSL();
+ $this->loginStatus();
+ $this->getSiteLanguage();
+ $this->pageStatus();
+ $this->logoutAction();
+ $this->loginAction();
+ $this->notFoundResponse();
+ $this->loadPlugins();
+ if ($this->loggedIn) {
+ $this->manuallyRefreshCacheData();
+ $this->addCustomModule();
+ $this->installUpdateModuleAction();
+ $this->changePasswordAction();
+ $this->deleteFileModuleAction();
+ $this->changePageThemeAction();
+ $this->backupAction();
+ $this->forceHttpsAction();
+ $this->saveChangesPopupAction();
+ $this->saveLogoutToLoginScreenAction();
+ $this->deletePageAction();
+ $this->saveAction();
+ $this->updateAction();
+ $this->uploadFileAction();
+ $this->notifyAction();
+ }
+ }
+
+ /**
+ * Set site language based on logged-in user
+ * @return string
+ * @throws Exception
+ */
+ public function getSiteLanguage(): string
+ {
+ if ($this->loggedIn) {
+ $lang = $this->get('config', 'adminLang');
+ } else {
+ $lang = $this->get('config', 'siteLang');
+ }
+
+ if (gettype($lang) === 'object' && empty(get_object_vars($lang))) {
+ $lang = 'en';
+ $this->set('config', 'siteLang', $lang);
+ $this->set('config', 'adminLang', $lang);
+ }
+
+ return $lang;
+ }
+
+ /**
+ * Display the HTML. Called after init()
+ * @return void
+ */
+ public function render(): void
+ {
+ header($this->headerResponse);
+
+ // Alert admin that page is hidden
+ if ($this->loggedIn) {
+ $loadingPage = null;
+ foreach ($this->get('config', 'menuItems') as $item) {
+ if ($this->currentPage === $item->slug) {
+ $loadingPage = $item;
+ }
+ }
+ if ($loadingPage && $loadingPage->visibility === 'hide') {
+ $this->alert('info',
+ 'This page (' . $this->currentPage . ') is currently hidden from the menu. Open menu visibility settings ');
+ }
+ }
+
+ $this->loadThemeAndFunctions();
+ }
+
+ /**
+ * Function used by plugins to add a hook
+ *
+ * @param string $hook
+ * @param callable $functionName
+ */
+ public function addListener(string $hook, callable $functionName): void
+ {
+ $this->listeners[$hook][] = $functionName;
+ }
+
+ /**
+ * Add alert message for admin
+ *
+ * @param string $class see bootstrap alerts classes
+ * @param string $message the message to display
+ * @param bool $sticky can it be closed?
+ * @return void
+ */
+ public function alert(string $class, string $message, bool $sticky = false): void
+ {
+ if (isset($_SESSION['alert'][$class])) {
+ foreach ($_SESSION['alert'][$class] as $v) {
+ if ($v['message'] === $message) {
+ return;
+ }
+ }
+ }
+ $_SESSION['alert'][$class][] = ['class' => $class, 'message' => $this->hook('alert', $message)[0], 'sticky' => $sticky];
+ }
+
+ /**
+ * Display alert message to the admin
+ * @return string
+ */
+ public function alerts(): string
+ {
+ if (!isset($_SESSION['alert'])) {
+ return '';
+ }
+ $output = '
';
+ $output .= '';
+ foreach ($_SESSION['alert'] as $alertClass) {
+ foreach ($alertClass as $alert) {
+ $output .= '
'
+ . (!$alert['sticky'] ? '× ' : '')
+ . $alert['message']
+ . $this->hideAlerts();
+ }
+ }
+ $output .= '
';
+ unset($_SESSION['alert']);
+ return $output;
+ }
+
+ /**
+ * Allow admin to dismiss alerts
+ * @return string
+ */
+ public function hideAlerts(): string
+ {
+ if (!$this->loggedIn) {
+ return '';
+ }
+ $output = '';
+ $output .= '
Hide all alerts until next login ';
+ return $output;
+ }
+
+ /**
+ * Get an asset (returns URL of the asset)
+ *
+ * @param string $location
+ * @return string
+ */
+ public function asset(string $location): string
+ {
+ return self::url('themes/' . $this->get('config', 'theme') . '/' . $location);
+ }
+
+ /**
+ * Backup whole WonderCMS installation
+ *
+ * @return void
+ * @throws Exception
+ */
+ public function backupAction(): void
+ {
+ if (!$this->loggedIn) {
+ return;
+ }
+ $backupList = glob($this->filesPath . '/*-backup-*.zip');
+ if (!empty($backupList)) {
+ $this->alert('danger',
+ 'Backup files detected. View and delete unnecessary backup files ');
+ }
+ if (isset($_POST['backup']) && $this->verifyFormActions()) {
+ $this->zipBackup();
+ }
+ }
+
+ /**
+ * Save if WCMS should force https
+ * @return void
+ * @throws Exception
+ */
+ public function forceHttpsAction(): void
+ {
+ if (isset($_POST['forceHttps']) && $this->verifyFormActions()) {
+ $this->set('config', 'forceHttps', $_POST['forceHttps'] === 'true');
+ $this->updateSecurityCache();
+
+ $this->alert('success', 'Force HTTPs was successfully changed.');
+ $this->redirect();
+ }
+ }
+
+ /**
+ * Save if WCMS should show the popup before saving the page content changes
+ * @return void
+ * @throws Exception
+ */
+ public function saveChangesPopupAction(): void
+ {
+ if (isset($_POST['saveChangesPopup']) && $this->verifyFormActions()) {
+ $this->set('config', 'saveChangesPopup', $_POST['saveChangesPopup'] === 'true');
+ $this->alert('success', 'Saving the confirmation popup settings changed.');
+ $this->redirect();
+ }
+ }
+
+ /**
+ * Save if admin should be redirected to login/last viewed page after logging out.
+ * @return void
+ * @throws Exception
+ */
+ public function saveLogoutToLoginScreenAction(): void
+ {
+ if (isset($_POST['logoutToLoginScreen']) && $this->verifyFormActions()) {
+ $redirectToLogin = $_POST['logoutToLoginScreen'] === 'true';
+ $message = $redirectToLogin
+ ? 'You will be redirected to login screen after logging out.'
+ : 'You will be redirected to last viewed screen after logging out.';
+ $this->set('config', 'logoutToLoginScreen', $_POST['logoutToLoginScreen'] === 'true');
+ $this->alert('success', $message);
+ $this->redirect();
+ }
+ }
+
+ /**
+ * Update cache for security settings.
+ * @return void
+ */
+ public function updateSecurityCache(): void
+ {
+ $content = ['forceHttps' => $this->isHttpsForced()];
+ $json = json_encode($content, JSON_FORCE_OBJECT | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+ file_put_contents($this->securityCachePath, $json, LOCK_EX);
+ }
+
+ /**
+ * Get a static block
+ *
+ * @param string $key name of the block
+ * @return string
+ */
+ public function block(string $key): string
+ {
+ $blocks = $this->get('blocks');
+ $content = '';
+
+ if (isset($blocks->{$key})) {
+ $content = $this->loggedIn
+ ? $this->editable($key, $blocks->{$key}->content, 'blocks')
+ : $blocks->{$key}->content;
+ }
+ return $this->hook('block', $content, $key)[0];
+ }
+
+ /**
+ * Change password
+ * @return void
+ * @throws Exception
+ */
+ public function changePasswordAction(): void
+ {
+ if (isset($_POST['old_password'], $_POST['new_password'], $_POST['repeat_password'])
+ && $_SESSION['token'] === $_POST['token']
+ && $this->loggedIn
+ && $this->hashVerify($_POST['token'])) {
+ if (!password_verify($_POST['old_password'], $this->get('config', 'password'))) {
+ $this->alert('danger',
+ 'Wrong password. Re-open security settings ');
+ $this->redirect();
+ return;
+ }
+ if (strlen($_POST['new_password']) < self::MIN_PASSWORD_LENGTH) {
+ $this->alert('danger',
+ sprintf('Password must be longer than %d characters. Re-open security settings ',
+ self::MIN_PASSWORD_LENGTH));
+ $this->redirect();
+ return;
+ }
+ if ($_POST['new_password'] !== $_POST['repeat_password']) {
+ $this->alert('danger',
+ 'New passwords do not match. Re-open security settings ');
+ $this->redirect();
+ return;
+ }
+ $this->set('config', 'password', password_hash($_POST['new_password'], PASSWORD_DEFAULT));
+ $this->set('config', 'forceLogout', true);
+ $this->logoutAction(true);
+ $this->alert('success', 'Password changed. Log in again. ', 1);
+ }
+ }
+
+ /**
+ * Check if folders are writable
+ * Executed once before creating the database file
+ *
+ * @param string $folder the relative path of the folder to check/create
+ * @return void
+ * @throws Exception
+ */
+ public function checkFolder(string $folder): void
+ {
+ if (!is_dir($folder) && !mkdir($folder, 0755) && !is_dir($folder)) {
+ throw new Exception('Could not create data folder.');
+ }
+ if (!is_writable($folder)) {
+ throw new Exception('Could write to data folder.');
+ }
+ }
+
+ /**
+ * Initialize the JSON database if it doesn't exist
+ * @return void
+ * @throws Exception
+ */
+ public function createDb(): void
+ {
+ // Check php requirements
+ $this->checkMinimumRequirements();
+ $password = $this->generatePassword();
+ $this->db = (object)[
+ self::DB_CONFIG => [
+ 'siteTitle' => 'Website title',
+ 'siteLang' => 'en',
+ 'adminLang' => 'en',
+ 'theme' => 'sky',
+ 'defaultPage' => 'home',
+ 'login' => 'loginURL',
+ 'forceLogout' => false,
+ 'forceHttps' => false,
+ 'saveChangesPopup' => false,
+ 'password' => password_hash($password, PASSWORD_DEFAULT),
+ 'lastLogins' => [],
+ 'lastModulesSync' => null,
+ 'customModules' => $this->defaultCustomModules(),
+ 'menuItems' => [
+ '0' => [
+ 'name' => 'Home',
+ 'slug' => 'home',
+ 'visibility' => 'show',
+ self::DB_MENU_ITEMS_SUBPAGE => new stdClass()
+ ],
+ '1' => [
+ 'name' => 'How to',
+ 'slug' => 'how-to',
+ 'visibility' => 'show',
+ self::DB_MENU_ITEMS_SUBPAGE => new stdClass()
+ ]
+ ]
+ ],
+ 'pages' => [
+ '404' => [
+ 'title' => '404',
+ 'keywords' => '404',
+ 'description' => '404',
+ 'content' => '404 - Page not found ',
+ self::DB_PAGES_SUBPAGE_KEY => new stdClass()
+ ],
+ 'home' => [
+ 'title' => 'Home',
+ 'keywords' => 'Enter, page, keywords, for, search, engines',
+ 'description' => 'A page description is also good for search engines.',
+ 'content' => 'Welcome to your website
+
+Your password for editing everything is: ' . $password . '
+
+Click here to login
+
+To install an awesome editor, open Settings/Plugins and click Install Summernote.
',
+ self::DB_PAGES_SUBPAGE_KEY => new stdClass()
+ ],
+ 'how-to' => [
+ 'title' => 'How to',
+ 'keywords' => 'Enter, keywords, for, this page',
+ 'description' => 'A page description is also good for search engines.',
+ 'content' => 'Easy editing
+After logging in, click anywhere to edit and click outside to save. Changes are live and shown immediately.
+
+Create new page
+Pages can be created in the Settings.
+
+Start a blog or change your theme
+To install, update or remove themes/plugins, visit the Settings.
+
+Support WonderCMS
+WonderCMS is free for over 12 years.
+Click here to support us by getting a T-shirt or with a donation .
',
+ self::DB_PAGES_SUBPAGE_KEY => new stdClass()
+ ]
+ ],
+ 'blocks' => [
+ 'subside' => [
+ 'content' => 'About your website
+
+
+Website description, contact form, mini map or anything else.
+This editable area is visible on all pages.
'
+ ],
+ 'footer' => [
+ 'content' => '©' . date('Y') . ' Your website'
+ ]
+ ]
+ ];
+ $this->save();
+ }
+
+ /**
+ * Default data for the Custom Modules
+ * @return array[]
+ */
+ private function defaultCustomModules(): array {
+ return [
+ 'themes' => [],
+ 'plugins' => []
+ ];
+ }
+
+ /**
+ * Create menu item
+ *
+ * @param string $name
+ * @param string|null $menu
+ * @param bool $createPage
+ * @param string $visibility show or hide
+ * @return void
+ * @throws Exception
+ */
+ public function createMenuItem(
+ string $name,
+ string $menu = null,
+ string $visibility = 'hide',
+ bool $createPage = false
+ ): void {
+ if (!in_array($visibility, ['show', 'hide'], true)) {
+ return;
+ }
+ $name = empty($name) ? 'empty' : str_replace([PHP_EOL, ' '], '', $name);
+ $slug = $this->createUniqueSlug($name, $menu);
+
+ $menuItems = $menuSelectionObject = clone $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS);
+ $menuTree = !empty($menu) || $menu === '0' ? explode('-', $menu) : [];
+ $slugTree = [];
+ if (count($menuTree)) {
+ foreach ($menuTree as $childMenuKey) {
+ $childMenu = $menuSelectionObject->{$childMenuKey};
+
+ if (!property_exists($childMenu, self::DB_MENU_ITEMS_SUBPAGE)) {
+ $childMenu->{self::DB_MENU_ITEMS_SUBPAGE} = new StdClass;
+ }
+
+ $menuSelectionObject = $childMenu->{self::DB_MENU_ITEMS_SUBPAGE};
+ $slugTree[] = $childMenu->slug;
+ }
+ }
+ $slugTree[] = $slug;
+
+ $menuCount = count(get_object_vars($menuSelectionObject));
+
+ $menuSelectionObject->{$menuCount} = new stdClass;
+ $menuSelectionObject->{$menuCount}->name = $name;
+ $menuSelectionObject->{$menuCount}->slug = $slug;
+ $menuSelectionObject->{$menuCount}->visibility = $visibility;
+ $menuSelectionObject->{$menuCount}->{self::DB_MENU_ITEMS_SUBPAGE} = new StdClass;
+ $this->set(self::DB_CONFIG, self::DB_MENU_ITEMS, $menuItems);
+
+ if ($createPage) {
+ $this->createPage($slugTree);
+ $_SESSION['redirect_to_name'] = $name;
+ $_SESSION['redirect_to'] = implode('/', $slugTree);
+ }
+ }
+
+ /**
+ * Update menu item
+ *
+ * @param string $name
+ * @param string $menu
+ * @param string $visibility show or hide
+ * @return void
+ * @throws Exception
+ */
+ public function updateMenuItem(string $name, string $menu, string $visibility = 'hide'): void
+ {
+ if (!in_array($visibility, ['show', 'hide'], true)) {
+ return;
+ }
+ $name = empty($name) ? 'empty' : str_replace([PHP_EOL, ' '], '', $name);
+ $slug = $this->createUniqueSlug($name, $menu);
+
+ $menuItems = $menuSelectionObject = clone $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS);
+ $menuTree = explode('-', $menu);
+ $slugTree = [];
+ $menuKey = array_pop($menuTree);
+ if (count($menuTree) > 0) {
+ foreach ($menuTree as $childMenuKey) {
+ $childMenu = $menuSelectionObject->{$childMenuKey};
+
+ if (!property_exists($childMenu, self::DB_MENU_ITEMS_SUBPAGE)) {
+ $childMenu->{self::DB_MENU_ITEMS_SUBPAGE} = new StdClass;
+ }
+
+ $menuSelectionObject = $childMenu->{self::DB_MENU_ITEMS_SUBPAGE};
+ $slugTree[] = $childMenu->slug;
+ }
+ }
+
+ $slugTree[] = $menuSelectionObject->{$menuKey}->slug;
+ $menuSelectionObject->{$menuKey}->name = $name;
+ $menuSelectionObject->{$menuKey}->slug = $slug;
+ $menuSelectionObject->{$menuKey}->visibility = $visibility;
+ $menuSelectionObject->{$menuKey}->{self::DB_MENU_ITEMS_SUBPAGE} = $menuSelectionObject->{$menuKey}->{self::DB_MENU_ITEMS_SUBPAGE} ?? new StdClass;
+ $this->set(self::DB_CONFIG, self::DB_MENU_ITEMS, $menuItems);
+
+ $this->updatePageSlug($slugTree, $slug);
+ if ($this->get(self::DB_CONFIG, 'defaultPage') === implode('/', $slugTree)) {
+ // Change old slug with new one
+ array_pop($slugTree);
+ $slugTree[] = $slug;
+ $this->set(self::DB_CONFIG, 'defaultPage', implode('/', $slugTree));
+ }
+ }
+
+ /**
+ * Check if slug already exists and creates unique one
+ *
+ * @param string $slug
+ * @param string|null $menu
+ * @return string
+ */
+ public function createUniqueSlug(string $slug, string $menu = null): string
+ {
+ $slug = $this->slugify($slug);
+ $allMenuItems = $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS);
+ $menuCount = count(get_object_vars($allMenuItems));
+
+ // Check if it is subpage
+ $menuTree = $menu ? explode('-', $menu) : [];
+ if (count($menuTree)) {
+ foreach ($menuTree as $childMenuKey) {
+ $allMenuItems = $allMenuItems->{$childMenuKey}->subpages;
+ }
+ }
+
+ foreach ($allMenuItems as $value) {
+ if ($value->slug === $slug) {
+ $slug .= '-' . $menuCount;
+ break;
+ }
+ }
+
+ return $slug;
+ }
+
+ /**
+ * Create new page
+ *
+ * @param array|null $slugTree
+ * @param bool $createMenuItem
+ * @return void
+ * @throws Exception
+ */
+ public function createPage(array $slugTree = null, bool $createMenuItem = false): void
+ {
+ $pageExists = false;
+ $pageData = null;
+ foreach ($slugTree as $parentPage) {
+ if (!$pageData) {
+ $pageData = $this->get(self::DB_PAGES_KEY)->{$parentPage};
+ continue;
+ }
+
+ $pageData = $pageData->subpages->{$parentPage} ?? null;
+ $pageExists = !empty($pageData);
+ }
+
+ if ($pageExists) {
+ $this->alert('danger', 'Cannot create page with existing slug.');
+ return;
+ }
+
+ $slug = array_pop($slugTree);
+ $pageSlug = $slug ?: $this->slugify($this->currentPage);
+ $allPages = $selectedPage = clone $this->get(self::DB_PAGES_KEY);
+ $menuKey = null;
+ if (!empty($slugTree)) {
+ foreach ($slugTree as $childSlug) {
+ // Find menu key tree
+ if ($createMenuItem) {
+ $menuKey = $this->findAndUpdateMenuKey($menuKey, $childSlug);
+ }
+
+ // Create new parent page if it doesn't exist
+ if (!$selectedPage->{$childSlug}) {
+ $parentTitle = mb_convert_case(str_replace('-', ' ', $childSlug), MB_CASE_TITLE);
+ $selectedPage->{$childSlug}->title = $parentTitle;
+ $selectedPage->{$childSlug}->keywords = 'Keywords, are, good, for, search, engines';
+ $selectedPage->{$childSlug}->description = 'A short description is also good.';
+
+ if ($createMenuItem) {
+ $this->createMenuItem($parentTitle, $menuKey);
+ $menuKey = $this->findAndUpdateMenuKey($menuKey, $childSlug); // Add newly added menu key
+ }
+ }
+
+ if (!property_exists($selectedPage->{$childSlug}, self::DB_PAGES_SUBPAGE_KEY)) {
+ $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY} = new StdClass;
+ }
+
+ $selectedPage = $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY};
+ }
+ }
+
+ $pageTitle = !$slug ? str_replace('-', ' ', $pageSlug) : $pageSlug;
+
+ $selectedPage->{$slug} = new stdClass;
+ $selectedPage->{$slug}->title = mb_convert_case($pageTitle, MB_CASE_TITLE);
+ $selectedPage->{$slug}->keywords = 'Keywords, are, good, for, search, engines';
+ $selectedPage->{$slug}->description = 'A short description is also good.';
+ $selectedPage->{$slug}->{self::DB_PAGES_SUBPAGE_KEY} = new StdClass;
+ $this->set(self::DB_PAGES_KEY, $allPages);
+
+ if ($createMenuItem) {
+ $this->createMenuItem($pageTitle, $menuKey);
+ }
+ }
+
+ /**
+ * Find and update menu key tree based on newly requested slug
+ * @param string|null $menuKey
+ * @param string $slug
+ * @return string
+ */
+ private function findAndUpdateMenuKey(?string $menuKey, string $slug): string
+ {
+ $menuKeys = $menuKey !== null ? explode('-', $menuKey) : $menuKey;
+ $menuItems = json_decode(json_encode($this->get(self::DB_CONFIG, self::DB_MENU_ITEMS)), true);
+ foreach ($menuKeys as $key) {
+ $menuItems = $menuItems[$key][self::DB_MENU_ITEMS_SUBPAGE] ?? [];
+ }
+
+ if (false !== ($index = array_search($slug, array_column($menuItems, 'slug'), true))) {
+ $menuKey = $menuKey === null ? $index : $menuKey . '-' . $index;
+ } elseif ($menuKey === null) {
+ $menuKey = count($menuItems);
+ }
+
+ return $menuKey;
+ }
+
+ /**
+ * Update page data
+ *
+ * @param array $slugTree
+ * @param string $fieldname
+ * @param string $content
+ * @return void
+ * @throws Exception
+ */
+ public function updatePage(array $slugTree, string $fieldname, string $content): void
+ {
+ $slug = array_pop($slugTree);
+ $allPages = $selectedPage = clone $this->get(self::DB_PAGES_KEY);
+ if (!empty($slugTree)) {
+ foreach ($slugTree as $childSlug) {
+ if (!property_exists($selectedPage->{$childSlug}, self::DB_PAGES_SUBPAGE_KEY)) {
+ $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY} = new StdClass;
+ }
+
+ $selectedPage = $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY};
+ }
+ }
+
+ $selectedPage->{$slug}->{$fieldname} = $content;
+ $this->set(self::DB_PAGES_KEY, $allPages);
+ }
+
+ /**
+ * Delete page key
+ *
+ * @param array $slugTree
+ * @param string $fieldname
+ *
+ * @return void
+ * @throws Exception
+ */
+ public function deletePageKey(array $slugTree, string $fieldname): void
+ {
+ $slug = array_pop($slugTree);
+ $selectedPage = clone $this->get(self::DB_PAGES_KEY);
+ if (!empty($slugTree)) {
+ foreach ($slugTree as $childSlug) {
+ if (!property_exists($selectedPage->{$childSlug}, self::DB_PAGES_SUBPAGE_KEY)) {
+ $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY} = new StdClass;
+ }
+
+ $selectedPage = $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY};
+ }
+ }
+
+ unset($selectedPage->{$slug}->{$fieldname});
+ $this->save();
+ }
+
+ /**
+ * Delete existing page by slug
+ *
+ * @param array|null $slugTree
+ * @throws Exception
+ */
+ public function deletePageFromDb(array $slugTree = null): void
+ {
+ $slug = array_pop($slugTree);
+
+ $selectedPage = $this->db->{self::DB_PAGES_KEY};
+ if (!empty($slugTree)) {
+ foreach ($slugTree as $childSlug) {
+ $selectedPage = $selectedPage->{$childSlug}->subpages;
+ }
+ }
+
+ unset($selectedPage->{$slug});
+ $this->save();
+ }
+
+ /**
+ * Update existing page slug
+ *
+ * @param array $slugTree
+ * @param string $newSlugName
+ * @throws Exception
+ */
+ public function updatePageSlug(array $slugTree, string $newSlugName): void
+ {
+ $slug = array_pop($slugTree);
+
+ $selectedPage = $this->db->{self::DB_PAGES_KEY};
+ if (!empty($slugTree)) {
+ foreach ($slugTree as $childSlug) {
+ $selectedPage = $selectedPage->{$childSlug}->subpages;
+ }
+ }
+
+ $selectedPage->{$newSlugName} = $selectedPage->{$slug};
+ unset($selectedPage->{$slug});
+ $this->save();
+ }
+
+ /**
+ * Load CSS and enable plugins to load CSS
+ * @return string
+ */
+ public function css(): string
+ {
+ if ($this->loggedIn) {
+ $styles = <<<'EOT'
+
+EOT;
+ return $this->hook('css', $styles)[0];
+ }
+ return $this->hook('css', '')[0];
+ }
+
+ /**
+ * Get database content
+ * @return stdClass
+ * @throws Exception
+ */
+ public function getDb(): stdClass
+ {
+ // initialize database if it doesn't exist
+ if (!file_exists($this->dbPath)) {
+ // this code only runs one time (on first page load/install)
+ $this->checkFolder(dirname($this->dbPath));
+ $this->checkFolder($this->filesPath);
+ $this->checkFolder($this->rootDir . '/' . self::THEMES_DIR);
+ $this->checkFolder($this->rootDir . '/' . self::PLUGINS_DIR);
+ $this->createDb();
+ }
+ return json_decode(file_get_contents($this->dbPath), false);
+ }
+
+ /**
+ * Get data from any json file
+ * @param string $path
+ * @return stdClass|null
+ */
+ public function getJsonFileData(string $path): ?array
+ {
+ if (is_file($path) && file_exists($path)) {
+ return json_decode(file_get_contents($path), true);
+ }
+
+ return null;
+ }
+
+ /**
+ * Delete theme
+ * @return void
+ */
+ public function deleteFileModuleAction(): void
+ {
+ if (!$this->loggedIn) {
+ return;
+ }
+ if (isset($_REQUEST['deleteModule'], $_REQUEST['type']) && $this->verifyFormActions(true)) {
+ $allowedDeleteTypes = ['files', 'plugins', 'themes'];
+ $filename = str_ireplace(
+ ['/', './', '../', '..', '~', '~/', '\\'],
+ null,
+ trim($_REQUEST['deleteModule'])
+ );
+ $type = str_ireplace(
+ ['/', './', '../', '..', '~', '~/', '\\'],
+ null,
+ trim($_REQUEST['type'])
+ );
+ if (!in_array($type, $allowedDeleteTypes, true)) {
+ $this->alert('danger',
+ 'Wrong delete folder path.');
+ $this->redirect();
+ }
+ if ($filename === $this->get('config', 'theme')) {
+ $this->alert('danger',
+ 'Cannot delete currently active theme. Re-open theme settings ');
+ $this->redirect();
+ }
+ $folder = $type === 'files' ? $this->filesPath : sprintf('%s/%s', $this->rootDir, $type);
+ $path = realpath("{$folder}/{$filename}");
+ if (file_exists($path)) {
+ $this->recursiveDelete($path);
+ $this->alert('success', "Deleted {$filename}.");
+ $this->redirect();
+ }
+ }
+ }
+
+ public function changePageThemeAction(): void
+ {
+ if (isset($_REQUEST['selectModule'], $_REQUEST['type']) && $this->verifyFormActions(true)) {
+ $theme = $_REQUEST['selectModule'];
+ if (!is_dir($this->rootDir . '/' . $_REQUEST['type'] . '/' . $theme)) {
+ return;
+ }
+
+ $this->set('config', 'theme', $theme);
+ $this->redirect();
+ }
+ }
+
+ /**
+ * Delete page
+ * @return void
+ * @throws Exception
+ */
+ public function deletePageAction(): void
+ {
+ if (!isset($_GET['delete']) || !$this->verifyFormActions(true)) {
+ return;
+ }
+ $slugTree = explode('/', $_GET['delete']);
+ $this->deletePageFromDb($slugTree);
+
+ $allMenuItems = $selectedMenuItem = clone $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS);
+ if (count(get_object_vars($allMenuItems)) === 1 && count($slugTree) === 1) {
+ $this->alert('danger', 'Last page cannot be deleted - at least one page must exist.');
+ $this->redirect();
+ }
+
+ $selectedMenuItemParent = $selectedMenuItemKey = null;
+ foreach ($slugTree as $slug) {
+ $selectedMenuItemParent = $selectedMenuItem->{self::DB_MENU_ITEMS_SUBPAGE} ?? $selectedMenuItem;
+ foreach ($selectedMenuItemParent as $menuItemKey => $menuItem) {
+ if ($menuItem->slug === $slug) {
+ $selectedMenuItem = $menuItem;
+ $selectedMenuItemKey = $menuItemKey;
+ break;
+ }
+ }
+ }
+ unset($selectedMenuItemParent->{$selectedMenuItemKey});
+ $allMenuItems = $this->reindexObject($allMenuItems);
+
+ $defaultPage = $this->get(self::DB_CONFIG, 'defaultPage');
+ $defaultPageArray = explode('/', $defaultPage);
+ $treeIntersect = array_intersect_assoc($defaultPageArray, $slugTree);
+ if ($treeIntersect === $slugTree) {
+ $this->set(self::DB_CONFIG, 'defaultPage', $allMenuItems->{0}->slug);
+ }
+ $this->set(self::DB_CONFIG, self::DB_MENU_ITEMS, $allMenuItems);
+
+ $this->alert('success', 'Page ' . $slug . ' deleted.');
+ $this->redirect();
+ }
+
+ /**
+ * Get editable block
+ *
+ * @param string $id id for the block
+ * @param string $content html content
+ * @param string $dataTarget
+ * @return string
+ */
+ public function editable(string $id, string $content, string $dataTarget = ''): string
+ {
+ return '' . $content . '
';
+ }
+
+ /**
+ * Get main website title, show edit icon if logged in
+ * @return string
+ */
+ public function siteTitle(): string
+ {
+ $output = $this->get('config', 'siteTitle');
+ if ($this->loggedIn) {
+ $output .= " ";
+ }
+ return $output;
+ }
+
+ /**
+ * Get footer, make it editable and show login link if it's set to default
+ * @return string
+ */
+ public function footer(): string
+ {
+ if ($this->loggedIn) {
+ $output = '';
+ } else {
+ $output = $this->get('blocks', 'footer')->content .
+ (!$this->loggedIn && $this->get('config', 'login') === 'loginURL'
+ ? ' • Login '
+ : '');
+ }
+ return $this->hook('footer', $output)[0];
+ }
+
+ /**
+ * Generate random password
+ * @return string
+ */
+ public function generatePassword(): string
+ {
+ $characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
+ return substr(str_shuffle($characters), 0, self::MIN_PASSWORD_LENGTH);
+ }
+
+ /**
+ * Get CSRF token
+ * @return string
+ * @throws Exception
+ */
+ public function getToken(): string
+ {
+ return $_SESSION['token'] ?? $_SESSION['token'] = bin2hex(random_bytes(32));
+ }
+
+ /**
+ * Get something from database
+ */
+ public function get()
+ {
+ $args = func_get_args();
+ $object = $this->db;
+
+ foreach ($args as $key => $arg) {
+ if (!property_exists($object, $arg)) {
+ $this->set(...array_merge($args, [new stdClass]));
+ }
+
+ $object = $object->{$arg};
+ }
+
+ return $object;
+ }
+
+ /**
+ * Download file content from url
+ * @param string $fileUrl
+ * @return string
+ */
+ private function downloadFileFromUrl(string $fileUrl): string
+ {
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_URL, $fileUrl);
+ $content = curl_exec($ch);
+ if (false === $content) {
+ $this->alert('danger', 'Cannot get content from url.');
+ }
+ curl_close($ch);
+
+ return (string)$content;
+ }
+
+ /**
+ * Get content of a file from main branch
+ *
+ * @param string $file the file we want
+ * @param string $repo
+ * @return string
+ */
+ public function getFileFromRepo(string $file, string $repo = self::WCMS_REPO): string
+ {
+ $repo = str_replace('https://github.com/', 'https://raw.githubusercontent.com/', $repo);
+ return $this->downloadFileFromUrl($repo . $file);
+ }
+
+ /**
+ * Get the latest version from main branch
+ * @param string $repo
+ * @return null|string
+ */
+ public function getOfficialVersion(string $repo = self::WCMS_REPO): ?string
+ {
+ return $this->getCheckFileFromRepo('version', $repo);
+ }
+
+ /**
+ * Get the files from main branch
+ * @param string $fileName
+ * @param string $repo
+ * @return null|string
+ */
+ public function getCheckFileFromRepo(string $fileName, string $repo = self::WCMS_REPO): ?string
+ {
+ $version = trim($this->getFileFromRepo($fileName, $repo));
+ return $version === '404: Not Found' || $version === '400: Invalid request' ? null : $version;
+ }
+
+ /**
+ * Compare token with hash_equals
+ *
+ * @param string $token
+ * @return bool
+ */
+ public function hashVerify(string $token): bool
+ {
+ return hash_equals($token, $this->getToken());
+ }
+
+ /**
+ * Return hooks from plugins
+ * @return array
+ */
+ public function hook(): array
+ {
+ $numArgs = func_num_args();
+ $args = func_get_args();
+ if ($numArgs < 2) {
+ trigger_error('Insufficient arguments', E_USER_ERROR);
+ }
+ $hookName = array_shift($args);
+ if (!isset($this->listeners[$hookName])) {
+ return $args;
+ }
+ foreach ($this->listeners[$hookName] as $func) {
+ $args = $func($args);
+ }
+ return $args;
+ }
+
+ /**
+ * Return array with all themes and their data
+ * @param string $type
+ * @return array
+ * @throws Exception
+ */
+ public function listAllModules(string $type = self::THEMES_DIR): array
+ {
+ $newData = [];
+ if ($this->loggedIn) {
+ $data = $this->getModulesCachedData($type);
+
+ foreach ($data as $dirName => $addon) {
+ $exists = is_dir($this->rootDir . "/$type/" . $dirName);
+ $currentVersion = $exists ? $this->getModuleVersion($type, $dirName) : null;
+ $newVersion = $addon['version'];
+ $update = $newVersion !== null && $currentVersion !== null && $newVersion > $currentVersion;
+ if ($update) {
+ $this->alert('info',
+ 'New ' . $type . ' update available. Open ' . $type . ' ');
+ }
+
+ $addonType = $exists ? self::THEME_PLUGINS_TYPES['exists'] : self::THEME_PLUGINS_TYPES['installs'];
+ $addonType = $update ? self::THEME_PLUGINS_TYPES['updates'] : $addonType;
+
+ $newData[$addonType][$dirName] = $addon;
+ $newData[$addonType][$dirName]['update'] = $update;
+ $newData[$addonType][$dirName]['install'] = !$exists;
+ $newData[$addonType][$dirName]['currentVersion'] = $currentVersion;
+ }
+ }
+
+ return $newData;
+ }
+
+ /**
+ * Check modules for cache
+ * @return void
+ * @throws Exception
+ */
+ public function checkModulesCache(): void
+ {
+ $db = $this->getDb();
+ $data = $this->getJsonFileData($this->modulesCachePath);
+ // Recreate cache if lastModulesSync is missing
+ $lastSync = $db->config->lastModulesSync ?? strtotime('-2 days');
+ if (empty($data) || strtotime($lastSync) < strtotime('-1 days')) {
+ $this->updateAndCacheModules();
+ }
+ }
+
+ /**
+ * Retrieve cached Themes/Plugins data
+ * @param string $type
+ * @return array|null
+ * @throws Exception
+ */
+ public function getModulesCachedData(string $type = self::THEMES_DIR): array
+ {
+ $this->checkModulesCache();
+ $data = $this->getJsonFileData($this->modulesCachePath);
+ return $data !== null && array_key_exists($type, $data) ? $data[$type] : [];
+ }
+
+ /**
+ * Retrieve cached single Theme/Plugin data
+ * @param string $moduleKey
+ * @param string $type
+ * @return array|null
+ * @throws Exception
+ */
+ public function getSingleModuleCachedData(string $moduleKey, string $type = self::THEMES_DIR): array
+ {
+ $data = $this->getModulesCachedData($type);
+ return $data !== null && array_key_exists($moduleKey, $data) ? $data[$moduleKey] : [];
+ }
+
+ /**
+ * Force cache refresh for updates
+ * @throws Exception
+ */
+ public function manuallyRefreshCacheData(): void
+ {
+ if (!isset($_REQUEST['manuallyResetCacheData']) || !$this->verifyFormActions(true)) {
+ return;
+ }
+ $this->updateAndCacheModules();
+ $this->checkWcmsCoreUpdate();
+ $this->set('config', 'lastModulesSync', date('Y/m/d'));
+ $this->redirect();
+ }
+
+ /**
+ * Forces http to https
+ */
+ private function forceSSL(): void
+ {
+ if ($this->isHttpsForced() && !Wcms::isCurrentlyOnSSL()) {
+ $this->updateSecurityCache();
+ $this->redirect();
+ }
+ }
+
+ /**
+ * Method checks for new modules and caches them
+ * @throws Exception
+ */
+ private function updateAndCacheModules(): void
+ {
+ $this->set('config', 'lastModulesSync', date('Y/m/d'));
+ $this->cacheModulesData();
+ }
+
+ /**
+ * Fetch module config from url
+ * @param string $url
+ * @param string $type
+ * @return object|null
+ */
+ private function fetchModuleConfig(string $url, string $type): ?object
+ {
+ $wcmsModules = json_decode(trim($this->downloadFileFromUrl($url)));
+ $wcmsModulesData = $wcmsModules && property_exists($wcmsModules, $type)
+ ? $wcmsModules->{$type}
+ : null;
+ if (null === $wcmsModulesData) {
+ $this->alert('danger', 'The wcms-modules.json file does not contain all the required information.');
+ return null;
+ }
+ $wcmsModulesData = get_mangled_object_vars($wcmsModulesData);
+ $returnData = reset($wcmsModulesData);
+ $name = key($wcmsModulesData);
+ $returnData->dirName = $name;
+ return $returnData;
+ }
+
+ /**
+ * Update cache for default themes/plugins modules.
+ * @return void
+ * @throws Exception
+ */
+ private function updateModulesCache(): void
+ {
+ $wcmsModules = trim($this->getFileFromRepo('wcms-modules.json', self::WCMS_CDN_REPO));
+ $jsonObject = json_decode($wcmsModules);
+ if (empty($jsonObject)) {
+ return;
+ }
+
+ $parsedCache = $this->moduleCacheMapper($jsonObject);
+ if (empty($parsedCache)) {
+ return;
+ }
+
+ $this->save($this->modulesCachePath, $parsedCache);
+ }
+
+ /**
+ * Mapper between wcms-modules.json and applications cache.json
+ * @param object $wcmsModule
+ * @return object
+ */
+ private function moduleCacheMapper(object $wcmsModule): object
+ {
+ $mappedModules = new stdClass;
+ foreach ($wcmsModule as $type => $value) {
+ if ($type === 'version') {
+ if ($value !== self::MODULES_JSON_VERSION) {
+ $this->alert('danger', 'The wcms-modules.json version is incorrect');
+ break;
+ }
+
+ continue;
+ }
+
+ $mappedModules->{$type} = new stdClass();
+ foreach ($value as $moduleName => $module) {
+ $parsedModule = $this->moduleCacheParser($module, $moduleName);
+ if (empty($parsedModule)) {
+ continue;
+ }
+
+ $mappedModules->{$type}->{$moduleName} = new stdClass();
+ $mappedModules->{$type}->{$moduleName} = $parsedModule;
+ }
+ }
+
+ return $mappedModules;
+ }
+
+ /**
+ * Parse module cache to
+ * @param object $module
+ * @param string $moduleName
+ * @return object|null
+ */
+ private function moduleCacheParser(object $module, string $moduleName): ?object {
+ if (!$this->validateWcmsModuleStructure($module)) {
+ return null;
+ }
+
+ return (object)[
+ "name" => $module->name,
+ "dirName" => $moduleName,
+ "repo" => $module->repo,
+ "zip" => $module->zip,
+ "summary" => $module->summary,
+ "version" => $module->version,
+ "image" => $module->image,
+ ];
+ }
+
+ /**
+ * Cache themes and plugins data
+ * @throws Exception
+ */
+ private function cacheModulesData(): void
+ {
+ $db = $this->getDb();
+
+ // Download wcms-modules as cache
+ $this->updateModulesCache();
+
+ // Cache custom modules
+ $returnArray = $this->getJsonFileData($this->modulesCachePath);
+
+ // If custom modules is missing from the DB, we add it
+ if (!property_exists($db->config, 'customModules')) {
+ $this->set('config', 'customModules', $this->defaultCustomModules());
+ $db = $this->getDb();
+ }
+
+ $arrayCustom = (array)$db->config->customModules;
+ foreach ($arrayCustom as $type => $modules) {
+ foreach ($modules as $url) {
+ $wcmsModuleData = $this->fetchModuleConfig($url, $type);
+ if (null === $wcmsModuleData) {
+ continue;
+ }
+
+ $name = $wcmsModuleData->dirName;
+ $wcmsModuleData = $this->moduleCacheParser($wcmsModuleData, $name);
+ $returnArray[$type][$name] = $wcmsModuleData;
+ }
+ }
+
+ $this->save($this->modulesCachePath, (object)$returnArray);
+ }
+
+ /**
+ * Cache single theme or plugin data
+ * @param string $url
+ * @param string $type
+ * @throws Exception
+ */
+ private function cacheSingleCacheModuleData(string $url, string $type): void
+ {
+ $returnArray = $this->getJsonFileData($this->modulesCachePath);
+
+ $wcmsModuleData = $this->fetchModuleConfig($url, $type);
+ if (null === $wcmsModuleData) {
+ return;
+ }
+ $name = $wcmsModuleData->dirName;
+ $wcmsModuleData = $this->moduleCacheParser($wcmsModuleData, $name);
+ $returnArray[$type][$name] = $wcmsModuleData;
+ $this->save($this->modulesCachePath, (object)$returnArray);
+ }
+
+ /**
+ * Check if the module url already exists
+ * @param string $repo
+ * @param string $type
+ * @return bool
+ * @throws Exception
+ */
+ private function checkIfModuleRepoExists(string $repo, string $type): bool
+ {
+ $data = $this->getModulesCachedData($type);
+ return in_array($repo, array_column($data, 'repo'));
+ }
+
+ /**
+ * Validate structure of the wcms module json
+ * @param object $wcmsModule
+ * @return bool
+ */
+ private function validateWcmsModuleStructure(object $wcmsModule): bool {
+ return property_exists($wcmsModule, 'name')
+ && property_exists($wcmsModule, 'repo')
+ && property_exists($wcmsModule, 'zip')
+ && property_exists($wcmsModule, 'summary')
+ && property_exists($wcmsModule, 'version')
+ && property_exists($wcmsModule, 'image');
+ }
+
+ /**
+ * Add custom url links for themes and plugins
+ * @throws Exception
+ */
+ public function addCustomModule(): void
+ {
+ if (!isset($_POST['pluginThemeUrl'], $_POST['pluginThemeType'], $_POST['password_recheck']) || !$this->verifyFormActions()) {
+ return;
+ }
+
+ if (!password_verify($_POST['password_recheck'], $this->get('config', 'password'))) {
+ $this->alert('danger', 'Invalid password.');
+ $this->redirect();
+ }
+
+ $type = $_POST['pluginThemeType'];
+ $url = rtrim(trim($_POST['pluginThemeUrl']), '/');
+ $customModules = (array)$this->get('config', 'customModules', $type);
+ $wcmsModuleData = $this->fetchModuleConfig($url, $type);
+ $errorMessage = null;
+ switch (true) {
+ case null === $wcmsModuleData || !$this->isValidModuleURL($url):
+ $errorMessage = 'Invalid URL. The module URL needs to contain the full path to the raw wcms-modules.json file.';
+ break;
+ case !$this->validateWcmsModuleStructure($wcmsModuleData):
+ $errorMessage = 'Module not added - the wcms-modules.json file does not contain all the required information.';
+ break;
+ case $this->checkIfModuleRepoExists($wcmsModuleData->repo, $type):
+ $errorMessage = 'Module already exists.';
+ break;
+ }
+ if ($errorMessage !== null) {
+ $this->alert('danger', $errorMessage);
+ $this->redirect();
+ }
+
+ $customModules[] = $url;
+ $this->set('config', 'customModules', $type, $customModules);
+ $this->cacheSingleCacheModuleData($url, $type);
+ $this->alert('success',
+ 'Module successfully added to ' . ucfirst($type) . ' .');
+ $this->redirect();
+ }
+
+ /**
+ * Read plugin version
+ * @param string $type
+ * @param string $name
+ * @return string|null
+ */
+ public function getModuleVersion(string $type, string $name): ?string
+ {
+ $version = null;
+ $path = sprintf('%s/%s/%s', $this->rootDir, $type, $name);
+ $wcmsModulesPath = $path . '/wcms-modules.json';
+ $versionPath = $path . '/version';
+ if (is_dir($path) && (is_file($wcmsModulesPath) || is_file($versionPath))) {
+ if (is_file($wcmsModulesPath)) {
+ $wcmsModules = json_decode(trim(file_get_contents($wcmsModulesPath)));
+ $version = $wcmsModules->{$type}->{$name}->version;
+ } else {
+ $version = trim(file_get_contents($versionPath));
+ }
+ }
+
+ return $version;
+ }
+
+ /**
+ * Install and update theme
+ * @throws Exception
+ */
+ public function installUpdateModuleAction(): void
+ {
+ if (!isset($_REQUEST['installModule'], $_REQUEST['type']) || !$this->verifyFormActions(true)) {
+ return;
+ }
+
+ $folderName = trim(htmlspecialchars($_REQUEST['installModule']));
+ $type = $_REQUEST['type'];
+ $cached = $this->getSingleModuleCachedData($folderName, $type);
+ $url = !empty($cached) ? $cached['zip'] : null;
+
+ if (empty($url)) {
+ $this->alert('danger', 'Unable to find theme or plugin.');
+ return;
+ }
+
+ $path = sprintf('%s/%s/', $this->rootDir, $type);
+
+ if (in_array($type, self::VALID_DIRS, true)) {
+ $zipFile = $this->filesPath . '/ZIPFromURL.zip';
+ $zipResource = fopen($zipFile, 'w');
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $url);
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+ curl_setopt($ch, CURLOPT_FILE, $zipResource);
+ curl_exec($ch);
+ $curlError = curl_error($ch);
+ curl_close($ch);
+ $zip = new \ZipArchive;
+ if ($curlError || $zip->open($zipFile) !== true || (stripos($url, '.zip') === false)) {
+ $this->recursiveDelete($this->rootDir . '/data/files/ZIPFromURL.zip');
+ $this->alert('danger',
+ 'Error opening ZIP file.' . ($curlError ? ' Error description: ' . $curlError : ''));
+ $this->redirect();
+ }
+ // First delete old plugin folder
+ $this->recursiveDelete($path . $folderName);
+
+ // Then extract new one
+ $zip->extractTo($path);
+ $zip->close();
+ $this->recursiveDelete($this->rootDir . '/data/files/ZIPFromURL.zip');
+ $moduleFolder = $path . $folderName . '-master';
+ if (!is_dir($moduleFolder)) {
+ $moduleFolder = $path . $folderName . '-main';
+ }
+ if (is_dir($moduleFolder) && !rename($moduleFolder, $path . $folderName)) {
+ throw new Exception('Theme or plugin not installed. Possible cause: themes or plugins folder is not writable.');
+ }
+ $this->alert('success', 'Successfully installed/updated ' . $folderName . '.');
+ $this->redirect();
+ }
+ }
+
+ /**
+ * Validate if custom module url has wcms-modules.json
+ * @param string $url
+ * @return boolean
+ */
+ private function isValidModuleURL(string $url): bool
+ {
+ return strpos($url, 'wcms-modules.json') !== false;
+ }
+
+ /**
+ * Verify if admin is logged in and has verified token for POST calls
+ * @param bool $isRequest
+ * @return bool
+ */
+ public function verifyFormActions(bool $isRequest = false): bool
+ {
+ return ($isRequest ? isset($_REQUEST['token']) : isset($_POST['token']))
+ && $this->loggedIn
+ && $this->hashVerify($isRequest ? $_REQUEST['token'] : $_POST['token']);
+ }
+
+ /**
+ * Load JS and enable plugins to load JS
+ * @return string
+ * @throws Exception
+ */
+ public function js(): string
+ {
+ if ($this->loggedIn) {
+ $scripts = <<
+
+
+EOT;
+ $scripts .= '';
+ $scripts .= '';
+ $scripts .= '';
+
+ return $this->hook('js', $scripts)[0];
+ }
+ return $this->hook('js', '')[0];
+ }
+
+ /**
+ * Load plugins (if any exist)
+ * @return void
+ */
+ public function loadPlugins(): void
+ {
+ $this->installedPlugins = [];
+ $plugins = $this->rootDir . '/plugins';
+ if (!is_dir($plugins) && !mkdir($plugins) && !is_dir($plugins)) {
+ return;
+ }
+ if (!is_dir($this->filesPath) && !mkdir($this->filesPath) && !is_dir($this->filesPath)) {
+ return;
+ }
+ foreach (glob($plugins . '/*', GLOB_ONLYDIR) as $dir) {
+ $pluginName = basename($dir);
+ if (file_exists($dir . '/' . $pluginName . '.php')) {
+ include $dir . '/' . $pluginName . '.php';
+ $this->installedPlugins[] = $pluginName;
+ }
+ }
+ }
+
+ /**
+ * Loads theme files and functions.php file (if they exist)
+ * @return void
+ */
+ public function loadThemeAndFunctions(): void
+ {
+ $location = $this->rootDir . '/themes/' . $this->get('config', 'theme');
+ if (file_exists($location . '/functions.php')) {
+ require_once $location . '/functions.php';
+ }
+
+ # If page does not exist for non-logged-in users, then show 404 theme if it exists.
+ $is404 = !$this->loggedIn && !$this->currentPageExists && $this->currentPage !== $this->get('config', 'login');
+ $customPageTemplate = sprintf('%s/%s.php', $location, $is404 ? '404' : $this->currentPage);
+ require_once file_exists($customPageTemplate) ? $customPageTemplate : $location . '/theme.php';
+ }
+
+ /**
+ * Admin login verification
+ * @return void
+ * @throws Exception
+ */
+ public function loginAction(): void
+ {
+ if ($this->currentPage !== $this->get('config', 'login')) {
+ return;
+ }
+ if ($this->loggedIn) {
+ $this->redirect();
+ }
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ return;
+ }
+ $password = $_POST['password'] ?? '';
+ if (password_verify($password, $this->get('config', 'password'))) {
+ session_regenerate_id(true);
+ $_SESSION['loggedIn'] = true;
+ $_SESSION['rootDir'] = $this->rootDir;
+ $this->set('config', 'forceLogout', false);
+ $this->saveAdminLoginIP();
+ $this->redirect();
+ }
+ $this->alert('test', '', 1);
+ $this->redirect($this->get('config', 'login'));
+ }
+
+ /**
+ * Save admins last 5 IPs
+ */
+ private function saveAdminLoginIP(): void
+ {
+ $getAdminIP = $_SERVER['HTTP_CLIENT_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? null;
+ if ($getAdminIP === null) {
+ return;
+ }
+
+ if (!$savedIPs = $this->get('config', 'lastLogins')) {
+ $this->set('config', 'lastLogins', []);
+ $savedIPs = [];
+ }
+ $savedIPs = (array)$savedIPs;
+ $savedIPs[date('Y/m/d H:i:s')] = $getAdminIP;
+ krsort($savedIPs);
+ $this->set('config', 'lastLogins', array_slice($savedIPs, 0, 5));
+ }
+
+ /**
+ * Check if admin is logged in
+ * @return void
+ */
+ public function loginStatus(): void
+ {
+ $this->loggedIn = $this->get('config', 'forceLogout')
+ ? false
+ : isset($_SESSION['loggedIn'], $_SESSION['rootDir']) && $_SESSION['rootDir'] === $this->rootDir;
+ }
+
+ /**
+ * Login form view
+ * @return array
+ */
+ public function loginView(): array
+ {
+ return [
+ 'title' => $this->hook('loginView', 'Login')[0],
+ 'description' => '',
+ 'keywords' => '',
+ 'content' => $this->hook('loginView', '
+
+ Logging in and checking for updates This might take a moment.
+ ')[0]
+ ];
+ }
+
+ /**
+ * Logout action
+ * @param bool $forceLogout
+ * @return void
+ */
+ public function logoutAction(bool $forceLogout = false): void
+ {
+ if ($forceLogout
+ || ($this->currentPage === 'logout'
+ && isset($_REQUEST['token'])
+ && $this->hashVerify($_REQUEST['token']))) {
+ $to = isset($_GET['to']) && !empty($_GET['to']) && !$this->isLogoutToLoginScreenEnabled()
+ ? $_GET['to']
+ : $this->get('config', 'login');
+ unset($_SESSION['loggedIn'], $_SESSION['rootDir'], $_SESSION['token'], $_SESSION['alert']);
+ $this->redirect($to);
+ }
+ }
+
+ /**
+ * If admin is logged in and on existing page, this will save previous page and push it to logout action
+ * @return string|null
+ */
+ private function logoutToUrl(): ?string
+ {
+ if (!$this->loggedIn || !$this->currentPageExists) {
+ return null;
+ }
+
+ return $this->getCurrentPagePath();
+ }
+
+ /**
+ * Return menu items, if they are set to be visible
+ * @return string
+ */
+ public function menu(): string
+ {
+ $output = '';
+ foreach ($this->get('config', 'menuItems') as $item) {
+ if ($item->visibility === 'hide') {
+ continue;
+ }
+ $output .= $this->renderPageNavMenuItem($item);
+ }
+ return $this->hook('menu', $output)[0];
+ }
+
+ /**
+ * 404 header response
+ * @return void
+ */
+ public function notFoundResponse(): void
+ {
+ if (!$this->loggedIn && !$this->currentPageExists && $this->headerResponseDefault) {
+ $this->headerResponse = 'HTTP/1.1 404 Not Found';
+ }
+ }
+
+ /**
+ * Return 404 page to visitors
+ * Admin can create a page that doesn't exist yet
+ */
+ public function notFoundView()
+ {
+ if ($this->loggedIn) {
+ return [
+ 'title' => str_replace('-', ' ', $this->currentPage),
+ 'description' => '',
+ 'keywords' => '',
+ 'content' => 'Click to create content '
+ ];
+ }
+ return $this->get('pages', '404');
+ }
+
+ /**
+ * Admin notifications
+ * Alerts for non-existent pages, changing default settings, new version/update
+ * @return void
+ * @throws Exception
+ */
+ public function notifyAction(): void
+ {
+ if (!$this->loggedIn) {
+ return;
+ }
+ if (!$this->currentPageExists) {
+ $this->alert(
+ 'info',
+ 'This page (' . $this->currentPage . ') doesn\'t exist. Editing the content below will create it.'
+ );
+ }
+ if ($this->get('config', 'login') === 'loginURL') {
+ $this->alert('danger',
+ 'Change your login URL and save it for later use. Open security settings ');
+ }
+
+ $this->checkModulesCache();
+ }
+
+ /**
+ * Checks if there is new Wcms version
+ */
+ private function checkWcmsCoreUpdate(): void
+ {
+ $onlineVersion = $this->getOfficialVersion();
+ if ($onlineVersion > VERSION) {
+ $this->alert(
+ 'info',
+ 'New WonderCMS update available
+ Check what\'s new
+ and backup your website before updating.
+ '
+ );
+ }
+ }
+
+ /**
+ * Update menu visibility state
+ *
+ * @param string $visibility - "show" for visible, "hide" for invisible
+ * @param string $menu
+ * @throws Exception
+ */
+ public function updateMenuItemVisibility(string $visibility, string $menu): void
+ {
+ if (!in_array($visibility, ['show', 'hide'], true)) {
+ return;
+ }
+
+ $menuTree = explode('-', $menu);
+ $menuItems = $menuSelectionObject = clone $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS);
+
+ // Find sub menu item
+ if ($menuTree) {
+ $mainParentMenu = array_shift($menuTree);
+ $menuSelectionObject = $menuItems->{$mainParentMenu};
+ foreach ($menuTree as $childMenuKey) {
+ $menuSelectionObject = $menuSelectionObject->subpages->{$childMenuKey};
+ }
+ }
+
+ $menuSelectionObject->visibility = $visibility;
+ $this->set(self::DB_CONFIG, self::DB_MENU_ITEMS, $menuItems);
+ }
+
+ /**
+ * Reorder the pages
+ *
+ * @param int $content 1 for down arrow or -1 for up arrow
+ * @param string $menu
+ * @return void
+ * @throws Exception
+ */
+ public function orderMenuItem(int $content, string $menu): void
+ {
+ // check if content is 1 or -1 as only those values are acceptable
+ if (!in_array($content, [1, -1], true)) {
+ return;
+ }
+ $menuTree = explode('-', $menu);
+ $mainParentMenu = $selectedMenuKey = array_shift($menuTree);
+ $menuItems = $menuSelectionObject = clone $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS);
+
+ // Sorting of subpages in menu
+ if ($menuTree) {
+ $selectedMenuKey = array_pop($menuTree);
+ $menuSelectionObject = $menuItems->{$mainParentMenu}->subpages;
+ foreach ($menuTree as $childMenuKey) {
+ $menuSelectionObject = $menuSelectionObject->{$childMenuKey}->subpages;
+ }
+ }
+
+ $targetPosition = $selectedMenuKey + $content;
+
+ // Find and switch target and selected menu position in DB
+ $selectedMenu = $menuSelectionObject->{$selectedMenuKey};
+ $targetMenu = $menuSelectionObject->{$targetPosition};
+ $menuSelectionObject->{$selectedMenuKey} = $targetMenu;
+ $menuSelectionObject->{$targetPosition} = $selectedMenu;
+
+ $this->set(self::DB_CONFIG, self::DB_MENU_ITEMS, $menuItems);
+ }
+
+ /**
+ * Return pages and display correct view (actual page or 404)
+ * Display different content and editable areas for admin
+ *
+ * @param string $key
+ * @return string
+ */
+ public function page(string $key): string
+ {
+ $segments = $this->getCurrentPageData();
+ if (!$this->currentPageExists || !$segments) {
+ $segments = $this->get('config', 'login') === $this->currentPage
+ ? (object)$this->loginView()
+ : (object)$this->notFoundView();
+ }
+
+ $segments->content = $segments->content ?? 'Click here add content ';
+ $keys = [
+ 'title' => $segments->title,
+ 'description' => $segments->description,
+ 'keywords' => $segments->keywords,
+ 'content' => $this->loggedIn
+ ? $this->editable('content', $segments->content, 'pages')
+ : $segments->content
+ ];
+ $content = $keys[$key] ?? '';
+ return $this->hook('page', $content, $key)[0];
+ }
+
+ /**
+ * Return database data of current page
+ *
+ * @return object|null
+ */
+ public function getCurrentPageData(): ?object
+ {
+ return $this->getPageData(implode('/', $this->currentPageTree));
+ }
+
+ /**
+ * Return database data of any page
+ *
+ * @param string $slugTree
+ * @return object|null
+ */
+ public function getPageData(string $slugTree): ?object
+ {
+ $arraySlugTree = explode('/', $slugTree);
+ $pageData = null;
+ foreach ($arraySlugTree as $slug) {
+ if ($pageData === null) {
+ $pageData = $this->get(self::DB_PAGES_KEY)->{$slug} ?? null;
+ continue;
+ }
+
+ $pageData = $pageData->{self::DB_PAGES_SUBPAGE_KEY}->{$slug} ?? null;
+ if (!$pageData) {
+ return null;
+ }
+ }
+
+ return $pageData;
+ }
+
+ /**
+ * Get current page url
+ *
+ * @return string
+ */
+ public function getCurrentPageUrl(): string
+ {
+ return self::url($this->getCurrentPagePath());
+ }
+
+ /**
+ * Get current page path
+ *
+ * @return string
+ */
+ public function getCurrentPagePath(): string
+ {
+ $path = '';
+ foreach ($this->currentPageTree as $parentPage) {
+ $path .= $parentPage . '/';
+ }
+
+ return $path;
+ }
+
+ /**
+ * Page status (exists or doesn't exist)
+ * @return void
+ */
+ public function pageStatus(): void
+ {
+ $this->currentPage = $this->parseUrl() ?: $this->get('config', 'defaultPage');
+ $this->currentPageExists = !empty($this->getCurrentPageData());
+ }
+
+ /**
+ * URL parser
+ * @return string
+ */
+ public function parseUrl(): string
+ {
+ $page = $_GET['page'] ?? null;
+ $page = !empty($page) ? trim(htmlspecialchars($page, ENT_QUOTES)) : null;
+
+ if (!isset($page) || !$page) {
+ $defaultPage = $this->get('config', 'defaultPage');
+ $this->currentPageTree = explode('/', $defaultPage);
+ return $defaultPage;
+ }
+
+ $this->currentPageTree = explode('/', rtrim($page, '/'));
+ if ($page === $this->get('config', 'login')) {
+ return $page;
+ }
+
+ $currentPage = end($this->currentPageTree);
+ return $this->slugify($currentPage);
+ }
+
+ /**
+ * Recursive delete - used for deleting files, themes, plugins
+ *
+ * @param string $file
+ * @return void
+ */
+ public function recursiveDelete(string $file): void
+ {
+ if (is_dir($file)) {
+ $files = new DirectoryIterator($file);
+ foreach ($files as $dirFile) {
+ if (!$dirFile->isDot()) {
+ $dirFile->isDir() ? $this->recursiveDelete($dirFile->getPathname()) : unlink($dirFile->getPathname());
+ }
+ }
+ rmdir($file);
+ } elseif (is_file($file)) {
+ unlink($file);
+ }
+ }
+
+ /**
+ * Redirect to any URL
+ *
+ * @param string $location
+ * @return void
+ */
+ public function redirect(string $location = ''): void
+ {
+ header('Location: ' . self::url($location));
+ die();
+ }
+
+ /**
+ * Save object to disk (default is set for DB)
+ * @param string|null $path
+ * @param object|null $content
+ * @return void
+ * @throws Exception
+ */
+ public function save(string $path = null, object $content = null): void
+ {
+ $path = $path ?? $this->dbPath;
+ $content = $content ?? $this->db;
+ $json = json_encode($content, JSON_FORCE_OBJECT | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+ if (empty($content) || empty($json) || json_last_error() !== JSON_ERROR_NONE) {
+ $errorMessage = sprintf(
+ '%s - Error while trying to save in %s: %s',
+ time(),
+ $path,
+ print_r($content, true)
+ );
+ try {
+ $randomNumber = random_bytes(8);
+ } catch (Exception $e) {
+ $randomNumber = microtime(false);
+ }
+ $logName = date('Y-m-d H:i:s') . '-error-' . bin2hex($randomNumber) . '.log';
+ $logsPath = sprintf('%s/data/logs', $this->rootDir);
+ $this->checkFolder($logsPath);
+ error_log(
+ $errorMessage,
+ 3,
+ sprintf('%s/%s', $logsPath, $logName)
+ );
+ return;
+ }
+ file_put_contents($path, $json, LOCK_EX);
+ }
+
+ /**
+ * Saving menu items, default page, login URL, theme, editable content
+ * @return void
+ * @throws Exception
+ */
+ public function saveAction(): void
+ {
+ if (!$this->loggedIn) {
+ return;
+ }
+ if (isset($_SESSION['redirect_to'])) {
+ $newUrl = $_SESSION['redirect_to'];
+ $newPageName = $_SESSION['redirect_to_name'];
+ unset($_SESSION['redirect_to'], $_SESSION['redirect_to_name']);
+ $this->alert('success',
+ "Page $newPageName created. Click here to open it.");
+ $this->redirect($newUrl);
+ }
+ if (isset($_POST['fieldname'], $_POST['content'], $_POST['target'], $_POST['token'])
+ && $this->hashVerify($_POST['token'])) {
+ [$fieldname, $content, $target, $menu, $visibility] = $this->hook('save', $_POST['fieldname'],
+ $_POST['content'], $_POST['target'], $_POST['menu'] ?? null, ($_POST['visibility'] ?? 'hide'));
+ if ($target === 'menuItemUpdate' && $menu !== null) {
+ $this->updateMenuItem($content, $menu, $visibility);
+ $_SESSION['redirect_to_name'] = $content;
+ $_SESSION['redirect_to'] = $this->slugify($content);
+ }
+ if ($target === 'menuItemCreate' && $menu !== null) {
+ $this->createMenuItem($content, $menu, $visibility, true);
+ }
+ if ($target === 'menuItemVsbl' && $menu !== null) {
+ $this->updateMenuItemVisibility($visibility, $menu);
+ }
+ if ($target === 'menuItemOrder' && $menu !== null) {
+ $this->orderMenuItem($content, $menu);
+ }
+ if ($fieldname === 'defaultPage' && $this->getPageData($content) === null) {
+ return;
+ }
+ if ($fieldname === 'login' && (empty($content) || $this->getPageData($content) !== null)) {
+ return;
+ }
+ if ($fieldname === 'theme' && !is_dir($this->rootDir . '/themes/' . $content)) {
+ return;
+ }
+ if ($target === 'config') {
+ $this->set('config', $fieldname, $content);
+ } elseif ($target === 'blocks') {
+ $this->set('blocks', $fieldname, 'content', $content);
+ } elseif ($target === 'pages') {
+ if (!$this->currentPageExists) {
+ $this->createPage($this->currentPageTree, true);
+ }
+ $this->updatePage($this->currentPageTree, $fieldname, $content);
+ }
+ }
+ }
+
+ /**
+ * Set something to database
+ * @return void
+ * @throws Exception
+ */
+ public function set(): void
+ {
+ $args = func_get_args();
+
+ $value = array_pop($args);
+ $lastKey = array_pop($args);
+ $data = $this->db;
+ foreach ($args as $arg) {
+ $data = $data->{$arg};
+ }
+ $data->{$lastKey} = $value;
+
+ $this->save();
+ }
+
+ /**
+ * Display admin settings panel
+ * @return string
+ * @throws Exception
+ */
+ public function settings(): string
+ {
+ if (!$this->loggedIn) {
+ return '';
+ }
+ $currentPageData = $this->getCurrentPageData();
+ $fileList = array_slice(scandir($this->filesPath), 2);
+ $logoutTo = $this->logoutToUrl();
+ $isHttpsForced = $this->isHttpsForced();
+ $isSaveChangesPopupEnabled = $this->isSaveChangesPopupEnabled();
+ $isLogoutToLoginScreenEnabled = $this->isLogoutToLoginScreenEnabled();
+ $output = '
+
+
Saving
+
Checking for updates
+
+
Settings
+
+
+
+
+
+
+
+
';
+ if ($this->currentPageExists && $currentPageData) {
+ $output .= '
+
Page title
+
+
' . ($currentPageData->title ?: '') . '
+
+
Page keywords
+
+
' . ($currentPageData->keywords ?: '') . '
+
+
Page description
+
+
' . ($currentPageData->description ?: '') . '
+
+
Delete page (' . $this->currentPage . ')';
+ } else {
+ $output .= 'This page doesn\'t exist. More settings will be displayed here after this page is created.';
+ }
+ $output .= '
+
+
+
+
Upload
+
+
Delete files
+
+
';
+ $output .= $this->renderModuleTab();
+ $output .= $this->renderModuleTab('plugins');
+ $output .= '
+
Admin login URL
+
Important: save your login URL to log in to your website next time:' . self::url($this->get('config', 'login')) . '
+
+
' . $this->get('config', 'login') . '
+
+
Site language config
+
HTML lang value for admin.
+
+
' . $this->get('config', 'adminLang') . '
+
+
HTML lang value for visitors.
+
+
' . $this->get('config', 'siteLang') . '
+
+
Password
+
+
Backup
+
+
+
+
How to restore backup
+
+
Save confirmation popup
+
If this is turned "ON", WonderCMS will always ask you to confirm any changes you make.
+
+
+
Login redirect
+
If this is set to "ON", when logging out, you will be redirected to the login page. If set to "OFF", you will be redirected to the last viewed page.
+
+
+
Force HTTPS
+
WonderCMS automatically checks for SSL, this will force to always use HTTPS.
+
+
Read more before enabling
';
+ $output .= $this->renderAdminLoginIPs();
+ $output .= '
+
+
+
+
+
+
+
+
';
+ return $this->hook('settings', $output)[0];
+ }
+
+ /**
+ * Render options for default page selection
+ *
+ * @param object $menuItem
+ * @param string $defaultPage
+ * @param string $parentSlug
+ * @param string $parentName
+ * @return string
+ */
+ private function renderDefaultPageOptions(
+ object $menuItem,
+ string $defaultPage,
+ string $parentSlug = '',
+ string $parentName = ''
+ ): string {
+ $slug = $parentSlug ? sprintf('%s/%s', $parentSlug, $menuItem->slug) : $menuItem->slug;
+ $name = $parentName ? sprintf('%s | %s', $parentName, $menuItem->name) : $menuItem->name;
+ $output = '' . $name . ' ';
+
+ foreach ($menuItem->subpages ?? [] as $subpage) {
+ $output .= $this->renderDefaultPageOptions($subpage, $defaultPage, $slug, $name);
+ }
+
+ return $output;
+ }
+
+ /**
+ * Render page navigation items
+ *
+ * @param object $item
+ * @param string $parentSlug
+ * @return string
+ */
+ private function renderPageNavMenuItem(object $item, string $parentSlug = ''): string
+ {
+ $subpages = $visibleSubpage = false;
+ if (property_exists($item, 'subpages') && !empty((array)$item->subpages)) {
+ $subpages = $item->subpages;
+ $visibleSubpage = $subpages && in_array('show', array_column((array)$subpages, 'visibility'));
+ }
+
+ $parentSlug .= $subpages ? $item->slug . '/' : $item->slug;
+ $output = '
+ ' . $item->name . ' ';
+
+ // Recursive method for rendering infinite subpages
+ if ($visibleSubpage) {
+ $output .= '';
+ foreach ($subpages as $subpage) {
+ if ($subpage->visibility === 'hide') {
+ continue;
+ }
+ $output .= $this->renderPageNavMenuItem($subpage, $parentSlug);
+ }
+ $output .= ' ';
+ }
+
+ $output .= ' ';
+
+ return $output;
+ }
+
+ /**
+ * Render menu item for settings
+ *
+ * @param string $menuKeyTree
+ * @param object $value
+ * @param bool $isFirstEl
+ * @param bool $isLastEl
+ * @param string $slugTree
+ * @return string
+ * @throws Exception
+ */
+ private function renderSettingsMenuItem(
+ string $menuKeyTree,
+ object $value,
+ bool $isFirstEl,
+ bool $isLastEl,
+ string $slugTree
+ ): string {
+ $arraySlugTree = explode('/', $slugTree);
+ array_shift($arraySlugTree);
+ $subMenuLevel = count($arraySlugTree);
+ $output = '
+
+
+
+
+
+ ';
+
+ if (!$isFirstEl) {
+ $output .= '';
+ }
+ if (!$isLastEl) {
+ $output .= '';
+ }
+ $output .= '
visit
+
+ ';
+
+ return $output;
+ }
+
+ /**
+ * Render sub menu item for settings
+ *
+ * @param object $subpages
+ * @param string $parentKeyTree
+ * @param string $parentSlugTree
+ * @return string
+ * @throws Exception
+ */
+ private function renderSettingsSubMenuItem(object $subpages, string $parentKeyTree, string $parentSlugTree): string
+ {
+ $subpages = get_mangled_object_vars($subpages);
+ reset($subpages);
+ $firstSubpage = key($subpages);
+ end($subpages);
+ $endSubpage = key($subpages);
+ $output = '';
+
+ foreach ($subpages as $subpageKey => $subpage) {
+ $keyTree = $parentKeyTree . '-' . $subpageKey;
+ $slugTree = $parentSlugTree . '/' . $subpage->slug;
+ $output .= '
+
';
+ $firstElement = ($subpageKey === $firstSubpage);
+ $lastElement = ($subpageKey === $endSubpage);
+ $output .= $this->renderSettingsMenuItem($keyTree, $subpage, $firstElement, $lastElement, $slugTree);
+
+ // Recursive method for rendering infinite subpages
+ if (property_exists($subpage, 'subpages')) {
+ $output .= $this->renderSettingsSubMenuItem($subpage->subpages, $keyTree, $slugTree);
+ }
+ $output .= '
+
';
+ }
+
+ return $output;
+ }
+
+ /**
+ * Render last login IPs
+ * @return string
+ */
+ private function renderAdminLoginIPs(): string
+ {
+ $getIPs = $this->get('config', 'lastLogins') ?? [];
+ $renderIPs = '';
+ foreach ($getIPs as $time => $adminIP) {
+ $renderIPs .= sprintf('%s - %s ', date('M d, Y H:i:s', strtotime($time)), $adminIP);
+ }
+ return 'Last 5 logins
+
+ ' . $renderIPs . '
+
';
+ }
+
+ /**
+ * Render Plugins/Themes cards
+ * @param string $type
+ * @return string
+ * @throws Exception
+ */
+ private function renderModuleTab(string $type = 'themes'): string
+ {
+ $output = '
+
Check for updates
+
+
';
+ $defaultImage = '
No preview ';
+ $updates = $exists = $installs = '';
+ foreach ($this->listAllModules($type) as $addonType => $addonModules) {
+ foreach ($addonModules as $directoryName => $addon) {
+ $name = $addon['name'];
+ $info = $addon['summary'];
+ $infoUrl = $addon['repo'];
+ $currentVersion = $addon['currentVersion'] ? sprintf('Installed version: %s',
+ $addon['currentVersion']) : '';
+ $isThemeSelected = $this->get('config', 'theme') === $directoryName;
+
+ $image = $addon['image'] !== null ? '
' : $defaultImage;
+ $installButton = $addon['install'] ? '
Install' : '';
+ $updateButton = !$addon['install'] && $addon['update'] ? '
Update to ' . $addon['version'] . '' : '';
+ $removeButton = !$addon['install'] ? '
' : '';
+ $inactiveThemeButton = $type === 'themes' && !$addon['install'] && !$isThemeSelected ? '
Activate' : '';
+ $activeThemeButton = $type === 'themes' && !$addon['install'] && $isThemeSelected ? '
Active ' : '';
+
+ $html = "
+
+ $image
+
$name
+
$info
+
$currentVersion More info
+
$inactiveThemeButton $activeThemeButton
+
$installButton
+
$updateButton $removeButton
+
+
";
+
+ switch ($addonType) {
+ case self::THEME_PLUGINS_TYPES['updates']:
+ $updates .= $html;
+ break;
+ case self::THEME_PLUGINS_TYPES['exists']:
+ $exists .= $html;
+ break;
+ case self::THEME_PLUGINS_TYPES['installs']:
+ default:
+ $installs .= $html;
+ break;
+ }
+ }
+ }
+ $output .= $updates;
+ $output .= $exists;
+ $output .= $installs;
+ $output .= '
+
Custom module
+
+
Read more about custom modules
+
';
+ return $output;
+ }
+
+ /**
+ * Slugify page
+ *
+ * @param string $text for slugifying
+ * @return string
+ */
+ public function slugify(string $text): string
+ {
+ $text = preg_replace('~[^\\pL\d]+~u', '-', $text);
+ $text = trim(htmlspecialchars(mb_strtolower($text), ENT_QUOTES), '/');
+ $text = trim($text, '-');
+ return empty($text) ? '-' : $text;
+ }
+
+ /**
+ * Delete something from database
+ * Has variadic arguments
+ * @return void
+ */
+ public function unset(): void
+ {
+ $numArgs = func_num_args();
+ $args = func_get_args();
+ switch ($numArgs) {
+ case 1:
+ unset($this->db->{$args[0]});
+ break;
+ case 2:
+ unset($this->db->{$args[0]}->{$args[1]});
+ break;
+ case 3:
+ unset($this->db->{$args[0]}->{$args[1]}->{$args[2]});
+ break;
+ case 4:
+ unset($this->db->{$args[0]}->{$args[1]}->{$args[2]}->{$args[3]});
+ break;
+ }
+ $this->save();
+ }
+
+ /**
+ * Update WonderCMS
+ * Overwrites index.php with latest version from GitHub
+ * @return void
+ */
+ public function updateAction(): void
+ {
+ if (!isset($_POST['update']) || !$this->verifyFormActions()) {
+ return;
+ }
+ $contents = $this->getFileFromRepo('index.php');
+ if ($contents) {
+ file_put_contents(__FILE__, $contents);
+ $this->alert('success', 'WonderCMS successfully updated. Wohoo!');
+ $this->redirect();
+ }
+ $this->alert('danger', 'Something went wrong. Could not update WonderCMS.');
+ $this->redirect();
+ }
+
+ /**
+ * Upload file to files folder
+ * List of allowed extensions
+ * @return void
+ */
+ public function uploadFileAction(): void
+ {
+ if (!isset($_FILES['uploadFile']) || !$this->verifyFormActions()) {
+ return;
+ }
+ $allowedMimeTypes = [
+ 'video/avi',
+ 'text/css',
+ 'text/x-asm',
+ 'application/msword',
+ 'application/vnd.ms-word',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'video/x-flv',
+ 'image/gif',
+ 'text/html',
+ 'image/x-icon',
+ 'image/jpeg',
+ 'application/octet-stream',
+ 'audio/mp4',
+ 'video/x-matroska',
+ 'video/quicktime',
+ 'audio/mpeg',
+ 'video/mp4',
+ 'video/mpeg',
+ 'application/vnd.oasis.opendocument.spreadsheet',
+ 'application/vnd.oasis.opendocument.text',
+ 'application/ogg',
+ 'video/ogg',
+ 'application/pdf',
+ 'image/png',
+ 'application/vnd.ms-powerpoint',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'application/photoshop',
+ 'application/rar',
+ 'image/svg',
+ 'image/svg+xml',
+ 'image/avif',
+ 'image/webp',
+ 'application/svg+xm',
+ 'text/plain',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'video/webm',
+ 'video/x-ms-wmv',
+ 'application/zip',
+ ];
+
+ $allowedExtensions = [
+ 'avi',
+ 'avif',
+ 'css',
+ 'doc',
+ 'docx',
+ 'flv',
+ 'gif',
+ 'htm',
+ 'html',
+ 'ico',
+ 'jpeg',
+ 'jpg',
+ 'kdbx',
+ 'm4a',
+ 'mkv',
+ 'mov',
+ 'mp3',
+ 'mp4',
+ 'mpg',
+ 'ods',
+ 'odt',
+ 'ogg',
+ 'ogv',
+ 'pdf',
+ 'png',
+ 'ppt',
+ 'pptx',
+ 'psd',
+ 'rar',
+ 'svg',
+ 'txt',
+ 'xls',
+ 'xlsx',
+ 'webm',
+ 'webp',
+ 'wmv',
+ 'zip',
+ ];
+ if (!isset($_FILES['uploadFile']['error']) || is_array($_FILES['uploadFile']['error'])) {
+ $this->alert('danger', 'Invalid parameters.');
+ $this->redirect();
+ }
+ switch ($_FILES['uploadFile']['error']) {
+ case UPLOAD_ERR_OK:
+ break;
+ case UPLOAD_ERR_NO_FILE:
+ $this->alert('danger',
+ 'No file selected. Re-open file options ');
+ $this->redirect();
+ break;
+ case UPLOAD_ERR_INI_SIZE:
+ case UPLOAD_ERR_FORM_SIZE:
+ $this->alert('danger',
+ 'File too large. Change maximum upload size limit or contact your host. Re-open file options ');
+ $this->redirect();
+ break;
+ default:
+ $this->alert('danger', 'Unknown error.');
+ $this->redirect();
+ }
+ $mimeType = '';
+ $fileName = basename(str_replace(
+ ['"', "'", '*', '<', '>', '%22', ''', '%', ';', '#', '&', './', '../', '/', '+'],
+ '',
+ htmlspecialchars(strip_tags($_FILES['uploadFile']['name']))
+ ));
+ $nameExploded = explode('.', $fileName);
+ $ext = strtolower(array_pop($nameExploded));
+
+ if (class_exists('finfo')) {
+ $finfo = new finfo(FILEINFO_MIME_TYPE);
+ $mimeType = $finfo->file($_FILES['uploadFile']['tmp_name']);
+ } elseif (function_exists('mime_content_type')) {
+ $mimeType = mime_content_type($_FILES['uploadFile']['tmp_name']);
+ } elseif (array_key_exists($ext, $allowedExtensions)) {
+ $mimeType = $allowedExtensions[$ext];
+ }
+ if (!in_array($mimeType, $allowedMimeTypes, true) || !in_array($ext, $allowedExtensions)) {
+ $this->alert('danger',
+ 'File format is not allowed. Re-open file options ');
+ $this->redirect();
+ }
+ if (!move_uploaded_file($_FILES['uploadFile']['tmp_name'], $this->filesPath . '/' . $fileName)) {
+ $this->alert('danger', 'Failed to move uploaded file.');
+ }
+ $this->alert('success',
+ 'File uploaded. Open file options to see your uploaded file ');
+ $this->redirect();
+ }
+
+ /**
+ * Get canonical URL
+ *
+ * @param string $location
+ * @return string
+ */
+ public static function url(string $location = ''): string
+ {
+ $showHttps = Wcms::isCurrentlyOnSSL();
+ $dataPath = sprintf('%s/%s', __DIR__, 'data');
+ $securityCachePath = sprintf('%s/%s', $dataPath, 'security.json');
+
+ if (is_file($securityCachePath) && file_exists($securityCachePath)) {
+ $securityCache = json_decode(file_get_contents($securityCachePath), true);
+ $showHttps = $securityCache['forceHttps'] ?? false;
+ }
+
+ $serverPort = ((($_SERVER['SERVER_PORT'] == '80') || ($_SERVER['SERVER_PORT'] == '443')) ? '' : ':' . $_SERVER['SERVER_PORT']);
+ return ($showHttps ? 'https' : 'http')
+ . '://' . ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'])
+ . ($_SERVER['HTTP_HOST'] ? '' : $serverPort)
+ . ((dirname($_SERVER['SCRIPT_NAME']) === '/') ? '' : dirname($_SERVER['SCRIPT_NAME']))
+ . '/' . $location;
+ }
+
+ /**
+ * Create a ZIP backup of whole WonderCMS installation (all files)
+ *
+ * @return void
+ */
+ public function zipBackup(): void
+ {
+ try {
+ $randomNumber = random_bytes(8);
+ } catch (Exception $e) {
+ $randomNumber = microtime(false);
+ }
+ $zipName = date('Y-m-d') . '-backup-' . bin2hex($randomNumber) . '.zip';
+ $zipPath = $this->rootDir . '/data/files/' . $zipName;
+ $zip = new ZipArchive();
+ if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
+ $this->alert('danger', 'Cannot create ZIP archive.');
+ }
+ $iterator = new RecursiveDirectoryIterator($this->rootDir);
+ $iterator->setFlags(RecursiveDirectoryIterator::SKIP_DOTS);
+ $files = new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::SELF_FIRST);
+ foreach ($files as $file) {
+ $file = realpath($file);
+ $source = realpath($this->rootDir);
+ if (is_dir($file)) {
+ $zip->addEmptyDir(str_replace($source . '/', '', $file . '/'));
+ } elseif (is_file($file)) {
+ $zip->addFromString(str_replace($source . '/', '', $file), file_get_contents($file));
+ }
+ }
+ $zip->close();
+ $this->redirect('data/files/' . $zipName);
+ }
+
+ /**
+ * Check if currently user is on https
+ * @return bool
+ */
+ public static function isCurrentlyOnSSL(): bool
+ {
+ return (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) === 'on')
+ || (isset($_SERVER['HTTP_FRONT_END_HTTPS']) && strtolower($_SERVER['HTTP_FRONT_END_HTTPS']) === 'on')
+ || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https');
+ }
+
+ /**
+ * Check compatibility
+ */
+ private function checkMinimumRequirements(): void
+ {
+ if (PHP_VERSION_ID <= 70200) {
+ die('To run WonderCMS, PHP version 7.2 or greater is required.
');
+ }
+ $extensions = ['curl', 'zip', 'mbstring'];
+ $missingExtensions = [];
+ foreach ($extensions as $ext) {
+ if (!extension_loaded($ext)) {
+ $missingExtensions[] = $ext;
+ }
+ }
+ if (!empty($missingExtensions)) {
+ die('The following extensions are required: '
+ . implode(', ', $missingExtensions)
+ . '. Contact your host or configure your server to enable them with correct permissions.
');
+ }
+ }
+
+ /**
+ * Helper for reseting the index key of the object
+ * @param stdClass $object
+ * @return stdClass
+ */
+ private function reindexObject(stdClass $object): stdClass
+ {
+ $reindexObject = new stdClass;
+ $index = 0;
+ foreach ($object as $value) {
+ $reindexObject->{$index} = $value;
+ $index++;
+ }
+ return $reindexObject;
+ }
+
+ /**
+ * Check if user has forced https
+ * @return bool
+ */
+ private function isHttpsForced(): bool
+ {
+ $value = $this->get('config', 'forceHttps');
+ if (gettype($value) === 'object' && empty(get_object_vars($value))) {
+ return false;
+ }
+
+ return $value ?? false;
+ }
+
+ /**
+ * Check if user has confirmation dialog enabled
+ * @return bool
+ */
+ private function isSaveChangesPopupEnabled(): bool
+ {
+ $value = $this->get('config', 'saveChangesPopup');
+ if (gettype($value) === 'object' && empty(get_object_vars($value))) {
+ return false;
+ }
+
+ return $value ?? false;
+ }
+
+ /**
+ * Check if admin will be redirected to the login screen or current page screen after logout.
+ * @return bool
+ */
+ private function isLogoutToLoginScreenEnabled(): bool
+ {
+ $value = $this->get('config', 'logoutToLoginScreen');
+ if (gettype($value) === 'object' && empty(get_object_vars($value))) {
+ return true;
+ }
+
+ return $value ?? true;
+ }
+}
diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-700.woff2 b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-700.woff2
new file mode 100644
index 0000000..e4b82d8
Binary files /dev/null and b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-700.woff2 differ
diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-regular.woff2 b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-regular.woff2
new file mode 100644
index 0000000..66649d4
Binary files /dev/null and b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-regular.woff2 differ
diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/style.css b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/style.css
new file mode 100644
index 0000000..10c8ee2
--- /dev/null
+++ b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/style.css
@@ -0,0 +1,557 @@
+@font-face {
+ font-family: 'Catamaran';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('fonts/catamaran-v7-latin-ext_latin-regular.woff2') format('woff2')
+}
+@font-face {
+ font-family: 'Catamaran';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('fonts/catamaran-v7-latin-ext_latin-700.woff2') format('woff2')
+}
+@font-face {
+ font-family: 'Catamaran';
+ font-style: normal;
+ font-weight: 900;
+ font-display: swap;
+ src: url('fonts/catamaran-v7-latin-ext_latin-900.woff2') format('woff2')
+}
+
+html, body, div, span, applet, object,
+iframe, h1, h2, h3, h4, h5, h6, p, blockquote,
+pre, a, abbr, acronym, address, big, cite,
+code, del, dfn, em, img, ins, kbd, q, s, samp, strike, strong, sub, sup, tt, var, b,
+u, i, center, dl, dt, dd, li, fieldset,
+form, label, legend, caption,
+tfoot, article, aside,
+canvas, details, embed, figure, figcaption,
+footer, header, hgroup, menu, nav, output, ruby,
+section, summary, time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline
+
+}
+
+html, body {
+ box-shadow: 0 0 200px rgba(0, 0, 0, 0.27) inset;
+ background-image: -webkit-linear-gradient(45deg, rgb(102, 95, 238) 0%, rgb(39, 194, 222) 100%);
+ min-height: 100%;
+ font-family: 'Catamaran';
+ color: #fff !important
+}
+.actions li {
+ list-style: none
+}
+
+input::-moz-focus-inner {
+ border: 0;
+ padding: 0
+}
+
+/* Basic */
+ html {
+ box-sizing: border-box
+ }
+
+ *, *:before, *:after {
+ box-sizing: inherit
+ }
+
+/* Type */
+ body, select, textarea {
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 16.5pt;
+ font-weight: normal;
+ line-height: 1.75
+ }
+ @media screen and (max-width: 1680px) {
+ body, input, select, textarea {
+ font-size: 13pt
+ }
+ }
+ @media screen and (max-width: 1280px) {
+ body, input, select, textarea {
+ font-size: 12pt
+ }
+ }
+ @media screen and (max-width: 360px) {
+ body, input, select, textarea {
+ font-size: 11pt
+ }
+ }
+
+ a {
+ -moz-transition: color 0.2s ease, border-bottom-color 0.2s ease;
+ -webkit-transition: color 0.2s ease, border-bottom-color 0.2s ease;
+ -ms-transition: color 0.2s ease, border-bottom-color 0.2s ease;
+ transition: color 0.2s ease, border-bottom-color 0.2s ease;
+ border-bottom: dotted 1px rgba(255, 255, 255, 0.35);
+ color: inherit;
+ text-decoration: none
+ }
+ a:hover {
+ border-bottom: solid 1px rgba(255, 255, 255, 0.88);
+ color: #ffffff
+ }
+
+ strong, b {
+ color: #ffffff;
+ font-weight: bold
+ }
+
+ em, i {
+ font-style: italic
+ }
+
+ p {
+ margin: 0 0 2em 0
+ }
+
+ h1, h2, h3, h4, h5, h6 {
+ color: #ffffff;
+ font-weight: bold;
+ line-height: 1.5
+ }
+ h1 a, h2 a, h3 a, h4 a, h5 a, h6 a {
+ color: inherit;
+ text-decoration: none
+ }
+
+ h1 {
+ font-size: 2.75em
+ }
+
+ h2 {
+ font-size: 1.75em
+ }
+
+ h3 {
+ font-size: 1.1em
+ }
+
+ h4 {
+ font-size: 1em
+ }
+
+ h5 {
+ font-size: 0.8em
+ }
+
+ h6 {
+ font-size: 0.6em
+ }
+
+ @media screen and (max-width: 736px) {
+ h1 {
+ font-size: 3em
+ }
+
+ h2 {
+ font-size: 1.75em
+ }
+
+ h3 {
+ font-size: 1em
+ }
+
+ h4 {
+ font-size: 0.8em
+ }
+
+ h5 {
+ font-size: 0.6em
+ }
+
+ h6 {
+ font-size: 0.6em
+ }
+ }
+
+ code {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 0.25em;
+ border: solid 1px rgba(255, 255, 255, 0.15);
+ font-family: "Courier New", monospace;
+ font-size: 0.9em;
+ margin: 0 0.25em;
+ padding: 0.25em 0.65em
+ }
+
+ pre {
+ -webkit-overflow-scrolling: touch;
+ font-family: "Courier New", monospace;
+ font-size: 0.9em;
+ margin: 0 0 2em 0
+ }
+ pre code {
+ display: block;
+ line-height: 1.75em;
+ padding: 1em 1.5em;
+ overflow-x: auto
+ }
+
+
+ .text-center {
+ text-align: center
+ }
+
+/* Button */
+ input[type="button"],
+ button,
+ .button {
+ -moz-appearance: none;
+ -webkit-appearance: none;
+ -ms-appearance: none;
+ appearance: none;
+ -moz-transition: border-color 0.2s ease;
+ -webkit-transition: border-color 0.2s ease;
+ -ms-transition: border-color 0.2s ease;
+ transition: border-color 0.2s ease;
+ background-color: #fff;
+ border: solid 1px !important;
+ border-color: rgba(255, 255, 255, 0.15) !important;
+ border-radius: 3em;
+ color: #393939 !important;
+ cursor: pointer;
+ display: inline-block;
+ font-size: 0.7em;
+ font-weight: bold;
+ letter-spacing: 0.25em;
+ line-height: 4.75em;
+ outline: 0;
+ padding: 0 3.75em;
+ position: relative;
+ text-align: center;
+ text-decoration: none;
+ text-transform: uppercase;
+ white-space: nowrap
+ }
+ input[type="button"]:after,
+ button:after,
+ .button:after {
+ -moz-transform: scale(0.25);
+ -webkit-transform: scale(0.25);
+ -ms-transform: scale(0.25);
+ transform: scale(0.25);
+ pointer-events: none;
+ -moz-transition: opacity 0.2s ease, -moz-transform 0.2s ease;
+ -webkit-transition: opacity 0.2s ease, -webkit-transform 0.2s ease;
+ -ms-transition: opacity 0.2s ease, -ms-transform 0.2s ease;
+ transition: opacity 0.2s ease, transform 0.2s ease;
+ background: #ffffff;
+ border-radius: 3em;
+ content: '';
+ height: 100%;
+ left: 0;
+ opacity: 0;
+ position: absolute;
+ top: 0;
+ width: 100%
+ }
+ input[type="button"]:hover,
+ button:hover,
+ .button:hover {
+ border-color: rgba(255, 255, 255, 0.6) !important
+ }
+ input[type="button"]:hover:after,
+ button:hover:after,
+ .button:hover:after {
+ opacity: 0.05;
+ -moz-transform: scale(1);
+ -webkit-transform: scale(1);
+ -ms-transform: scale(1);
+ transform: scale(1)
+ }
+ input[type="button"]:hover:active,
+ button:hover:active,
+ .button:hover:active {
+ border-color: #ffffff !important
+ }
+ input[type="button"]:hover:active:after,
+ button:hover:active:after,
+ .button:hover:active:after {
+ opacity: 0.1
+ }
+
+ input[type="password"] {
+ border: 0;
+ outline: 0;
+ padding: 15px;
+ border-radius: 10px;
+ width: 300px
+ }
+
+/* Wrapper */
+ .wrapper {
+ position: relative
+ }
+ .wrapper > .inner {
+ width: 100%;
+ padding: 5em 4em 2em 4em
+ }
+ @media screen and (max-width: 1680px) {
+ footer > .inner {
+ padding: 2em 4em 2em 4em !important
+ }
+ }
+ @media screen and (max-width: 736px) {
+
+ .wrapper > .inner {
+ padding: 2em 2em 2em 2em
+ }
+ footer > .inner {
+ padding: 2em 2em 2em 2em !important
+ }
+ }
+ .wrapper.style2 {
+ background-color: #5052b5
+ }
+
+ .wrapper.fullscreen {
+ min-height: calc(87vh - 2.5em)
+ }
+ @media screen and (max-width: 736px) {
+
+ .wrapper.fullscreen {
+ min-height: calc(40vh - 5.5em)
+ }
+ }
+
+/* Wrapper */
+ #topMenu + #wrapper {
+ margin-left: 0;
+ position: relative
+ }
+ @media screen and (max-width: 736px) {
+ #topMenu + #wrapper {
+ padding-top: 0;
+ top: 2em
+ }
+
+ }
+
+ #header + #wrapper > .wrapper > .inner {
+ margin: 0 auto
+ }
+
+/* Menu */
+ #topMenu {
+ padding: 0;
+ background:0;
+ cursor: default;
+ height: 5.4em;
+ left: 0;
+ text-align: center;
+ top: 0;
+ width: 100%;
+ line-height: 3.5em;
+ position: relative;
+ z-index: 20
+ }
+
+ #topMenu > .inner {
+ display: -moz-flex;
+ display: -webkit-flex;
+ display: -ms-flex;
+ display: flex;
+ -moz-flex-direction: row;
+ -webkit-flex-direction: row;
+ -ms-flex-direction: row;
+ flex-direction: row;
+ -moz-justify-content: center;
+ -webkit-justify-content: center;
+ -ms-justify-content: center;
+ justify-content: center;
+ -moz-transform: translateY(0);
+ -webkit-transform: translateY(0);
+ -ms-transform: translateY(0);
+ transform: translateY(0);
+ -moz-transition: opacity 1s ease;
+ -webkit-transition: opacity 1s ease;
+ -ms-transition: opacity 1s ease;
+ transition: opacity 1s ease;
+ min-height: 100%;
+ opacity: 1;
+ width: 100%
+ }
+ #topMenu nav {
+ height: inherit;
+ line-height: inherit;
+ margin-top: 1em
+ }
+ #topMenu nav ul {
+ display: -moz-flex;
+ display: -webkit-flex;
+ display: -ms-flex;
+ display: flex;
+ height: inherit;
+ line-height: inherit;
+ list-style: none;
+ padding: 0
+ }
+ #topMenu nav a {
+ height: inherit;
+ line-height: inherit;
+ padding: 0
+ }
+ #topMenu nav > ul > li {
+ margin: 0 1em 0 1em;
+ opacity: 1;
+ padding: 0;
+ position: relative;
+ height: inherit;
+ line-height: inherit
+ }
+
+ #topMenu nav a {
+ border: 0;
+ font-size: 0.70em;
+ font-weight: bold;
+ letter-spacing: 0.25em;
+ line-height: 1.75;
+ outline: 0;
+ padding: 2em 0;
+ position: relative;
+ text-decoration: none;
+ text-transform: uppercase
+ }
+ #topMenu nav li.active, nav li.active a {
+ color: #fff !important
+ }
+ #topMenu nav .active a{
+ border-bottom: 1px solid #ffffff7d
+ }
+ #topMenu nav a:hover {
+ border-bottom: 1px solid #ffffff59
+
+ }
+ #topMenu nav a.active {
+ color: #ffffff
+ }
+ #topMenu nav a.active:after {
+ max-width: 100%
+ }
+
+ @media screen and (max-width: 736px) {
+ #topMenu {
+ height: auto;
+ font-size: 0.94em;
+ position: relative;
+ background-color: rgba(0, 0, 0, 0.30);
+ padding-bottom: 20px
+ }
+ #topMenu nav ul {
+ display: block;
+ float: left
+ }
+ #topMenu nav > ul > li {
+ display: block;
+ float: left;
+ margin: 0 1em 0 2em
+ }
+ #topMenu nav .active a {
+ border-bottom: 1px solid #fff
+ }
+ footer {
+ font-size: 1em
+ }
+ }
+
+/* Intro */
+ #intro p {
+ font-size: 1.25em
+ }
+ @media screen and (max-width: 736px) {
+ #intro p {
+ font-size: 1em
+ }
+ }
+
+/* Footer */
+ footer {
+ text-align: right
+ }
+
+/* Submenus */
+ .subPageDropdown a {
+ border: 0 !important
+ }
+
+ .subPageDropdown ul {
+ margin: 0;
+ padding-left: 0
+ }
+
+ .subPageDropdown li {
+ color: #fff;
+ display: block;
+ float: left;
+ position: relative;
+ padding: 0 1em 0 1em;
+ text-decoration: none;
+ transition-duration: 0.5s
+ }
+
+ #topMenu li a {
+ color: rgba(255, 255, 255, 0.8)
+ }
+
+ #topMenu li:hover,
+ #topMenu li:focus-within {
+ cursor: pointer
+ }
+
+ #topMenu li:focus-within a {
+ outline: none
+ }
+
+ #topMenu .nav-item {
+ margin-top: 5px
+ }
+
+ ul.subPageDropdown {
+ visibility: hidden;
+ opacity: 0;
+ position: absolute;
+ margin-top: 10px;
+ display: none;
+ padding-left: 10px !important
+ }
+
+ #topMenu ul li:hover > ul,
+ #topMenu ul li:focus-within > ul,
+ #topMenu ul li ul:hover,
+ #topMenu ul li ul:focus {
+ visibility: visible;
+ opacity: 1;
+ display: block
+ }
+
+ #topMenu ul li ul li {
+ clear: both;
+ text-align: left;
+ background-color: rgba(0, 0, 0, 0.30);
+ white-space: nowrap
+ }
+
+ /* Submenus dropdown arrow */
+ .menu li > a:after {
+ content: ' ▼';
+ font-weight: bold
+ }
+
+ .menu > li > a:after {
+ content: ' ▼';
+ font-weight: bold
+ }
+
+ .menu li > a:only-child:after {
+ content: ''
+ }
diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/theme.php b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/theme.php
new file mode 100644
index 0000000..b670182
--- /dev/null
+++ b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/theme.php
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = $Wcms->get('config', 'siteTitle') ?> - = $Wcms->page('title') ?>
+
+
+
+
+ = $Wcms->css() ?>
+
+
+
+
+
+
+
+ = $Wcms->settings() ?>
+
+ = $Wcms->alerts() ?>
+
+
+
+
+
+
+
+ = $Wcms->page('content') ?>
+
+
+
+
+
+
+
+ = $Wcms->block('subside') ?>
+
+
+
+
+
+
+
+
+ = $Wcms->js() ?>
+
+
+
diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/wcms-modules.json b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/wcms-modules.json
new file mode 100644
index 0000000..27864a2
--- /dev/null
+++ b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/wcms-modules.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "themes": {
+ "sky": {
+ "name": "Sky",
+ "repo": "https://github.com/robiso/sky/tree/master",
+ "zip": "https://github.com/robiso/sky/archive/master.zip",
+ "summary": "Default WonderCMS theme (2022). Theme works without Bootstrap and jQuery.",
+ "version": "3.2.4",
+ "image": "https://raw.githubusercontent.com/robiso/sky/master/preview.jpg"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.recipes/wondercms_php8/tools/backup.d/storage_backup.sh b/.recipes/wondercms_php8/tools/backup.d/storage_backup.sh
new file mode 100755
index 0000000..45e652f
--- /dev/null
+++ b/.recipes/wondercms_php8/tools/backup.d/storage_backup.sh
@@ -0,0 +1,125 @@
+#!/bin/bash
+#
+# A service script to backup the entire WonderCMS website.
+# Creates a tarball in $BASE_DIR/storage/backups/tarballs folder
+# (by default). An optional parameter may change the target folder.
+#
+# Call as a Docker manager user (member of the docker Linux group) via cron.
+#
+# Author: Kovács Zoltán
+# License: GNU/GPL 3+ https://www.gnu.org/licenses/gpl-3.0.en.html
+# 2025-03-05 v0.2
+# mod: gitbackup handling stub has been temporarily removed.
+# 2025-01-14 v0.1 Initial version.
+
+# Accepted environment variables and their defaults.
+#
+PAR_BASEDIR=${PAR_BASEDIR:-""} # Service's base folder
+PAR_BACKUPDIR=${PAR_BACKUPDIR:-""} # Folder to dump within
+
+# Messages (maybe overridden by configuration).
+#
+MSG_DOCKERGRPNEED="You must be a member of the docker group."
+MSG_DOESNOTRUN="This service doesn't run."
+MSG_MISSINGDEP="Fatal: missing dependency"
+MSG_MISSINGYML="Fatal: didn't find the docker-compose.yml file"
+MSG_NONWRITE="The target directory isn't writable"
+MSG_NOLOCATE="Cannot locate the WonderCMS container."
+
+# Other initialisations.
+#
+BACKUPDIR="storage/backups/tarballs" # Folder to dump within
+GITBACKUP="storage_gitbackup.sh" # Git backup utility
+SERVICENAME="wondercms" # The composed WonderCMS service
+USER=${USER:-LOGNAME} # Fix for cron enviroment only
+YMLFILE="docker-compose.yml"
+
+# Checks the dependencies.
+#
+TR=$(which tr 2>/dev/null)
+if [ -z "$TR" ]; then echo "$MSG_MISSINGDEP tr."; exit 1 ; fi
+for item in basename cat cut date dirname docker \
+ find grep hostname id pwd tail xargs
+do
+ if [ -n "$(which $item)" ]
+ then export $(echo $item | "$TR" '[:lower:]' '[:upper:]' | "$TR" '-' '_')=$(which $item)
+ else echo "$MSG_MISSINGDEP $item." >&2; exit 1; fi
+done
+# All dependencies are available via "$THECOMMAND" (upper case) call.
+#
+# Let's find which version of docker-compose is installed.
+if [ $($DOCKER compose version 2>&1 >/dev/null; echo $?) -eq 0 ]; then
+ # We'll use v2 if it is available.
+ DOCKER_COMPOSE="$DOCKER"
+ commandstring="compose"
+else
+ # Otherwise falling back to v1.
+ DOCKER_COMPOSE="$(which docker-compose)"
+ commandstring=""
+fi
+# One of the two is mandatory.
+if [ -z "$DOCKER_COMPOSE" ];then echo "$MSG_MISSINGDEP docker-compose" >&2; exit 1; fi
+# Below docker-compose should be called as "$DOCKER_COMPOSE" $commandstring sequence.
+
+# Where I'm?
+# https://gist.github.com/TheMengzor/968e5ea87e99d9c41782
+SOURCE="$0"
+while [ -h "$SOURCE" ]; do
+ # resolve $SOURCE until the file is no longer a symlink
+ SCRPATH="$( cd -P "$("$DIRNAME" "$SOURCE" )" && echo "$PWD" )" #"
+ SOURCE="$("$READLINK" "$SOURCE")"
+ # if $SOURCE was a relative symlink, we need to resolve it
+ # relative to the path where the symlink file was located
+ [[ $SOURCE != /* ]] && SOURCE="$SCRPATH/$SOURCE"
+done; SCRPATH="$( cd -P "$("$DIRNAME" "$SOURCE" )" && echo "$PWD" )" #"
+
+# Need to be root or a Docker manager user.
+#
+[[ "$USER" != 'root' ]] \
+&& [[ -z "$(echo "$("$ID" -Gn "$USER") " | "$GREP" ' docker ')" ]] \
+&& echo "$MSG_DOCKERGRPNEED" >&2 && exit 1 #"
+
+# Searches the base folder, containing a docker-compose.yml file.
+#
+# Called from the base folder (./)?
+BASE_DIR="$PAR_BASEDIR"
+TEST_DIR="$SCRPATH"
+[[ -z "$BASE_DIR" ]] && [[ -r "$TEST_DIR/$YMLFILE" ]] && BASE_DIR="$TEST_DIR"
+# Called from ./tools?
+TEST_DIR="$("$DIRNAME" "$TEST_DIR")"
+[[ -z "$BASE_DIR" ]] && [[ -r "$TEST_DIR/$YMLFILE" ]] && BASE_DIR="$TEST_DIR"
+# Called from ./tools/*.d?
+TEST_DIR="$("$DIRNAME" "$TEST_DIR")"
+[[ -z "$BASE_DIR" ]] && [[ -r "$TEST_DIR/$YMLFILE" ]] && BASE_DIR="$TEST_DIR"
+# On failure gives it up here.
+if [ -z "$BASE_DIR" -o ! -r "$BASE_DIR/$YMLFILE" ]; then
+ echo "$MSG_MISSINGYML" >&2; exit 1
+fi
+# Sets the absolute paths.
+BACKUPDIR="${PAR_BACKUPDIR:-$BASE_DIR/$BACKUPDIR}"
+
+# The dump target folder must be writable.
+#
+[[ ! -w "$BACKUPDIR" ]] \
+&& echo "$MSG_NONWRITE: $BACKUPDIR" >&2 && exit 1
+
+# The service must be running - silently gives up here if not.
+#
+[[ -z "$(cd "$BASE_DIR"; "$DOCKER_COMPOSE" $commandstring ps --services --filter "status=running")" ]] \
+&& exit 1
+
+# Converts the service name to an actual running container's name.
+#
+MYCONTAINER="$("$DOCKER" inspect -f '{{.Name}}' $(cd "$BASE_DIR"; "$DOCKER_COMPOSE" $commandstring ps -q "$SERVICENAME") | "$CUT" -c2-)"
+# Gives up here if failed.
+if [ -z "$MYCONTAINER" ]; then echo "$MSG_NOLOCATE" >&2; exit 1; fi
+
+# Tries the FS backup.
+if [ -w "$BACKUPDIR" ]; then
+ BACKUP_NAME=$MYCONTAINER.$("$DATE" '+%Y%m%d_%H%M%S').$("$HOSTNAME")
+ "$DOCKER" exec $MYCONTAINER sh \
+ -c "cd /var/www/html; tar cz ." \
+ > "$BACKUPDIR/$BACKUP_NAME.tgz" 2>>"$BACKUPDIR/$BACKUP_NAME.log"
+fi
+
+# That's all, Folks! :)