From 299877b39363b4cdcd01a9e8ad92b85e7fc81e44 Mon Sep 17 00:00:00 2001 From: Robert Isoski Date: Sat, 1 Jan 2022 13:17:56 +0100 Subject: [PATCH] 320 (#242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TLDR: Submenus - currently works best only in new Sky theme - you can customize your theme to display submenus Option to hide alerts until logging out Force HTTPS option in Settings Option to have a template for each page Save confirmation popup option in Settings (optional forcing to confirm every changes made by admin) New deault theme Sky Fix to check if imported repository is main or master branch Fix for deleting default page Fix for updating homepage if that one is a subpage Fix for resetting array key of menu item Other minor improvements and bugfixes * Update index.php Beta testing * Update index.php * Add files via upload * Origin/320 (#236) * Add subpage support. Still missing add button inside settings, dropdown in themes, dropdown in default page selection and UI fixes. * - Add new add subpage button in settings - Added subpage backward compatibility fixes - Removed grid connection between admin css and bootstrap * - Added option to select subpage as default page * - Added option to have different templates per each page * Fix for creating new submenu error * Fix for master/main default branch from custom repositories for plugins/themes * Fix for Error "Creating default object from empty value" * - Fix for deleting default page - Fix for updating homepage if that one is a subpage - Fix for checking if repo contains master or main branch - Fix for resetting array key of menu item * - Fix delete all pages - Show/hide page fix * - Force HTTPS functionality * - Force HTTPS functionality * - Force HTTPS functionality * - Force HTTPS functionality * Update theme.php * Update style.css * Update index.php * - Fix for rename of the pages and subpages inside menu - Fix for forcing the HTTPS - Added security settings cache file - Fix for hiding the alerts - Added setting for the "save changes popup" * - Simple blog small fix * Update theme.php * Update style.css * Update style.css * Update theme.php * Update version * Update license Co-authored-by: Slaven Stančič --- index.php | 1198 +++++++++++++---- license | 2 +- themes/essence/css/style.css | 92 -- themes/essence/summary | 2 - themes/essence/theme.php | 92 -- themes/essence/version | 1 - .../catamaran-v7-latin-ext_latin-700.woff2 | Bin 0 -> 10416 bytes ...catamaran-v7-latin-ext_latin-regular.woff2 | Bin 0 -> 10332 bytes themes/sky/css/style.css | 553 ++++++++ themes/sky/summary | 3 + themes/sky/theme.php | 84 ++ themes/sky/version | 1 + version | 2 +- 13 files changed, 1550 insertions(+), 480 deletions(-) delete mode 100644 themes/essence/css/style.css delete mode 100644 themes/essence/summary delete mode 100644 themes/essence/theme.php delete mode 100644 themes/essence/version create mode 100644 themes/sky/css/fonts/catamaran-v7-latin-ext_latin-700.woff2 create mode 100644 themes/sky/css/fonts/catamaran-v7-latin-ext_latin-regular.woff2 create mode 100644 themes/sky/css/style.css create mode 100644 themes/sky/summary create mode 100644 themes/sky/theme.php create mode 100644 themes/sky/version diff --git a/index.php b/index.php index 471d5f19..89578b21 100644 --- a/index.php +++ b/index.php @@ -7,7 +7,7 @@ */ session_start(); -define('VERSION', '3.1.4'); +define('VERSION', '3.2.0'); mb_internal_encoding('UTF-8'); if (defined('PHPUNIT_TESTING') === false) { @@ -27,6 +27,13 @@ class Wcms '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; @@ -39,6 +46,9 @@ class Wcms /** @var string $currentPage - current page */ public $currentPage = ''; + /** @var array $currentPageTree - Tree hierarchy of the current page */ + public $currentPageTree = []; + /** @var bool $currentPageExists - check if current page exists */ public $currentPageExists = false; @@ -57,6 +67,9 @@ class Wcms /** @var string $themesPluginsCachePath path to cached json file with Themes/Plugins data */ protected $themesPluginsCachePath; + /** @var string $securityCachePath path to security json file with force https caching data */ + protected $securityCachePath; + /** @var string $dbPath path to database.js */ protected $dbPath; @@ -72,7 +85,7 @@ class Wcms /** @var string $headerResponse header status */ public $headerResponse = 'HTTP/1.0 200 OK'; - /** + /** * Constructor * * @param string $dataFolder @@ -108,6 +121,7 @@ public function setPaths( $this->dbPath = sprintf('%s/%s', $this->dataPath, $dbName); $this->filesPath = sprintf('%s/%s', $this->dataPath, $filesFolder); $this->themesPluginsCachePath = sprintf('%s/%s', $this->dataPath, 'cache.json'); + $this->securityCachePath = sprintf('%s/%s', $this->dataPath, 'security.json'); } /** @@ -118,8 +132,9 @@ public function setPaths( */ public function init(): void { - $this->pageStatus(); + $this->forceSSL(); $this->loginStatus(); + $this->pageStatus(); $this->logoutAction(); $this->loginAction(); $this->notFoundResponse(); @@ -132,7 +147,8 @@ public function init(): void $this->deleteFileThemePluginAction(); $this->changePageThemeAction(); $this->backupAction(); - $this->betterSecurityAction(); + $this->forceHttpsAction(); + $this->saveChangesPopupAction(); $this->deletePageAction(); $this->saveAction(); $this->updateAction(); @@ -207,16 +223,25 @@ public function alerts(): string return ''; } $output = ''; - $output .= '
'; + $output .= '
'; + $output .= ''; foreach ($_SESSION['alert'] as $alertClass) { foreach ($alertClass as $alert) { $output .= '
' - . (!$alert['sticky'] ? '' : '') + . (!$alert['sticky'] ? '' : '') . $alert['message'] - . '
'; + . $this->hideAlerts(); } } $output .= '
'; @@ -224,6 +249,20 @@ public function alerts(): string 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) * @@ -257,28 +296,46 @@ public function backupAction(): void } /** - * Replace the .htaccess with one adding security settings + * Save if WCMS should force https * @return void + * @throws Exception */ - public function betterSecurityAction(): void + public function forceHttpsAction(): void { - if (isset($_POST['betterSecurity']) && $this->verifyFormActions()) { - if ($_POST['betterSecurity'] === 'on') { - if ($contents = $this->getFileFromRepo('htaccess-ultimate', self::WCMS_CDN_REPO)) { - file_put_contents('.htaccess', trim($contents)); - } - $this->alert('success', 'Improved security turned ON.'); - $this->redirect(); - } elseif ($_POST['betterSecurity'] === 'off') { - if ($contents = $this->getFileFromRepo('htaccess', self::WCMS_CDN_REPO)) { - file_put_contents('.htaccess', trim($contents)); - } - $this->alert('success', 'Improved security turned OFF.'); - $this->redirect(); - } + 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(); } } + /** + * 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 * @@ -304,7 +361,7 @@ public function block(string $key): string */ public function changePasswordAction(): void { - if (isset($_POST['old_password'], $_POST['new_password']) + if (isset($_POST['old_password'], $_POST['new_password'], $_POST['repeat_password']) && $_SESSION['token'] === $_POST['token'] && $this->loggedIn && $this->hashVerify($_POST['token'])) { @@ -321,14 +378,21 @@ public function changePasswordAction(): void $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 we can run WonderCMS properly + * Check if folders are writable * Executed once before creating the database file * * @param string $folder the relative path of the folder to check/create @@ -346,7 +410,7 @@ public function checkFolder(string $folder): void } /** - * Initialize the JSON database if doesn't exist + * Initialize the JSON database if it doesn't exist * @return void */ public function createDb(): void @@ -355,12 +419,14 @@ public function createDb(): void $this->checkMinimumRequirements(); $password = $this->generatePassword(); $this->db = (object)[ - 'config' => [ + self::DB_CONFIG => [ 'siteTitle' => 'Website title', - 'theme' => 'essence', + 'theme' => 'sky', 'defaultPage' => 'home', 'login' => 'loginURL', 'forceLogout' => false, + 'forceHttps' => false, + 'saveChangesPopup' => false, 'password' => password_hash($password, PASSWORD_DEFAULT), 'lastLogins' => [], 'defaultRepos' => [ @@ -376,12 +442,14 @@ public function createDb(): void '0' => [ 'name' => 'Home', 'slug' => 'home', - 'visibility' => 'show' + 'visibility' => 'show', + self::DB_MENU_ITEMS_SUBPAGE => new stdClass() ], '1' => [ - 'name' => 'Example', - 'slug' => 'example', - 'visibility' => 'show' + 'name' => 'How to', + 'slug' => 'how-to', + 'visibility' => 'show', + self::DB_MENU_ITEMS_SUBPAGE => new stdClass() ] ] ], @@ -390,34 +458,39 @@ public function createDb(): void 'title' => '404', 'keywords' => '404', 'description' => '404', - 'content' => '

Sorry, page not found. :(

' + 'content' => '

404 - Page not found

', + self::DB_PAGES_SUBPAGE_KEY => new stdClass() ], 'home' => [ 'title' => 'Home', - 'keywords' => 'Keywords, are, good, for, search, engines', - 'description' => 'A short description is also good.', - 'content' => '

It\'s alive!

+ '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. Your password is: ' . $password . '

+

Click here to login

-

To install an awesome editor, open Settings -> Plugins -> Install Summernote.

' +

To install an awesome editor, open Settings/Plugins and click Install Summernote.

', + self::DB_PAGES_SUBPAGE_KEY => new stdClass() ], - 'example' => [ - 'title' => 'Example', - 'keywords' => 'Keywords, are, good, for, search, engines', - 'description' => 'A short description is also good.', - 'content' => '

Easy editing

-

Click anywhere to edit, click outside the area to save. Changes are live and shown immediately.

+ '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 Menu above.

+

Create new page

+

Pages can be created in the Settings.

-

Install themes and plugins

+

Start a blog or change your theme

To install, update or remove themes/plugins, visit the Settings.

-

Please support WonderCMS

-

WonderCMS has been free for over 10 years.

-

Click here to support us by getting a t-shirt or here to donate.

' +

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' => [ @@ -439,76 +512,301 @@ public function createDb(): void /** * Create menu item * - * @param string $content + * @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 createMenuItem(string $content, string $menu, string $visibility = 'hide'): void + public function updateMenuItem(string $name, string $menu, string $visibility = 'hide'): void { - $conf = 'config'; - $field = 'menuItems'; - $exist = is_numeric($menu); - $content = empty($content) ? 'empty' : str_replace([PHP_EOL, '
'], '', $content); - $slug = $this->slugify($content); - $menuCount = count(get_object_vars($this->get($conf, $field))); + if (!in_array($visibility, ['show', 'hide'], true)) { + return; + } + $name = empty($name) ? 'empty' : str_replace([PHP_EOL, '
'], '', $name); + $slug = $this->createUniqueSlug($name, $menu); - $db = $this->getDb(); - foreach ($db->config->{$field} as $value) { + $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; } } - if (!$exist) { - $this->set($conf, $field, $menuCount, new StdClass); - $this->set($conf, $field, $menuCount, 'name', str_replace('-', ' ', $content)); - $this->set($conf, $field, $menuCount, 'slug', $slug); - $this->set($conf, $field, $menuCount, 'visibility', $visibility); - if ($menu) { - $this->createPage($slug); - $_SESSION['redirect_to_name'] = $content; - $_SESSION['redirect_to'] = $slug; + + 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; } - } else { - $oldSlug = $this->get($conf, $field, $menu, 'slug'); - $this->set($conf, $field, $menu, 'name', $content); - $this->set($conf, $field, $menu, 'slug', $slug); - $this->set($conf, $field, $menu, 'visibility', $visibility); - - $oldPageContent = $this->get('pages', $oldSlug); - $this->unset('pages', $oldSlug); - $this->set('pages', $slug, $oldPageContent); - $this->set('pages', $slug, 'title', $content); - if ($this->get('config', 'defaultPage') === $oldSlug) { - $this->set('config', 'defaultPage', $slug); + + $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); + } } /** - * Create new page + * 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 string $slug the name of the page in URL + * @param array $slugTree + * @param string $fieldname + * @param string $content * @return void * @throws Exception */ - public function createPage($slug = ''): void + public function updatePage(array $slugTree, string $fieldname, string $content): void { - $this->db->pages->{$slug ?: $this->currentPage} = new stdClass; - $this->save(); - $pageName = $slug ?: $this->slugify($this->currentPage); - $this->set('pages', $pageName, 'title', (!$slug) - ? mb_convert_case(str_replace('-', ' ', $this->currentPage), MB_CASE_TITLE) - : mb_convert_case(str_replace('-', ' ', $slug), MB_CASE_TITLE)); - $this->set('pages', $pageName, 'keywords', - 'Keywords, are, good, for, search, engines'); - $this->set('pages', $pageName, 'description', - 'A short description is also good.'); - if (!$slug) { - $this->createMenuItem($this->slugify($this->currentPage), ''); + $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 existing page by slug + * + * @param array|null $slugTree + */ + 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}); + } + + /** + * Update existing page slug + * + * @param array $slugTree + * @param string $newSlugName + */ + 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(); } /** @@ -519,7 +817,7 @@ public function css(): string { if ($this->loggedIn) { $styles = <<<'EOT' - + EOT; return $this->hook('css', $styles)[0]; } @@ -616,27 +914,44 @@ public function changePageThemeAction(): void /** * Delete page * @return void + * @throws Exception */ public function deletePageAction(): void { if (!isset($_GET['delete']) || !$this->verifyFormActions(true)) { return; } - $slug = $_GET['delete']; - if (isset($this->get('pages')->{$slug})) { - $this->unset('pages', $slug); + $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) { + $this->alert('danger', 'Last page cannot be deleted - at least one page must exist.'); + $this->redirect(); } - $menuItems = json_decode(json_encode($this->get('config', 'menuItems')), true); - if (false !== ($index = array_search($slug, array_column($menuItems, 'slug'), true))) { - unset($menuItems[$index]); - $newMenu = array_values($menuItems); - $this->set('config', 'menuItems', json_decode(json_encode($newMenu), false)); - if ($this->get('config', 'defaultPage') === $slug) { - $allMenuItems = $this->get('config', 'menuItems') ?? []; - $firstMenuItem = reset($allMenuItems); - $this->set('config', 'defaultPage', $firstMenuItem->slug ?? $slug); + + $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(); } @@ -880,6 +1195,17 @@ public function manuallyRefreshCacheData(): void $this->redirect(); } + /** + * Forces http to https + */ + private function forceSSL(): void + { + if ($this->isHttpsForced() && !Wcms::isCurrentlyOnSSL()) { + $this->updateSecurityCache(); + $this->redirect(); + } + } + /** * Method checks for new repos and caches them */ @@ -902,6 +1228,7 @@ private function updateAndCacheThemePluginRepos(): void /** * Cache themes and plugins data + * @throws Exception */ private function cacheThemesPluginsData(): void { @@ -934,6 +1261,7 @@ private function cacheThemesPluginsData(): void * Cache single theme or plugin data * @param string $repo * @param string $type + * @throws Exception */ private function cacheSingleCacheThemePluginData(string $repo, string $type): void { @@ -952,7 +1280,7 @@ private function cacheSingleCacheThemePluginData(string $repo, string $type): vo * Gathers single theme/plugin data from repository * @param string $repo * @param string $type - * @param array $savedData + * @param array|null $savedData * @return array|null */ private function downloadThemePluginsData(string $repo, string $type, ?array $savedData = []): ?array @@ -990,16 +1318,22 @@ private function downloadThemePluginsData(string $repo, string $type, ?array $sa /** * Check if branch is master or main + * @param string $repo + * @param string $branch * @return bool */ private function checkBranch(string $repo, string $branch): bool { - $repoFilesUrl = sprintf('%s/%s/', $repo, $branch); - return $this->getOfficialVersion($repoFilesUrl) !== null; + $repoFilesUrl = sprintf('%s/archive/%s.zip', $repo, $branch); + $headers = @get_headers($repoFilesUrl); + return empty(array_filter($headers, static function ($header) { + return (strpos($header, '404 Not Found')); + })); } /** * Add custom repository links for themes and plugins + * @throws Exception */ public function addCustomThemePluginRepository(): void { @@ -1018,7 +1352,8 @@ public function addCustomThemePluginRepository(): void case in_array($url, $defaultRepositories, true) || in_array($url, $customRepositories, true): $errorMessage = 'Repository already exists.'; break; - case $this->getOfficialVersion(sprintf('%s/master/', $url)) === null && $this->getOfficialVersion(sprintf('%s/main/', $url)) === null: + case $this->getOfficialVersion(sprintf('%s/master/', $url)) === null + && $this->getOfficialVersion(sprintf('%s/main/', $url)) === null: $errorMessage = 'Repository not added - missing version file.'; break; } @@ -1063,14 +1398,14 @@ public function installUpdateThemePluginAction(): void return; } $url = $_REQUEST['installThemePlugin']; - if(!$this->isValidGitURL($url)) { + if (!$this->isValidGitURL($url)) { $this->alert('danger', 'Invalid repository URL. Only GitHub and GitLab are supported.'); $this->redirect(); } $type = $_REQUEST['type']; $path = sprintf('%s/%s/', $this->rootDir, $type); - $folders = explode('/', str_replace(['/archive/master.zip','/archive/main.zip'], '', $url)); + $folders = explode('/', str_replace(['/archive/master.zip', '/archive/main.zip'], '', $url)); $folderName = array_pop($folders); if (in_array($type, self::VALID_DIRS, true)) { @@ -1139,7 +1474,7 @@ public function js(): string $scripts = << - + EOT; $scripts .= ''; $scripts .= ''; @@ -1179,7 +1514,9 @@ public function loadThemeAndFunctions(): void if (file_exists($location . '/functions.php')) { require_once $location . '/functions.php'; } - require_once $location . '/theme.php'; + + $customPageTemplate = sprintf('%s/%s.php', $location, $this->currentPage); + require_once file_exists($customPageTemplate) ? $customPageTemplate : $location . '/theme.php'; } /** @@ -1206,7 +1543,7 @@ public function loginAction(): void $this->saveAdminLoginIP(); $this->redirect(); } - $this->alert('danger', 'Wrong password.'); + $this->alert('test', '', 1); $this->redirect($this->get('config', 'login')); } @@ -1252,12 +1589,14 @@ public function loginView(): array 'description' => '', 'keywords' => '', 'content' => ' - + +
-
- - - +
+

Login to your website

+

+ +
' @@ -1291,13 +1630,7 @@ public function menu(): string if ($item->visibility === 'hide') { continue; } - $output .= - '
  • - ' . $item->name . ' -
  • '; - } - if ($this->loggedIn) { - $output .= ""; + $output .= $this->renderPageNavMenuItem($item); } return $this->hook('menu', $output)[0]; } @@ -1344,17 +1677,17 @@ public function notifyAction(): void if (!$this->currentPageExists) { $this->alert( 'info', - 'This page (' . $this->currentPage . ') doesn\'t exist. Click inside the content below to create it.' + 'This page (' . $this->currentPage . ') doesn\'t exist. Editing the content below will create it.' ); } if ($this->get('config', 'login') === 'loginURL') { $this->alert('danger', - 'Change both your default password and login URL. Open security settings'); + 'Change your login URL and save it for later use. Open security settings'); } $db = $this->getDb(); $lastSync = $db->config->defaultRepos->lastSync; - if (strtotime($lastSync) < strtotime('-1 days')) { + if (strtotime($lastSync) < strtotime('-7 days')) { $this->checkWcmsCoreUpdate(); } } @@ -1369,46 +1702,83 @@ private function checkWcmsCoreUpdate(): void $this->alert( 'info', '

    New WonderCMS update available

    -

     - Backup your website and - check what\'s new before updating.

    -
    - + 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 int $menu + * @param string $menu * @return void + * @throws Exception */ - public function orderMenuItem(int $content, int $menu): void + 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])) { + if (!in_array($content, [1, -1], true)) { return; } - $conf = 'config'; - $field = 'menuItems'; - $targetPosition = $menu + $content; - // save the target to avoid overwrite - // use clone to copy the object entirely - $tmp = clone $this->get($conf, $field, $targetPosition); - $move = $this->get($conf, $field, $menu); - // move the menu item to new position - $this->set($conf, $field, $targetPosition, 'name', $move->name); - $this->set($conf, $field, $targetPosition, 'slug', $move->slug); - $this->set($conf, $field, $targetPosition, 'visibility', $move->visibility); - // write the other menu item to the previous position - $this->set($conf, $field, $menu, 'name', $tmp->name); - $this->set($conf, $field, $menu, 'slug', $tmp->slug); - $this->set($conf, $field, $menu, 'visibility', $tmp->visibility); + $menuTree = $menu ? explode('-', $menu) : null; + $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); } /** @@ -1420,11 +1790,13 @@ public function orderMenuItem(int $content, int $menu): void */ public function page(string $key): string { - $segments = $this->currentPageExists - ? $this->get('pages', $this->currentPage) - : ($this->get('config', 'login') === $this->currentPage + $segments = $this->getCurrentPageData(); + if (!$this->currentPageExists || !$segments) { + $segments = $this->get('config', 'login') === $this->currentPage ? (object)$this->loginView() - : (object)$this->notFoundView()); + : (object)$this->notFoundView(); + } + $segments->content = $segments->content ?? '

    Click here add content

    '; $keys = [ 'title' => $segments->title, @@ -1438,21 +1810,64 @@ public function page(string $key): string 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 + { + $path = ''; + foreach ($this->currentPageTree as $parentPage) { + $path .= $parentPage . '/'; + } + + return self::url($path); + } + /** * Page status (exists or doesn't exist) * @return void */ public function pageStatus(): void { - $this->currentPage = empty($this->parseUrl()) ? $this->get('config', 'defaultPage') : $this->parseUrl(); - if (isset($this->get('pages')->{$this->currentPage})) { - $this->currentPageExists = true; - } - if (isset($_GET['page']) - && !$this->loggedIn - && $this->currentPage !== $this->slugify($_GET['page'])) { - $this->currentPageExists = false; - } + $this->currentPage = $this->parseUrl() ?: $this->get('config', 'defaultPage'); + $this->currentPageExists = !empty($this->getCurrentPageData()); } /** @@ -1461,10 +1876,19 @@ public function pageStatus(): void */ public function parseUrl(): string { - if (isset($_GET['page']) && $_GET['page'] === $this->get('config', 'login')) { + if (!isset($_GET['page'])) { + $defaultPage = $this->get('config', 'defaultPage'); + $this->currentPageTree = explode('/', $defaultPage); + return $defaultPage; + } + + $this->currentPageTree = explode('/', rtrim($_GET['page'], '/')); + if ($_GET['page'] === $this->get('config', 'login')) { return htmlspecialchars($_GET['page'], ENT_QUOTES); } - return isset($_GET['page']) ? $this->slugify($_GET['page']) : ''; + + $currentPage = end($this->currentPageTree); + return $this->slugify($currentPage); } /** @@ -1551,28 +1975,31 @@ public function saveAction(): void $newUrl = $_SESSION['redirect_to']; $newPageName = $_SESSION['redirect_to_name']; unset($_SESSION['redirect_to'], $_SESSION['redirect_to_name']); - $this->alert('success', "Page $newPageName created."); + $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'], ($_POST['visibility'] ?? 'hide')); - if ($target === 'menuItem') { - $this->createMenuItem($content, $menu, $visibility); + if ($target === 'menuItemUpdate') { + $this->updateMenuItem($content, $menu, $visibility); $_SESSION['redirect_to_name'] = $content; $_SESSION['redirect_to'] = $this->slugify($content); } + if ($target === 'menuItemCreate') { + $this->createMenuItem($content, $menu, $visibility, true); + } if ($target === 'menuItemVsbl') { - $this->set('config', $fieldname, $menu, 'visibility', $visibility); + $this->updateMenuItemVisibility($visibility, $menu); } if ($target === 'menuItemOrder') { $this->orderMenuItem($content, $menu); } - if ($fieldname === 'defaultPage' && !isset($this->get('pages')->$content)) { + if ($fieldname === 'defaultPage' && $this->getPageData($content) === null) { return; } - if ($fieldname === 'login' && (empty($content) || isset($this->get('pages')->$content))) { + if ($fieldname === 'login' && (empty($content) || $this->getPageData($content) !== null)) { return; } if ($fieldname === 'theme' && !is_dir($this->rootDir . '/themes/' . $content)) { @@ -1583,10 +2010,10 @@ public function saveAction(): void } elseif ($target === 'blocks') { $this->set('blocks', $fieldname, 'content', $content); } elseif ($target === 'pages') { - if (!isset($this->get('pages')->{$this->currentPage})) { - $this->createPage(); + if (!$this->currentPageExists) { + $this->createPage($this->currentPageTree, true); } - $this->set('pages', $this->currentPage, $fieldname, $content); + $this->updatePage($this->currentPageTree, $fieldname, $content); } } } @@ -1594,25 +2021,20 @@ public function saveAction(): void /** * Set something to database * @return void + * @throws Exception */ public function set(): void { - $numArgs = func_num_args(); $args = func_get_args(); - switch ($numArgs) { - case 2: - $this->db->{$args[0]} = $args[1]; - break; - case 3: - $this->db->{$args[0]}->{$args[1]} = $args[2]; - break; - case 4: - $this->db->{$args[0]}->{$args[1]}->{$args[2]} = $args[3]; - break; - case 5: - $this->db->{$args[0]}->{$args[1]}->{$args[2]}->{$args[3]} = $args[4]; - break; + + $value = array_pop($args); + $lastKey = array_pop($args); + $data = $this->db; + foreach ($args as $arg) { + $data = $data->{$arg}; } + $data->{$lastKey} = $value; + $this->save(); } @@ -1626,17 +2048,19 @@ public function settings(): string if (!$this->loggedIn) { return ''; } + $currentPageData = $this->getCurrentPageData(); $fileList = array_slice(scandir($this->filesPath), 2); $output = ' +


    Saving


    Checking for updates

    - Settings + Settings