From 48faad59c7f1675b5d8ae6dfe2117b40c8e82a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20Zolt=C3=A1n?= Date: Wed, 5 Mar 2025 21:01:39 +0100 Subject: [PATCH] Added a new recipe: wondercms_php8. --- .metadata | Bin 12599 -> 14515 bytes .recipes/wondercms_php8/README.md | 0 .recipes/wondercms_php8/docker-compose.yml | 46 + .../storage/volumes/wonder_html/.htaccess | 8 + .../storage/volumes/wonder_html/index.php | 3068 +++++++++++++++++ .../catamaran-v7-latin-ext_latin-700.woff2 | Bin 0 -> 10416 bytes ...catamaran-v7-latin-ext_latin-regular.woff2 | Bin 0 -> 10332 bytes .../wonder_html/themes/sky/css/style.css | 557 +++ .../volumes/wonder_html/themes/sky/theme.php | 84 + .../wonder_html/themes/sky/wcms-modules.json | 13 + .../tools/backup.d/storage_backup.sh | 125 + 11 files changed, 3901 insertions(+) create mode 100644 .recipes/wondercms_php8/README.md create mode 100644 .recipes/wondercms_php8/docker-compose.yml create mode 100644 .recipes/wondercms_php8/storage/volumes/wonder_html/.htaccess create mode 100644 .recipes/wondercms_php8/storage/volumes/wonder_html/index.php create mode 100644 .recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-700.woff2 create mode 100644 .recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-regular.woff2 create mode 100644 .recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/style.css create mode 100644 .recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/theme.php create mode 100644 .recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/wcms-modules.json create mode 100755 .recipes/wondercms_php8/tools/backup.d/storage_backup.sh diff --git a/.metadata b/.metadata index f155ec15d38d96bacc265451fd6936148d57c7ec..ff38257313ab7022405ede87773030b888115002 100644 GIT binary patch delta 1540 zcmdmaK}LbaWEnvPp2XEB(iwnYmQbDQWC2$7jU^iyRgqOO zWLM?qWI{<4)r=gXlNTsUO+GKlH`!jAZE}Z<#pGySfz30S;+c6|S3%7EnCT+|G*@); z2Trj``uvma*igK2 zCQmlh7ulT5_K|Ti9|s4zhXQ2vC;w3v+?>LZ&#a1OS4n<;PBDsc9FcA5lNFg|L7}wy zCHE)B$$R9ud61)FvZ0iW(3v%mKz;Gx93v>wemHKv$}7n}c|Q|J0Gb^{1{OHn^nj@W z#W{`8KtlJ_0TEG_stB-CMJJyZ=9(PG!M6FI$Ok4jvTR4O6C5#y`m&qfi8nBzTO6-1 zG1*2>j0ZUYCVvzVot!6?T2GeU`pLz``f2%jCB^#5i6x1-iA9Ndx@G3NIf*5idAg|; zCGilJxq*RRd45`&5sG8)YBnZ;A^-#?|J2pq{8DNQCOl&M`TFRci7CZq7-HER)x3h;BAxv}4@7f$1_c&y^)7 z(iwo@7<)12I)br_o#7Al40Ginit(); + $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', ' + + +
+
+

Login to your website

+

+ + + +
+
')[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 + +
'; + 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 = ''; + + 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 = ''; + + 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 ? '' . $name . '' : $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 0000000000000000000000000000000000000000..e4b82d836a8e0ac511ee3d5c523fd9131f08ce42 GIT binary patch literal 10416 zcmV;hC{NdSPew8T0RR9104T5k5C8xG09zaY04P!b0RR9100000000000000000000 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 uPDmPdBYBm2FP#cTy?hS{!3pVL12 .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') ?> + + + + + css() ?> + + + + + + + + settings() ?> + + alerts() ?> + +
+
+ +
+
+ +
+
+
+ + page('content') ?> + +
+
+ +
+
+ + block('subside') ?> + +
+
+
+ +
+
+ + footer() ?> + +
+
+ + + 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! :)