init(); $Wcms->render(); } class Wcms { private const MODULES_JSON_VERSION = 1; private const THEMES_DIR = 'themes'; private const PLUGINS_DIR = 'plugins'; private const VALID_DIRS = [self::THEMES_DIR, self::PLUGINS_DIR]; private const THEME_PLUGINS_TYPES = [ 'installs' => 'install', 'updates' => 'update', 'exists' => 'exist', ]; /** Database main keys */ public const DB_CONFIG = 'config'; public const DB_MENU_ITEMS = 'menuItems'; public const DB_MENU_ITEMS_SUBPAGE = 'subpages'; public const DB_PAGES_KEY = 'pages'; public const DB_PAGES_SUBPAGE_KEY = 'subpages'; /** @var int MIN_PASSWORD_LENGTH minimum number of characters */ public const MIN_PASSWORD_LENGTH = 8; /** @var string WCMS_REPO - repo URL */ public const WCMS_REPO = 'https://raw.githubusercontent.com/WonderCMS/wondercms/main/'; /** @var string WCMS_CDN_REPO - CDN repo URL */ public const WCMS_CDN_REPO = 'https://raw.githubusercontent.com/WonderCMS/wondercms-cdn-files/main/'; /** @var string $currentPage - current page */ public $currentPage = ''; /** @var array $currentPageTree - Tree hierarchy of the current page */ public $currentPageTree = []; /** @var array $installedPlugins - Currently installed plugins */ public $installedPlugins = []; /** @var bool $currentPageExists - check if current page exists */ public $currentPageExists = false; /** @var object $db - content of database.js */ protected $db; /** @var bool $loggedIn - check if admin is logged in */ public $loggedIn = false; /** @var array $listeners for hooks */ public $listeners = []; /** @var string $dataPath path to data folder */ public $dataPath; /** @var string $modulesCachePath path to cached json file with Themes/Plugins data */ protected $modulesCachePath; /** @var string $securityCachePath path to security json file with force https caching data */ protected $securityCachePath; /** @var string $dbPath path to database.js */ protected $dbPath; /** @var string $filesPath path to uploaded files */ public $filesPath; /** @var string $rootDir root dir of the install (where index.php is) */ public $rootDir; /** @var bool $headerResponseDefault read default header response */ public $headerResponseDefault = true; /** @var string $headerResponse header status */ public $headerResponse = 'HTTP/1.0 200 OK'; /** * Constructor * * @param string $dataFolder * @param string $filesFolder * @param string $dbName * @param string $rootDir * @throws Exception */ public function __construct( string $dataFolder = 'data', string $filesFolder = 'files', string $dbName = 'database.js', string $rootDir = __DIR__ ) { $this->rootDir = $rootDir; $this->setPaths($dataFolder, $filesFolder, $dbName); $this->db = $this->getDb(); } /** * Setting default paths * * @param string $dataFolder * @param string $filesFolder * @param string $dbName */ public function setPaths( string $dataFolder = 'data', string $filesFolder = 'files', string $dbName = 'database.js' ): void { $this->dataPath = sprintf('%s/%s', $this->rootDir, $dataFolder); $this->dbPath = sprintf('%s/%s', $this->dataPath, $dbName); $this->filesPath = sprintf('%s/%s', $this->dataPath, $filesFolder); $this->modulesCachePath = sprintf('%s/%s', $this->dataPath, 'cache.json'); $this->securityCachePath = sprintf('%s/%s', $this->dataPath, 'security.json'); } /** * Init function called on each page load * * @return void * @throws Exception */ public function init(): void { $this->forceSSL(); $this->loginStatus(); $this->getSiteLanguage(); $this->pageStatus(); $this->logoutAction(); $this->loginAction(); $this->notFoundResponse(); $this->loadPlugins(); if ($this->loggedIn) { $this->manuallyRefreshCacheData(); $this->addCustomModule(); $this->installUpdateModuleAction(); $this->changePasswordAction(); $this->deleteFileModuleAction(); $this->changePageThemeAction(); $this->backupAction(); $this->forceHttpsAction(); $this->saveChangesPopupAction(); $this->saveLogoutToLoginScreenAction(); $this->deletePageAction(); $this->saveAction(); $this->updateAction(); $this->uploadFileAction(); $this->notifyAction(); } } /** * Set site language based on logged-in user * @return string * @throws Exception */ public function getSiteLanguage(): string { if ($this->loggedIn) { $lang = $this->get('config', 'adminLang'); } else { $lang = $this->get('config', 'siteLang'); } if (gettype($lang) === 'object' && empty(get_object_vars($lang))) { $lang = 'en'; $this->set('config', 'siteLang', $lang); $this->set('config', 'adminLang', $lang); } return $lang; } /** * Display the HTML. Called after init() * @return void */ public function render(): void { header($this->headerResponse); // Alert admin that page is hidden if ($this->loggedIn) { $loadingPage = null; foreach ($this->get('config', 'menuItems') as $item) { if ($this->currentPage === $item->slug) { $loadingPage = $item; } } if ($loadingPage && $loadingPage->visibility === 'hide') { $this->alert('info', 'This page (' . $this->currentPage . ') is currently hidden from the menu. Open menu visibility settings'); } } $this->loadThemeAndFunctions(); } /** * Function used by plugins to add a hook * * @param string $hook * @param callable $functionName */ public function addListener(string $hook, callable $functionName): void { $this->listeners[$hook][] = $functionName; } /** * Add alert message for admin * * @param string $class see bootstrap alerts classes * @param string $message the message to display * @param bool $sticky can it be closed? * @return void */ public function alert(string $class, string $message, bool $sticky = false): void { if (isset($_SESSION['alert'][$class])) { foreach ($_SESSION['alert'][$class] as $v) { if ($v['message'] === $message) { return; } } } $_SESSION['alert'][$class][] = ['class' => $class, 'message' => $this->hook('alert', $message)[0], 'sticky' => $sticky]; } /** * Display alert message to the admin * @return string */ public function alerts(): string { if (!isset($_SESSION['alert'])) { return ''; } $output = '
'; $output .= ''; foreach ($_SESSION['alert'] as $alertClass) { foreach ($alertClass as $alert) { $output .= '
' . (!$alert['sticky'] ? '' : '') . $alert['message'] . $this->hideAlerts(); } } $output .= '
'; unset($_SESSION['alert']); return $output; } /** * Allow admin to dismiss alerts * @return string */ public function hideAlerts(): string { if (!$this->loggedIn) { return ''; } $output = ''; $output .= '
Hide all alerts until next login
'; return $output; } /** * Get an asset (returns URL of the asset) * * @param string $location * @return string */ public function asset(string $location): string { return self::url('themes/' . $this->get('config', 'theme') . '/' . $location); } /** * Backup whole WonderCMS installation * * @return void * @throws Exception */ public function backupAction(): void { if (!$this->loggedIn) { return; } $backupList = glob($this->filesPath . '/*-backup-*.zip'); if (!empty($backupList)) { $this->alert('danger', 'Backup files detected. View and delete unnecessary backup files'); } if (isset($_POST['backup']) && $this->verifyFormActions()) { $this->zipBackup(); } } /** * Save if WCMS should force https * @return void * @throws Exception */ public function forceHttpsAction(): void { if (isset($_POST['forceHttps']) && $this->verifyFormActions()) { $this->set('config', 'forceHttps', $_POST['forceHttps'] === 'true'); $this->updateSecurityCache(); $this->alert('success', 'Force HTTPs was successfully changed.'); $this->redirect(); } } /** * Save if WCMS should show the popup before saving the page content changes * @return void * @throws Exception */ public function saveChangesPopupAction(): void { if (isset($_POST['saveChangesPopup']) && $this->verifyFormActions()) { $this->set('config', 'saveChangesPopup', $_POST['saveChangesPopup'] === 'true'); $this->alert('success', 'Saving the confirmation popup settings changed.'); $this->redirect(); } } /** * Save if admin should be redirected to login/last viewed page after logging out. * @return void * @throws Exception */ public function saveLogoutToLoginScreenAction(): void { if (isset($_POST['logoutToLoginScreen']) && $this->verifyFormActions()) { $redirectToLogin = $_POST['logoutToLoginScreen'] === 'true'; $message = $redirectToLogin ? 'You will be redirected to login screen after logging out.' : 'You will be redirected to last viewed screen after logging out.'; $this->set('config', 'logoutToLoginScreen', $_POST['logoutToLoginScreen'] === 'true'); $this->alert('success', $message); $this->redirect(); } } /** * Update cache for security settings. * @return void */ public function updateSecurityCache(): void { $content = ['forceHttps' => $this->isHttpsForced()]; $json = json_encode($content, JSON_FORCE_OBJECT | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); file_put_contents($this->securityCachePath, $json, LOCK_EX); } /** * Get a static block * * @param string $key name of the block * @return string */ public function block(string $key): string { $blocks = $this->get('blocks'); $content = ''; if (isset($blocks->{$key})) { $content = $this->loggedIn ? $this->editable($key, $blocks->{$key}->content, 'blocks') : $blocks->{$key}->content; } return $this->hook('block', $content, $key)[0]; } /** * Change password * @return void * @throws Exception */ public function changePasswordAction(): void { if (isset($_POST['old_password'], $_POST['new_password'], $_POST['repeat_password']) && $_SESSION['token'] === $_POST['token'] && $this->loggedIn && $this->hashVerify($_POST['token'])) { if (!password_verify($_POST['old_password'], $this->get('config', 'password'))) { $this->alert('danger', 'Wrong password. Re-open security settings'); $this->redirect(); return; } if (strlen($_POST['new_password']) < self::MIN_PASSWORD_LENGTH) { $this->alert('danger', sprintf('Password must be longer than %d characters. Re-open security settings', self::MIN_PASSWORD_LENGTH)); $this->redirect(); return; } if ($_POST['new_password'] !== $_POST['repeat_password']) { $this->alert('danger', 'New passwords do not match. Re-open security settings'); $this->redirect(); return; } $this->set('config', 'password', password_hash($_POST['new_password'], PASSWORD_DEFAULT)); $this->set('config', 'forceLogout', true); $this->logoutAction(true); $this->alert('success', '
Password changed. Log in again.
', 1); } } /** * Check if folders are writable * Executed once before creating the database file * * @param string $folder the relative path of the folder to check/create * @return void * @throws Exception */ public function checkFolder(string $folder): void { if (!is_dir($folder) && !mkdir($folder, 0755) && !is_dir($folder)) { throw new Exception('Could not create data folder.'); } if (!is_writable($folder)) { throw new Exception('Could write to data folder.'); } } /** * Initialize the JSON database if it doesn't exist * @return void * @throws Exception */ public function createDb(): void { // Check php requirements $this->checkMinimumRequirements(); $password = $this->generatePassword(); $this->db = (object)[ self::DB_CONFIG => [ 'siteTitle' => 'Website title', 'siteLang' => 'en', 'adminLang' => 'en', 'theme' => 'sky', 'defaultPage' => 'home', 'login' => 'loginURL', 'forceLogout' => false, 'forceHttps' => false, 'saveChangesPopup' => false, 'password' => password_hash($password, PASSWORD_DEFAULT), 'lastLogins' => [], 'lastModulesSync' => null, 'customModules' => $this->defaultCustomModules(), 'menuItems' => [ '0' => [ 'name' => 'Home', 'slug' => 'home', 'visibility' => 'show', self::DB_MENU_ITEMS_SUBPAGE => new stdClass() ], '1' => [ 'name' => 'How to', 'slug' => 'how-to', 'visibility' => 'show', self::DB_MENU_ITEMS_SUBPAGE => new stdClass() ] ] ], 'pages' => [ '404' => [ 'title' => '404', 'keywords' => '404', 'description' => '404', 'content' => '

404 - Page not found

', self::DB_PAGES_SUBPAGE_KEY => new stdClass() ], 'home' => [ 'title' => 'Home', 'keywords' => 'Enter, page, keywords, for, search, engines', 'description' => 'A page description is also good for search engines.', 'content' => '

Welcome to your website

Your password for editing everything is: ' . $password . '

Click here to login

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

', self::DB_PAGES_SUBPAGE_KEY => new stdClass() ], 'how-to' => [ 'title' => 'How to', 'keywords' => 'Enter, keywords, for, this page', 'description' => 'A page description is also good for search engines.', 'content' => '

Easy editing

After logging in, click anywhere to edit and click outside to save. Changes are live and shown immediately.

Create new page

Pages can be created in the Settings.

Start a blog or change your theme

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

Support WonderCMS

WonderCMS is free for over 12 years.
Click here to support us by getting a T-shirt or with a donation.

', self::DB_PAGES_SUBPAGE_KEY => new stdClass() ] ], 'blocks' => [ 'subside' => [ 'content' => '

About your website


Website description, contact form, mini map or anything else.

This editable area is visible on all pages.

' ], 'footer' => [ 'content' => '©' . date('Y') . ' Your website' ] ] ]; $this->save(); } /** * Default data for the Custom Modules * @return array[] */ private function defaultCustomModules(): array { return [ 'themes' => [], 'plugins' => [] ]; } /** * Create menu item * * @param string $name * @param string|null $menu * @param bool $createPage * @param string $visibility show or hide * @return void * @throws Exception */ public function createMenuItem( string $name, string $menu = null, string $visibility = 'hide', bool $createPage = false ): void { if (!in_array($visibility, ['show', 'hide'], true)) { return; } $name = empty($name) ? 'empty' : str_replace([PHP_EOL, '
'], '', $name); $slug = $this->createUniqueSlug($name, $menu); $menuItems = $menuSelectionObject = clone $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS); $menuTree = !empty($menu) || $menu === '0' ? explode('-', $menu) : []; $slugTree = []; if (count($menuTree)) { foreach ($menuTree as $childMenuKey) { $childMenu = $menuSelectionObject->{$childMenuKey}; if (!property_exists($childMenu, self::DB_MENU_ITEMS_SUBPAGE)) { $childMenu->{self::DB_MENU_ITEMS_SUBPAGE} = new StdClass; } $menuSelectionObject = $childMenu->{self::DB_MENU_ITEMS_SUBPAGE}; $slugTree[] = $childMenu->slug; } } $slugTree[] = $slug; $menuCount = count(get_object_vars($menuSelectionObject)); $menuSelectionObject->{$menuCount} = new stdClass; $menuSelectionObject->{$menuCount}->name = $name; $menuSelectionObject->{$menuCount}->slug = $slug; $menuSelectionObject->{$menuCount}->visibility = $visibility; $menuSelectionObject->{$menuCount}->{self::DB_MENU_ITEMS_SUBPAGE} = new StdClass; $this->set(self::DB_CONFIG, self::DB_MENU_ITEMS, $menuItems); if ($createPage) { $this->createPage($slugTree); $_SESSION['redirect_to_name'] = $name; $_SESSION['redirect_to'] = implode('/', $slugTree); } } /** * Update menu item * * @param string $name * @param string $menu * @param string $visibility show or hide * @return void * @throws Exception */ public function updateMenuItem(string $name, string $menu, string $visibility = 'hide'): void { if (!in_array($visibility, ['show', 'hide'], true)) { return; } $name = empty($name) ? 'empty' : str_replace([PHP_EOL, '
'], '', $name); $slug = $this->createUniqueSlug($name, $menu); $menuItems = $menuSelectionObject = clone $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS); $menuTree = explode('-', $menu); $slugTree = []; $menuKey = array_pop($menuTree); if (count($menuTree) > 0) { foreach ($menuTree as $childMenuKey) { $childMenu = $menuSelectionObject->{$childMenuKey}; if (!property_exists($childMenu, self::DB_MENU_ITEMS_SUBPAGE)) { $childMenu->{self::DB_MENU_ITEMS_SUBPAGE} = new StdClass; } $menuSelectionObject = $childMenu->{self::DB_MENU_ITEMS_SUBPAGE}; $slugTree[] = $childMenu->slug; } } $slugTree[] = $menuSelectionObject->{$menuKey}->slug; $menuSelectionObject->{$menuKey}->name = $name; $menuSelectionObject->{$menuKey}->slug = $slug; $menuSelectionObject->{$menuKey}->visibility = $visibility; $menuSelectionObject->{$menuKey}->{self::DB_MENU_ITEMS_SUBPAGE} = $menuSelectionObject->{$menuKey}->{self::DB_MENU_ITEMS_SUBPAGE} ?? new StdClass; $this->set(self::DB_CONFIG, self::DB_MENU_ITEMS, $menuItems); $this->updatePageSlug($slugTree, $slug); if ($this->get(self::DB_CONFIG, 'defaultPage') === implode('/', $slugTree)) { // Change old slug with new one array_pop($slugTree); $slugTree[] = $slug; $this->set(self::DB_CONFIG, 'defaultPage', implode('/', $slugTree)); } } /** * Check if slug already exists and creates unique one * * @param string $slug * @param string|null $menu * @return string */ public function createUniqueSlug(string $slug, string $menu = null): string { $slug = $this->slugify($slug); $allMenuItems = $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS); $menuCount = count(get_object_vars($allMenuItems)); // Check if it is subpage $menuTree = $menu ? explode('-', $menu) : []; if (count($menuTree)) { foreach ($menuTree as $childMenuKey) { $allMenuItems = $allMenuItems->{$childMenuKey}->subpages; } } foreach ($allMenuItems as $value) { if ($value->slug === $slug) { $slug .= '-' . $menuCount; break; } } return $slug; } /** * Create new page * * @param array|null $slugTree * @param bool $createMenuItem * @return void * @throws Exception */ public function createPage(array $slugTree = null, bool $createMenuItem = false): void { $pageExists = false; $pageData = null; foreach ($slugTree as $parentPage) { if (!$pageData) { $pageData = $this->get(self::DB_PAGES_KEY)->{$parentPage}; continue; } $pageData = $pageData->subpages->{$parentPage} ?? null; $pageExists = !empty($pageData); } if ($pageExists) { $this->alert('danger', 'Cannot create page with existing slug.'); return; } $slug = array_pop($slugTree); $pageSlug = $slug ?: $this->slugify($this->currentPage); $allPages = $selectedPage = clone $this->get(self::DB_PAGES_KEY); $menuKey = null; if (!empty($slugTree)) { foreach ($slugTree as $childSlug) { // Find menu key tree if ($createMenuItem) { $menuKey = $this->findAndUpdateMenuKey($menuKey, $childSlug); } // Create new parent page if it doesn't exist if (!$selectedPage->{$childSlug}) { $parentTitle = mb_convert_case(str_replace('-', ' ', $childSlug), MB_CASE_TITLE); $selectedPage->{$childSlug}->title = $parentTitle; $selectedPage->{$childSlug}->keywords = 'Keywords, are, good, for, search, engines'; $selectedPage->{$childSlug}->description = 'A short description is also good.'; if ($createMenuItem) { $this->createMenuItem($parentTitle, $menuKey); $menuKey = $this->findAndUpdateMenuKey($menuKey, $childSlug); // Add newly added menu key } } if (!property_exists($selectedPage->{$childSlug}, self::DB_PAGES_SUBPAGE_KEY)) { $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY} = new StdClass; } $selectedPage = $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY}; } } $pageTitle = !$slug ? str_replace('-', ' ', $pageSlug) : $pageSlug; $selectedPage->{$slug} = new stdClass; $selectedPage->{$slug}->title = mb_convert_case($pageTitle, MB_CASE_TITLE); $selectedPage->{$slug}->keywords = 'Keywords, are, good, for, search, engines'; $selectedPage->{$slug}->description = 'A short description is also good.'; $selectedPage->{$slug}->{self::DB_PAGES_SUBPAGE_KEY} = new StdClass; $this->set(self::DB_PAGES_KEY, $allPages); if ($createMenuItem) { $this->createMenuItem($pageTitle, $menuKey); } } /** * Find and update menu key tree based on newly requested slug * @param string|null $menuKey * @param string $slug * @return string */ private function findAndUpdateMenuKey(?string $menuKey, string $slug): string { $menuKeys = $menuKey !== null ? explode('-', $menuKey) : $menuKey; $menuItems = json_decode(json_encode($this->get(self::DB_CONFIG, self::DB_MENU_ITEMS)), true); foreach ($menuKeys as $key) { $menuItems = $menuItems[$key][self::DB_MENU_ITEMS_SUBPAGE] ?? []; } if (false !== ($index = array_search($slug, array_column($menuItems, 'slug'), true))) { $menuKey = $menuKey === null ? $index : $menuKey . '-' . $index; } elseif ($menuKey === null) { $menuKey = count($menuItems); } return $menuKey; } /** * Update page data * * @param array $slugTree * @param string $fieldname * @param string $content * @return void * @throws Exception */ public function updatePage(array $slugTree, string $fieldname, string $content): void { $slug = array_pop($slugTree); $allPages = $selectedPage = clone $this->get(self::DB_PAGES_KEY); if (!empty($slugTree)) { foreach ($slugTree as $childSlug) { if (!property_exists($selectedPage->{$childSlug}, self::DB_PAGES_SUBPAGE_KEY)) { $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY} = new StdClass; } $selectedPage = $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY}; } } $selectedPage->{$slug}->{$fieldname} = $content; $this->set(self::DB_PAGES_KEY, $allPages); } /** * Delete page key * * @param array $slugTree * @param string $fieldname * * @return void * @throws Exception */ public function deletePageKey(array $slugTree, string $fieldname): void { $slug = array_pop($slugTree); $selectedPage = clone $this->get(self::DB_PAGES_KEY); if (!empty($slugTree)) { foreach ($slugTree as $childSlug) { if (!property_exists($selectedPage->{$childSlug}, self::DB_PAGES_SUBPAGE_KEY)) { $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY} = new StdClass; } $selectedPage = $selectedPage->{$childSlug}->{self::DB_PAGES_SUBPAGE_KEY}; } } unset($selectedPage->{$slug}->{$fieldname}); $this->save(); } /** * Delete existing page by slug * * @param array|null $slugTree * @throws Exception */ public function deletePageFromDb(array $slugTree = null): void { $slug = array_pop($slugTree); $selectedPage = $this->db->{self::DB_PAGES_KEY}; if (!empty($slugTree)) { foreach ($slugTree as $childSlug) { $selectedPage = $selectedPage->{$childSlug}->subpages; } } unset($selectedPage->{$slug}); $this->save(); } /** * Update existing page slug * * @param array $slugTree * @param string $newSlugName * @throws Exception */ public function updatePageSlug(array $slugTree, string $newSlugName): void { $slug = array_pop($slugTree); $selectedPage = $this->db->{self::DB_PAGES_KEY}; if (!empty($slugTree)) { foreach ($slugTree as $childSlug) { $selectedPage = $selectedPage->{$childSlug}->subpages; } } $selectedPage->{$newSlugName} = $selectedPage->{$slug}; unset($selectedPage->{$slug}); $this->save(); } /** * Load CSS and enable plugins to load CSS * @return string */ public function css(): string { if ($this->loggedIn) { $styles = <<<'EOT' EOT; return $this->hook('css', $styles)[0]; } return $this->hook('css', '')[0]; } /** * Get database content * @return stdClass * @throws Exception */ public function getDb(): stdClass { // initialize database if it doesn't exist if (!file_exists($this->dbPath)) { // this code only runs one time (on first page load/install) $this->checkFolder(dirname($this->dbPath)); $this->checkFolder($this->filesPath); $this->checkFolder($this->rootDir . '/' . self::THEMES_DIR); $this->checkFolder($this->rootDir . '/' . self::PLUGINS_DIR); $this->createDb(); } return json_decode(file_get_contents($this->dbPath), false); } /** * Get data from any json file * @param string $path * @return stdClass|null */ public function getJsonFileData(string $path): ?array { if (is_file($path) && file_exists($path)) { return json_decode(file_get_contents($path), true); } return null; } /** * Delete theme * @return void */ public function deleteFileModuleAction(): void { if (!$this->loggedIn) { return; } if (isset($_REQUEST['deleteModule'], $_REQUEST['type']) && $this->verifyFormActions(true)) { $allowedDeleteTypes = ['files', 'plugins', 'themes']; $filename = str_ireplace( ['/', './', '../', '..', '~', '~/', '\\'], null, trim($_REQUEST['deleteModule']) ); $type = str_ireplace( ['/', './', '../', '..', '~', '~/', '\\'], null, trim($_REQUEST['type']) ); if (!in_array($type, $allowedDeleteTypes, true)) { $this->alert('danger', 'Wrong delete folder path.'); $this->redirect(); } if ($filename === $this->get('config', 'theme')) { $this->alert('danger', 'Cannot delete currently active theme. Re-open theme settings'); $this->redirect(); } $folder = $type === 'files' ? $this->filesPath : sprintf('%s/%s', $this->rootDir, $type); $path = realpath("{$folder}/{$filename}"); if (file_exists($path)) { $this->recursiveDelete($path); $this->alert('success', "Deleted {$filename}."); $this->redirect(); } } } public function changePageThemeAction(): void { if (isset($_REQUEST['selectModule'], $_REQUEST['type']) && $this->verifyFormActions(true)) { $theme = $_REQUEST['selectModule']; if (!is_dir($this->rootDir . '/' . $_REQUEST['type'] . '/' . $theme)) { return; } $this->set('config', 'theme', $theme); $this->redirect(); } } /** * Delete page * @return void * @throws Exception */ public function deletePageAction(): void { if (!isset($_GET['delete']) || !$this->verifyFormActions(true)) { return; } $slugTree = explode('/', $_GET['delete']); $this->deletePageFromDb($slugTree); $allMenuItems = $selectedMenuItem = clone $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS); if (count(get_object_vars($allMenuItems)) === 1 && count($slugTree) === 1) { $this->alert('danger', 'Last page cannot be deleted - at least one page must exist.'); $this->redirect(); } $selectedMenuItemParent = $selectedMenuItemKey = null; foreach ($slugTree as $slug) { $selectedMenuItemParent = $selectedMenuItem->{self::DB_MENU_ITEMS_SUBPAGE} ?? $selectedMenuItem; foreach ($selectedMenuItemParent as $menuItemKey => $menuItem) { if ($menuItem->slug === $slug) { $selectedMenuItem = $menuItem; $selectedMenuItemKey = $menuItemKey; break; } } } unset($selectedMenuItemParent->{$selectedMenuItemKey}); $allMenuItems = $this->reindexObject($allMenuItems); $defaultPage = $this->get(self::DB_CONFIG, 'defaultPage'); $defaultPageArray = explode('/', $defaultPage); $treeIntersect = array_intersect_assoc($defaultPageArray, $slugTree); if ($treeIntersect === $slugTree) { $this->set(self::DB_CONFIG, 'defaultPage', $allMenuItems->{0}->slug); } $this->set(self::DB_CONFIG, self::DB_MENU_ITEMS, $allMenuItems); $this->alert('success', 'Page ' . $slug . ' deleted.'); $this->redirect(); } /** * Get editable block * * @param string $id id for the block * @param string $content html content * @param string $dataTarget * @return string */ public function editable(string $id, string $content, string $dataTarget = ''): string { return '' . $content . ''; } /** * Get main website title, show edit icon if logged in * @return string */ public function siteTitle(): string { $output = $this->get('config', 'siteTitle'); if ($this->loggedIn) { $output .= ""; } return $output; } /** * Get footer, make it editable and show login link if it's set to default * @return string */ public function footer(): string { if ($this->loggedIn) { $output = ''; } else { $output = $this->get('blocks', 'footer')->content . (!$this->loggedIn && $this->get('config', 'login') === 'loginURL' ? ' • Login' : ''); } return $this->hook('footer', $output)[0]; } /** * Generate random password * @return string */ public function generatePassword(): string { $characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; return substr(str_shuffle($characters), 0, self::MIN_PASSWORD_LENGTH); } /** * Get CSRF token * @return string * @throws Exception */ public function getToken(): string { return $_SESSION['token'] ?? $_SESSION['token'] = bin2hex(random_bytes(32)); } /** * Get something from database */ public function get() { $args = func_get_args(); $object = $this->db; foreach ($args as $key => $arg) { if (!property_exists($object, $arg)) { $this->set(...array_merge($args, [new stdClass])); } $object = $object->{$arg}; } return $object; } /** * Download file content from url * @param string $fileUrl * @return string */ private function downloadFileFromUrl(string $fileUrl): string { $ch = curl_init(); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_URL, $fileUrl); $content = curl_exec($ch); if (false === $content) { $this->alert('danger', 'Cannot get content from url.'); } curl_close($ch); return (string)$content; } /** * Get content of a file from main branch * * @param string $file the file we want * @param string $repo * @return string */ public function getFileFromRepo(string $file, string $repo = self::WCMS_REPO): string { $repo = str_replace('https://github.com/', 'https://raw.githubusercontent.com/', $repo); return $this->downloadFileFromUrl($repo . $file); } /** * Get the latest version from main branch * @param string $repo * @return null|string */ public function getOfficialVersion(string $repo = self::WCMS_REPO): ?string { return $this->getCheckFileFromRepo('version', $repo); } /** * Get the files from main branch * @param string $fileName * @param string $repo * @return null|string */ public function getCheckFileFromRepo(string $fileName, string $repo = self::WCMS_REPO): ?string { $version = trim($this->getFileFromRepo($fileName, $repo)); return $version === '404: Not Found' || $version === '400: Invalid request' ? null : $version; } /** * Compare token with hash_equals * * @param string $token * @return bool */ public function hashVerify(string $token): bool { return hash_equals($token, $this->getToken()); } /** * Return hooks from plugins * @return array */ public function hook(): array { $numArgs = func_num_args(); $args = func_get_args(); if ($numArgs < 2) { trigger_error('Insufficient arguments', E_USER_ERROR); } $hookName = array_shift($args); if (!isset($this->listeners[$hookName])) { return $args; } foreach ($this->listeners[$hookName] as $func) { $args = $func($args); } return $args; } /** * Return array with all themes and their data * @param string $type * @return array * @throws Exception */ public function listAllModules(string $type = self::THEMES_DIR): array { $newData = []; if ($this->loggedIn) { $data = $this->getModulesCachedData($type); foreach ($data as $dirName => $addon) { $exists = is_dir($this->rootDir . "/$type/" . $dirName); $currentVersion = $exists ? $this->getModuleVersion($type, $dirName) : null; $newVersion = $addon['version']; $update = $newVersion !== null && $currentVersion !== null && $newVersion > $currentVersion; if ($update) { $this->alert('info', 'New ' . $type . ' update available. Open ' . $type . ''); } $addonType = $exists ? self::THEME_PLUGINS_TYPES['exists'] : self::THEME_PLUGINS_TYPES['installs']; $addonType = $update ? self::THEME_PLUGINS_TYPES['updates'] : $addonType; $newData[$addonType][$dirName] = $addon; $newData[$addonType][$dirName]['update'] = $update; $newData[$addonType][$dirName]['install'] = !$exists; $newData[$addonType][$dirName]['currentVersion'] = $currentVersion; } } return $newData; } /** * Check modules for cache * @return void * @throws Exception */ public function checkModulesCache(): void { $db = $this->getDb(); $data = $this->getJsonFileData($this->modulesCachePath); // Recreate cache if lastModulesSync is missing $lastSync = $db->config->lastModulesSync ?? strtotime('-2 days'); if (empty($data) || strtotime($lastSync) < strtotime('-1 days')) { $this->updateAndCacheModules(); } } /** * Retrieve cached Themes/Plugins data * @param string $type * @return array|null * @throws Exception */ public function getModulesCachedData(string $type = self::THEMES_DIR): array { $this->checkModulesCache(); $data = $this->getJsonFileData($this->modulesCachePath); return $data !== null && array_key_exists($type, $data) ? $data[$type] : []; } /** * Retrieve cached single Theme/Plugin data * @param string $moduleKey * @param string $type * @return array|null * @throws Exception */ public function getSingleModuleCachedData(string $moduleKey, string $type = self::THEMES_DIR): array { $data = $this->getModulesCachedData($type); return $data !== null && array_key_exists($moduleKey, $data) ? $data[$moduleKey] : []; } /** * Force cache refresh for updates * @throws Exception */ public function manuallyRefreshCacheData(): void { if (!isset($_REQUEST['manuallyResetCacheData']) || !$this->verifyFormActions(true)) { return; } $this->updateAndCacheModules(); $this->checkWcmsCoreUpdate(); $this->set('config', 'lastModulesSync', date('Y/m/d')); $this->redirect(); } /** * Forces http to https */ private function forceSSL(): void { if ($this->isHttpsForced() && !Wcms::isCurrentlyOnSSL()) { $this->updateSecurityCache(); $this->redirect(); } } /** * Method checks for new modules and caches them * @throws Exception */ private function updateAndCacheModules(): void { $this->set('config', 'lastModulesSync', date('Y/m/d')); $this->cacheModulesData(); } /** * Fetch module config from url * @param string $url * @param string $type * @return object|null */ private function fetchModuleConfig(string $url, string $type): ?object { $wcmsModules = json_decode(trim($this->downloadFileFromUrl($url))); $wcmsModulesData = $wcmsModules && property_exists($wcmsModules, $type) ? $wcmsModules->{$type} : null; if (null === $wcmsModulesData) { $this->alert('danger', 'The wcms-modules.json file does not contain all the required information.'); return null; } $wcmsModulesData = get_mangled_object_vars($wcmsModulesData); $returnData = reset($wcmsModulesData); $name = key($wcmsModulesData); $returnData->dirName = $name; return $returnData; } /** * Update cache for default themes/plugins modules. * @return void * @throws Exception */ private function updateModulesCache(): void { $wcmsModules = trim($this->getFileFromRepo('wcms-modules.json', self::WCMS_CDN_REPO)); $jsonObject = json_decode($wcmsModules); if (empty($jsonObject)) { return; } $parsedCache = $this->moduleCacheMapper($jsonObject); if (empty($parsedCache)) { return; } $this->save($this->modulesCachePath, $parsedCache); } /** * Mapper between wcms-modules.json and applications cache.json * @param object $wcmsModule * @return object */ private function moduleCacheMapper(object $wcmsModule): object { $mappedModules = new stdClass; foreach ($wcmsModule as $type => $value) { if ($type === 'version') { if ($value !== self::MODULES_JSON_VERSION) { $this->alert('danger', 'The wcms-modules.json version is incorrect'); break; } continue; } $mappedModules->{$type} = new stdClass(); foreach ($value as $moduleName => $module) { $parsedModule = $this->moduleCacheParser($module, $moduleName); if (empty($parsedModule)) { continue; } $mappedModules->{$type}->{$moduleName} = new stdClass(); $mappedModules->{$type}->{$moduleName} = $parsedModule; } } return $mappedModules; } /** * Parse module cache to * @param object $module * @param string $moduleName * @return object|null */ private function moduleCacheParser(object $module, string $moduleName): ?object { if (!$this->validateWcmsModuleStructure($module)) { return null; } return (object)[ "name" => $module->name, "dirName" => $moduleName, "repo" => $module->repo, "zip" => $module->zip, "summary" => $module->summary, "version" => $module->version, "image" => $module->image, ]; } /** * Cache themes and plugins data * @throws Exception */ private function cacheModulesData(): void { $db = $this->getDb(); // Download wcms-modules as cache $this->updateModulesCache(); // Cache custom modules $returnArray = $this->getJsonFileData($this->modulesCachePath); // If custom modules is missing from the DB, we add it if (!property_exists($db->config, 'customModules')) { $this->set('config', 'customModules', $this->defaultCustomModules()); $db = $this->getDb(); } $arrayCustom = (array)$db->config->customModules; foreach ($arrayCustom as $type => $modules) { foreach ($modules as $url) { $wcmsModuleData = $this->fetchModuleConfig($url, $type); if (null === $wcmsModuleData) { continue; } $name = $wcmsModuleData->dirName; $wcmsModuleData = $this->moduleCacheParser($wcmsModuleData, $name); $returnArray[$type][$name] = $wcmsModuleData; } } $this->save($this->modulesCachePath, (object)$returnArray); } /** * Cache single theme or plugin data * @param string $url * @param string $type * @throws Exception */ private function cacheSingleCacheModuleData(string $url, string $type): void { $returnArray = $this->getJsonFileData($this->modulesCachePath); $wcmsModuleData = $this->fetchModuleConfig($url, $type); if (null === $wcmsModuleData) { return; } $name = $wcmsModuleData->dirName; $wcmsModuleData = $this->moduleCacheParser($wcmsModuleData, $name); $returnArray[$type][$name] = $wcmsModuleData; $this->save($this->modulesCachePath, (object)$returnArray); } /** * Check if the module url already exists * @param string $repo * @param string $type * @return bool * @throws Exception */ private function checkIfModuleRepoExists(string $repo, string $type): bool { $data = $this->getModulesCachedData($type); return in_array($repo, array_column($data, 'repo')); } /** * Validate structure of the wcms module json * @param object $wcmsModule * @return bool */ private function validateWcmsModuleStructure(object $wcmsModule): bool { return property_exists($wcmsModule, 'name') && property_exists($wcmsModule, 'repo') && property_exists($wcmsModule, 'zip') && property_exists($wcmsModule, 'summary') && property_exists($wcmsModule, 'version') && property_exists($wcmsModule, 'image'); } /** * Add custom url links for themes and plugins * @throws Exception */ public function addCustomModule(): void { if (!isset($_POST['pluginThemeUrl'], $_POST['pluginThemeType'], $_POST['password_recheck']) || !$this->verifyFormActions()) { return; } if (!password_verify($_POST['password_recheck'], $this->get('config', 'password'))) { $this->alert('danger', 'Invalid password.'); $this->redirect(); } $type = $_POST['pluginThemeType']; $url = rtrim(trim($_POST['pluginThemeUrl']), '/'); $customModules = (array)$this->get('config', 'customModules', $type); $wcmsModuleData = $this->fetchModuleConfig($url, $type); $errorMessage = null; switch (true) { case null === $wcmsModuleData || !$this->isValidModuleURL($url): $errorMessage = 'Invalid URL. The module URL needs to contain the full path to the raw wcms-modules.json file.'; break; case !$this->validateWcmsModuleStructure($wcmsModuleData): $errorMessage = 'Module not added - the wcms-modules.json file does not contain all the required information.'; break; case $this->checkIfModuleRepoExists($wcmsModuleData->repo, $type): $errorMessage = 'Module already exists.'; break; } if ($errorMessage !== null) { $this->alert('danger', $errorMessage); $this->redirect(); } $customModules[] = $url; $this->set('config', 'customModules', $type, $customModules); $this->cacheSingleCacheModuleData($url, $type); $this->alert('success', 'Module successfully added to ' . ucfirst($type) . '.'); $this->redirect(); } /** * Read plugin version * @param string $type * @param string $name * @return string|null */ public function getModuleVersion(string $type, string $name): ?string { $version = null; $path = sprintf('%s/%s/%s', $this->rootDir, $type, $name); $wcmsModulesPath = $path . '/wcms-modules.json'; $versionPath = $path . '/version'; if (is_dir($path) && (is_file($wcmsModulesPath) || is_file($versionPath))) { if (is_file($wcmsModulesPath)) { $wcmsModules = json_decode(trim(file_get_contents($wcmsModulesPath))); $version = $wcmsModules->{$type}->{$name}->version; } else { $version = trim(file_get_contents($versionPath)); } } return $version; } /** * Install and update theme * @throws Exception */ public function installUpdateModuleAction(): void { if (!isset($_REQUEST['installModule'], $_REQUEST['type']) || !$this->verifyFormActions(true)) { return; } $folderName = trim(htmlspecialchars($_REQUEST['installModule'])); $type = $_REQUEST['type']; $cached = $this->getSingleModuleCachedData($folderName, $type); $url = !empty($cached) ? $cached['zip'] : null; if (empty($url)) { $this->alert('danger', 'Unable to find theme or plugin.'); return; } $path = sprintf('%s/%s/', $this->rootDir, $type); if (in_array($type, self::VALID_DIRS, true)) { $zipFile = $this->filesPath . '/ZIPFromURL.zip'; $zipResource = fopen($zipFile, 'w'); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_FILE, $zipResource); curl_exec($ch); $curlError = curl_error($ch); curl_close($ch); $zip = new \ZipArchive; if ($curlError || $zip->open($zipFile) !== true || (stripos($url, '.zip') === false)) { $this->recursiveDelete($this->rootDir . '/data/files/ZIPFromURL.zip'); $this->alert('danger', 'Error opening ZIP file.' . ($curlError ? ' Error description: ' . $curlError : '')); $this->redirect(); } // First delete old plugin folder $this->recursiveDelete($path . $folderName); // Then extract new one $zip->extractTo($path); $zip->close(); $this->recursiveDelete($this->rootDir . '/data/files/ZIPFromURL.zip'); $moduleFolder = $path . $folderName . '-master'; if (!is_dir($moduleFolder)) { $moduleFolder = $path . $folderName . '-main'; } if (is_dir($moduleFolder) && !rename($moduleFolder, $path . $folderName)) { throw new Exception('Theme or plugin not installed. Possible cause: themes or plugins folder is not writable.'); } $this->alert('success', 'Successfully installed/updated ' . $folderName . '.'); $this->redirect(); } } /** * Validate if custom module url has wcms-modules.json * @param string $url * @return boolean */ private function isValidModuleURL(string $url): bool { return strpos($url, 'wcms-modules.json') !== false; } /** * Verify if admin is logged in and has verified token for POST calls * @param bool $isRequest * @return bool */ public function verifyFormActions(bool $isRequest = false): bool { return ($isRequest ? isset($_REQUEST['token']) : isset($_POST['token'])) && $this->loggedIn && $this->hashVerify($isRequest ? $_REQUEST['token'] : $_POST['token']); } /** * Load JS and enable plugins to load JS * @return string * @throws Exception */ public function js(): string { if ($this->loggedIn) { $scripts = << EOT; $scripts .= ''; $scripts .= ''; $scripts .= ''; return $this->hook('js', $scripts)[0]; } return $this->hook('js', '')[0]; } /** * Load plugins (if any exist) * @return void */ public function loadPlugins(): void { $this->installedPlugins = []; $plugins = $this->rootDir . '/plugins'; if (!is_dir($plugins) && !mkdir($plugins) && !is_dir($plugins)) { return; } if (!is_dir($this->filesPath) && !mkdir($this->filesPath) && !is_dir($this->filesPath)) { return; } foreach (glob($plugins . '/*', GLOB_ONLYDIR) as $dir) { $pluginName = basename($dir); if (file_exists($dir . '/' . $pluginName . '.php')) { include $dir . '/' . $pluginName . '.php'; $this->installedPlugins[] = $pluginName; } } } /** * Loads theme files and functions.php file (if they exist) * @return void */ public function loadThemeAndFunctions(): void { $location = $this->rootDir . '/themes/' . $this->get('config', 'theme'); if (file_exists($location . '/functions.php')) { require_once $location . '/functions.php'; } # If page does not exist for non-logged-in users, then show 404 theme if it exists. $is404 = !$this->loggedIn && !$this->currentPageExists && $this->currentPage !== $this->get('config', 'login'); $customPageTemplate = sprintf('%s/%s.php', $location, $is404 ? '404' : $this->currentPage); require_once file_exists($customPageTemplate) ? $customPageTemplate : $location . '/theme.php'; } /** * Admin login verification * @return void * @throws Exception */ public function loginAction(): void { if ($this->currentPage !== $this->get('config', 'login')) { return; } if ($this->loggedIn) { $this->redirect(); } if ($_SERVER['REQUEST_METHOD'] !== 'POST') { return; } $password = $_POST['password'] ?? ''; if (password_verify($password, $this->get('config', 'password'))) { session_regenerate_id(true); $_SESSION['loggedIn'] = true; $_SESSION['rootDir'] = $this->rootDir; $this->set('config', 'forceLogout', false); $this->saveAdminLoginIP(); $this->redirect(); } $this->alert('test', '', 1); $this->redirect($this->get('config', 'login')); } /** * Save admins last 5 IPs */ private function saveAdminLoginIP(): void { $getAdminIP = $_SERVER['HTTP_CLIENT_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? null; if ($getAdminIP === null) { return; } if (!$savedIPs = $this->get('config', 'lastLogins')) { $this->set('config', 'lastLogins', []); $savedIPs = []; } $savedIPs = (array)$savedIPs; $savedIPs[date('Y/m/d H:i:s')] = $getAdminIP; krsort($savedIPs); $this->set('config', 'lastLogins', array_slice($savedIPs, 0, 5)); } /** * Check if admin is logged in * @return void */ public function loginStatus(): void { $this->loggedIn = $this->get('config', 'forceLogout') ? false : isset($_SESSION['loggedIn'], $_SESSION['rootDir']) && $_SESSION['rootDir'] === $this->rootDir; } /** * Login form view * @return array */ public function loginView(): array { return [ 'title' => $this->hook('loginView', 'Login')[0], 'description' => '', 'keywords' => '', 'content' => $this->hook('loginView', '

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; } }