diff --git a/.metadata b/.metadata index f155ec1..ff38257 100644 Binary files a/.metadata and b/.metadata differ diff --git a/.recipes/wondercms_php8/README.md b/.recipes/wondercms_php8/README.md new file mode 100644 index 0000000..e69de29 diff --git a/.recipes/wondercms_php8/docker-compose.yml b/.recipes/wondercms_php8/docker-compose.yml new file mode 100644 index 0000000..298bab9 --- /dev/null +++ b/.recipes/wondercms_php8/docker-compose.yml @@ -0,0 +1,46 @@ +# WonderCMS Flat File application with official Apache 2.4.x and PHP 8.x. +# +# This recipe doesn't extend the official image, it keeps all necessary +# modifications in the container. The application itself must be added +# with a persistent volume. +# +# Based on https://github.com/robiso/docker-wondercms/ +# +services: + # https://github.com/WonderCMS/wondercms + # https://github.com/robiso/docker-wondercms/ + # https://hub.docker.com/_/php + wondercms: + image: php:8-apache + restart: unless-stopped + # Take a look the possible public port collision. + ports: + - 8201:80 + volumes: + - /etc/localtime:/etc/localtime:ro + # Needs R/W for UID 33 (www-data). + - ./storage/volumes/wonder_html/:/var/www/html/ + environment: + TZ: Europe/Budapest + # We don't want to extend the official image to maintain + # watchtower's monitoring for updates. So we use CMD to + # make all the necessary changes. Unfortunately this will + # slightly prolong the start of the service. + command: + - /bin/bash + - -c + - | + DEBIAN_FRONTEND=noninteractive apt update + apt install -y libzip-dev zip + apt clean + rm -rf /var/lib/apt/lists/* + docker-php-ext-configure zip + docker-php-ext-install zip + a2enmod rewrite + cp -p /usr/local/etc/php/php.ini-production /usr/local/etc/php/conf.d/php.ini + apache2-foreground + + extra_hosts: + - "host.docker.internal:host-gateway" + labels: + com.centurylinklabs.watchtower.enable: true diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/.htaccess b/.recipes/wondercms_php8/storage/volumes/wonder_html/.htaccess new file mode 100644 index 0000000..f452eea --- /dev/null +++ b/.recipes/wondercms_php8/storage/volumes/wonder_html/.htaccess @@ -0,0 +1,8 @@ +Options -Indexes +ServerSignature Off +RewriteEngine on +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.+)$ index.php?page=$1 [QSA,L] +RewriteRule database.js - [F] +RewriteRule cache.json - [F] diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/index.php b/.recipes/wondercms_php8/storage/volumes/wonder_html/index.php new file mode 100644 index 0000000..1fa5736 --- /dev/null +++ b/.recipes/wondercms_php8/storage/volumes/wonder_html/index.php @@ -0,0 +1,3068 @@ +init(); + $Wcms->render(); +} + +class Wcms +{ + private const MODULES_JSON_VERSION = 1; + private const THEMES_DIR = 'themes'; + private const PLUGINS_DIR = 'plugins'; + private const VALID_DIRS = [self::THEMES_DIR, self::PLUGINS_DIR]; + private const THEME_PLUGINS_TYPES = [ + 'installs' => 'install', + 'updates' => 'update', + 'exists' => 'exist', + ]; + + /** Database main keys */ + public const DB_CONFIG = 'config'; + public const DB_MENU_ITEMS = 'menuItems'; + public const DB_MENU_ITEMS_SUBPAGE = 'subpages'; + public const DB_PAGES_KEY = 'pages'; + public const DB_PAGES_SUBPAGE_KEY = 'subpages'; + + /** @var int MIN_PASSWORD_LENGTH minimum number of characters */ + public const MIN_PASSWORD_LENGTH = 8; + + /** @var string WCMS_REPO - repo URL */ + public const WCMS_REPO = 'https://raw.githubusercontent.com/WonderCMS/wondercms/main/'; + + /** @var string WCMS_CDN_REPO - CDN repo URL */ + public const WCMS_CDN_REPO = 'https://raw.githubusercontent.com/WonderCMS/wondercms-cdn-files/main/'; + + /** @var string $currentPage - current page */ + public $currentPage = ''; + + /** @var array $currentPageTree - Tree hierarchy of the current page */ + public $currentPageTree = []; + + /** @var array $installedPlugins - Currently installed plugins */ + public $installedPlugins = []; + + /** @var bool $currentPageExists - check if current page exists */ + public $currentPageExists = false; + + /** @var object $db - content of database.js */ + protected $db; + + /** @var bool $loggedIn - check if admin is logged in */ + public $loggedIn = false; + + /** @var array $listeners for hooks */ + public $listeners = []; + + /** @var string $dataPath path to data folder */ + public $dataPath; + + /** @var string $modulesCachePath path to cached json file with Themes/Plugins data */ + protected $modulesCachePath; + + /** @var string $securityCachePath path to security json file with force https caching data */ + protected $securityCachePath; + + /** @var string $dbPath path to database.js */ + protected $dbPath; + + /** @var string $filesPath path to uploaded files */ + public $filesPath; + + /** @var string $rootDir root dir of the install (where index.php is) */ + public $rootDir; + + /** @var bool $headerResponseDefault read default header response */ + public $headerResponseDefault = true; + + /** @var string $headerResponse header status */ + public $headerResponse = 'HTTP/1.0 200 OK'; + + /** + * Constructor + * + * @param string $dataFolder + * @param string $filesFolder + * @param string $dbName + * @param string $rootDir + * @throws Exception + */ + public function __construct( + string $dataFolder = 'data', + string $filesFolder = 'files', + string $dbName = 'database.js', + string $rootDir = __DIR__ + ) { + $this->rootDir = $rootDir; + $this->setPaths($dataFolder, $filesFolder, $dbName); + $this->db = $this->getDb(); + } + + /** + * Setting default paths + * + * @param string $dataFolder + * @param string $filesFolder + * @param string $dbName + */ + public function setPaths( + string $dataFolder = 'data', + string $filesFolder = 'files', + string $dbName = 'database.js' + ): void { + $this->dataPath = sprintf('%s/%s', $this->rootDir, $dataFolder); + $this->dbPath = sprintf('%s/%s', $this->dataPath, $dbName); + $this->filesPath = sprintf('%s/%s', $this->dataPath, $filesFolder); + $this->modulesCachePath = sprintf('%s/%s', $this->dataPath, 'cache.json'); + $this->securityCachePath = sprintf('%s/%s', $this->dataPath, 'security.json'); + } + + /** + * Init function called on each page load + * + * @return void + * @throws Exception + */ + public function init(): void + { + $this->forceSSL(); + $this->loginStatus(); + $this->getSiteLanguage(); + $this->pageStatus(); + $this->logoutAction(); + $this->loginAction(); + $this->notFoundResponse(); + $this->loadPlugins(); + if ($this->loggedIn) { + $this->manuallyRefreshCacheData(); + $this->addCustomModule(); + $this->installUpdateModuleAction(); + $this->changePasswordAction(); + $this->deleteFileModuleAction(); + $this->changePageThemeAction(); + $this->backupAction(); + $this->forceHttpsAction(); + $this->saveChangesPopupAction(); + $this->saveLogoutToLoginScreenAction(); + $this->deletePageAction(); + $this->saveAction(); + $this->updateAction(); + $this->uploadFileAction(); + $this->notifyAction(); + } + } + + /** + * Set site language based on logged-in user + * @return string + * @throws Exception + */ + public function getSiteLanguage(): string + { + if ($this->loggedIn) { + $lang = $this->get('config', 'adminLang'); + } else { + $lang = $this->get('config', 'siteLang'); + } + + if (gettype($lang) === 'object' && empty(get_object_vars($lang))) { + $lang = 'en'; + $this->set('config', 'siteLang', $lang); + $this->set('config', 'adminLang', $lang); + } + + return $lang; + } + + /** + * Display the HTML. Called after init() + * @return void + */ + public function render(): void + { + header($this->headerResponse); + + // Alert admin that page is hidden + if ($this->loggedIn) { + $loadingPage = null; + foreach ($this->get('config', 'menuItems') as $item) { + if ($this->currentPage === $item->slug) { + $loadingPage = $item; + } + } + if ($loadingPage && $loadingPage->visibility === 'hide') { + $this->alert('info', + 'This page (' . $this->currentPage . ') is currently hidden from the menu. Open menu visibility settings'); + } + } + + $this->loadThemeAndFunctions(); + } + + /** + * Function used by plugins to add a hook + * + * @param string $hook + * @param callable $functionName + */ + public function addListener(string $hook, callable $functionName): void + { + $this->listeners[$hook][] = $functionName; + } + + /** + * Add alert message for admin + * + * @param string $class see bootstrap alerts classes + * @param string $message the message to display + * @param bool $sticky can it be closed? + * @return void + */ + public function alert(string $class, string $message, bool $sticky = false): void + { + if (isset($_SESSION['alert'][$class])) { + foreach ($_SESSION['alert'][$class] as $v) { + if ($v['message'] === $message) { + return; + } + } + } + $_SESSION['alert'][$class][] = ['class' => $class, 'message' => $this->hook('alert', $message)[0], 'sticky' => $sticky]; + } + + /** + * Display alert message to the admin + * @return string + */ + public function alerts(): string + { + if (!isset($_SESSION['alert'])) { + return ''; + } + $output = '
'; + $output .= ''; + foreach ($_SESSION['alert'] as $alertClass) { + foreach ($alertClass as $alert) { + $output .= '
' + . (!$alert['sticky'] ? '' : '') + . $alert['message'] + . $this->hideAlerts(); + } + } + $output .= '
'; + unset($_SESSION['alert']); + return $output; + } + + /** + * Allow admin to dismiss alerts + * @return string + */ + public function hideAlerts(): string + { + if (!$this->loggedIn) { + return ''; + } + $output = ''; + $output .= '
Hide all alerts until next login
'; + return $output; + } + + /** + * Get an asset (returns URL of the asset) + * + * @param string $location + * @return string + */ + public function asset(string $location): string + { + return self::url('themes/' . $this->get('config', 'theme') . '/' . $location); + } + + /** + * Backup whole WonderCMS installation + * + * @return void + * @throws Exception + */ + public function backupAction(): void + { + if (!$this->loggedIn) { + return; + } + $backupList = glob($this->filesPath . '/*-backup-*.zip'); + if (!empty($backupList)) { + $this->alert('danger', + 'Backup files detected. View and delete unnecessary backup files'); + } + if (isset($_POST['backup']) && $this->verifyFormActions()) { + $this->zipBackup(); + } + } + + /** + * Save if WCMS should force https + * @return void + * @throws Exception + */ + public function forceHttpsAction(): void + { + if (isset($_POST['forceHttps']) && $this->verifyFormActions()) { + $this->set('config', 'forceHttps', $_POST['forceHttps'] === 'true'); + $this->updateSecurityCache(); + + $this->alert('success', 'Force HTTPs was successfully changed.'); + $this->redirect(); + } + } + + /** + * Save if WCMS should show the popup before saving the page content changes + * @return void + * @throws Exception + */ + public function saveChangesPopupAction(): void + { + if (isset($_POST['saveChangesPopup']) && $this->verifyFormActions()) { + $this->set('config', 'saveChangesPopup', $_POST['saveChangesPopup'] === 'true'); + $this->alert('success', 'Saving the confirmation popup settings changed.'); + $this->redirect(); + } + } + + /** + * Save if admin should be redirected to login/last viewed page after logging out. + * @return void + * @throws Exception + */ + public function saveLogoutToLoginScreenAction(): void + { + if (isset($_POST['logoutToLoginScreen']) && $this->verifyFormActions()) { + $redirectToLogin = $_POST['logoutToLoginScreen'] === 'true'; + $message = $redirectToLogin + ? 'You will be redirected to login screen after logging out.' + : 'You will be redirected to last viewed screen after logging out.'; + $this->set('config', 'logoutToLoginScreen', $_POST['logoutToLoginScreen'] === 'true'); + $this->alert('success', $message); + $this->redirect(); + } + } + + /** + * Update cache for security settings. + * @return void + */ + public function updateSecurityCache(): void + { + $content = ['forceHttps' => $this->isHttpsForced()]; + $json = json_encode($content, JSON_FORCE_OBJECT | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + file_put_contents($this->securityCachePath, $json, LOCK_EX); + } + + /** + * Get a static block + * + * @param string $key name of the block + * @return string + */ + public function block(string $key): string + { + $blocks = $this->get('blocks'); + $content = ''; + + if (isset($blocks->{$key})) { + $content = $this->loggedIn + ? $this->editable($key, $blocks->{$key}->content, 'blocks') + : $blocks->{$key}->content; + } + return $this->hook('block', $content, $key)[0]; + } + + /** + * Change password + * @return void + * @throws Exception + */ + public function changePasswordAction(): void + { + if (isset($_POST['old_password'], $_POST['new_password'], $_POST['repeat_password']) + && $_SESSION['token'] === $_POST['token'] + && $this->loggedIn + && $this->hashVerify($_POST['token'])) { + if (!password_verify($_POST['old_password'], $this->get('config', 'password'))) { + $this->alert('danger', + 'Wrong password. Re-open security settings'); + $this->redirect(); + return; + } + if (strlen($_POST['new_password']) < self::MIN_PASSWORD_LENGTH) { + $this->alert('danger', + sprintf('Password must be longer than %d characters. Re-open security settings', + self::MIN_PASSWORD_LENGTH)); + $this->redirect(); + return; + } + if ($_POST['new_password'] !== $_POST['repeat_password']) { + $this->alert('danger', + 'New passwords do not match. Re-open security settings'); + $this->redirect(); + return; + } + $this->set('config', 'password', password_hash($_POST['new_password'], PASSWORD_DEFAULT)); + $this->set('config', 'forceLogout', true); + $this->logoutAction(true); + $this->alert('success', '
Password changed. Log in again.
', 1); + } + } + + /** + * Check if folders are writable + * Executed once before creating the database file + * + * @param string $folder the relative path of the folder to check/create + * @return void + * @throws Exception + */ + public function checkFolder(string $folder): void + { + if (!is_dir($folder) && !mkdir($folder, 0755) && !is_dir($folder)) { + throw new Exception('Could not create data folder.'); + } + if (!is_writable($folder)) { + throw new Exception('Could write to data folder.'); + } + } + + /** + * Initialize the JSON database if it doesn't exist + * @return void + * @throws Exception + */ + public function createDb(): void + { + // Check php requirements + $this->checkMinimumRequirements(); + $password = $this->generatePassword(); + $this->db = (object)[ + self::DB_CONFIG => [ + 'siteTitle' => 'Website title', + 'siteLang' => 'en', + 'adminLang' => 'en', + 'theme' => 'sky', + 'defaultPage' => 'home', + 'login' => 'loginURL', + 'forceLogout' => false, + 'forceHttps' => false, + 'saveChangesPopup' => false, + 'password' => password_hash($password, PASSWORD_DEFAULT), + 'lastLogins' => [], + 'lastModulesSync' => null, + 'customModules' => $this->defaultCustomModules(), + 'menuItems' => [ + '0' => [ + 'name' => 'Home', + 'slug' => 'home', + 'visibility' => 'show', + self::DB_MENU_ITEMS_SUBPAGE => new stdClass() + ], + '1' => [ + 'name' => 'How to', + 'slug' => 'how-to', + 'visibility' => 'show', + self::DB_MENU_ITEMS_SUBPAGE => new stdClass() + ] + ] + ], + 'pages' => [ + '404' => [ + 'title' => '404', + 'keywords' => '404', + 'description' => '404', + 'content' => '

404 - Page not found

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

Welcome to your website

+ +

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

+ +

Click here to login

+ +

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

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

Easy editing

+

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

+ +

Create new page

+

Pages can be created in the Settings.

+ +

Start a blog or change your theme

+

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

+ +

Support WonderCMS

+

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

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

About your website

+ +
+

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

+

This editable area is visible on all pages.

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

Login to your website

+

+ + + +
+
')[0] + ]; + } + + /** + * Logout action + * @param bool $forceLogout + * @return void + */ + public function logoutAction(bool $forceLogout = false): void + { + if ($forceLogout + || ($this->currentPage === 'logout' + && isset($_REQUEST['token']) + && $this->hashVerify($_REQUEST['token']))) { + $to = isset($_GET['to']) && !empty($_GET['to']) && !$this->isLogoutToLoginScreenEnabled() + ? $_GET['to'] + : $this->get('config', 'login'); + unset($_SESSION['loggedIn'], $_SESSION['rootDir'], $_SESSION['token'], $_SESSION['alert']); + $this->redirect($to); + } + } + + /** + * If admin is logged in and on existing page, this will save previous page and push it to logout action + * @return string|null + */ + private function logoutToUrl(): ?string + { + if (!$this->loggedIn || !$this->currentPageExists) { + return null; + } + + return $this->getCurrentPagePath(); + } + + /** + * Return menu items, if they are set to be visible + * @return string + */ + public function menu(): string + { + $output = ''; + foreach ($this->get('config', 'menuItems') as $item) { + if ($item->visibility === 'hide') { + continue; + } + $output .= $this->renderPageNavMenuItem($item); + } + return $this->hook('menu', $output)[0]; + } + + /** + * 404 header response + * @return void + */ + public function notFoundResponse(): void + { + if (!$this->loggedIn && !$this->currentPageExists && $this->headerResponseDefault) { + $this->headerResponse = 'HTTP/1.1 404 Not Found'; + } + } + + /** + * Return 404 page to visitors + * Admin can create a page that doesn't exist yet + */ + public function notFoundView() + { + if ($this->loggedIn) { + return [ + 'title' => str_replace('-', ' ', $this->currentPage), + 'description' => '', + 'keywords' => '', + 'content' => '

Click to create content

' + ]; + } + return $this->get('pages', '404'); + } + + /** + * Admin notifications + * Alerts for non-existent pages, changing default settings, new version/update + * @return void + * @throws Exception + */ + public function notifyAction(): void + { + if (!$this->loggedIn) { + return; + } + if (!$this->currentPageExists) { + $this->alert( + 'info', + 'This page (' . $this->currentPage . ') doesn\'t exist. Editing the content below will create it.' + ); + } + if ($this->get('config', 'login') === 'loginURL') { + $this->alert('danger', + 'Change your login URL and save it for later use. Open security settings'); + } + + $this->checkModulesCache(); + } + + /** + * Checks if there is new Wcms version + */ + private function checkWcmsCoreUpdate(): void + { + $onlineVersion = $this->getOfficialVersion(); + if ($onlineVersion > VERSION) { + $this->alert( + 'info', + '

New WonderCMS update available

+ Check what\'s new + and backup your website before updating. +
+ +
+ + +
' + ); + } + } + + /** + * Update menu visibility state + * + * @param string $visibility - "show" for visible, "hide" for invisible + * @param string $menu + * @throws Exception + */ + public function updateMenuItemVisibility(string $visibility, string $menu): void + { + if (!in_array($visibility, ['show', 'hide'], true)) { + return; + } + + $menuTree = explode('-', $menu); + $menuItems = $menuSelectionObject = clone $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS); + + // Find sub menu item + if ($menuTree) { + $mainParentMenu = array_shift($menuTree); + $menuSelectionObject = $menuItems->{$mainParentMenu}; + foreach ($menuTree as $childMenuKey) { + $menuSelectionObject = $menuSelectionObject->subpages->{$childMenuKey}; + } + } + + $menuSelectionObject->visibility = $visibility; + $this->set(self::DB_CONFIG, self::DB_MENU_ITEMS, $menuItems); + } + + /** + * Reorder the pages + * + * @param int $content 1 for down arrow or -1 for up arrow + * @param string $menu + * @return void + * @throws Exception + */ + public function orderMenuItem(int $content, string $menu): void + { + // check if content is 1 or -1 as only those values are acceptable + if (!in_array($content, [1, -1], true)) { + return; + } + $menuTree = explode('-', $menu); + $mainParentMenu = $selectedMenuKey = array_shift($menuTree); + $menuItems = $menuSelectionObject = clone $this->get(self::DB_CONFIG, self::DB_MENU_ITEMS); + + // Sorting of subpages in menu + if ($menuTree) { + $selectedMenuKey = array_pop($menuTree); + $menuSelectionObject = $menuItems->{$mainParentMenu}->subpages; + foreach ($menuTree as $childMenuKey) { + $menuSelectionObject = $menuSelectionObject->{$childMenuKey}->subpages; + } + } + + $targetPosition = $selectedMenuKey + $content; + + // Find and switch target and selected menu position in DB + $selectedMenu = $menuSelectionObject->{$selectedMenuKey}; + $targetMenu = $menuSelectionObject->{$targetPosition}; + $menuSelectionObject->{$selectedMenuKey} = $targetMenu; + $menuSelectionObject->{$targetPosition} = $selectedMenu; + + $this->set(self::DB_CONFIG, self::DB_MENU_ITEMS, $menuItems); + } + + /** + * Return pages and display correct view (actual page or 404) + * Display different content and editable areas for admin + * + * @param string $key + * @return string + */ + public function page(string $key): string + { + $segments = $this->getCurrentPageData(); + if (!$this->currentPageExists || !$segments) { + $segments = $this->get('config', 'login') === $this->currentPage + ? (object)$this->loginView() + : (object)$this->notFoundView(); + } + + $segments->content = $segments->content ?? '

Click here add content

'; + $keys = [ + 'title' => $segments->title, + 'description' => $segments->description, + 'keywords' => $segments->keywords, + 'content' => $this->loggedIn + ? $this->editable('content', $segments->content, 'pages') + : $segments->content + ]; + $content = $keys[$key] ?? ''; + return $this->hook('page', $content, $key)[0]; + } + + /** + * Return database data of current page + * + * @return object|null + */ + public function getCurrentPageData(): ?object + { + return $this->getPageData(implode('/', $this->currentPageTree)); + } + + /** + * Return database data of any page + * + * @param string $slugTree + * @return object|null + */ + public function getPageData(string $slugTree): ?object + { + $arraySlugTree = explode('/', $slugTree); + $pageData = null; + foreach ($arraySlugTree as $slug) { + if ($pageData === null) { + $pageData = $this->get(self::DB_PAGES_KEY)->{$slug} ?? null; + continue; + } + + $pageData = $pageData->{self::DB_PAGES_SUBPAGE_KEY}->{$slug} ?? null; + if (!$pageData) { + return null; + } + } + + return $pageData; + } + + /** + * Get current page url + * + * @return string + */ + public function getCurrentPageUrl(): string + { + return self::url($this->getCurrentPagePath()); + } + + /** + * Get current page path + * + * @return string + */ + public function getCurrentPagePath(): string + { + $path = ''; + foreach ($this->currentPageTree as $parentPage) { + $path .= $parentPage . '/'; + } + + return $path; + } + + /** + * Page status (exists or doesn't exist) + * @return void + */ + public function pageStatus(): void + { + $this->currentPage = $this->parseUrl() ?: $this->get('config', 'defaultPage'); + $this->currentPageExists = !empty($this->getCurrentPageData()); + } + + /** + * URL parser + * @return string + */ + public function parseUrl(): string + { + $page = $_GET['page'] ?? null; + $page = !empty($page) ? trim(htmlspecialchars($page, ENT_QUOTES)) : null; + + if (!isset($page) || !$page) { + $defaultPage = $this->get('config', 'defaultPage'); + $this->currentPageTree = explode('/', $defaultPage); + return $defaultPage; + } + + $this->currentPageTree = explode('/', rtrim($page, '/')); + if ($page === $this->get('config', 'login')) { + return $page; + } + + $currentPage = end($this->currentPageTree); + return $this->slugify($currentPage); + } + + /** + * Recursive delete - used for deleting files, themes, plugins + * + * @param string $file + * @return void + */ + public function recursiveDelete(string $file): void + { + if (is_dir($file)) { + $files = new DirectoryIterator($file); + foreach ($files as $dirFile) { + if (!$dirFile->isDot()) { + $dirFile->isDir() ? $this->recursiveDelete($dirFile->getPathname()) : unlink($dirFile->getPathname()); + } + } + rmdir($file); + } elseif (is_file($file)) { + unlink($file); + } + } + + /** + * Redirect to any URL + * + * @param string $location + * @return void + */ + public function redirect(string $location = ''): void + { + header('Location: ' . self::url($location)); + die(); + } + + /** + * Save object to disk (default is set for DB) + * @param string|null $path + * @param object|null $content + * @return void + * @throws Exception + */ + public function save(string $path = null, object $content = null): void + { + $path = $path ?? $this->dbPath; + $content = $content ?? $this->db; + $json = json_encode($content, JSON_FORCE_OBJECT | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + if (empty($content) || empty($json) || json_last_error() !== JSON_ERROR_NONE) { + $errorMessage = sprintf( + '%s - Error while trying to save in %s: %s', + time(), + $path, + print_r($content, true) + ); + try { + $randomNumber = random_bytes(8); + } catch (Exception $e) { + $randomNumber = microtime(false); + } + $logName = date('Y-m-d H:i:s') . '-error-' . bin2hex($randomNumber) . '.log'; + $logsPath = sprintf('%s/data/logs', $this->rootDir); + $this->checkFolder($logsPath); + error_log( + $errorMessage, + 3, + sprintf('%s/%s', $logsPath, $logName) + ); + return; + } + file_put_contents($path, $json, LOCK_EX); + } + + /** + * Saving menu items, default page, login URL, theme, editable content + * @return void + * @throws Exception + */ + public function saveAction(): void + { + if (!$this->loggedIn) { + return; + } + if (isset($_SESSION['redirect_to'])) { + $newUrl = $_SESSION['redirect_to']; + $newPageName = $_SESSION['redirect_to_name']; + unset($_SESSION['redirect_to'], $_SESSION['redirect_to_name']); + $this->alert('success', + "Page $newPageName created. Click here to open it."); + $this->redirect($newUrl); + } + if (isset($_POST['fieldname'], $_POST['content'], $_POST['target'], $_POST['token']) + && $this->hashVerify($_POST['token'])) { + [$fieldname, $content, $target, $menu, $visibility] = $this->hook('save', $_POST['fieldname'], + $_POST['content'], $_POST['target'], $_POST['menu'] ?? null, ($_POST['visibility'] ?? 'hide')); + if ($target === 'menuItemUpdate' && $menu !== null) { + $this->updateMenuItem($content, $menu, $visibility); + $_SESSION['redirect_to_name'] = $content; + $_SESSION['redirect_to'] = $this->slugify($content); + } + if ($target === 'menuItemCreate' && $menu !== null) { + $this->createMenuItem($content, $menu, $visibility, true); + } + if ($target === 'menuItemVsbl' && $menu !== null) { + $this->updateMenuItemVisibility($visibility, $menu); + } + if ($target === 'menuItemOrder' && $menu !== null) { + $this->orderMenuItem($content, $menu); + } + if ($fieldname === 'defaultPage' && $this->getPageData($content) === null) { + return; + } + if ($fieldname === 'login' && (empty($content) || $this->getPageData($content) !== null)) { + return; + } + if ($fieldname === 'theme' && !is_dir($this->rootDir . '/themes/' . $content)) { + return; + } + if ($target === 'config') { + $this->set('config', $fieldname, $content); + } elseif ($target === 'blocks') { + $this->set('blocks', $fieldname, 'content', $content); + } elseif ($target === 'pages') { + if (!$this->currentPageExists) { + $this->createPage($this->currentPageTree, true); + } + $this->updatePage($this->currentPageTree, $fieldname, $content); + } + } + } + + /** + * Set something to database + * @return void + * @throws Exception + */ + public function set(): void + { + $args = func_get_args(); + + $value = array_pop($args); + $lastKey = array_pop($args); + $data = $this->db; + foreach ($args as $arg) { + $data = $data->{$arg}; + } + $data->{$lastKey} = $value; + + $this->save(); + } + + /** + * Display admin settings panel + * @return string + * @throws Exception + */ + public function settings(): string + { + if (!$this->loggedIn) { + return ''; + } + $currentPageData = $this->getCurrentPageData(); + $fileList = array_slice(scandir($this->filesPath), 2); + $logoutTo = $this->logoutToUrl(); + $isHttpsForced = $this->isHttpsForced(); + $isSaveChangesPopupEnabled = $this->isSaveChangesPopupEnabled(); + $isLogoutToLoginScreenEnabled = $this->isLogoutToLoginScreenEnabled(); + $output = ' + +


Saving

+


Checking for updates

+
+ Settings + +
'; + return $this->hook('settings', $output)[0]; + } + + /** + * Render options for default page selection + * + * @param object $menuItem + * @param string $defaultPage + * @param string $parentSlug + * @param string $parentName + * @return string + */ + private function renderDefaultPageOptions( + object $menuItem, + string $defaultPage, + string $parentSlug = '', + string $parentName = '' + ): string { + $slug = $parentSlug ? sprintf('%s/%s', $parentSlug, $menuItem->slug) : $menuItem->slug; + $name = $parentName ? sprintf('%s | %s', $parentName, $menuItem->name) : $menuItem->name; + $output = ''; + + foreach ($menuItem->subpages ?? [] as $subpage) { + $output .= $this->renderDefaultPageOptions($subpage, $defaultPage, $slug, $name); + } + + return $output; + } + + /** + * Render page navigation items + * + * @param object $item + * @param string $parentSlug + * @return string + */ + private function renderPageNavMenuItem(object $item, string $parentSlug = ''): string + { + $subpages = $visibleSubpage = false; + if (property_exists($item, 'subpages') && !empty((array)$item->subpages)) { + $subpages = $item->subpages; + $visibleSubpage = $subpages && in_array('show', array_column((array)$subpages, 'visibility')); + } + + $parentSlug .= $subpages ? $item->slug . '/' : $item->slug; + $output = ''; + + return $output; + } + + /** + * Render menu item for settings + * + * @param string $menuKeyTree + * @param object $value + * @param bool $isFirstEl + * @param bool $isLastEl + * @param string $slugTree + * @return string + * @throws Exception + */ + private function renderSettingsMenuItem( + string $menuKeyTree, + object $value, + bool $isFirstEl, + bool $isLastEl, + string $slugTree + ): string { + $arraySlugTree = explode('/', $slugTree); + array_shift($arraySlugTree); + $subMenuLevel = count($arraySlugTree); + $output = '
+ +
+
+ +
+
'; + + if (!$isFirstEl) { + $output .= ''; + } + if (!$isLastEl) { + $output .= ''; + } + $output .= ' visit +
+ '; + + return $output; + } + + /** + * Render sub menu item for settings + * + * @param object $subpages + * @param string $parentKeyTree + * @param string $parentSlugTree + * @return string + * @throws Exception + */ + private function renderSettingsSubMenuItem(object $subpages, string $parentKeyTree, string $parentSlugTree): string + { + $subpages = get_mangled_object_vars($subpages); + reset($subpages); + $firstSubpage = key($subpages); + end($subpages); + $endSubpage = key($subpages); + $output = ''; + + foreach ($subpages as $subpageKey => $subpage) { + $keyTree = $parentKeyTree . '-' . $subpageKey; + $slugTree = $parentSlugTree . '/' . $subpage->slug; + $output .= '
+
'; + $firstElement = ($subpageKey === $firstSubpage); + $lastElement = ($subpageKey === $endSubpage); + $output .= $this->renderSettingsMenuItem($keyTree, $subpage, $firstElement, $lastElement, $slugTree); + + // Recursive method for rendering infinite subpages + if (property_exists($subpage, 'subpages')) { + $output .= $this->renderSettingsSubMenuItem($subpage->subpages, $keyTree, $slugTree); + } + $output .= '
+
'; + } + + return $output; + } + + /** + * Render last login IPs + * @return string + */ + private function renderAdminLoginIPs(): string + { + $getIPs = $this->get('config', 'lastLogins') ?? []; + $renderIPs = ''; + foreach ($getIPs as $time => $adminIP) { + $renderIPs .= sprintf('%s - %s
', date('M d, Y H:i:s', strtotime($time)), $adminIP); + } + return '

Last 5 logins

+
+ ' . $renderIPs . ' +
'; + } + + /** + * Render Plugins/Themes cards + * @param string $type + * @return string + * @throws Exception + */ + private function renderModuleTab(string $type = 'themes'): string + { + $output = '
+ Check for updates +
+
'; + $defaultImage = 'No preview'; + $updates = $exists = $installs = ''; + foreach ($this->listAllModules($type) as $addonType => $addonModules) { + foreach ($addonModules as $directoryName => $addon) { + $name = $addon['name']; + $info = $addon['summary']; + $infoUrl = $addon['repo']; + $currentVersion = $addon['currentVersion'] ? sprintf('Installed version: %s', + $addon['currentVersion']) : ''; + $isThemeSelected = $this->get('config', 'theme') === $directoryName; + + $image = $addon['image'] !== null ? '' . $name . '' : $defaultImage; + $installButton = $addon['install'] ? ' Install' : ''; + $updateButton = !$addon['install'] && $addon['update'] ? ' Update to ' . $addon['version'] . '' : ''; + $removeButton = !$addon['install'] ? '' : ''; + $inactiveThemeButton = $type === 'themes' && !$addon['install'] && !$isThemeSelected ? ' Activate' : ''; + $activeThemeButton = $type === 'themes' && !$addon['install'] && $isThemeSelected ? 'Active' : ''; + + $html = "
+
+ $image +

$name

+

$info

+

$currentVersion
More info

+
$inactiveThemeButton $activeThemeButton
+
$installButton
+
$updateButton $removeButton
+
+
"; + + switch ($addonType) { + case self::THEME_PLUGINS_TYPES['updates']: + $updates .= $html; + break; + case self::THEME_PLUGINS_TYPES['exists']: + $exists .= $html; + break; + case self::THEME_PLUGINS_TYPES['installs']: + default: + $installs .= $html; + break; + } + } + } + $output .= $updates; + $output .= $exists; + $output .= $installs; + $output .= '
+

Custom module

+
+
+
+ +
+
+ +
+

Read more about custom modules

+
'; + return $output; + } + + /** + * Slugify page + * + * @param string $text for slugifying + * @return string + */ + public function slugify(string $text): string + { + $text = preg_replace('~[^\\pL\d]+~u', '-', $text); + $text = trim(htmlspecialchars(mb_strtolower($text), ENT_QUOTES), '/'); + $text = trim($text, '-'); + return empty($text) ? '-' : $text; + } + + /** + * Delete something from database + * Has variadic arguments + * @return void + */ + public function unset(): void + { + $numArgs = func_num_args(); + $args = func_get_args(); + switch ($numArgs) { + case 1: + unset($this->db->{$args[0]}); + break; + case 2: + unset($this->db->{$args[0]}->{$args[1]}); + break; + case 3: + unset($this->db->{$args[0]}->{$args[1]}->{$args[2]}); + break; + case 4: + unset($this->db->{$args[0]}->{$args[1]}->{$args[2]}->{$args[3]}); + break; + } + $this->save(); + } + + /** + * Update WonderCMS + * Overwrites index.php with latest version from GitHub + * @return void + */ + public function updateAction(): void + { + if (!isset($_POST['update']) || !$this->verifyFormActions()) { + return; + } + $contents = $this->getFileFromRepo('index.php'); + if ($contents) { + file_put_contents(__FILE__, $contents); + $this->alert('success', 'WonderCMS successfully updated. Wohoo!'); + $this->redirect(); + } + $this->alert('danger', 'Something went wrong. Could not update WonderCMS.'); + $this->redirect(); + } + + /** + * Upload file to files folder + * List of allowed extensions + * @return void + */ + public function uploadFileAction(): void + { + if (!isset($_FILES['uploadFile']) || !$this->verifyFormActions()) { + return; + } + $allowedMimeTypes = [ + 'video/avi', + 'text/css', + 'text/x-asm', + 'application/msword', + 'application/vnd.ms-word', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'video/x-flv', + 'image/gif', + 'text/html', + 'image/x-icon', + 'image/jpeg', + 'application/octet-stream', + 'audio/mp4', + 'video/x-matroska', + 'video/quicktime', + 'audio/mpeg', + 'video/mp4', + 'video/mpeg', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.text', + 'application/ogg', + 'video/ogg', + 'application/pdf', + 'image/png', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/photoshop', + 'application/rar', + 'image/svg', + 'image/svg+xml', + 'image/avif', + 'image/webp', + 'application/svg+xm', + 'text/plain', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'video/webm', + 'video/x-ms-wmv', + 'application/zip', + ]; + + $allowedExtensions = [ + 'avi', + 'avif', + 'css', + 'doc', + 'docx', + 'flv', + 'gif', + 'htm', + 'html', + 'ico', + 'jpeg', + 'jpg', + 'kdbx', + 'm4a', + 'mkv', + 'mov', + 'mp3', + 'mp4', + 'mpg', + 'ods', + 'odt', + 'ogg', + 'ogv', + 'pdf', + 'png', + 'ppt', + 'pptx', + 'psd', + 'rar', + 'svg', + 'txt', + 'xls', + 'xlsx', + 'webm', + 'webp', + 'wmv', + 'zip', + ]; + if (!isset($_FILES['uploadFile']['error']) || is_array($_FILES['uploadFile']['error'])) { + $this->alert('danger', 'Invalid parameters.'); + $this->redirect(); + } + switch ($_FILES['uploadFile']['error']) { + case UPLOAD_ERR_OK: + break; + case UPLOAD_ERR_NO_FILE: + $this->alert('danger', + 'No file selected. Re-open file options'); + $this->redirect(); + break; + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + $this->alert('danger', + 'File too large. Change maximum upload size limit or contact your host. Re-open file options'); + $this->redirect(); + break; + default: + $this->alert('danger', 'Unknown error.'); + $this->redirect(); + } + $mimeType = ''; + $fileName = basename(str_replace( + ['"', "'", '*', '<', '>', '%22', ''', '%', ';', '#', '&', './', '../', '/', '+'], + '', + htmlspecialchars(strip_tags($_FILES['uploadFile']['name'])) + )); + $nameExploded = explode('.', $fileName); + $ext = strtolower(array_pop($nameExploded)); + + if (class_exists('finfo')) { + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($_FILES['uploadFile']['tmp_name']); + } elseif (function_exists('mime_content_type')) { + $mimeType = mime_content_type($_FILES['uploadFile']['tmp_name']); + } elseif (array_key_exists($ext, $allowedExtensions)) { + $mimeType = $allowedExtensions[$ext]; + } + if (!in_array($mimeType, $allowedMimeTypes, true) || !in_array($ext, $allowedExtensions)) { + $this->alert('danger', + 'File format is not allowed. Re-open file options'); + $this->redirect(); + } + if (!move_uploaded_file($_FILES['uploadFile']['tmp_name'], $this->filesPath . '/' . $fileName)) { + $this->alert('danger', 'Failed to move uploaded file.'); + } + $this->alert('success', + 'File uploaded. Open file options to see your uploaded file'); + $this->redirect(); + } + + /** + * Get canonical URL + * + * @param string $location + * @return string + */ + public static function url(string $location = ''): string + { + $showHttps = Wcms::isCurrentlyOnSSL(); + $dataPath = sprintf('%s/%s', __DIR__, 'data'); + $securityCachePath = sprintf('%s/%s', $dataPath, 'security.json'); + + if (is_file($securityCachePath) && file_exists($securityCachePath)) { + $securityCache = json_decode(file_get_contents($securityCachePath), true); + $showHttps = $securityCache['forceHttps'] ?? false; + } + + $serverPort = ((($_SERVER['SERVER_PORT'] == '80') || ($_SERVER['SERVER_PORT'] == '443')) ? '' : ':' . $_SERVER['SERVER_PORT']); + return ($showHttps ? 'https' : 'http') + . '://' . ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME']) + . ($_SERVER['HTTP_HOST'] ? '' : $serverPort) + . ((dirname($_SERVER['SCRIPT_NAME']) === '/') ? '' : dirname($_SERVER['SCRIPT_NAME'])) + . '/' . $location; + } + + /** + * Create a ZIP backup of whole WonderCMS installation (all files) + * + * @return void + */ + public function zipBackup(): void + { + try { + $randomNumber = random_bytes(8); + } catch (Exception $e) { + $randomNumber = microtime(false); + } + $zipName = date('Y-m-d') . '-backup-' . bin2hex($randomNumber) . '.zip'; + $zipPath = $this->rootDir . '/data/files/' . $zipName; + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE) !== true) { + $this->alert('danger', 'Cannot create ZIP archive.'); + } + $iterator = new RecursiveDirectoryIterator($this->rootDir); + $iterator->setFlags(RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::SELF_FIRST); + foreach ($files as $file) { + $file = realpath($file); + $source = realpath($this->rootDir); + if (is_dir($file)) { + $zip->addEmptyDir(str_replace($source . '/', '', $file . '/')); + } elseif (is_file($file)) { + $zip->addFromString(str_replace($source . '/', '', $file), file_get_contents($file)); + } + } + $zip->close(); + $this->redirect('data/files/' . $zipName); + } + + /** + * Check if currently user is on https + * @return bool + */ + public static function isCurrentlyOnSSL(): bool + { + return (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) === 'on') + || (isset($_SERVER['HTTP_FRONT_END_HTTPS']) && strtolower($_SERVER['HTTP_FRONT_END_HTTPS']) === 'on') + || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https'); + } + + /** + * Check compatibility + */ + private function checkMinimumRequirements(): void + { + if (PHP_VERSION_ID <= 70200) { + die('

To run WonderCMS, PHP version 7.2 or greater is required.

'); + } + $extensions = ['curl', 'zip', 'mbstring']; + $missingExtensions = []; + foreach ($extensions as $ext) { + if (!extension_loaded($ext)) { + $missingExtensions[] = $ext; + } + } + if (!empty($missingExtensions)) { + die('

The following extensions are required: ' + . implode(', ', $missingExtensions) + . '. Contact your host or configure your server to enable them with correct permissions.

'); + } + } + + /** + * Helper for reseting the index key of the object + * @param stdClass $object + * @return stdClass + */ + private function reindexObject(stdClass $object): stdClass + { + $reindexObject = new stdClass; + $index = 0; + foreach ($object as $value) { + $reindexObject->{$index} = $value; + $index++; + } + return $reindexObject; + } + + /** + * Check if user has forced https + * @return bool + */ + private function isHttpsForced(): bool + { + $value = $this->get('config', 'forceHttps'); + if (gettype($value) === 'object' && empty(get_object_vars($value))) { + return false; + } + + return $value ?? false; + } + + /** + * Check if user has confirmation dialog enabled + * @return bool + */ + private function isSaveChangesPopupEnabled(): bool + { + $value = $this->get('config', 'saveChangesPopup'); + if (gettype($value) === 'object' && empty(get_object_vars($value))) { + return false; + } + + return $value ?? false; + } + + /** + * Check if admin will be redirected to the login screen or current page screen after logout. + * @return bool + */ + private function isLogoutToLoginScreenEnabled(): bool + { + $value = $this->get('config', 'logoutToLoginScreen'); + if (gettype($value) === 'object' && empty(get_object_vars($value))) { + return true; + } + + return $value ?? true; + } +} diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-700.woff2 b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-700.woff2 new file mode 100644 index 0000000..e4b82d8 Binary files /dev/null and b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-700.woff2 differ diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-regular.woff2 b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-regular.woff2 new file mode 100644 index 0000000..66649d4 Binary files /dev/null and b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/fonts/catamaran-v7-latin-ext_latin-regular.woff2 differ diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/style.css b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/style.css new file mode 100644 index 0000000..10c8ee2 --- /dev/null +++ b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/css/style.css @@ -0,0 +1,557 @@ +@font-face { + font-family: 'Catamaran'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('fonts/catamaran-v7-latin-ext_latin-regular.woff2') format('woff2') +} +@font-face { + font-family: 'Catamaran'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('fonts/catamaran-v7-latin-ext_latin-700.woff2') format('woff2') +} +@font-face { + font-family: 'Catamaran'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: url('fonts/catamaran-v7-latin-ext_latin-900.woff2') format('woff2') +} + +html, body, div, span, applet, object, +iframe, h1, h2, h3, h4, h5, h6, p, blockquote, +pre, a, abbr, acronym, address, big, cite, +code, del, dfn, em, img, ins, kbd, q, s, samp, strike, strong, sub, sup, tt, var, b, +u, i, center, dl, dt, dd, li, fieldset, +form, label, legend, caption, +tfoot, article, aside, +canvas, details, embed, figure, figcaption, +footer, header, hgroup, menu, nav, output, ruby, +section, summary, time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline + +} + +html, body { + box-shadow: 0 0 200px rgba(0, 0, 0, 0.27) inset; + background-image: -webkit-linear-gradient(45deg, rgb(102, 95, 238) 0%, rgb(39, 194, 222) 100%); + min-height: 100%; + font-family: 'Catamaran'; + color: #fff !important +} +.actions li { + list-style: none +} + +input::-moz-focus-inner { + border: 0; + padding: 0 +} + +/* Basic */ + html { + box-sizing: border-box + } + + *, *:before, *:after { + box-sizing: inherit + } + +/* Type */ + body, select, textarea { + color: rgba(255, 255, 255, 0.8); + font-size: 16.5pt; + font-weight: normal; + line-height: 1.75 + } + @media screen and (max-width: 1680px) { + body, input, select, textarea { + font-size: 13pt + } + } + @media screen and (max-width: 1280px) { + body, input, select, textarea { + font-size: 12pt + } + } + @media screen and (max-width: 360px) { + body, input, select, textarea { + font-size: 11pt + } + } + + a { + -moz-transition: color 0.2s ease, border-bottom-color 0.2s ease; + -webkit-transition: color 0.2s ease, border-bottom-color 0.2s ease; + -ms-transition: color 0.2s ease, border-bottom-color 0.2s ease; + transition: color 0.2s ease, border-bottom-color 0.2s ease; + border-bottom: dotted 1px rgba(255, 255, 255, 0.35); + color: inherit; + text-decoration: none + } + a:hover { + border-bottom: solid 1px rgba(255, 255, 255, 0.88); + color: #ffffff + } + + strong, b { + color: #ffffff; + font-weight: bold + } + + em, i { + font-style: italic + } + + p { + margin: 0 0 2em 0 + } + + h1, h2, h3, h4, h5, h6 { + color: #ffffff; + font-weight: bold; + line-height: 1.5 + } + h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { + color: inherit; + text-decoration: none + } + + h1 { + font-size: 2.75em + } + + h2 { + font-size: 1.75em + } + + h3 { + font-size: 1.1em + } + + h4 { + font-size: 1em + } + + h5 { + font-size: 0.8em + } + + h6 { + font-size: 0.6em + } + + @media screen and (max-width: 736px) { + h1 { + font-size: 3em + } + + h2 { + font-size: 1.75em + } + + h3 { + font-size: 1em + } + + h4 { + font-size: 0.8em + } + + h5 { + font-size: 0.6em + } + + h6 { + font-size: 0.6em + } + } + + code { + background: rgba(255, 255, 255, 0.05); + border-radius: 0.25em; + border: solid 1px rgba(255, 255, 255, 0.15); + font-family: "Courier New", monospace; + font-size: 0.9em; + margin: 0 0.25em; + padding: 0.25em 0.65em + } + + pre { + -webkit-overflow-scrolling: touch; + font-family: "Courier New", monospace; + font-size: 0.9em; + margin: 0 0 2em 0 + } + pre code { + display: block; + line-height: 1.75em; + padding: 1em 1.5em; + overflow-x: auto + } + + + .text-center { + text-align: center + } + +/* Button */ + input[type="button"], + button, + .button { + -moz-appearance: none; + -webkit-appearance: none; + -ms-appearance: none; + appearance: none; + -moz-transition: border-color 0.2s ease; + -webkit-transition: border-color 0.2s ease; + -ms-transition: border-color 0.2s ease; + transition: border-color 0.2s ease; + background-color: #fff; + border: solid 1px !important; + border-color: rgba(255, 255, 255, 0.15) !important; + border-radius: 3em; + color: #393939 !important; + cursor: pointer; + display: inline-block; + font-size: 0.7em; + font-weight: bold; + letter-spacing: 0.25em; + line-height: 4.75em; + outline: 0; + padding: 0 3.75em; + position: relative; + text-align: center; + text-decoration: none; + text-transform: uppercase; + white-space: nowrap + } + input[type="button"]:after, + button:after, + .button:after { + -moz-transform: scale(0.25); + -webkit-transform: scale(0.25); + -ms-transform: scale(0.25); + transform: scale(0.25); + pointer-events: none; + -moz-transition: opacity 0.2s ease, -moz-transform 0.2s ease; + -webkit-transition: opacity 0.2s ease, -webkit-transform 0.2s ease; + -ms-transition: opacity 0.2s ease, -ms-transform 0.2s ease; + transition: opacity 0.2s ease, transform 0.2s ease; + background: #ffffff; + border-radius: 3em; + content: ''; + height: 100%; + left: 0; + opacity: 0; + position: absolute; + top: 0; + width: 100% + } + input[type="button"]:hover, + button:hover, + .button:hover { + border-color: rgba(255, 255, 255, 0.6) !important + } + input[type="button"]:hover:after, + button:hover:after, + .button:hover:after { + opacity: 0.05; + -moz-transform: scale(1); + -webkit-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1) + } + input[type="button"]:hover:active, + button:hover:active, + .button:hover:active { + border-color: #ffffff !important + } + input[type="button"]:hover:active:after, + button:hover:active:after, + .button:hover:active:after { + opacity: 0.1 + } + + input[type="password"] { + border: 0; + outline: 0; + padding: 15px; + border-radius: 10px; + width: 300px + } + +/* Wrapper */ + .wrapper { + position: relative + } + .wrapper > .inner { + width: 100%; + padding: 5em 4em 2em 4em + } + @media screen and (max-width: 1680px) { + footer > .inner { + padding: 2em 4em 2em 4em !important + } + } + @media screen and (max-width: 736px) { + + .wrapper > .inner { + padding: 2em 2em 2em 2em + } + footer > .inner { + padding: 2em 2em 2em 2em !important + } + } + .wrapper.style2 { + background-color: #5052b5 + } + + .wrapper.fullscreen { + min-height: calc(87vh - 2.5em) + } + @media screen and (max-width: 736px) { + + .wrapper.fullscreen { + min-height: calc(40vh - 5.5em) + } + } + +/* Wrapper */ + #topMenu + #wrapper { + margin-left: 0; + position: relative + } + @media screen and (max-width: 736px) { + #topMenu + #wrapper { + padding-top: 0; + top: 2em + } + + } + + #header + #wrapper > .wrapper > .inner { + margin: 0 auto + } + +/* Menu */ + #topMenu { + padding: 0; + background:0; + cursor: default; + height: 5.4em; + left: 0; + text-align: center; + top: 0; + width: 100%; + line-height: 3.5em; + position: relative; + z-index: 20 + } + + #topMenu > .inner { + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + -moz-flex-direction: row; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -moz-justify-content: center; + -webkit-justify-content: center; + -ms-justify-content: center; + justify-content: center; + -moz-transform: translateY(0); + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + -moz-transition: opacity 1s ease; + -webkit-transition: opacity 1s ease; + -ms-transition: opacity 1s ease; + transition: opacity 1s ease; + min-height: 100%; + opacity: 1; + width: 100% + } + #topMenu nav { + height: inherit; + line-height: inherit; + margin-top: 1em + } + #topMenu nav ul { + display: -moz-flex; + display: -webkit-flex; + display: -ms-flex; + display: flex; + height: inherit; + line-height: inherit; + list-style: none; + padding: 0 + } + #topMenu nav a { + height: inherit; + line-height: inherit; + padding: 0 + } + #topMenu nav > ul > li { + margin: 0 1em 0 1em; + opacity: 1; + padding: 0; + position: relative; + height: inherit; + line-height: inherit + } + + #topMenu nav a { + border: 0; + font-size: 0.70em; + font-weight: bold; + letter-spacing: 0.25em; + line-height: 1.75; + outline: 0; + padding: 2em 0; + position: relative; + text-decoration: none; + text-transform: uppercase + } + #topMenu nav li.active, nav li.active a { + color: #fff !important + } + #topMenu nav .active a{ + border-bottom: 1px solid #ffffff7d + } + #topMenu nav a:hover { + border-bottom: 1px solid #ffffff59 + + } + #topMenu nav a.active { + color: #ffffff + } + #topMenu nav a.active:after { + max-width: 100% + } + + @media screen and (max-width: 736px) { + #topMenu { + height: auto; + font-size: 0.94em; + position: relative; + background-color: rgba(0, 0, 0, 0.30); + padding-bottom: 20px + } + #topMenu nav ul { + display: block; + float: left + } + #topMenu nav > ul > li { + display: block; + float: left; + margin: 0 1em 0 2em + } + #topMenu nav .active a { + border-bottom: 1px solid #fff + } + footer { + font-size: 1em + } + } + +/* Intro */ + #intro p { + font-size: 1.25em + } + @media screen and (max-width: 736px) { + #intro p { + font-size: 1em + } + } + +/* Footer */ + footer { + text-align: right + } + +/* Submenus */ + .subPageDropdown a { + border: 0 !important + } + + .subPageDropdown ul { + margin: 0; + padding-left: 0 + } + + .subPageDropdown li { + color: #fff; + display: block; + float: left; + position: relative; + padding: 0 1em 0 1em; + text-decoration: none; + transition-duration: 0.5s + } + + #topMenu li a { + color: rgba(255, 255, 255, 0.8) + } + + #topMenu li:hover, + #topMenu li:focus-within { + cursor: pointer + } + + #topMenu li:focus-within a { + outline: none + } + + #topMenu .nav-item { + margin-top: 5px + } + + ul.subPageDropdown { + visibility: hidden; + opacity: 0; + position: absolute; + margin-top: 10px; + display: none; + padding-left: 10px !important + } + + #topMenu ul li:hover > ul, + #topMenu ul li:focus-within > ul, + #topMenu ul li ul:hover, + #topMenu ul li ul:focus { + visibility: visible; + opacity: 1; + display: block + } + + #topMenu ul li ul li { + clear: both; + text-align: left; + background-color: rgba(0, 0, 0, 0.30); + white-space: nowrap + } + + /* Submenus dropdown arrow */ + .menu li > a:after { + content: ' ▼'; + font-weight: bold + } + + .menu > li > a:after { + content: ' ▼'; + font-weight: bold + } + + .menu li > a:only-child:after { + content: '' + } diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/theme.php b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/theme.php new file mode 100644 index 0000000..b670182 --- /dev/null +++ b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/theme.php @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + <?= $Wcms->get('config', 'siteTitle') ?> - <?= $Wcms->page('title') ?> + + + + + css() ?> + + + + + + + + settings() ?> + + alerts() ?> + +
+
+ +
+
+ +
+
+
+ + page('content') ?> + +
+
+ +
+
+ + block('subside') ?> + +
+
+
+ +
+
+ + footer() ?> + +
+
+ + + js() ?> + + + diff --git a/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/wcms-modules.json b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/wcms-modules.json new file mode 100644 index 0000000..27864a2 --- /dev/null +++ b/.recipes/wondercms_php8/storage/volumes/wonder_html/themes/sky/wcms-modules.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "themes": { + "sky": { + "name": "Sky", + "repo": "https://github.com/robiso/sky/tree/master", + "zip": "https://github.com/robiso/sky/archive/master.zip", + "summary": "Default WonderCMS theme (2022). Theme works without Bootstrap and jQuery.", + "version": "3.2.4", + "image": "https://raw.githubusercontent.com/robiso/sky/master/preview.jpg" + } + } +} \ No newline at end of file diff --git a/.recipes/wondercms_php8/tools/backup.d/storage_backup.sh b/.recipes/wondercms_php8/tools/backup.d/storage_backup.sh new file mode 100755 index 0000000..45e652f --- /dev/null +++ b/.recipes/wondercms_php8/tools/backup.d/storage_backup.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# +# A service script to backup the entire WonderCMS website. +# Creates a tarball in $BASE_DIR/storage/backups/tarballs folder +# (by default). An optional parameter may change the target folder. +# +# Call as a Docker manager user (member of the docker Linux group) via cron. +# +# Author: Kovács Zoltán +# License: GNU/GPL 3+ https://www.gnu.org/licenses/gpl-3.0.en.html +# 2025-03-05 v0.2 +# mod: gitbackup handling stub has been temporarily removed. +# 2025-01-14 v0.1 Initial version. + +# Accepted environment variables and their defaults. +# +PAR_BASEDIR=${PAR_BASEDIR:-""} # Service's base folder +PAR_BACKUPDIR=${PAR_BACKUPDIR:-""} # Folder to dump within + +# Messages (maybe overridden by configuration). +# +MSG_DOCKERGRPNEED="You must be a member of the docker group." +MSG_DOESNOTRUN="This service doesn't run." +MSG_MISSINGDEP="Fatal: missing dependency" +MSG_MISSINGYML="Fatal: didn't find the docker-compose.yml file" +MSG_NONWRITE="The target directory isn't writable" +MSG_NOLOCATE="Cannot locate the WonderCMS container." + +# Other initialisations. +# +BACKUPDIR="storage/backups/tarballs" # Folder to dump within +GITBACKUP="storage_gitbackup.sh" # Git backup utility +SERVICENAME="wondercms" # The composed WonderCMS service +USER=${USER:-LOGNAME} # Fix for cron enviroment only +YMLFILE="docker-compose.yml" + +# Checks the dependencies. +# +TR=$(which tr 2>/dev/null) +if [ -z "$TR" ]; then echo "$MSG_MISSINGDEP tr."; exit 1 ; fi +for item in basename cat cut date dirname docker \ + find grep hostname id pwd tail xargs +do + if [ -n "$(which $item)" ] + then export $(echo $item | "$TR" '[:lower:]' '[:upper:]' | "$TR" '-' '_')=$(which $item) + else echo "$MSG_MISSINGDEP $item." >&2; exit 1; fi +done +# All dependencies are available via "$THECOMMAND" (upper case) call. +# +# Let's find which version of docker-compose is installed. +if [ $($DOCKER compose version 2>&1 >/dev/null; echo $?) -eq 0 ]; then + # We'll use v2 if it is available. + DOCKER_COMPOSE="$DOCKER" + commandstring="compose" +else + # Otherwise falling back to v1. + DOCKER_COMPOSE="$(which docker-compose)" + commandstring="" +fi +# One of the two is mandatory. +if [ -z "$DOCKER_COMPOSE" ];then echo "$MSG_MISSINGDEP docker-compose" >&2; exit 1; fi +# Below docker-compose should be called as "$DOCKER_COMPOSE" $commandstring sequence. + +# Where I'm? +# https://gist.github.com/TheMengzor/968e5ea87e99d9c41782 +SOURCE="$0" +while [ -h "$SOURCE" ]; do + # resolve $SOURCE until the file is no longer a symlink + SCRPATH="$( cd -P "$("$DIRNAME" "$SOURCE" )" && echo "$PWD" )" #" + SOURCE="$("$READLINK" "$SOURCE")" + # if $SOURCE was a relative symlink, we need to resolve it + # relative to the path where the symlink file was located + [[ $SOURCE != /* ]] && SOURCE="$SCRPATH/$SOURCE" +done; SCRPATH="$( cd -P "$("$DIRNAME" "$SOURCE" )" && echo "$PWD" )" #" + +# Need to be root or a Docker manager user. +# +[[ "$USER" != 'root' ]] \ +&& [[ -z "$(echo "$("$ID" -Gn "$USER") " | "$GREP" ' docker ')" ]] \ +&& echo "$MSG_DOCKERGRPNEED" >&2 && exit 1 #" + +# Searches the base folder, containing a docker-compose.yml file. +# +# Called from the base folder (./)? +BASE_DIR="$PAR_BASEDIR" +TEST_DIR="$SCRPATH" +[[ -z "$BASE_DIR" ]] && [[ -r "$TEST_DIR/$YMLFILE" ]] && BASE_DIR="$TEST_DIR" +# Called from ./tools? +TEST_DIR="$("$DIRNAME" "$TEST_DIR")" +[[ -z "$BASE_DIR" ]] && [[ -r "$TEST_DIR/$YMLFILE" ]] && BASE_DIR="$TEST_DIR" +# Called from ./tools/*.d? +TEST_DIR="$("$DIRNAME" "$TEST_DIR")" +[[ -z "$BASE_DIR" ]] && [[ -r "$TEST_DIR/$YMLFILE" ]] && BASE_DIR="$TEST_DIR" +# On failure gives it up here. +if [ -z "$BASE_DIR" -o ! -r "$BASE_DIR/$YMLFILE" ]; then + echo "$MSG_MISSINGYML" >&2; exit 1 +fi +# Sets the absolute paths. +BACKUPDIR="${PAR_BACKUPDIR:-$BASE_DIR/$BACKUPDIR}" + +# The dump target folder must be writable. +# +[[ ! -w "$BACKUPDIR" ]] \ +&& echo "$MSG_NONWRITE: $BACKUPDIR" >&2 && exit 1 + +# The service must be running - silently gives up here if not. +# +[[ -z "$(cd "$BASE_DIR"; "$DOCKER_COMPOSE" $commandstring ps --services --filter "status=running")" ]] \ +&& exit 1 + +# Converts the service name to an actual running container's name. +# +MYCONTAINER="$("$DOCKER" inspect -f '{{.Name}}' $(cd "$BASE_DIR"; "$DOCKER_COMPOSE" $commandstring ps -q "$SERVICENAME") | "$CUT" -c2-)" +# Gives up here if failed. +if [ -z "$MYCONTAINER" ]; then echo "$MSG_NOLOCATE" >&2; exit 1; fi + +# Tries the FS backup. +if [ -w "$BACKUPDIR" ]; then + BACKUP_NAME=$MYCONTAINER.$("$DATE" '+%Y%m%d_%H%M%S').$("$HOSTNAME") + "$DOCKER" exec $MYCONTAINER sh \ + -c "cd /var/www/html; tar cz ." \ + > "$BACKUPDIR/$BACKUP_NAME.tgz" 2>>"$BACKUPDIR/$BACKUP_NAME.log" +fi + +# That's all, Folks! :)