';
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 . '
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.
';
}
/**
* 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', '
Logging in and checking for updates
This might take a moment.
')[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 = '
';
if ($this->currentPageExists && $currentPageData) {
$output .= '
Page title
' . ($currentPageData->title ?: '') . '
Page keywords
' . ($currentPageData->keywords ?: '') . '
Page description
' . ($currentPageData->description ?: '') . '
Delete page (' . $this->currentPage . ')';
} else {
$output .= 'This page doesn\'t exist. More settings will be displayed here after this page is created.';
}
$output .= '
';
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;
}
}