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