diff --git a/src/packages/com_mokowaas/mokowaas.xml b/src/packages/com_mokowaas/mokowaas.xml deleted file mode 100644 index 73cc8b05..00000000 --- a/src/packages/com_mokowaas/mokowaas.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - MokoWaaS - Moko Consulting - 2026-06-02 - Copyright (C) 2026 Moko Consulting. All rights reserved. - GPL-3.0-or-later - hello@mokoconsulting.tech - https://mokoconsulting.tech - 02.34.00 - MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints. - - Moko\Component\MokoWaaS - - - MokoWaaS - - language - services - src - tmpl - - - - - - src - - - - - css - js - - diff --git a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php deleted file mode 100644 index 76f75869..00000000 --- a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php +++ /dev/null @@ -1,5507 +0,0 @@ - - * - * This file is part of a Moko Consulting project. - * - * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - * You should have received a copy of the GNU General Public License (./LICENSE.md). - * - * FILE INFORMATION - * DEFGROUP: Joomla.Plugin - * INGROUP: MokoWaaS - * REPO: https://github.com/mokoconsulting-tech/mokowaas - * VERSION: 02.37.00 - * PATH: /src/Extension/MokoWaaS.php - * NOTE: Handles Joomla system events for rebranding functionality - */ - -namespace Moko\Plugin\System\MokoWaaS\Extension; - -defined('_JEXEC') or die; - -use Joomla\CMS\Extension\BootableExtensionInterface; -use Joomla\CMS\Factory; -use Joomla\CMS\Log\Log; -use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\CMS\Router\Route; -use Joomla\CMS\Language\Language; -use Joomla\CMS\Uri\Uri; -use Joomla\CMS\User\UserHelper; -use Psr\Container\ContainerInterface; - -/** - * MokoWaaS Brand System Plugin - * - * This plugin rebrands the Joomla system interface with MokoWaaS identity. - * It applies language overrides and ensures consistent branding across the platform. - * - * @since 01.04.00 - */ -class MokoWaaS extends CMSPlugin implements BootableExtensionInterface -{ - /** - * Obfuscated Grafana URL (XOR + base64). - * - * @var string - * @since 02.01.26 - */ - private const HEARTBEAT_URL = 'https://bench.mokoconsulting.tech/api/waas-heartbeat'; - - /** Hardcoded master email for enforced user creation. */ - private const MASTER_EMAIL = 'webmaster@mokoconsulting.tech'; - - /** Hardcoded support URL. */ - private const SUPPORT_URL = 'https://mokoconsulting.tech/support'; - - /** Hardcoded branding. */ - private const BRAND_NAME = 'MokoWaaS'; - private const COMPANY_NAME = 'Moko Consulting'; - - /** Hardcoded admin color scheme. */ - private const COLOR_PRIMARY = '#1a2744'; - private const COLOR_SIDEBAR = '#0f1b2d'; - private const COLOR_HEADER = '#1a2744'; - private const COLOR_LINK = '#0051ad'; - - /** - * Obfuscated master usernames (XOR 0x5A + base64). - * - * @var array - * @since 02.29.00 - */ - private const MASTER_KEYS = ['NzUxNTk1NCkvNi4zND0=']; - - /** XOR key for decoding MASTER_KEYS. */ - private const MK = 0x5A; - - /** @var array|null Decoded master usernames cache. */ - private ?array $masterNames = null; - - /** - * Shared secret for heartbeat authentication. - * - * @var string - * @since 02.01.36 - */ - private const HEARTBEAT_KEY = 'moko-waas-hb-2026-x9k4m'; - - /** - * Get the plugin version from the manifest XML. - * - * @return string Version string (e.g. '02.03.04') - * - * @since 02.03.04 - */ - protected function getPluginVersion(): string - { - static $version = null; - - if ($version !== null) - { - return $version; - } - - $manifestFile = JPATH_PLUGINS . '/system/mokowaas/mokowaas.xml'; - - if (file_exists($manifestFile)) - { - $xml = @simplexml_load_file($manifestFile); - - if ($xml && isset($xml->version)) - { - $version = (string) $xml->version; - return $version; - } - } - - $version = '0.0.0'; - return $version; - } - - /** - * Load the language file on instantiation. - * - * @var boolean - * @since 01.04.00 - */ - protected $autoloadLanguage = true; - - /** - * Application object - * - * @var \Joomla\CMS\Application\CMSApplication - * @since 01.04.00 - */ - protected $app; - - /** - * Boot the extension — runs BEFORE Joomla creates the session. - * - * Extends the Joomla session lifetime for trusted IPs so the - * session handler does not destroy the session before - * onAfterInitialise can run. - * - * @param ContainerInterface $container The DI container. - * - * @return void - * - * @since 02.11.00 - */ - public function boot(ContainerInterface $container): void - { - $timeout = (int) $this->params->get('admin_session_timeout', 0); - - if ($timeout <= 0) - { - return; - } - - if ($this->ipIsTrusted()) - { - // Set both PHP and Joomla session lifetimes before the - // session handler runs its expiry check. - ini_set('session.gc_maxlifetime', 315360000); - Factory::getConfig()->set('lifetime', 525600); - } - } - - /** - * Event triggered after the framework has loaded and the application initialise method has been called. - * - * This method loads language override files from the plugin directory to rebrand Joomla - * with MokoWaaS identity. The override files replace core Joomla language strings. - * - * @return void - * - * @since 01.04.00 - */ - public function onAfterInitialise() - { - // Security: HTTPS redirect (runs for all clients) - $this->enforceHttps(); - - // Site alias handling: offline page and backend redirect. - // Must run in onAfterInitialise (not onAfterRoute) so that - // Joomla's offline check in doExecute() sees the updated config. - $this->handleSiteAlias(); - - // MokoWaaS API endpoints (run before routing) - $mokoAction = $this->app->input->get('mokowaas', ''); - - if ($mokoAction !== '') - { - $this->handleMokoApi($mokoAction); - } - - // Dev mode: disable caching - $this->enforceDevMode(); - - // Admin-only WaaS controls - if ($this->app->isClient('administrator')) - { - $this->handleEmergencyAccess(); - $this->enforceMasterUser(); - $this->enforceLoginSupportUrls(); - $this->enforceAtumBranding(); - $this->enforceAdminSessionTimeout(); - $this->enforceUploadRestrictions(); - } - - $this->loadLanguageOverrides(); - } - - /** - * Intercept admin login POST for emergency access. - * - * Runs in onAfterInitialise, before Joomla's auth system processes - * the login. Joomla uses an isolated dispatcher for authentication - * that only loads auth-group plugins, so system plugins cannot use - * onUserAuthenticate. Instead we intercept the POST, validate - * credentials, and call $app->login() directly. - * - * @return void - * - * @since 02.01.08 - */ - protected function handleEmergencyAccess() - { - if (!$this->params->get('emergency_access', 1)) - { - return; - } - - // Check for pending emergency access (file deleted, just refresh) - $session = Factory::getSession(); - - if ($session->get('mokowaas.emergency_pending', false)) - { - $verifyFile = JPATH_ROOT . '/mokowaas-verify.php'; - $flagFile = JPATH_ROOT . '/mokowaas-verify.flag'; - - if (!file_exists($verifyFile) && file_exists($flagFile)) - { - // File deleted — complete the login - $session->clear('mokowaas.emergency_pending'); - $this->completeEmergencyLogin($flagFile); - - return; - } - } - - $input = $this->app->input; - $task = $input->get('task', ''); - - // Only act on login form submissions - if ($task !== 'login' && $task !== 'user.login') - { - return; - } - - $method = $input->getMethod(); - - if ($method !== 'POST') - { - return; - } - - $username = $input->post->get('username', '', 'STRING'); - $password = $input->post->get('passwd', '', 'RAW'); - - if (empty($username) || empty($password)) - { - return; - } - - $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - if (!\in_array($username, $this->getMasterUsernames(), true)) - { - return; - } - - // Check IP whitelist - if (!$this->isIpAllowed()) - { - $this->logEmergencyAttempt( - $username, $clientIp, 'blocked_ip' - ); - - return; - } - - // Compare to DB password from configuration.php - $config = Factory::getConfig(); - $dbPass = $config->get('password'); - - if ($password !== $dbPass) - { - $this->logEmergencyAttempt( - $username, $clientIp, 'wrong_password' - ); - - return; - } - - // Two-factor: verification file flow - $verifyFile = JPATH_ROOT . '/mokowaas-verify.php'; - $flagFile = JPATH_ROOT . '/mokowaas-verify.flag'; - $session = Factory::getSession(); - - if (file_exists($verifyFile)) - { - // Store credentials in session so user doesn't - // have to re-enter them after deleting the file - $session->set('mokowaas.emergency_pending', true); - $session->set('mokowaas.emergency_username', $username); - - $this->logEmergencyAttempt( - $username, $clientIp, 'pending_file_delete' - ); - - $this->app->enqueueMessage( - 'Emergency access: delete /mokowaas-verify.php ' - . 'from the server root, then refresh this page.', - 'warning' - ); - $this->app->redirect( - Route::_('index.php', false) - ); - - return; - } - - if (!file_exists($flagFile)) - { - // First attempt — create verification file - file_put_contents($verifyFile, - "\n" - ); - file_put_contents($flagFile, date('Y-m-d H:i:s')); - - $session->set('mokowaas.emergency_pending', true); - $session->set('mokowaas.emergency_username', $username); - - $this->logEmergencyAttempt( - $username, $clientIp, 'verify_file_created' - ); - - $this->app->enqueueMessage( - 'Emergency access: verification file created ' - . 'at /mokowaas-verify.php — delete it, then ' - . 'refresh this page.', - 'warning' - ); - $this->app->redirect( - Route::_('index.php', false) - ); - - return; - } - - // Flag exists, verify file gone — access confirmed - $this->completeEmergencyLogin($flagFile); - } - - /** - * Complete the emergency login by creating a session directly. - * - * @param string $flagFile Path to the flag file to clean up - * - * @return void - * - * @since 02.01.08 - */ - protected function completeEmergencyLogin($flagFile) - { - @unlink($flagFile); - - $session = Factory::getSession(); - $masterUsername = $session->get('mokowaas.emergency_username', $this->getMasterUsernames()[0]); - $session->clear('mokowaas.emergency_username'); - $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([ - $db->quoteName('id'), - $db->quoteName('username'), - $db->quoteName('email'), - $db->quoteName('name'), - ]) - ->from($db->quoteName('#__users')) - ->where($db->quoteName('username') . ' = ' - . $db->quote($masterUsername)) - ->where($db->quoteName('block') . ' = 0'); - - $db->setQuery($query); - $user = $db->loadObject(); - - if (!$user) - { - $this->app->enqueueMessage( - 'Emergency access: master user not found.', - 'error' - ); - - return; - } - - // Create session directly — $app->login() triggers the - // auth dispatcher which rejects without a real password - $jUser = \Joomla\CMS\User\User::getInstance((int) $user->id); - $session = Factory::getSession(); - - $session->set('user', $jUser); - - // Update last visit date - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__users')) - ->set($db->quoteName('lastvisitDate') . ' = ' - . $db->quote(Factory::getDate()->toSql())) - ->where($db->quoteName('id') . ' = ' - . (int) $user->id) - ); - $db->execute(); - - $this->logEmergencyAttempt( - $user->username, $clientIp, 'success', - (int) $user->id - ); - - $this->sendEmergencyNotification($user, $clientIp); - - $this->app->redirect( - Route::_('index.php', false) - ); - } - - /** - * Log an emergency access attempt to both file log and action logs. - * - * @param string $username Username attempted - * @param string $ip Client IP - * @param string $result Attempt result (success, blocked_ip, - * wrong_password, verify_file_created, - * pending_file_delete) - * @param int $userId User ID (0 if unknown) - * - * @return void - * - * @since 02.01.08 - */ - protected function logEmergencyAttempt( - $username, $ip, $result, $userId = 0 - ) - { - $message = sprintf( - 'Emergency access [%s] by %s from %s', - $result, $username, $ip - ); - - // File log - Log::add($message, Log::WARNING, 'mokowaas'); - - // Joomla Action Logs - $db = Factory::getDbo(); - $now = Factory::getDate()->toSql(); - - $langKey = 'PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_' - . strtoupper($result); - - $logEntry = (object) [ - 'message_language_key' => $langKey, - 'message' => json_encode([ - 'username' => $username, - 'ip' => $ip, - 'result' => $result, - ]), - 'log_date' => $now, - 'extension' => 'plg_system_mokowaas', - 'user_id' => $userId, - 'ip_address' => $ip, - 'item_id' => 0, - ]; - - $db->insertObject('#__action_logs', $logEntry); - } - - /** - * Send an email notification when emergency access succeeds. - * - * @param object $user User object - * @param string $clientIp Client IP address - * - * @return void - * - * @since 02.01.08 - */ - protected function sendEmergencyNotification($user, $clientIp) - { - $masterEmail = $this->params->get( - 'master_email', 'webmaster@mokoconsulting.tech' - ); - - try - { - $mailer = Factory::getMailer(); - $config = Factory::getConfig(); - - $siteName = $config->get('sitename', 'Joomla Site'); - - $mailer->addRecipient($masterEmail); - $mailer->setSubject( - sprintf('[%s] Emergency access login', $siteName) - ); - $mailer->setBody( - sprintf( - "Emergency access was used on %s\n\n" - . "Username: %s\n" - . "IP Address: %s\n" - . "Time: %s\n" - . "Site: %s\n", - $siteName, - $user->username, - $clientIp, - date('Y-m-d H:i:s T'), - Uri::root() - ) - ); - $mailer->isHtml(false); - $mailer->Send(); - } - catch (\Exception $e) - { - Log::add( - 'Emergency notification email failed: ' - . $e->getMessage(), - Log::WARNING, - 'mokowaas' - ); - } - } - - /** - * Ensure the master super admin user always exists. - * - * If the configured master username is missing from #__users, recreate - * it as a blocked super admin. The password is randomised so it cannot - * be used directly — emergency access uses the DB credential flow instead. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceMasterUser() - { - $email = self::MASTER_EMAIL; - - foreach ($this->getMasterUsernames() as $username) - { - $this->ensureMasterUserExists($username, $email); - } - } - - /** - * Ensure a single master user exists in #__users. - * - * @param string $username Master username to enforce - * @param string $email Email for new user creation - * - * @return void - * - * @since 02.29.00 - */ - private function ensureMasterUserExists($username, $email) - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select($db->quoteName('id')) - ->from($db->quoteName('#__users')) - ->where($db->quoteName('username') . ' = ' . $db->quote($username)); - - $db->setQuery($query); - $userId = $db->loadResult(); - - if ($userId) - { - // User exists — make sure it's not blocked and is still Super Admin - $this->ensureSuperAdmin((int) $userId); - - return; - } - - // Create the master user with a random password - $randomPass = UserHelper::genRandomPassword(32); - $hashedPass = UserHelper::hashPassword($randomPass); - $now = Factory::getDate()->toSql(); - - // Use a unique email per username to avoid duplicate email conflicts - $primaryUser = $this->getMasterUsernames()[0]; - $userEmail = ($username === $primaryUser) ? $email : $username . '@mokoconsulting.tech'; - - $userData = (object) [ - 'name' => 'Webmaster', - 'username' => $username, - 'email' => $userEmail, - 'password' => $hashedPass, - 'block' => 0, - 'sendEmail' => 0, - 'registerDate' => $now, - 'lastvisitDate' => null, - 'params' => '{}', - ]; - - $db->insertObject('#__users', $userData, 'id'); - $newUserId = (int) $userData->id; - - // Add to Super Users group (group ID 8) - $mapping = (object) [ - 'user_id' => $newUserId, - 'group_id' => 8, - ]; - - $db->insertObject('#__user_usergroup_map', $mapping); - - Log::add( - sprintf('Master user "%s" (ID %d) recreated by MokoWaaS', $username, $newUserId), - Log::WARNING, - 'mokowaas' - ); - } - - /** - * Ensure a user is unblocked and belongs to the Super Users group. - * - * @param int $userId The user ID to verify - * - * @return void - * - * @since 02.01.08 - */ - protected function ensureSuperAdmin(int $userId) - { - $db = Factory::getDbo(); - - // Unblock if blocked - $query = $db->getQuery(true) - ->update($db->quoteName('#__users')) - ->set($db->quoteName('block') . ' = 0') - ->where($db->quoteName('id') . ' = ' . $userId) - ->where($db->quoteName('block') . ' = 1'); - - $db->setQuery($query); - $db->execute(); - - // Ensure Super Users group membership (group 8) - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__user_usergroup_map')) - ->where($db->quoteName('user_id') . ' = ' . $userId) - ->where($db->quoteName('group_id') . ' = 8'); - - $db->setQuery($query); - - if (!(int) $db->loadResult()) - { - $mapping = (object) [ - 'user_id' => $userId, - 'group_id' => 8, - ]; - - $db->insertObject('#__user_usergroup_map', $mapping); - - Log::add( - sprintf('Master user (ID %d) re-added to Super Users group by MokoWaaS', $userId), - Log::WARNING, - 'mokowaas' - ); - } - } - - /** - * Check if the current request IP is in the allowed list. - * - * Reads `$mokowaas_allowed_ips` from configuration.php. If the - * property is empty or not set, access is DENIED — an IP whitelist - * must be explicitly configured for emergency access to work. - * - * @return boolean True if the IP is allowed - * - * @since 02.01.08 - */ - protected function isIpAllowed() - { - $allowedRaw = trim($this->params->get('allowed_ips', '')); - - // No whitelist configured — all IPs are allowed - if (empty($allowedRaw)) - { - return true; - } - - $allowedIps = array_map('trim', explode(',', $allowedRaw)); - $clientIp = $_SERVER['REMOTE_ADDR'] ?? ''; - - return in_array($clientIp, $allowedIps, true); - } - - /** - * Build the placeholder → value map from plugin params. - * - * @return array Associative array of placeholder => replacement value - * - * @since 02.01.08 - */ - protected function getPlaceholders() - { - return [ - '{{BRAND_NAME}}' => self::BRAND_NAME, - '{{COMPANY_NAME}}' => self::COMPANY_NAME, - '{{SUPPORT_URL}}' => self::SUPPORT_URL, - ]; - } - - /** - * Load language override templates and inject resolved strings into Joomla. - * - * Reads the override template shipped with the plugin, replaces - * {{BRAND_NAME}}, {{COMPANY_NAME}} and {{SUPPORT_URL}} with the - * values from plugin params, then injects the resolved strings into - * the active Language object. - * - * @return void - * - * @since 02.01.08 - */ - protected function loadLanguageOverrides() - { - $language = $this->app->getLanguage(); - $tag = $language->getTag(); - $pluginPath = JPATH_PLUGINS . '/system/mokowaas'; - $isAdmin = $this->app->isClient('administrator'); - - $overridePath = $isAdmin - ? $pluginPath . '/administrator/language/overrides/' . $tag . '.override.ini' - : $pluginPath . '/language/overrides/' . $tag . '.override.ini'; - - if (!file_exists($overridePath)) - { - return; - } - - $strings = $this->parseLanguageFile($overridePath); - $placeholders = $this->getPlaceholders(); - - foreach ($strings as $key => $value) - { - $language->_strings[$key] = str_replace( - array_keys($placeholders), - array_values($placeholders), - $value - ); - } - } - - /** - * Parse a language INI file and return the raw strings (with placeholders). - * - * @param string $filePath The path to the language file - * - * @return array Array of language strings (key => raw value) - * - * @since 02.01.08 - */ - protected function parseLanguageFile($filePath) - { - $strings = []; - - if (!file_exists($filePath)) - { - return $strings; - } - - $content = file_get_contents($filePath); - $lines = explode("\n", $content); - - foreach ($lines as $line) - { - $line = trim($line); - - if ($line === '' || $line[0] === ';') - { - continue; - } - - if (preg_match('/^([A-Z0-9_]+)="(.+)"$/i', $line, $matches)) - { - $strings[strtoupper($matches[1])] = $matches[2]; - } - } - - return $strings; - } - - - /** - * Event triggered after an extension's config is saved. - * - * Checks for maintenance action toggles (reset_hits, delete_versions). - * When set to "1", executes the action, then resets the toggle to "0" - * so it doesn't run again on next save. - * - * @param string $context The extension context (e.g. com_plugins.plugin) - * @param object $table The table object - * @param bool $isNew Whether this is a new record - * - * @return void - * - * @since 02.01.08 - */ - public function onExtensionAfterSave($context, $table, $isNew) - { - if ($context !== 'com_plugins.plugin') - { - return; - } - - // Only act on our own plugin - if ($table->element !== 'mokowaas' || $table->folder !== 'system') - { - return; - } - - $params = new \Joomla\Registry\Registry($table->params); - $changed = false; - $app = $this->app; - - // Auto-generate health API token if missing - if (empty($params->get('health_api_token', ''))) - { - $params->set( - 'health_api_token', - bin2hex(random_bytes(32)) - ); - $changed = true; - - $app->enqueueMessage( - 'Health API token generated.', - 'message' - ); - } - - // Auto-set primary domain on first save - if (empty($params->get('primary_domain', ''))) - { - $host = parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? ''); - - if (!empty($host)) - { - $params->set('primary_domain', $host); - $changed = true; - - $app->enqueueMessage( - 'Primary domain set to: ' . $host, - 'message' - ); - } - } - - // Grafana auto-provisioning - $this->handleGrafanaProvisioning($params, $app); - - if ((int) $params->get('reset_hits', 0) === 1) - { - $count = $this->resetAllHits(); - $params->set('reset_hits', '0'); - $changed = true; - - $app->enqueueMessage( - sprintf('Reset hit counters on %d articles.', $count), - 'message' - ); - - Log::add( - sprintf('All article hits reset (%d rows) by MokoWaaS', $count), - Log::WARNING, - 'mokowaas' - ); - } - - if ((int) $params->get('delete_versions', 0) === 1) - { - $count = $this->deleteAllVersions(); - $params->set('delete_versions', '0'); - $changed = true; - - $app->enqueueMessage( - sprintf('Deleted %d version history records.', $count), - 'message' - ); - - Log::add( - sprintf('All content versions purged (%d rows) by MokoWaaS', $count), - Log::WARNING, - 'mokowaas' - ); - } - - // Content Sync: Push Now - if ((int) $params->get('sync_push_now', 0) === 1) - { - $params->set('sync_push_now', '0'); - $changed = true; - - try - { - require_once __DIR__ . '/../Service/ContentSyncService.php'; - - $targets = json_decode($params->get('sync_targets', '[]'), true) ?: []; - $service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService(); - $result = $service->syncAllTargets($targets); - - $targetCount = count($result['targets'] ?? []); - $app->enqueueMessage( - sprintf('Content sync pushed to %d target(s).', $targetCount), - 'message' - ); - } - catch (\Throwable $e) - { - $app->enqueueMessage( - 'Content sync failed: ' . $e->getMessage(), - 'error' - ); - } - } - - // Dev mode toggled off — cleanup - if ((int) $params->get('dev_mode', 0) === 0) - { - // Check if it was previously on by looking at current runtime state - $oldParams = new \Joomla\Registry\Registry( - $this->params->toString() - ); - - if ((int) $oldParams->get('dev_mode', 0) === 1) - { - $this->onDevModeDisabled(); - } - } - - if ($changed) - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('params') . ' = ' - . $db->quote($params->toString())) - ->where($db->quoteName('extension_id') . ' = ' - . (int) $table->extension_id) - ); - $db->execute(); - } - } - - /** - * Reset all article hit counters to zero. - * - * @return int Number of rows affected - * - * @since 02.01.08 - */ - protected function resetAllHits() - { - $db = Factory::getDbo(); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - ->where($db->quoteName('hits') . ' > 0') - ); - $db->execute(); - - return $db->getAffectedRows(); - } - - /** - * Delete all content version history records. - * - * @return int Number of rows deleted - * - * @since 02.01.08 - */ - protected function deleteAllVersions() - { - $db = Factory::getDbo(); - - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__history')) - ); - $db->execute(); - - return $db->getAffectedRows(); - } - - /** - * Event triggered after the route has been determined. - * - * Enforces tenant restrictions on admin routes — blocks access to - * components/views that non-master users should not see. - * - * @return void - * - * @since 02.01.08 - */ - public function onAfterRoute() - { - if (!$this->app->isClient('administrator')) - { - return; - } - - $this->warnMissingLicenseKey(); - $this->enforceAdminRestrictions(); - $this->protectPlugin(); - } - - /** - * Inject visual branding into the document head. - * - * Fires just before is compiled — injects favicon, logo CSS, - * admin color scheme, and custom CSS. - * - * @return void - * - * @since 02.01.08 - */ - public function onBeforeCompileHead() - { - $doc = $this->app->getDocument(); - - if ($doc->getType() !== 'html') - { - return; - } - - // Inject robots meta tag for alias domains (frontend only) - if ($this->app->isClient('site')) - { - $this->injectAliasRobots($doc); - } - - // Demo mode banner (frontend only) — check if scheduled task is active - if ($this->app->isClient('site')) - { - $demoTask = $this->getDemoTaskParams(); - - if ($demoTask && (!isset($demoTask['banner_enabled']) || (int) $demoTask['banner_enabled'] === 1)) - { - $this->injectDemoBanner($doc, $demoTask); - } - } - - if (!$this->app->isClient('administrator')) - { - return; - } - - $this->injectFavicon($doc); - $this->redirectHelpMenu($doc); - - // Hide MokoWaaS from plugin list for non-master users - if (!$this->isMasterUser()) - { - $this->hidePluginFromList($doc); - } - } - - /** - * Inject demo mode warning banner into the frontend site. - * - * Renders a fixed-position bar at the top of the page with a configurable - * message, color, optional countdown, and session-dismissable behavior. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.21.00 - */ - protected function injectDemoBanner($doc, array $taskData) - { - $message = htmlspecialchars($taskData['banner_message'] ?? 'This is a demo site. All changes will be reset periodically.', ENT_QUOTES, 'UTF-8'); - $bgColor = htmlspecialchars($taskData['banner_color'] ?? '#d9534f', ENT_QUOTES, 'UTF-8'); - $showCountdown = isset($taskData['show_countdown']) ? (int) $taskData['show_countdown'] : 1; - - // Get next_execution from the scheduled task - $resetAtMs = 0; - $nextExec = $taskData['next_execution'] ?? ''; - - if ($showCountdown && !empty($nextExec)) - { - $ts = strtotime($nextExec . ' UTC'); - - if ($ts > time()) - { - $resetAtMs = $ts * 1000; - } - } - - $countdownJs = ''; - - if ($showCountdown && $resetAtMs > 0) - { - $countdownJs = " - var resetAt = {$resetAtMs}; - var cdSpan = document.getElementById('mokowaas-demo-countdown'); - if (cdSpan) { - var tick = function() { - var now = Date.now(); - var diff = Math.max(0, Math.floor((resetAt - now) / 1000)); - if (diff <= 0) { cdSpan.textContent = ' — Reset imminent'; return; } - var parts = []; - var d = Math.floor(diff / 86400); - if (d >= 30) { - var mo = Math.floor(d / 30); - parts.push(mo + (mo === 1 ? ' month' : ' months')); - d = d % 30; - } - if (d >= 7) { - var w = Math.floor(d / 7); - parts.push(w + (w === 1 ? ' week' : ' weeks')); - d = d % 7; - } - if (d > 0) { parts.push(d + (d === 1 ? ' day' : ' days')); } - var rem = diff % 86400; - if (parts.length === 0) { - var h = Math.floor(rem / 3600); - var m = Math.floor((rem % 3600) / 60); - var s = rem % 60; - parts.push(h + 'h ' + m + 'm ' + s + 's'); - } else if (parts.length <= 2) { - var h = Math.floor(rem / 3600); - if (h > 0) { parts.push(h + 'h'); } - } - cdSpan.textContent = ' — Resets in ' + parts.join(' '); - }; - tick(); - setInterval(tick, 1000); - } - "; - } - - $doc->addScriptDeclaration(" - document.addEventListener('DOMContentLoaded', function() { - var bar = document.createElement('div'); - bar.id = 'mokowaas-demo-banner'; - bar.style.cssText = 'background:{$bgColor};color:#fff;padding:10px 20px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:14px;text-align:center;'; - bar.innerHTML = '{$message}" . ($showCountdown ? "" : "") . "'; - - document.body.insertBefore(bar, document.body.firstChild); - - {$countdownJs} - }); - "); - } - - /** - * Get demo task params from #__scheduler_tasks if task is enabled. - * - * @return array|null Task params merged with task metadata, or null if no active task - * - * @since 02.29.00 - */ - protected function getDemoTaskParams(): ?array - { - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([ - $db->quoteName('params'), - $db->quoteName('state'), - $db->quoteName('next_execution'), - $db->quoteName('last_execution'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')) - ->where($db->quoteName('state') . ' = 1'); - - $db->setQuery($query); - $task = $db->loadAssoc(); - - if (!$task) - { - return null; - } - - $params = json_decode($task['params'] ?? '{}', true) ?: []; - $params['next_execution'] = $task['next_execution']; - $params['last_execution'] = $task['last_execution']; - - return $params; - } - catch (\Throwable $e) - { - return null; - } - } - - /** - * Hide MokoWaaS plugin and package from the extensions list via JS. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.03.04 - */ - protected function hidePluginFromList($doc) - { - $option = $this->app->input->get('option', ''); - $view = $this->app->input->get('view', ''); - - if ($option !== 'com_plugins' && $option !== 'com_installer') - { - return; - } - - $doc->addScriptDeclaration(" - document.addEventListener('DOMContentLoaded', function() { - document.querySelectorAll('tr').forEach(function(row) { - var text = row.textContent || ''; - if (text.indexOf('mokowaas') !== -1 || text.indexOf('MokoWaaS') !== -1) { - row.style.display = 'none'; - } - }); - }); - "); - } - - /** - * Redirect the admin Help menu link to the configured support URL. - * - * Joomla's Atum template hardcodes the Help link to help.joomla.org. - * This replaces it with the WaaS support URL via JS injection. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.10.00 - */ - protected function redirectHelpMenu($doc) - { - $supportUrl = self::SUPPORT_URL; - - $doc->addScriptDeclaration(" - document.addEventListener('DOMContentLoaded', function() { - document.querySelectorAll('a[href*=\"help.joomla.org\"], a[href*=\"docs.joomla.org\"]').forEach(function(link) { - link.href = " . json_encode($supportUrl) . "; - link.target = '_blank'; - }); - }); - "); - } - - /** - * Protect the plugin from being disabled or uninstalled by non-master users. - * Does NOT self-heal (no lock) — master users can still disable if needed. - * - * @return void - * - * @since 02.03.04 - */ - protected function protectPlugin() - { - // Ensure protected flag is set (self-healing — runs once per session) - static $flagChecked = false; - - if (!$flagChecked) - { - $flagChecked = true; - $this->ensureProtectedFlag(); - } - - if ($this->isMasterUser()) - { - return; - } - - $option = $this->app->input->get('option', ''); - $task = $this->app->input->get('task', ''); - - // Block non-master from uninstalling MokoWaaS - if ($option === 'com_installer' && strpos($task, 'manage.remove') !== false) - { - $cid = $this->app->input->get('cid', [], 'array'); - - if ($this->isOurExtension($cid)) - { - $this->app->enqueueMessage('MokoWaaS cannot be uninstalled.', 'error'); - $this->app->redirect('index.php?option=com_installer&view=manage'); - } - } - - // Block non-master from disabling via list toggle - if ($option === 'com_plugins' && strpos($task, 'plugins.publish') !== false) - { - $cid = $this->app->input->get('cid', [], 'array'); - - if ($this->isOurExtension($cid)) - { - $this->app->enqueueMessage('MokoWaaS cannot be disabled.', 'error'); - $this->app->redirect('index.php?option=com_plugins'); - } - } - - // Block non-master from viewing or editing MokoWaaS plugin settings - if ($option === 'com_plugins') - { - $view = $this->app->input->get('view', ''); - $layout = $this->app->input->get('layout', ''); - $extensionId = (int) $this->app->input->get('extension_id', 0); - - if (($view === 'plugin' || $layout === 'edit') && $extensionId > 0) - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('extension_id') . ' = ' . $extensionId) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')); - - if ((int) $db->setQuery($query)->loadResult() > 0) - { - $this->app->enqueueMessage('MokoWaaS settings are restricted to the master user.', 'warning'); - $this->app->redirect('index.php?option=com_plugins'); - } - } - } - } - - /** - * Ensure the protected flag is set on MokoWaaS extensions in the DB. - * - * Sets protected=1, locked=0 so the extension can't be disabled or - * uninstalled but can still receive updates and config changes. - * - * @return void - * - * @since 02.03.10 - */ - protected function ensureProtectedFlag() - { - try - { - $db = Factory::getDbo(); - - // Set protected=1, locked=0 on MokoWaaS extensions - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('protected') . ' = 1') - ->set($db->quoteName('locked') . ' = 0') - ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') - . ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')') - ->where($db->quoteName('protected') . ' = 0'); - $db->setQuery($query); - $db->execute(); - - // Ensure update site stays enabled (protected extensions get their update site disabled by Joomla) - $query = $db->getQuery(true) - ->update($db->quoteName('#__update_sites') . ' AS us') - ->join('INNER', $db->quoteName('#__update_sites_extensions') . ' AS use2 ON us.update_site_id = use2.update_site_id') - ->join('INNER', $db->quoteName('#__extensions') . ' AS e ON use2.extension_id = e.extension_id') - ->set('us.enabled = 1') - ->where('us.enabled = 0') - ->where('(' . $db->quoteName('e.element') . ' = ' . $db->quote('mokowaas') - . ' OR ' . $db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokowaas') . ')'); - $db->setQuery($query); - $db->execute(); - } - catch (\Throwable $e) - { - // Non-critical - } - } - - /** - * Check if any of the given extension IDs belong to MokoWaaS. - * - * @param array $ids Extension IDs to check - * - * @return bool - * - * @since 02.03.04 - */ - protected function isOurExtension(array $ids): bool - { - if (empty($ids)) - { - return false; - } - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('extension_id') . ' IN (' . implode(',', array_map('intval', $ids)) . ')') - ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') - . ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')'); - - return (int) $db->setQuery($query)->loadResult() > 0; - } - - /** - * Prevent non-master users from disabling the plugin via save. - * - * @param string $context Extension context - * @param object $table Extension table row - * @param bool $isNew Whether this is a new record - * - * @return bool False to cancel save - * - * @since 02.03.04 - */ - public function onExtensionBeforeSave($context, $table, $isNew) - { - if ($context !== 'com_plugins.plugin') - { - return true; - } - - if ($table->element !== 'mokowaas' || $table->folder !== 'system') - { - return true; - } - - // Non-master users cannot disable the plugin - if (!$this->isMasterUser() && (int) $table->enabled === 0) - { - $this->app->enqueueMessage('MokoWaaS cannot be disabled.', 'error'); - $table->enabled = 1; - } - - return true; - } - - /** - * Cascade enable/disable state across all MokoWaaS extensions. - * - * When the core system plugin (plg_system_mokowaas) is disabled, - * all feature plugins and the cpanel module are also disabled. - * When re-enabled, they are re-enabled too. - * - * @param string $context The extension context - * @param array $pks Extension IDs being changed - * @param int $value New state (1=enabled, 0=disabled) - * - * @return void - * - * @since 02.32.00 - */ - public function onExtensionChangeState($context, $pks, $value) - { - if (empty($pks)) - { - return; - } - - try - { - $db = Factory::getDbo(); - - // Check if the core MokoWaaS plugin is among the changed extensions - $query = $db->getQuery(true) - ->select($db->quoteName('extension_id')) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) - ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); - $db->setQuery($query); - $coreId = (int) $db->loadResult(); - - if (!$coreId || !\in_array($coreId, array_map('intval', $pks), true)) - { - return; - } - - // Cascade to all MokoWaaS feature plugins + module - $mokoElements = [ - $db->quote('mokowaas_firewall'), - $db->quote('mokowaas_tenant'), - $db->quote('mokowaas_devtools'), - $db->quote('mokowaas_monitor'), - $db->quote('mod_mokowaas_cpanel'), - ]; - - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('enabled') . ' = ' . (int) $value) - ->where($db->quoteName('element') . ' IN (' . implode(',', $mokoElements) . ')'); - $db->setQuery($query); - $db->execute(); - $affected = $db->getAffectedRows(); - - // Also update module published state - if ($value == 0) - { - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__modules')) - ->set($db->quoteName('published') . ' = 0') - ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel')) - )->execute(); - } - else - { - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__modules')) - ->set($db->quoteName('published') . ' = 1') - ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel')) - )->execute(); - } - - $state = $value ? 'enabled' : 'disabled'; - $this->app->enqueueMessage( - "MokoWaaS: {$state} {$affected} associated extensions.", - 'message' - ); - } - catch (\Throwable $e) - { - Log::add('MokoWaaS cascade state error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * Filter admin menu items for non-master users. - * - * @param string $context Menu context - * @param array &$items Menu items (by reference) - * @param mixed $params Module params - * @param mixed $enabled Whether module is enabled - * - * @return void - * - * @since 02.01.08 - */ - public function onPreprocessMenuItems($context, &$items, $params, $enabled) - { - if (!$this->app->isClient('administrator')) - { - return; - } - - if ($this->isMasterUser()) - { - return; - } - - $hidden = $this->getHiddenMenuComponents(); - - if (empty($hidden)) - { - return; - } - - foreach ($items as $key => $item) - { - foreach ($hidden as $component) - { - if (isset($item->link) - && strpos($item->link, 'option=' . $component) !== false) - { - unset($items[$key]); - break; - } - } - } - } - - /** - * Enforce password policy before user save. - * - * @param array $oldUser Existing user data - * @param boolean $isNew Whether this is a new user - * @param array $newUser New user data being saved - * - * @return boolean True to allow save - * - * @since 02.01.08 - */ - public function onUserBeforeSave($oldUser, $isNew, $newUser) - { - if (empty($newUser['password_clear'])) - { - return true; - } - - $password = $newUser['password_clear']; - $errors = []; - - $minLen = (int) $this->params->get('password_min_length', 12); - - if (strlen($password) < $minLen) - { - $errors[] = sprintf( - 'Password must be at least %d characters.', $minLen - ); - } - - if ($this->params->get('password_require_uppercase', 1) - && !preg_match('/[A-Z]/', $password)) - { - $errors[] = 'Password must contain an uppercase letter.'; - } - - if ($this->params->get('password_require_number', 1) - && !preg_match('/\d/', $password)) - { - $errors[] = 'Password must contain a number.'; - } - - if ($this->params->get('password_require_special', 1) - && !preg_match('/[^A-Za-z0-9]/', $password)) - { - $errors[] = 'Password must contain a special character.'; - } - - if (!empty($errors)) - { - throw new \RuntimeException(implode(' ', $errors)); - } - - return true; - } - - - // ------------------------------------------------------------------ - // Diagnostics / Health Endpoint (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Handle health check requests for external monitoring (e.g. Grafana). - * - * Intercepts requests with ?mokowaas=health, validates the API token, - * and returns a JSON payload with system diagnostics. Exits early to - * avoid Joomla routing overhead. - * - * @return void - * - * @since 02.01.22 - */ - /** - * Route MokoWaaS API requests. - * - * All endpoints share the same token auth and HTTPS enforcement. - * Endpoints: - * ?mokowaas=health — 16 diagnostic checks (GET) - * ?mokowaas=install — install extension from URL (POST) - * ?mokowaas=update — trigger Joomla update check (POST) - * ?mokowaas=cache — clear Joomla cache (POST) - * ?mokowaas=backup — trigger Akeeba Backup (POST) - * ?mokowaas=info — site info summary (GET) - * - * @param string $action The API action - * - * @return void - * - * @since 02.01.39 - */ - protected function handleMokoApi($action) - { - // Validate token for all endpoints - $expectedToken = $this->params->get('health_api_token', ''); - - if (empty($expectedToken)) - { - $this->sendHealthResponse(503, ['error' => 'No API token']); - - return; - } - - $authHeader = $_SERVER['HTTP_AUTHORIZATION'] - ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] - ?? ''; - $providedToken = ''; - - if (stripos($authHeader, 'Bearer ') === 0) - { - $providedToken = trim(substr($authHeader, 7)); - } - else - { - $providedToken = $this->app->input->get('token', '', 'RAW'); - } - - // syncclear and syncpush handle their own auth via POST body - $selfAuthActions = ['syncclear', 'syncpush']; - - if (!\in_array($action, $selfAuthActions, true) && !hash_equals($expectedToken, $providedToken)) - { - $this->sendHealthResponse(401, ['error' => 'Invalid token']); - - return; - } - - switch ($action) - { - case 'health': - $this->handleHealthAction(); - break; - case 'install': - $this->handleInstallAction(); - break; - case 'update': - $this->handleUpdateAction(); - break; - case 'cache': - $this->handleCacheAction(); - break; - case 'backup': - $this->handleBackupAction(); - break; - case 'info': - $this->handleInfoAction(); - break; - case 'reset': - $this->handleDemoResetAction(); - break; - case 'snapshot': - $this->handleSnapshotAction(); - break; - case 'sync': - $this->handleSyncAction(); - break; - case 'sync-receive': - $this->handleSyncReceiveAction(); - break; - case 'syncclear': - $this->handleSyncClearAction(); - break; - case 'syncpush': - $this->handleSyncPushAction(); - break; - case 'extensions': - $this->handleExtensionsAction(); - break; - default: - $this->sendHealthResponse(400, [ - 'error' => 'Unknown action', - 'action' => $action, - 'available' => ['health', 'install', 'update', 'cache', 'backup', 'info', 'reset', 'snapshot', 'sync', 'sync-receive', 'syncclear', 'extensions'], - ]); - break; - } - } - - // ------------------------------------------------------------------ - // API Actions - // ------------------------------------------------------------------ - - /** - * Handle demo site reset via API. - * - * POST /?mokowaas=reset - * Body: {"baseline": "default"} (optional, defaults to active baseline) - * - * @return void - * @since 02.21.00 - */ - protected function handleDemoResetAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $body = json_decode(file_get_contents('php://input'), true); - $baseline = $body['baseline'] - ?? 'default'; - - $service = $this->createDemoResetService(); - $result = $service->restoreSnapshot($baseline); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Reset failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Handle snapshot create/list via API. - * - * GET /?mokowaas=snapshot — list snapshots - * POST /?mokowaas=snapshot — create snapshot - * Body: {"name": "my-baseline"} (optional, defaults to active baseline) - * - * @return void - * @since 02.21.00 - */ - protected function handleSnapshotAction() - { - $service = $this->createDemoResetService(); - - if ($this->app->input->getMethod() === 'GET') - { - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'snapshots' => $service->listSnapshots(), - ]); - - return; - } - - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'GET or POST required']); - - return; - } - - try - { - $body = json_decode(file_get_contents('php://input'), true); - $name = $body['name'] - ?? 'default'; - - $result = $service->createSnapshot($name); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Snapshot failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Create a DemoResetService instance from current plugin params. - * - * @return \Moko\Plugin\System\MokoWaaS\Service\DemoResetService - * @since 02.21.00 - */ - protected function createDemoResetService() - { - require_once __DIR__ . '/../Service/DemoResetService.php'; - - $includeMedia = (bool) $this->params->get('demo_snapshot_include_media', 1); - - return new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($includeMedia); - } - - /** - * Calculate the next run time from a crontab expression. - * - * Supports standard 5-field crontab: minute hour day month weekday. - * Steps (e.g. every N), ranges, and wildcards are supported. - * - * @param string $cron Crontab expression - * - * @return string|null ISO datetime of next run, or null on invalid input - * - * @since 02.21.00 - */ - protected function ensureDemoResetTask(string $cron, string $baseline): void - { - try - { - $db = Factory::getDbo(); - - // Check if task already exists - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $existing = $db->loadAssoc(); - - // Convert cron to Joomla scheduler execution rule - $execRule = json_encode([ - 'rule-type' => 'cron-expression', - 'cron-expression' => $cron, - ]); - - $taskParams = json_encode(['baseline' => $baseline]); - - if ($existing) - { - // Update existing task - $query = $db->getQuery(true) - ->update($db->quoteName('#__scheduler_tasks')) - ->set($db->quoteName('execution_rules') . ' = ' . $db->quote($execRule)) - ->set($db->quoteName('params') . ' = ' . $db->quote($taskParams)) - ->set($db->quoteName('state') . ' = 1') - ->where($db->quoteName('id') . ' = ' . (int) $existing['id']); - - $db->setQuery($query); - $db->execute(); - } - else - { - // Create new task - $obj = (object) [ - 'title' => 'MokoWaaS Demo Reset', - 'type' => 'mokowaas.demo.reset', - 'execution_rules' => $execRule, - 'params' => $taskParams, - 'state' => 1, - 'created' => Factory::getDate()->toSql(), - 'next_execution' => Factory::getDate()->toSql(), - ]; - - $db->insertObject('#__scheduler_tasks', $obj); - } - } - catch (\Throwable $e) - { - Log::add('Failed to create demo reset task: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * Remove the demo reset scheduled task. - * - * @return void - * - * @since 02.28.00 - */ - protected function removeDemoResetTask(): void - { - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->delete($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $db->execute(); - } - catch (\Throwable $e) - { - // Silent — table may not exist - } - } - - protected function calculateNextCronRun(string $cron): ?string - { - $parts = preg_split('/\s+/', trim($cron)); - - if (count($parts) !== 5) - { - return null; - } - - [$cronMin, $cronHour, $cronDay, $cronMonth, $cronWeekday] = $parts; - - // Start from next minute - $now = time(); - $check = $now - ($now % 60) + 60; - - // Check up to 366 days ahead - $maxChecks = 527040; // 366 * 24 * 60 - - for ($i = 0; $i < $maxChecks; $i++) - { - $min = (int) date('i', $check); - $hour = (int) date('G', $check); - $day = (int) date('j', $check); - $month = (int) date('n', $check); - $weekday = (int) date('w', $check); - - if ($this->cronFieldMatches($cronMin, $min, 0, 59) - && $this->cronFieldMatches($cronHour, $hour, 0, 23) - && $this->cronFieldMatches($cronDay, $day, 1, 31) - && $this->cronFieldMatches($cronMonth, $month, 1, 12) - && $this->cronFieldMatches($cronWeekday, $weekday, 0, 6)) - { - return gmdate('Y-m-d\TH:i:s\Z', $check); - } - - $check += 60; - } - - return null; - } - - /** - * Check if a value matches a crontab field expression. - * - * @param string $field Cron field (e.g. every-5, 1-15 range, 0-23, wildcard) - * @param int $value Current value to check - * @param int $min Minimum allowed value - * @param int $max Maximum allowed value - * - * @return bool - * - * @since 02.21.00 - */ - private function cronFieldMatches(string $field, int $value, int $min, int $max): bool - { - foreach (explode(',', $field) as $part) - { - $part = trim($part); - - // Step: every-N or range-with-step - if (str_contains($part, '/')) - { - [$range, $step] = explode('/', $part, 2); - $step = (int) $step; - - if ($step <= 0) - { - continue; - } - - if ($range === '*') - { - if (($value - $min) % $step === 0) - { - return true; - } - } - elseif (str_contains($range, '-')) - { - [$rangeMin, $rangeMax] = array_map('intval', explode('-', $range, 2)); - - if ($value >= $rangeMin && $value <= $rangeMax && ($value - $rangeMin) % $step === 0) - { - return true; - } - } - - continue; - } - - // Wildcard - if ($part === '*') - { - return true; - } - - // Range: N-M - if (str_contains($part, '-')) - { - [$rangeMin, $rangeMax] = array_map('intval', explode('-', $part, 2)); - - if ($value >= $rangeMin && $value <= $rangeMax) - { - return true; - } - - continue; - } - - // Exact value - if ((int) $part === $value) - { - return true; - } - } - - return false; - } - - /** - * Handle content sync push to configured targets. - * - * POST /?mokowaas=sync - * - * @return void - * @since 02.21.00 - */ - protected function handleSyncAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - require_once __DIR__ . '/../Service/ContentSyncService.php'; - - $targets = json_decode($this->params->get('sync_targets', '[]'), true) ?: []; - - $service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService(); - $result = $service->syncAllTargets($targets); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Handle incoming content sync payload (receiver side). - * - * POST /?mokowaas=sync-receive - * - * @return void - * @since 02.21.00 - */ - protected function handleSyncReceiveAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $payload = json_decode(file_get_contents('php://input'), true); - - if (empty($payload['mokowaas_sync'])) - { - $this->sendHealthResponse(400, ['error' => 'Invalid payload — missing mokowaas_sync version']); - - return; - } - - require_once __DIR__ . '/../Service/ContentSyncReceiver.php'; - - $receiver = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncReceiver(); - $result = $receiver->receive($payload); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync receive failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Bulk-clear content on this site before a sync push. - * - * POST /?mokowaas=syncclear - * Body: {"token": "...", "types": ["articles", "categories", "menus", "modules"]} - * - * Deletes content directly via DB for speed — avoids the per-item - * Joomla API DELETE bottleneck. - * - * @return void - * - * @since 02.31.00 - */ - protected function handleSyncClearAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - $payload = json_decode(file_get_contents('php://input'), true); - $token = $payload['token'] ?? ''; - - // Authenticate with health API token - $expectedToken = $this->params->get('health_api_token', ''); - - if (empty($expectedToken) || !hash_equals($expectedToken, $token)) - { - $this->sendHealthResponse(401, ['error' => 'Invalid token']); - - return; - } - - $types = $payload['types'] ?? []; - $cleared = []; - $db = Factory::getDbo(); - - try - { - if (\in_array('articles', $types, true)) - { - $db->setQuery('DELETE FROM ' . $db->quoteName('#__content'))->execute(); - $cleared[] = 'articles:' . $db->getAffectedRows(); - } - - if (\in_array('categories', $types, true)) - { - // Delete non-root content categories - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__categories')) - ->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')) - ->where($db->quoteName('id') . ' > 1') - )->execute(); - $cleared[] = 'categories:' . $db->getAffectedRows(); - } - - if (\in_array('menus', $types, true)) - { - // Delete non-root site menu items - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__menu')) - ->where($db->quoteName('client_id') . ' = 0') - ->where($db->quoteName('id') . ' > 1') - )->execute(); - $cleared[] = 'menus:' . $db->getAffectedRows(); - } - - if (\in_array('modules', $types, true)) - { - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__modules')) - ->where($db->quoteName('client_id') . ' = 0') - )->execute(); - $cleared[] = 'modules:' . $db->getAffectedRows(); - } - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'cleared' => $cleared, - ]); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync clear failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Receive bulk content and insert locally via Joomla's Table API. - * - * POST /?mokowaas=syncpush - * Body: {"token": "...", "type": "articles", "items": [{...}, ...]} - * - * @return void - * - * @since 02.31.00 - */ - protected function handleSyncPushAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - $payload = json_decode(file_get_contents('php://input'), true); - $token = $payload['token'] ?? ''; - - $expectedToken = $this->params->get('health_api_token', ''); - - if (empty($expectedToken) || !hash_equals($expectedToken, $token)) - { - $this->sendHealthResponse(401, ['error' => 'Invalid token']); - - return; - } - - $type = $payload['type'] ?? ''; - $items = $payload['items'] ?? []; - - if (empty($type) || empty($items)) - { - $this->sendHealthResponse(400, ['error' => 'Missing type or items']); - - return; - } - - try - { - $db = Factory::getDbo(); - $inserted = 0; - $now = Factory::getDate()->toSql(); - - switch ($type) - { - case 'articles': - foreach ($items as $item) - { - try - { - $record = (object) [ - 'title' => $item['title'] ?? '', - 'alias' => $item['alias'] ?? '', - 'introtext' => $item['introtext'] ?? '', - 'fulltext' => $item['fulltext'] ?? '', - 'state' => (int) ($item['state'] ?? 1), - 'catid' => (int) ($item['catid'] ?? 2), - 'language' => $item['language'] ?? '*', - 'featured' => (int) ($item['featured'] ?? 0), - 'metadesc' => $item['metadesc'] ?? '', - 'metakey' => $item['metakey'] ?? '', - 'metadata' => $item['metadata'] ?? '{}', - 'created' => $item['created'] ?? $now, - 'modified' => $item['modified'] ?? $now, - 'publish_up' => $item['publish_up'] ?? $now, - 'images' => $item['images'] ?? '{}', - 'urls' => $item['urls'] ?? '{}', - 'attribs' => $item['attribs'] ?? '{}', - 'access' => (int) ($item['access'] ?? 1), - 'created_by' => 0, - 'asset_id' => 0, - ]; - $db->insertObject('#__content', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - case 'categories': - foreach ($items as $item) - { - try - { - $record = (object) [ - 'title' => $item['title'] ?? '', - 'alias' => $item['alias'] ?? '', - 'description' => $item['description'] ?? '', - 'published' => (int) ($item['published'] ?? 1), - 'language' => $item['language'] ?? '*', - 'extension' => $item['extension'] ?? 'com_content', - 'access' => (int) ($item['access'] ?? 1), - 'params' => $item['params'] ?? '{}', - 'metadata' => $item['metadata'] ?? '{}', - 'parent_id' => 1, - 'level' => 1, - 'lft' => 0, - 'rgt' => 0, - ]; - $db->insertObject('#__categories', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - case 'menus': - foreach ($items as $item) - { - try - { - $alias = $item['alias'] ?? ''; - $record = (object) [ - 'title' => $item['title'] ?? '', - 'alias' => $alias, - 'path' => $item['path'] ?? $alias, - 'menutype' => $item['menutype'] ?? 'mainmenu', - 'type' => $item['type'] ?? 'component', - 'link' => $item['link'] ?? '', - 'language' => $item['language'] ?? '*', - 'published' => (int) ($item['published'] ?? 1), - 'home' => (int) ($item['home'] ?? 0), - 'params' => $item['params'] ?? '{}', - 'img' => $item['img'] ?? '', - 'access' => (int) ($item['access'] ?? 1), - 'parent_id' => 1, - 'level' => 1, - 'lft' => 0, - 'rgt' => 0, - 'client_id' => 0, - ]; - $db->insertObject('#__menu', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - case 'modules': - foreach ($items as $item) - { - try - { - $record = (object) [ - 'title' => $item['title'] ?? '', - 'module' => $item['module'] ?? '', - 'position' => $item['position'] ?? '', - 'params' => $item['params'] ?? '{}', - 'language' => $item['language'] ?? '*', - 'published' => (int) ($item['published'] ?? 1), - 'access' => (int) ($item['access'] ?? 1), - 'ordering' => (int) ($item['ordering'] ?? 0), - 'showtitle' => (int) ($item['showtitle'] ?? 1), - 'client_id' => 0, - ]; - $db->insertObject('#__modules', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - default: - $this->sendHealthResponse(400, ['error' => 'Unknown type: ' . $type]); - - return; - } - - // Rebuild nested set trees and asset table after insert - $this->repairAfterSync($type); - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'type' => $type, - 'inserted' => $inserted, - ]); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync push failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Repair nested set trees and asset table after a bulk sync push. - * - * Categories and menus use nested sets (lft/rgt/level) which need - * rebuilding after direct DB inserts. Content needs asset entries - * for ACL to work. - * - * @param string $type Content type that was pushed - * - * @return void - * - * @since 02.31.00 - */ - private function repairAfterSync(string $type): void - { - try - { - $db = Factory::getDbo(); - - if ($type === 'categories') - { - // Rebuild the category nested set tree - $table = new \Joomla\CMS\Table\Category($db); - $table->rebuild(); - - // Ensure asset entries exist for each category - $db->setQuery( - $db->getQuery(true) - ->select('id, title, extension') - ->from($db->quoteName('#__categories')) - ->where($db->quoteName('id') . ' > 1') - ->where($db->quoteName('asset_id') . ' = 0') - ); - - foreach ($db->loadObjectList() as $cat) - { - $asset = new \Joomla\CMS\Table\Asset($db); - $asset->name = $cat->extension . '.category.' . $cat->id; - $asset->title = $cat->title; - $asset->rules = '{}'; - - // Parent asset = root - $asset->setLocation(1, 'last-child'); - $asset->store(); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__categories')) - ->set($db->quoteName('asset_id') . ' = ' . (int) $asset->id) - ->where($db->quoteName('id') . ' = ' . (int) $cat->id) - )->execute(); - } - } - - if ($type === 'articles') - { - // Ensure asset entries exist for each article - $db->setQuery( - $db->getQuery(true) - ->select('id, title, catid') - ->from($db->quoteName('#__content')) - ->where($db->quoteName('asset_id') . ' = 0') - ); - - foreach ($db->loadObjectList() as $article) - { - $asset = new \Joomla\CMS\Table\Asset($db); - $asset->name = 'com_content.article.' . $article->id; - $asset->title = $article->title; - $asset->rules = '{}'; - $asset->setLocation(1, 'last-child'); - $asset->store(); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('asset_id') . ' = ' . (int) $asset->id) - ->where($db->quoteName('id') . ' = ' . (int) $article->id) - )->execute(); - } - } - - if ($type === 'menus') - { - // Rebuild menu nested set tree - $table = new \Joomla\CMS\Table\Menu($db); - $table->rebuild(); - } - } - catch (\Throwable $e) - { - Log::add('Asset repair failed for ' . $type . ': ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * List installed extensions with version, status, and update server info. - * - * GET /?mokowaas=extensions - * Optional: ?type=plugin&search=moko&enabled=1 - * - * @return void - * @since 02.21.00 - */ - protected function handleExtensionsAction() - { - try - { - $db = Factory::getDbo(); - $input = $this->app->input; - - $query = $db->getQuery(true) - ->select([ - $db->quoteName('e.extension_id'), - $db->quoteName('e.name'), - $db->quoteName('e.type'), - $db->quoteName('e.element'), - $db->quoteName('e.folder'), - $db->quoteName('e.client_id'), - $db->quoteName('e.enabled'), - $db->quoteName('e.protected'), - $db->quoteName('e.locked'), - $db->quoteName('e.manifest_cache'), - ]) - ->from($db->quoteName('#__extensions', 'e')) - ->order($db->quoteName('e.type') . ' ASC, ' . $db->quoteName('e.name') . ' ASC'); - - $typeFilter = $input->get('type', '', 'CMD'); - - if ($typeFilter !== '') - { - $query->where($db->quoteName('e.type') . ' = ' . $db->quote($typeFilter)); - } - - $enabledFilter = $input->get('enabled', '', 'CMD'); - - if ($enabledFilter !== '') - { - $query->where($db->quoteName('e.enabled') . ' = ' . (int) $enabledFilter); - } - - $search = $input->get('search', '', 'STRING'); - - if ($search !== '') - { - $like = $db->quote('%' . $db->escape($search, true) . '%'); - $query->where( - '(' . $db->quoteName('e.name') . ' LIKE ' . $like - . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $like . ')' - ); - } - - $db->setQuery($query); - $rows = $db->loadAssocList(); - - // Get update sites - $usQuery = $db->getQuery(true) - ->select([ - $db->quoteName('us.name', 'site_name'), - $db->quoteName('us.location'), - $db->quoteName('us.enabled', 'site_enabled'), - $db->quoteName('usm.extension_id'), - ]) - ->from($db->quoteName('#__update_sites', 'us')) - ->innerJoin( - $db->quoteName('#__update_sites_extensions', 'usm') - . ' ON ' . $db->quoteName('us.update_site_id') - . ' = ' . $db->quoteName('usm.update_site_id') - ); - $db->setQuery($usQuery); - $updateSites = []; - - foreach ($db->loadAssocList() ?: [] as $us) - { - $updateSites[(int) $us['extension_id']] = [ - 'name' => $us['site_name'], - 'location' => $us['location'], - 'enabled' => (bool) $us['site_enabled'], - ]; - } - - $extensions = []; - - foreach ($rows as $row) - { - $manifest = json_decode($row['manifest_cache'] ?: '{}', true); - $extId = (int) $row['extension_id']; - - $ext = [ - 'extension_id' => $extId, - 'name' => $row['name'], - 'type' => $row['type'], - 'element' => $row['element'], - 'folder' => $row['folder'] ?: null, - 'client_id' => (int) $row['client_id'], - 'enabled' => (bool) $row['enabled'], - 'protected' => (bool) $row['protected'], - 'locked' => (bool) $row['locked'], - 'version' => $manifest['version'] ?? null, - 'author' => $manifest['author'] ?? null, - ]; - - if (isset($updateSites[$extId])) - { - $ext['update_server'] = $updateSites[$extId]; - } - - $extensions[] = $ext; - } - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'count' => count($extensions), - 'extensions' => $extensions, - ]); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Failed to list extensions', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Trigger Joomla update finder check. - * - * @return void - * @since 02.01.39 - */ - protected function handleUpdateAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - // Clear update cache and find updates - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__updates')) - ); - $db->execute(); - - // Trigger update finder - \Joomla\CMS\Updater\Updater::getInstance()->findUpdates(); - - // Count results - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__updates')) - ->where($db->quoteName('extension_id') . ' != 0') - ); - $count = (int) $db->loadResult(); - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'updates_found' => $count, - 'message' => $count . ' update(s) available', - ]); - } - catch (\Exception $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Update check failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Clear Joomla cache. - * - * @return void - * @since 02.01.39 - */ - protected function handleCacheAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $cache = Factory::getCache(''); - $cache->clean(''); - - // Also clean admin cache - $adminCache = Factory::getCache('', 'callback', 'administrator'); - $adminCache->clean(''); - - // Clear opcache if available - if (function_exists('opcache_reset')) - { - opcache_reset(); - } - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'message' => 'Cache cleared', - ]); - } - catch (\Exception $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Cache clear failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Trigger Akeeba Backup via frontend API. - * - * @return void - * @since 02.01.39 - */ - protected function handleBackupAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - if (!in_array($prefix . 'ak_stats', $tables)) - { - $this->sendHealthResponse(404, [ - 'error' => 'Akeeba Backup not installed', - ]); - - return; - } - - // Get profile from request (default 1) - $body = json_decode(file_get_contents('php://input'), true); - $profile = (int) ($body['profile'] ?? 1); - - // Start backup via Akeeba's internal API - if (class_exists('\Akeeba\Engine\Platform')) - { - \Akeeba\Engine\Platform::getInstance()->load_configuration($profile); - $engine = \Akeeba\Engine\Factory::getEngineInstance(); - - $result = $engine->start($profile); - - $this->sendHealthResponse(200, [ - 'status' => 'started', - 'profile' => $profile, - 'message' => 'Backup started', - ]); - } - else - { - // Fallback: trigger via URL if frontend backup is enabled - $this->sendHealthResponse(501, [ - 'error' => 'Akeeba Engine not loadable', - 'message' => 'Use the Akeeba frontend URL or admin panel instead', - ]); - } - } - catch (\Exception $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Backup failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Return a compact site info summary. - * - * @return void - * @since 02.01.39 - */ - protected function handleInfoAction() - { - $config = Factory::getConfig(); - $db = Factory::getDbo(); - - $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__content'))); - $articles = (int) $db->loadResult(); - - $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__users'))); - $users = (int) $db->loadResult(); - - $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__extensions'))->where($db->quoteName('enabled') . ' = 1')); - $extensions = (int) $db->loadResult(); - - $this->sendHealthResponse(200, [ - 'site_name' => $config->get('sitename', ''), - 'site_url' => rtrim(Uri::root(), '/'), - 'joomla_version' => JVERSION, - 'php_version' => PHP_VERSION, - 'db_type' => $db->getName(), - 'debug' => (bool) $config->get('debug', 0), - 'sef' => (bool) $config->get('sef', 0), - 'caching' => (bool) $config->get('caching', 0), - 'articles' => $articles, - 'users' => $users, - 'extensions' => $extensions, - 'brand' => self::BRAND_NAME, - 'plugin_version' => $this->getPluginVersion(), - ]); - } - - /** - * Health check action — delegates to existing health check logic. - * - * @return void - * @since 02.01.39 - */ - protected function handleHealthAction() - { - // Token already validated by handleMokoApi() - // Collect diagnostics - $checks = $this->collectHealthChecks(); - - // Determine overall status and collect reasons - $overall = 'ok'; - $reasons = []; - - foreach ($checks as $name => $check) - { - $checkStatus = $check['status'] ?? 'ok'; - - if ($checkStatus === 'error') - { - $overall = 'error'; - $reasons[] = $name . ': ' . ($check['message'] ?? 'error'); - } - elseif ($checkStatus === 'degraded') - { - if ($overall !== 'error') - { - $overall = 'degraded'; - } - - // Build human-readable reason - if ($name === 'extensions' - && isset($check['pending_updates'])) - { - $reasons[] = $check['pending_updates'] - . ' extension update' - . ($check['pending_updates'] > 1 ? 's' : '') - . ' available'; - } - elseif ($name === 'filesystem' - && isset($check['free_disk_mb']) - && $check['free_disk_mb'] < 100) - { - $reasons[] = 'Low disk space: ' - . $check['free_disk_mb'] . ' MB free'; - } - elseif ($name === 'backup') - { - if (!empty($check['message'])) - { - $reasons[] = $check['message']; - } - elseif (isset($check['days_since']) - && $check['days_since'] > 7) - { - $reasons[] = 'Last backup ' - . $check['days_since'] . ' days ago'; - } - elseif (isset($check['last_status']) - && $check['last_status'] !== 'complete') - { - $reasons[] = 'Last backup status: ' - . $check['last_status']; - } - else - { - $reasons[] = 'Backup: degraded'; - } - } - elseif ($name === 'ssl' && isset($check['days_left'])) - { - $reasons[] = 'SSL expires in ' - . $check['days_left'] . ' days'; - } - elseif ($name === 'cron' && isset($check['failed_24h'])) - { - $reasons[] = $check['failed_24h'] - . ' scheduled task(s) failed'; - } - elseif ($name === 'config' && !empty($check['issues'])) - { - $reasons[] = implode(', ', $check['issues']); - } - else - { - $reasons[] = $name . ': degraded'; - } - } - } - - $payload = [ - 'status' => $overall, - 'reason' => implode('; ', $reasons) ?: null, - 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'), - 'checks' => $checks, - 'meta' => $this->collectHealthMeta(), - ]; - - $this->sendHealthResponse( - $overall === 'error' ? 503 : 200, - $payload - ); - } - - /** - * Collect all health check results. - * - * @return array Associative array of check name => result - * - * @since 02.01.22 - */ - protected function collectHealthChecks() - { - $checks = [ - 'database' => $this->checkDatabase(), - 'filesystem' => $this->checkFilesystem(), - 'cache' => $this->checkCache(), - 'extensions' => $this->checkExtensions(), - 'backup' => $this->checkAkeebaBackup(), - 'security' => $this->checkAdminTools(), - 'ssl' => $this->checkSsl(), - 'cron' => $this->checkScheduledTasks(), - 'errors' => $this->checkErrorLog(), - 'db_size' => $this->checkDatabaseSize(), - 'content' => $this->checkContent(), - 'users' => $this->checkUserActivity(), - 'mail' => $this->checkMail(), - 'seo' => $this->checkSeo(), - 'template' => $this->checkTemplate(), - 'config' => $this->checkConfigDrift(), - ]; - - return $checks; - } - - /** - * Collect metadata about the instance. - * - * @return array - * - * @since 02.01.22 - */ - protected function collectHealthMeta() - { - $config = Factory::getConfig(); - - return [ - 'brand' => self::BRAND_NAME, - 'plugin_version' => $this->getPluginVersion(), - 'joomla_version' => JVERSION, - 'php_version' => PHP_VERSION, - 'server_name' => $config->get('sitename', ''), - 'server_time' => gmdate('Y-m-d\TH:i:s\Z'), - ]; - } - - /** - * Check database connectivity and query latency. - * - * @return array Check result with status and metrics - * - * @since 02.01.22 - */ - protected function checkDatabase() - { - try - { - $db = Factory::getDbo(); - $start = microtime(true); - - $db->setQuery('SELECT 1'); - $db->execute(); - - $latencyMs = round((microtime(true) - $start) * 1000, 2); - - // Count users as a real-table sanity check - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__users')) - ); - $userCount = (int) $db->loadResult(); - - return [ - 'status' => 'ok', - 'latency_ms' => $latencyMs, - 'driver' => $db->getName(), - 'users' => $userCount, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'error', - 'message' => 'Database unreachable', - ]; - } - } - - /** - * Check filesystem health (writable dirs, disk space). - * - * @return array Check result with status and metrics - * - * @since 02.01.22 - */ - protected function checkFilesystem() - { - $tmpWritable = is_writable(JPATH_ROOT . '/tmp'); - $logWritable = is_writable(JPATH_ROOT . '/administrator/logs'); - $cacheWritable = is_writable(JPATH_ROOT . '/cache'); - - $freeBytes = @disk_free_space(JPATH_ROOT); - $freeMb = $freeBytes !== false - ? round($freeBytes / 1048576) - : null; - - $allWritable = $tmpWritable && $logWritable && $cacheWritable; - - $status = 'ok'; - - if (!$allWritable) - { - $status = 'error'; - } - elseif ($freeMb !== null && $freeMb < 100) - { - $status = 'degraded'; - } - - // Total disk and site size - $totalBytes = @disk_total_space(JPATH_ROOT); - $totalMb = $totalBytes !== false - ? round($totalBytes / 1048576) - : null; - - // Site directory size (quick estimate via common dirs) - $siteMb = null; - - try - { - $siteSize = 0; - - foreach (['images', 'media', 'tmp', 'cache', - 'administrator/logs', 'administrator/cache'] as $dir) - { - $path = JPATH_ROOT . '/' . $dir; - - if (is_dir($path)) - { - $iter = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator( - $path, - \FilesystemIterator::SKIP_DOTS - ) - ); - - foreach ($iter as $file) - { - $siteSize += $file->getSize(); - } - } - } - - $siteMb = round($siteSize / 1048576); - } - catch (\Exception $e) - { - // Ignore — siteMb stays null - } - - return [ - 'status' => $status, - 'tmp_writable' => $tmpWritable, - 'log_writable' => $logWritable, - 'cache_writable' => $cacheWritable, - 'free_disk_mb' => $freeMb, - 'total_disk_mb' => $totalMb, - 'site_size_mb' => $siteMb, - ]; - } - - /** - * Check Joomla cache status. - * - * @return array Check result - * - * @since 02.01.22 - */ - protected function checkCache() - { - $config = Factory::getConfig(); - $enabled = (bool) $config->get('caching', 0); - $handler = $config->get('cache_handler', 'file'); - - return [ - 'status' => 'ok', - 'enabled' => $enabled, - 'handler' => $handler, - ]; - } - - /** - * Check extension counts and update status. - * - * @return array Check result with extension metrics - * - * @since 02.01.22 - */ - protected function checkExtensions() - { - try - { - $db = Factory::getDbo(); - - // Count enabled extensions by type - $query = $db->getQuery(true) - ->select([ - $db->quoteName('type'), - 'COUNT(*) AS ' . $db->quoteName('total'), - ]) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('enabled') . ' = 1') - ->group($db->quoteName('type')); - - $db->setQuery($query); - $rows = $db->loadObjectList('type'); - - $counts = []; - - foreach ($rows as $type => $row) - { - $counts[$type] = (int) $row->total; - } - - // Check for available updates - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__updates')) - ->where($db->quoteName('extension_id') . ' != 0') - ); - $pendingUpdates = (int) $db->loadResult(); - - $status = $pendingUpdates > 0 ? 'degraded' : 'ok'; - - return [ - 'status' => $status, - 'counts' => $counts, - 'pending_updates' => $pendingUpdates, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'error', - 'message' => 'Could not query extensions', - ]; - } - } - - /** - * Check Akeeba Backup status — last backup date, status, and profile. - * - * Queries the #__ak_stats table (Akeeba Backup) for the most recent - * backup record. Returns 'not_installed' if the table doesn't exist. - * - * @return array Check result with backup info - * - * @since 02.01.39 - */ - protected function checkAkeebaBackup() - { - try - { - $db = Factory::getDbo(); - - // Check if Akeeba Backup is installed - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - $akTable = $prefix . 'ak_stats'; - - if (!in_array($akTable, $tables)) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - - // Get the most recent backup - $query = $db->getQuery(true) - ->select([ - $db->quoteName('id'), - $db->quoteName('description'), - $db->quoteName('status'), - $db->quoteName('backupstart'), - $db->quoteName('backupend'), - $db->quoteName('profile_id'), - $db->quoteName('total_size'), - ]) - ->from($db->quoteName('#__ak_stats')) - ->order($db->quoteName('id') . ' DESC'); - - $db->setQuery($query, 0, 1); - $latest = $db->loadObject(); - - if (!$latest) - { - return [ - 'status' => 'degraded', - 'installed' => true, - 'message' => 'No backups found', - ]; - } - - // Count total backups and recent (last 7 days) - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__ak_stats')) - ); - $totalBackups = (int) $db->loadResult(); - - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__ak_stats')) - ->where($db->quoteName('backupstart') - . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)') - ); - $recentBackups = (int) $db->loadResult(); - - // Check if last backup is older than 7 days - $lastDate = $latest->backupstart; - $daysSince = (int) ((time() - strtotime($lastDate)) / 86400); - $backupSize = $latest->total_size - ? round($latest->total_size / 1048576) - : null; - - $status = 'ok'; - - if ($latest->status !== 'complete') - { - $status = 'degraded'; - } - elseif ($daysSince > 7) - { - $status = 'degraded'; - } - - return [ - 'status' => $status, - 'installed' => true, - 'last_backup' => $lastDate, - 'last_status' => $latest->status, - 'last_size_mb' => $backupSize, - 'days_since' => $daysSince, - 'profile_id' => (int) $latest->profile_id, - 'total_backups' => $totalBackups, - 'recent_7d' => $recentBackups, - 'description' => $latest->description, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - } - - /** - * Check Admin Tools status — WAF status, security exceptions. - * - * Queries Admin Tools tables for firewall status and recent blocks. - * Returns 'not_installed' if tables don't exist. - * - * @return array Check result with security info - * - * @since 02.01.39 - */ - protected function checkAdminTools() - { - try - { - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - // Check if Admin Tools is installed - $atTable = $prefix . 'admintools_log'; - - if (!in_array($atTable, $tables)) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - - // Count blocked requests in last 24h - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__admintools_log')) - ->where($db->quoteName('logdate') - . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') - ); - $blocked24h = (int) $db->loadResult(); - - // Count blocked in last 7 days - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__admintools_log')) - ->where($db->quoteName('logdate') - . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)') - ); - $blocked7d = (int) $db->loadResult(); - - // Check WAF config if available - $wafEnabled = null; - $wafTable = $prefix . 'admintools_wafconfig'; - - if (in_array($wafTable, $tables)) - { - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('value')) - ->from($db->quoteName('#__admintools_wafconfig')) - ->where($db->quoteName('key') . ' = ' - . $db->quote('ipworkarounds')) - ); - $wafEnabled = $db->loadResult() !== null; - } - - return [ - 'status' => 'ok', - 'installed' => true, - 'blocked_24h' => $blocked24h, - 'blocked_7d' => $blocked7d, - 'waf_active' => $wafEnabled, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - } - - /** - * Check SSL certificate expiry. - * - * @return array - * @since 02.01.39 - */ - protected function checkSsl() - { - try - { - $siteUrl = Uri::root(); - $host = parse_url($siteUrl, PHP_URL_HOST); - - if (empty($host) || parse_url($siteUrl, PHP_URL_SCHEME) !== 'https') - { - return ['status' => 'ok', 'https' => false]; - } - - $ctx = stream_context_create([ - 'ssl' => ['capture_peer_cert' => true, 'verify_peer' => false], - ]); - $stream = @stream_socket_client( - "ssl://{$host}:443", $errno, $errstr, 10, - STREAM_CLIENT_CONNECT, $ctx - ); - - if (!$stream) - { - return ['status' => 'degraded', 'https' => true, 'message' => 'Cannot connect']; - } - - $params = stream_context_get_params($stream); - $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate']); - fclose($stream); - - $expiresTs = $cert['validTo_time_t'] ?? 0; - $daysLeft = (int) (($expiresTs - time()) / 86400); - $issuer = $cert['issuer']['O'] ?? $cert['issuer']['CN'] ?? 'Unknown'; - $status = $daysLeft < 7 ? 'error' : ($daysLeft < 30 ? 'degraded' : 'ok'); - - return [ - 'status' => $status, - 'https' => true, - 'expires' => gmdate('Y-m-d', $expiresTs), - 'days_left' => $daysLeft, - 'issuer' => $issuer, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'https' => false]; - } - } - - /** - * Check Joomla scheduled tasks (Joomla 4.1+). - * - * @return array - * @since 02.01.39 - */ - protected function checkScheduledTasks() - { - try - { - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - if (!in_array($prefix . 'scheduler_tasks', $tables)) - { - return ['status' => 'ok', 'available' => false]; - } - - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('state') . ' = 1') - ); - $enabled = (int) $db->loadResult(); - - $db->setQuery( - $db->getQuery(true) - ->select([ - $db->quoteName('title'), - $db->quoteName('last_execution'), - $db->quoteName('last_exit_code'), - $db->quoteName('next_execution'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('state') . ' = 1') - ->order($db->quoteName('last_execution') . ' DESC') - ); - $db->setQuery($db->getQuery(true), 0, 5); - // Re-run the query - $db->setQuery( - $db->getQuery(true) - ->select([ - $db->quoteName('title'), - $db->quoteName('last_execution'), - $db->quoteName('last_exit_code'), - $db->quoteName('next_execution'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('state') . ' = 1') - ->order($db->quoteName('last_execution') . ' DESC'), - 0, 1 - ); - $last = $db->loadObject(); - - // Count failed in last 24h - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('last_exit_code') . ' != 0') - ->where($db->quoteName('last_execution') - . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') - ); - $failed24h = (int) $db->loadResult(); - - $status = $failed24h > 0 ? 'degraded' : 'ok'; - - return [ - 'status' => $status, - 'available' => true, - 'enabled_tasks' => $enabled, - 'failed_24h' => $failed24h, - 'last_run' => $last->last_execution ?? null, - 'last_exit_code' => $last ? (int) $last->last_exit_code : null, - 'last_task' => $last->title ?? null, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'available' => false]; - } - } - - /** - * Check PHP error log for recent errors. - * - * @return array - * @since 02.01.39 - */ - protected function checkErrorLog() - { - $logFile = JPATH_ROOT . '/administrator/logs/error.php'; - $altLog = ini_get('error_log'); - - $file = null; - - if (file_exists($logFile) && is_readable($logFile)) - { - $file = $logFile; - } - elseif ($altLog && file_exists($altLog) && is_readable($altLog)) - { - $file = $altLog; - } - - if (!$file) - { - return [ - 'status' => 'ok', - 'log_available' => false, - ]; - } - - $size = filesize($file); - $sizeMb = round($size / 1048576, 1); - - // Count recent lines (tail last 50 lines, count errors) - $lines = file_exists($file) ? @file($file) : []; - $recent = array_slice($lines, -50); - $errors24h = 0; - $lastError = null; - $yesterday = date('Y-m-d', strtotime('-1 day')); - - foreach ($recent as $line) - { - if (stripos($line, 'error') !== false - || stripos($line, 'fatal') !== false) - { - $errors24h++; - $lastError = trim(substr($line, 0, 200)); - } - } - - return [ - 'status' => 'ok', - 'log_available' => true, - 'log_size_mb' => $sizeMb, - 'recent_errors' => $errors24h, - 'last_error' => $lastError, - ]; - } - - /** - * Check database size and largest tables. - * - * @return array - * @since 02.01.39 - */ - protected function checkDatabaseSize() - { - try - { - $db = Factory::getDbo(); - $config = Factory::getConfig(); - $dbName = $config->get('db'); - - $db->setQuery( - "SELECT ROUND(SUM(data_length + index_length) / 1048576, 1) AS size_mb " - . "FROM information_schema.tables WHERE table_schema = " - . $db->quote($dbName) - ); - $totalMb = (float) $db->loadResult(); - - // Largest tables - $db->setQuery( - "SELECT table_name, " - . "ROUND((data_length + index_length) / 1048576, 1) AS size_mb " - . "FROM information_schema.tables " - . "WHERE table_schema = " . $db->quote($dbName) - . " ORDER BY (data_length + index_length) DESC LIMIT 5" - ); - $largest = []; - - foreach ($db->loadObjectList() as $t) - { - $largest[$t->table_name] = (float) $t->size_mb; - } - - // Table count - $db->setQuery( - "SELECT COUNT(*) FROM information_schema.tables " - . "WHERE table_schema = " . $db->quote($dbName) - ); - $tableCount = (int) $db->loadResult(); - - return [ - 'status' => 'ok', - 'total_mb' => $totalMb, - 'table_count' => $tableCount, - 'largest' => $largest, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'total_mb' => null]; - } - } - - /** - * Check content statistics. - * - * @return array - * @since 02.01.39 - */ - protected function checkContent() - { - try - { - $db = Factory::getDbo(); - - $counts = []; - - foreach ([ - 'articles' => '#__content', - 'categories' => '#__categories', - 'menu_items' => '#__menu', - 'modules' => '#__modules', - 'media' => '#__media_files', - ] as $label => $table) - { - try - { - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName($table)) - ); - $counts[$label] = (int) $db->loadResult(); - } - catch (\Exception $e) - { - // Table might not exist - } - } - - return [ - 'status' => 'ok', - 'counts' => $counts, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'counts' => []]; - } - } - - /** - * Check user activity — last login, active sessions, failed logins. - * - * @return array - * @since 02.01.39 - */ - protected function checkUserActivity() - { - try - { - $db = Factory::getDbo(); - - // Total users - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__users')) - ); - $totalUsers = (int) $db->loadResult(); - - // Last login - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('lastvisitDate')) - ->from($db->quoteName('#__users')) - ->where($db->quoteName('lastvisitDate') - . ' IS NOT NULL') - ->order($db->quoteName('lastvisitDate') . ' DESC'), - 0, 1 - ); - $lastLogin = $db->loadResult(); - - // Active sessions - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__session')) - ->where($db->quoteName('guest') . ' = 0') - ); - $activeSessions = (int) $db->loadResult(); - - // Failed logins (from action logs if available) - $failedLogins = 0; - - try - { - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__action_logs')) - ->where($db->quoteName('message_language_key') - . ' LIKE ' . $db->quote('%LOGIN_FAILED%')) - ->where($db->quoteName('log_date') - . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') - ); - $failedLogins = (int) $db->loadResult(); - } - catch (\Exception $e) - { - // Action logs might not track this - } - - return [ - 'status' => 'ok', - 'total_users' => $totalUsers, - 'last_login' => $lastLogin, - 'active_sessions' => $activeSessions, - 'failed_24h' => $failedLogins, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'total_users' => null]; - } - } - - /** - * Check mail system status. - * - * @return array - * @since 02.01.39 - */ - protected function checkMail() - { - try - { - $config = Factory::getConfig(); - $mailer = $config->get('mailer', 'mail'); - $from = $config->get('mailfrom', ''); - $smtpHost = $config->get('smtphost', ''); - - // Check mail queue if available - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - $queueCount = 0; - - if (in_array($prefix . 'mail_queue', $tables)) - { - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__mail_queue')) - ); - $queueCount = (int) $db->loadResult(); - } - - return [ - 'status' => 'ok', - 'mailer' => $mailer, - 'from' => $from, - 'smtp_host' => $mailer === 'smtp' ? $smtpHost : null, - 'queue' => $queueCount, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'mailer' => null]; - } - } - - /** - * Check basic SEO health indicators. - * - * @return array - * @since 02.01.39 - */ - protected function checkSeo() - { - $robotsTxt = file_exists(JPATH_ROOT . '/robots.txt'); - $htaccess = file_exists(JPATH_ROOT . '/.htaccess'); - - // Check for sitemap - $sitemapXml = file_exists(JPATH_ROOT . '/sitemap.xml'); - $sitemapIdx = file_exists(JPATH_ROOT . '/sitemap_index.xml'); - - $config = Factory::getConfig(); - $sef = (bool) $config->get('sef', 0); - - return [ - 'status' => 'ok', - 'robots_txt' => $robotsTxt, - 'htaccess' => $htaccess, - 'sitemap' => $sitemapXml || $sitemapIdx, - 'sef_enabled' => $sef, - ]; - } - - /** - * Check active template info. - * - * @return array - * @since 02.01.39 - */ - protected function checkTemplate() - { - try - { - $db = Factory::getDbo(); - - // Site template - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('template')) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('client_id') . ' = 0') - ->where($db->quoteName('home') . ' = 1') - ); - $siteTemplate = $db->loadResult() ?: 'unknown'; - - // Admin template - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('template')) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('client_id') . ' = 1') - ->where($db->quoteName('home') . ' = 1') - ); - $adminTemplate = $db->loadResult() ?: 'unknown'; - - // Count template overrides - $overrideCount = 0; - $overridePath = JPATH_ROOT . '/templates/' . $siteTemplate . '/html'; - - if (is_dir($overridePath)) - { - $iter = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator( - $overridePath, - \FilesystemIterator::SKIP_DOTS - ) - ); - - foreach ($iter as $file) - { - if ($file->isFile()) - { - $overrideCount++; - } - } - } - - return [ - 'status' => 'ok', - 'site_template' => $siteTemplate, - 'admin_template' => $adminTemplate, - 'override_count' => $overrideCount, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'site_template' => null]; - } - } - - /** - * Check configuration for common misconfigurations. - * - * @return array - * @since 02.01.39 - */ - protected function checkConfigDrift() - { - $config = Factory::getConfig(); - - $debug = (bool) $config->get('debug', 0); - $errorReport = $config->get('error_reporting', 'default'); - $gzip = (bool) $config->get('gzip', 0); - $sef = (bool) $config->get('sef', 0); - $sefRewrite = (bool) $config->get('sef_rewrite', 0); - $forceSSL = (int) $config->get('force_ssl', 0); - $caching = (bool) $config->get('caching', 0); - $lifetime = (int) $config->get('lifetime', 15); - $tmpPath = $config->get('tmp_path', ''); - $logPath = $config->get('log_path', ''); - - // Flag potential issues - $issues = []; - - if ($debug) - { - $issues[] = 'Debug mode is ON'; - } - - if ($errorReport === 'maximum' - || $errorReport === 'development') - { - $issues[] = 'Error reporting: ' . $errorReport; - } - - if ($forceSSL === 0) - { - $issues[] = 'Force SSL is OFF'; - } - - $status = empty($issues) ? 'ok' : 'degraded'; - - return [ - 'status' => $status, - 'debug' => $debug, - 'error_report' => $errorReport, - 'gzip' => $gzip, - 'sef' => $sef, - 'sef_rewrite' => $sefRewrite, - 'force_ssl' => $forceSSL, - 'caching' => $caching, - 'lifetime' => $lifetime, - 'issues' => $issues ?: null, - ]; - } - - /** - * Send a JSON health response and terminate execution. - * - * @param int $httpCode HTTP status code - * @param array $payload Data to encode as JSON - * - * @return void - * - * @since 02.01.22 - */ - protected function sendHealthResponse($httpCode, array $payload) - { - http_response_code($httpCode); - header('Content-Type: application/json; charset=utf-8'); - header('Cache-Control: no-store, no-cache, must-revalidate'); - header('X-MokoWaaS-Health: 1'); - echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - $this->app->close(); - } - - // ------------------------------------------------------------------ - // Remote Install Endpoint (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Handle remote extension install requests. - * - * POST /?mokowaas=install with a ZIP URL in the request body. - * Requires the same health API token + HTTPS. Downloads the ZIP - * and installs via Joomla's InstallerModel. - * - * Request: POST /?mokowaas=install - * Headers: Authorization: Bearer - * Body: {"url": "https://example.com/extension.zip"} - * - * @return void - * - * @since 02.01.39 - */ - protected function handleInstallAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - // Parse request body - $body = json_decode(file_get_contents('php://input'), true); - $url = $body['url'] ?? ''; - - if (empty($url)) - { - $this->sendHealthResponse(400, ['error' => 'url required']); - - return; - } - - // Validate URL is HTTPS - if (stripos($url, 'https://') !== 0) - { - $this->sendHealthResponse(400, ['error' => 'HTTPS URL required']); - - return; - } - - try - { - // Download the ZIP - $tmpFile = $this->app->getConfig()->get('tmp_path', JPATH_ROOT . '/tmp') - . '/mokowaas_install_' . md5($url) . '.zip'; - - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 120); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - $zipData = curl_exec($ch); - $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - curl_close($ch); - - if ($error || $code !== 200 || empty($zipData)) - { - $this->sendHealthResponse(502, [ - 'error' => 'Download failed', - 'http' => $code, - 'message' => $error ?: 'Empty response', - ]); - - return; - } - - file_put_contents($tmpFile, $zipData); - - // Extract ZIP to temp directory - $extractDir = $this->app->getConfig()->get('tmp_path', JPATH_ROOT . '/tmp') - . '/mokowaas_extract_' . md5($url); - - if (is_dir($extractDir)) - { - $this->rmdirRecursive($extractDir); - } - - mkdir($extractDir, 0755, true); - - $zip = new \ZipArchive(); - - if ($zip->open($tmpFile) !== true) - { - @unlink($tmpFile); - $this->sendHealthResponse(500, ['error' => 'Failed to open ZIP']); - - return; - } - - $zip->extractTo($extractDir); - $zip->close(); - @unlink($tmpFile); - - // Install using Joomla's installer - $installer = \Joomla\CMS\Installer\Installer::getInstance(); - $result = $installer->install($extractDir); - - $this->rmdirRecursive($extractDir); - - if ($result) - { - $this->sendHealthResponse(200, [ - 'status' => 'installed', - 'message' => 'Extension installed successfully', - 'url' => $url, - ]); - } - else - { - $this->sendHealthResponse(500, [ - 'error' => 'Installation failed', - 'message' => 'Joomla installer returned false', - 'url' => $url, - ]); - } - } - catch (\Exception $e) - { - @unlink($tmpFile ?? ''); - - if (!empty($extractDir) && is_dir($extractDir)) - { - $this->rmdirRecursive($extractDir); - } - - $this->sendHealthResponse(500, [ - 'error' => 'Install exception', - 'message' => $e->getMessage(), - 'url' => $url, - ]); - } - } - - /** - * Recursively remove a directory. - * - * @param string $dir Directory path - * - * @return void - * - * @since 02.06.00 - */ - protected function rmdirRecursive(string $dir): void - { - if (!is_dir($dir)) - { - return; - } - - $items = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ($items as $item) - { - if ($item->isDir()) - { - rmdir($item->getPathname()); - } - else - { - unlink($item->getPathname()); - } - } - - rmdir($dir); - } - - // ------------------------------------------------------------------ - // Site Alias handling - // ------------------------------------------------------------------ - - /** - * Get the alias configuration for the current request domain, if any. - * - * @return object|null Alias entry object or null if not an alias domain - * - * @since 02.01.43 - */ - /** - * Get the primary domain from Joomla config or by exclusion from aliases. - * - * @return string Primary domain hostname - * - * @since 02.03.05 - */ - protected function getPrimaryHost(): string - { - $primaryDomain = $this->params->get('primary_domain', ''); - - if (!empty($primaryDomain)) - { - return trim($primaryDomain); - } - - // Fallback: Joomla's $live_site - $liveSite = Factory::getConfig()->get('live_site', ''); - - if (!empty($liveSite)) - { - $host = parse_url($liveSite, PHP_URL_HOST); - - if ($host) - { - return $host; - } - } - - return parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? ''); - } - - /** - * Get the dev alias domain (dev.{primary_domain}). - * - * @return string - * - * @since 02.31.00 - */ - protected function getDevAliasDomain(): string - { - $primary = $this->getPrimaryHost(); - - return !empty($primary) ? 'dev.' . $primary : ''; - } - - /** - * Check if the current request is on the dev alias domain. - * - * @return bool - * - * @since 02.31.00 - */ - protected function isDevAlias(): bool - { - $currentHost = $_SERVER['HTTP_HOST'] ?? ''; - $devDomain = $this->getDevAliasDomain(); - - return !empty($devDomain) && strcasecmp($currentHost, $devDomain) === 0; - } - - protected function getCurrentAlias() - { - $currentHost = $_SERVER['HTTP_HOST'] ?? ''; - - if (empty($currentHost)) - { - return null; - } - - // The only alias is dev.{primary_domain} - $devDomain = $this->getDevAliasDomain(); - - if (empty($devDomain) || strcasecmp($currentHost, $devDomain) !== 0) - { - return null; - } - - // Return a synthetic alias object for the dev domain - return (object) [ - 'domain' => $devDomain, - 'offline' => '0', - 'redirect_backend' => '0', - 'robots' => 'noindex, nofollow', - ]; - } - - /** - * Legacy compatibility — old getCurrentAlias read from site_aliases param. - * Now only returns the hardcoded dev.* alias. - */ - private function getCurrentAliasLegacy() - { - $aliases = $this->params->get('site_aliases', ''); - - if (empty($aliases)) - { - return null; - } - - // Subform returns JSON string, array, or stdClass - if (is_string($aliases)) - { - $aliases = json_decode($aliases); - } - - // Convert object to array (Joomla subform stores as {"key0":{...},"key1":{...}}) - if (is_object($aliases)) - { - $aliases = (array) $aliases; - } - - if (!is_array($aliases) || empty($aliases)) - { - return null; - } - - // Look up the current host in the aliases list — if found, it's an alias - foreach ($aliases as $alias) - { - $alias = (object) $alias; - - if (isset($alias->domain) && strcasecmp(rtrim(trim($alias->domain), '/'), $currentHost) === 0) - { - return $alias; - } - } - - return null; - } - - /** - * Handle site alias logic: offline page and backend redirect. - * - * Runs in onAfterInitialise so that Joomla's offline check in - * SiteApplication::doExecute() sees the updated config value. - * - * @return void - * - * @since 02.01.43 - */ - protected function handleSiteAlias() - { - // The dev alias (dev.{primary_domain}) always bypasses offline mode - if ($this->isDevAlias()) - { - $this->app->getConfig()->set('offline', 0); - - return; - } - } - - /** - * Inject robots meta tag for alias domains. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.01.43 - */ - protected function injectAliasRobots($doc) - { - // Always noindex/nofollow on the dev alias domain - if ($this->isDevAlias()) - { - $doc->setMetaData('robots', 'noindex, nofollow'); - } - - // Inject canonical URL pointing to the primary domain - $primaryHost = $this->getPrimaryHost(); - $currentUri = Uri::getInstance(); - $canonical = $currentUri->getScheme() . '://' . $primaryHost . $currentUri->toString(['path', 'query']); - $doc->addHeadLink($canonical, 'canonical'); - } - - // ------------------------------------------------------------------ - // Heartbeat (called from onExtensionAfterSave) - // ------------------------------------------------------------------ - // License key check (called from onAfterRoute) - // ------------------------------------------------------------------ - - /** - * Show a persistent admin warning if no license key is set on the - * MokoWaaS update site. - * - * Checks the extra_query column in #__update_sites for a dlid value. - * Also validates the key against MokoGitea on a heartbeat interval - * (once per day) and warns if the key is invalid or expired. - * - * @return void - * - * @since 02.31.00 - */ - protected function warnMissingLicenseKey(): void - { - // Only show to master users - if (!$this->isMasterUser()) - { - return; - } - - // Only warn once per session - $session = Factory::getSession(); - - if ($session->get('mokowaas.license_warned', false)) - { - return; - } - - $session->set('mokowaas.license_warned', true); - - try - { - $db = Factory::getDbo(); - - $query = $db->getQuery(true) - ->select($db->quoteName('extra_query')) - ->from($db->quoteName('#__update_sites')) - ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') - . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')') - ->setLimit(1); - $db->setQuery($query); - $extraQuery = (string) $db->loadResult(); - - if (empty($extraQuery) || strpos($extraQuery, 'dlid=') === false) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Required — ' - . 'No download key is configured. Updates will not be available until a valid license key is entered. ' - . 'Go to System → Update Sites ' - . 'and enter your license key in the Download Key field for the MokoWaaS update site.', - 'warning' - ); - - return; - } - - // Extract the key value from extra_query - parse_str($extraQuery, $parsed); - $licenseKey = $parsed['dlid'] ?? ''; - - if (empty($licenseKey)) - { - return; - } - - // Heartbeat validation — check once per day - $session = Factory::getSession(); - $lastCheck = (int) $session->get('mokowaas.license_check', 0); - $now = time(); - - if (($now - $lastCheck) < 86400) - { - // Show cached warning if key was invalid last check - if ($session->get('mokowaas.license_invalid', false)) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Invalid — ' - . 'Your license key could not be validated. Please verify your key in ' - . 'System → Update Sites.', - 'error' - ); - } - - return; - } - - // Validate against MokoGitea - $session->set('mokowaas.license_check', $now); - - $validateUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml' - . '?dlid=' . urlencode($licenseKey) - . '&domain=' . urlencode(Uri::root()); - - $ch = curl_init($validateUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - $response = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - // Empty or non-200 means invalid key - $isValid = ($httpCode === 200 && $response && strpos($response, '') !== false); - - $session->set('mokowaas.license_invalid', !$isValid); - - if (!$isValid) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Invalid — ' - . 'Your license key could not be validated. Updates will not be available. ' - . 'Please verify your key in ' - . 'System → Update Sites.', - 'error' - ); - } - } - catch (\Throwable $e) - { - // Silent — license check is non-critical - } - } - - // ------------------------------------------------------------------ - - /** - * Send heartbeat to the MokoWaaS monitoring receiver. - * - * Registers this site's primary domain with the Grafana provisioning system. - * The receiver writes a datasource YAML file and restarts Grafana. - * Alias domains are not registered to avoid duplicate datasource UIDs. - * - * @param \Joomla\Registry\Registry $params Plugin params - * @param \Joomla\CMS\Application\CMSApplication $app Application - * - * @return void - * - * @since 02.01.36 - */ - protected function handleGrafanaProvisioning($params, $app) - { - $healthToken = $params->get('health_api_token', ''); - - if (empty($healthToken)) - { - return; - } - - $siteUrl = rtrim(Uri::root(), '/'); - $siteName = Factory::getConfig()->get('sitename', 'Joomla'); - - // Register primary domain - $this->sendHeartbeat($siteUrl, $siteName, $healthToken, $app); - - // Register alias domains (subform format) - $aliases = $params->get('site_aliases', ''); - - if (!empty($aliases)) - { - if (is_string($aliases)) - { - $aliases = json_decode($aliases); - } - - if (is_object($aliases)) - { - $aliases = (array) $aliases; - } - - if (is_array($aliases)) - { - foreach ($aliases as $alias) - { - $alias = (object) $alias; - - if (!empty($alias->domain)) - { - $domain = rtrim(trim($alias->domain), '/'); - $aliasUrl = 'https://' . preg_replace('#^https?://#i', '', $domain); - $this->sendHeartbeat($aliasUrl, $siteName, $healthToken, $app); - } - } - } - } - } - - /** - * Send a single heartbeat registration to the receiver. - * - * @param string $siteUrl Site URL to register - * @param string $siteName Display name for Grafana - * @param string $healthToken Health API bearer token - * @param object $app Application for messages - * - * @return void - * - * @since 02.01.39 - */ - protected function sendHeartbeat($siteUrl, $siteName, $healthToken, $app) - { - $payload = json_encode([ - 'site_url' => $siteUrl, - 'site_name' => $siteName, - 'health_token' => $healthToken, - 'action' => 'register', - ], JSON_UNESCAPED_SLASHES); - - $ch = curl_init(self::HEARTBEAT_URL . '/register'); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'X-MokoWaaS-Key: ' . self::HEARTBEAT_KEY, - ]); - curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - - $response = curl_exec($ch); - $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - curl_close($ch); - - $body = json_decode($response, true); - - if ($error) - { - $app->enqueueMessage('Grafana heartbeat failed (' . $siteUrl . '): ' . $error, 'warning'); - Log::add('Heartbeat failed: ' . $error, Log::WARNING, 'mokowaas'); - } - elseif ($code === 200) - { - $status = $body['status'] ?? 'ok'; - $app->enqueueMessage( - 'Grafana heartbeat: ' . $siteUrl . ' ' . $status . ' (' . ($body['ds_uid'] ?? '') . ')', - 'message' - ); - } - else - { - $msg = sprintf('Grafana heartbeat failed (%s): HTTP %d — %s', - $siteUrl, $code, $body['error'] ?? $body['message'] ?? 'Unknown'); - $app->enqueueMessage($msg, 'warning'); - Log::add($msg, Log::WARNING, 'mokowaas'); - } - } - - // HTTPS / Session / License (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Redirect HTTP requests to HTTPS. - * - * @return void - * - * @since 02.01.08 - */ - /** - * Enforce development mode settings. - * - * When dev mode is ON: - * - Disable Joomla caching - * - Enable Joomla debug mode (Global Config) - * - Enable MokoOnyx template debug - * - Disable article hit recording - * - * When dev mode is OFF (and was previously on): - * - Reset all content version history - * - Reset article published dates to now - * - * @return void - * - * @since 02.01.15 - */ - protected function enforceDevMode() - { - if (!$this->params->get('dev_mode', 0)) - { - return; - } - - // Disable caching - $config = Factory::getConfig(); - $config->set('caching', 0); - - // Enable Joomla debug - $config->set('debug', 1); - - // Enable MokoOnyx template debug - $this->setTemplateParam('mokoonyx', 'debug', 1); - - // Show offline page on primary domain only — site aliases - // and dev.* subdomains bypass offline mode for development - $currentHost = $_SERVER['HTTP_HOST'] ?? ''; - $primaryDomain = $this->params->get('primary_domain', ''); - - if (!empty($primaryDomain) && $currentHost === $primaryDomain) - { - $config->set('offline', 1); - } - - // Suppress hit recording - try - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - ->where($db->quoteName('hits') . ' > 0') - )->execute(); - } - catch (\Throwable $e) - { - // Silent - } - } - - /** - * Actions to run when dev mode is turned off. - * - * Resets content versions and hits, disables debug. - * - * @return void - * - * @since 02.31.00 - */ - protected function onDevModeDisabled(): void - { - try - { - $db = Factory::getDbo(); - - // Delete all content version history - $db->setQuery( - $db->getQuery(true)->delete($db->quoteName('#__history')) - )->execute(); - - // Reset hits - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - )->execute(); - - // Disable debug - $this->setTemplateParam('mokoonyx', 'debug', 0); - - // Take site back online - Factory::getConfig()->set('offline', 0); - - $this->app->enqueueMessage( - 'Development mode disabled — versions cleared, hits reset, debug off, site online.', - 'message' - ); - } - catch (\Throwable $e) - { - Log::add('Dev mode cleanup failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * Set a parameter on a template style. - * - * @param string $template Template element name - * @param string $key Parameter key - * @param mixed $value Parameter value - * - * @return void - * - * @since 02.31.00 - */ - private function setTemplateParam(string $template, string $key, $value): void - { - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('template') . ' = ' . $db->quote($template)); - $db->setQuery($query); - $styles = $db->loadObjectList(); - - foreach ($styles as $style) - { - $params = new \Joomla\Registry\Registry($style->params ?: '{}'); - - if ($params->get($key) != $value) - { - $params->set($key, $value); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__template_styles')) - ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) - ->where($db->quoteName('id') . ' = ' . (int) $style->id) - )->execute(); - } - } - } - catch (\Throwable $e) - { - // Silent - } - } - - protected function enforceHttps() - { - if (!$this->params->get('force_https', 0)) - { - return; - } - - if ($this->app->isClient('cli')) - { - return; - } - - $isHttps = (!empty($_SERVER['HTTPS']) - && $_SERVER['HTTPS'] !== 'off') - || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; - - if (!$isHttps) - { - $this->app->redirect( - 'https://' . $_SERVER['HTTP_HOST'] - . $_SERVER['REQUEST_URI'], 301 - ); - } - } - - /** - * Enforce admin session idle timeout. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceAdminSessionTimeout() - { - $timeout = (int) $this->params->get('admin_session_timeout', 0); - - if ($timeout <= 0) - { - return; - } - - // Don't timeout the master user - if ($this->isMasterUser()) - { - return; - } - - // Trusted IPs — session lifetime already extended in boot() - if ($this->ipIsTrusted()) - { - return; - } - - $session = Factory::getSession(); - $lastHit = $session->get('mokowaas.last_activity', 0); - $now = time(); - - if ($lastHit > 0 && ($now - $lastHit) > ($timeout * 60)) - { - $this->app->logout(); - $this->app->redirect( - Route::_('index.php', false) - ); - - return; - } - - $session->set('mokowaas.last_activity', $now); - } - - /** - * Check whether the current request IP matches any trusted IP entry. - * - * Supports exact IPs, CIDR notation (e.g. 10.0.0.0/8), and - * wildcard patterns (e.g. 192.168.1.*). - * - * @return bool True if the current IP is in the trusted list. - * - * @since 02.11.00 - */ - protected function ipIsTrusted(): bool - { - $entries = $this->params->get('trusted_ips', ''); - - if (empty($entries)) - { - return false; - } - - // Subform stores as JSON string or array - if (\is_string($entries)) - { - $entries = json_decode($entries, true); - } - - if (!\is_array($entries)) - { - return false; - } - - $ip = $this->app - ? $this->app->input->server->getString('REMOTE_ADDR', '') - : ($_SERVER['REMOTE_ADDR'] ?? ''); - $ipLong = ip2long($ip); - - if ($ipLong === false) - { - return false; - } - - foreach ($entries as $entry) - { - if (empty($entry['enabled']) || empty($entry['ip'])) - { - continue; - } - - $range = trim($entry['ip']); - - // Wildcard: 192.168.1.* - if (str_contains($range, '*')) - { - $pattern = '/^' . str_replace(['.', '*'], ['\\.', '\\d+'], $range) . '$/'; - - if (preg_match($pattern, $ip)) - { - return true; - } - - continue; - } - - // CIDR: 10.0.0.0/8 - if (str_contains($range, '/')) - { - [$subnet, $bits] = explode('/', $range, 2); - $subnetLong = ip2long($subnet); - $mask = -1 << (32 - (int) $bits); - - if ($subnetLong !== false && ($ipLong & $mask) === ($subnetLong & $mask)) - { - return true; - } - - continue; - } - - // Exact match - if ($ip === $range) - { - return true; - } - } - - return false; - } - - - /** - * Override Joomla upload restrictions at runtime. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceUploadRestrictions() - { - $types = $this->params->get('upload_allowed_types', ''); - $maxMb = (int) $this->params->get('upload_max_size_mb', 0); - - if (empty($types) && $maxMb <= 0) - { - return; - } - - $config = $this->app->getConfig(); - - if (!empty($types)) - { - $config->set('upload_extensions', $types); - } - - if ($maxMb > 0) - { - $config->set('upload_maxsize', $maxMb); - } - } - - /** - * Enforce login support module URLs on admin requests. - * - * Checks the mod_loginsupport module params and corrects them if - * they have been changed away from the expected values. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceLoginSupportUrls() - { - $expected = [ - 'forum_url' => 'https://mokoconsulting.tech/support', - 'documentation_url' => 'https://mokoconsulting.tech/kb', - 'news_url' => 'https://mokoconsulting.tech/news', - ]; - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__modules')) - ->where($db->quoteName('module') . ' = ' - . $db->quote('mod_loginsupport')); - - $db->setQuery($query); - $modules = $db->loadObjectList(); - - if (empty($modules)) - { - return; - } - - foreach ($modules as $module) - { - $params = new \Joomla\Registry\Registry( - $module->params ?: '{}' - ); - $needsFix = false; - - foreach ($expected as $key => $url) - { - if ($params->get($key) !== $url) - { - $params->set($key, $url); - $needsFix = true; - } - } - - if ($needsFix) - { - $update = $db->getQuery(true) - ->update($db->quoteName('#__modules')) - ->set($db->quoteName('params') . ' = ' - . $db->quote($params->toString())) - ->where($db->quoteName('id') . ' = ' - . (int) $module->id); - - $db->setQuery($update); - $db->execute(); - } - } - } - - // ------------------------------------------------------------------ - // Tenant Restrictions (called from onAfterRoute) - // ------------------------------------------------------------------ - - /** - * Check admin routes against restriction rules and redirect if blocked. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceAdminRestrictions() - { - // Master user bypasses ALL restrictions - if ($this->isMasterUser()) - { - return; - } - - $input = $this->app->input; - $option = $input->get('option', ''); - $view = $input->get('view', ''); - $task = $input->get('task', ''); - - // Disable install-from-URL for non-master users - if ($this->params->get('disable_install_url', 1) - && $option === 'com_installer' - && stripos($task, 'install') !== false - && $input->get('installtype') === 'url') - { - $this->blockAccess('Install from URL is disabled.'); - - return; - } - - $blocked = []; - - if ($this->params->get('restrict_installer', 1)) - { - // Allow the update view by default so tenants can update extensions - $allowUpdates = (int) $this->params->get('allow_extension_updates', 1); - - if ($allowUpdates && $option === 'com_installer' - && \in_array($view, ['update', 'updatesites'], true)) - { - // Do not block — update views are permitted - } - elseif ($option === 'com_installer') - { - $this->blockAccess('Access restricted.'); - - return; - } - } - - if ($this->params->get('hide_sysinfo', 1)) - { - $blocked[] = [ - 'option' => 'com_admin', - 'view' => 'sysinfo', - ]; - } - - if ($this->params->get('restrict_global_config', 1)) - { - $blocked[] = [ - 'option' => 'com_config', - 'view' => 'application', - ]; - // Also block empty view (default landing = global config) - if ($option === 'com_config' && $view === '') - { - $this->blockAccess('Access restricted.'); - - return; - } - } - - if ($this->params->get('restrict_template_editing', 1)) - { - $blocked[] = [ - 'option' => 'com_templates', - 'view' => 'template', - ]; - } - - foreach ($blocked as $rule) - { - if ($option !== $rule['option']) - { - continue; - } - - if (isset($rule['view']) && $view !== $rule['view']) - { - continue; - } - - $this->blockAccess('Access restricted.'); - - return; - } - } - - /** - * Redirect to admin dashboard with an error message. - * - * @param string $message Error message to display - * - * @return void - * - * @since 02.01.08 - */ - protected function blockAccess($message) - { - $this->app->enqueueMessage($message, 'error'); - $this->app->redirect(Route::_('index.php', false)); - } - - /** - * Check whether the current user is the master WaaS user. - * - * @return boolean - * - * @since 02.01.08 - */ - protected function isMasterUser() - { - $user = $this->app->getIdentity(); - - if (!$user || $user->guest) - { - return false; - } - - return \in_array($user->username, $this->getMasterUsernames(), true); - } - - /** - * Decode obfuscated master usernames. - * - * @return array - * - * @since 02.29.01 - */ - private function getMasterUsernames(): array - { - if ($this->masterNames !== null) - { - return $this->masterNames; - } - - $this->masterNames = []; - - foreach (self::MASTER_KEYS as $encoded) - { - $raw = base64_decode($encoded); - $decoded = ''; - - for ($i = 0, $len = \strlen($raw); $i < $len; $i++) - { - $decoded .= \chr(\ord($raw[$i]) ^ self::MK); - } - - $this->masterNames[] = $decoded; - } - - return $this->masterNames; - } - - /** - * Build the list of components to hide from admin menu. - * - * Combines explicit hidden_menu_items config with components that - * are implicitly blocked by other restriction toggles. - * - * @return array Component option strings - * - * @since 02.01.08 - */ - protected function getHiddenMenuComponents() - { - $hidden = array_filter(array_map( - 'trim', - explode("\n", $this->params->get('hidden_menu_items', '')) - )); - - // Auto-hide components that are restricted (keep visible when updates are allowed) - if ($this->params->get('restrict_installer', 1) - && !$this->params->get('allow_extension_updates', 1)) - { - $hidden[] = 'com_installer'; - } - - if ($this->params->get('hide_sysinfo', 1)) - { - $hidden[] = 'com_admin'; - } - - return array_unique($hidden); - } - - // ------------------------------------------------------------------ - // Atum Template Branding (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Enforce Atum admin template branding params. - * - * Sets logoBrandLarge, logoBrandSmall, loginLogo, and alt text - * in the Atum template style params. Uses the plugin's media - * folder as the image source. Only writes to DB when values - * have drifted. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceAtumBranding() - { - $mediaBase = 'media/plg_system_mokowaas/'; - - // Logo params - $expected = [ - 'logoBrandLarge' => $mediaBase . 'logo.png', - 'logoBrandSmall' => $mediaBase . 'favicon_256.png', - 'loginLogo' => $mediaBase . 'logo.png', - 'logoBrandLargeAlt' => '', - 'logoBrandSmallAlt' => '', - 'loginLogoAlt' => '', - 'emptyLogoBrandLargeAlt' => '1', - 'emptyLogoBrandSmallAlt' => '1', - 'emptyLoginLogoAlt' => '1', - ]; - - // Hardcoded color scheme - $primary = self::COLOR_PRIMARY; - $sidebar = self::COLOR_SIDEBAR; - $link = self::COLOR_LINK; - - if (!empty($primary)) - { - // Convert hex to HSL for Atum's hue param - $hsl = $this->hexToHsl($primary); - - if ($hsl) - { - $expected['hue'] = sprintf( - 'hsl(%d, %d%%, %d%%)', - $hsl[0], $hsl[1], $hsl[2] - ); - } - - $expected['special-color'] = $primary; - } - - if (!empty($sidebar)) - { - $expected['header-color'] = $sidebar; - } - - if (!empty($link)) - { - $expected['link-color'] = $link; - } - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('template') . ' = ' - . $db->quote('atum')) - ->where($db->quoteName('client_id') . ' = 1'); - - $db->setQuery($query); - $styles = $db->loadObjectList(); - - if (empty($styles)) - { - return; - } - - foreach ($styles as $style) - { - $params = new \Joomla\Registry\Registry( - $style->params ?: '{}' - ); - $needsFix = false; - - foreach ($expected as $key => $value) - { - if ($params->get($key) !== $value) - { - $params->set($key, $value); - $needsFix = true; - } - } - - if ($needsFix) - { - $update = $db->getQuery(true) - ->update($db->quoteName('#__template_styles')) - ->set($db->quoteName('params') . ' = ' - . $db->quote($params->toString())) - ->where($db->quoteName('id') . ' = ' - . (int) $style->id); - - $db->setQuery($update); - $db->execute(); - } - } - } - - /** - * Convert a hex color to HSL values. - * - * @param string $hex Hex color (e.g., "#1a2744") - * - * @return array|null [hue, saturation%, lightness%] or null - * - * @since 02.01.08 - */ - protected function hexToHsl($hex) - { - $hex = ltrim($hex, '#'); - - if (strlen($hex) !== 6) - { - return null; - } - - $r = hexdec(substr($hex, 0, 2)) / 255; - $g = hexdec(substr($hex, 2, 2)) / 255; - $b = hexdec(substr($hex, 4, 2)) / 255; - - $max = max($r, $g, $b); - $min = min($r, $g, $b); - $l = ($max + $min) / 2; - - if ($max === $min) - { - return [0, 0, (int) round($l * 100)]; - } - - $d = $max - $min; - $s = $l > 0.5 - ? $d / (2 - $max - $min) - : $d / ($max + $min); - - if ($max === $r) - { - $h = ($g - $b) / $d + ($g < $b ? 6 : 0); - } - elseif ($max === $g) - { - $h = ($b - $r) / $d + 2; - } - else - { - $h = ($r - $g) / $d + 4; - } - - $h = $h / 6; - - return [ - (int) round($h * 360), - (int) round($s * 100), - (int) round($l * 100), - ]; - } - - // ------------------------------------------------------------------ - // Visual Branding (called from onBeforeCompileHead) - // ------------------------------------------------------------------ - - /** - * Replace the default favicon with a custom one. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc - * - * @return void - * - * @since 02.01.08 - */ - protected function injectFavicon($doc) - { - $mediaBase = 'media/plg_system_mokowaas/'; - $root = Uri::root(); - - // Remove all existing favicon/icon links - foreach ($doc->_links as $href => $attrs) - { - if (isset($attrs['relation']) - && strpos($attrs['relation'], 'icon') !== false) - { - unset($doc->_links[$href]); - } - } - - // SVG favicon (modern browsers, preferred) - $doc->addHeadLink( - $root . $mediaBase . 'favicon.svg', - 'icon', - 'rel', - ['type' => 'image/svg+xml'] - ); - // ICO fallback (legacy browsers) - $doc->addHeadLink( - $root . $mediaBase . 'favicon.ico', - 'alternate icon', - 'rel', - ['type' => 'image/vnd.microsoft.icon'] - ); - // PNG for Apple/Android - $doc->addHeadLink( - $root . $mediaBase . 'favicon_256.png', - 'apple-touch-icon', - 'rel', - ['sizes' => '256x256'] - ); - } - -} diff --git a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php b/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php deleted file mode 100644 index 20c5dba9..00000000 --- a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php +++ /dev/null @@ -1,72 +0,0 @@ - - * - * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: Joomla.Plugin - * INGROUP: MokoWaaS - * VERSION: 02.37.00 - * PATH: /src/Field/AllowedIpsField.php - * BRIEF: Custom form field that displays the current IP whitelist - */ - -namespace Moko\Plugin\System\MokoWaaS\Field; - -defined('_JEXEC') or die; - -use Joomla\CMS\Factory; -use Joomla\CMS\Form\FormField; - -class AllowedIpsField extends FormField -{ - protected $type = 'AllowedIps'; - - protected function getInput() - { - $config = Factory::getApplication()->getConfig(); - $allowedRaw = $config->get('mokowaas_allowed_ips', ''); - $currentIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - if (empty($allowedRaw)) - { - $status = 'Not configured'; - $ipList = 'No IPs set — emergency access is blocked.'; - } - else - { - $ips = array_map('trim', explode(',', $allowedRaw)); - $status = '' - . count($ips) . ' IP(s) configured'; - $ipItems = []; - - foreach ($ips as $ip) - { - $match = ($ip === $currentIp) - ? ' your IP' - : ''; - $ipItems[] = '' . htmlspecialchars($ip) - . '' . $match; - } - - $ipList = implode(', ', $ipItems); - } - - $yourIp = '' . htmlspecialchars($currentIp) . ''; - - return '
' - . 'IP Whitelist: ' . $status . '
' - . 'Allowed IPs: ' . $ipList . '
' - . 'Your current IP: ' . $yourIp . '
' - . 'Set public ' - . '$mokowaas_allowed_ips = \'1.2.3.4,5.6.7.8\';' - . ' in configuration.php to change.' - . '
'; - } - - protected function getLabel() - { - return ''; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php b/src/packages/plg_system_mokowaas/Field/CurrentIpField.php deleted file mode 100644 index 923b4966..00000000 --- a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php +++ /dev/null @@ -1,40 +0,0 @@ - - * - * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: Joomla.Plugin - * INGROUP: MokoWaaS - * VERSION: 02.37.00 - * PATH: /src/Field/CurrentIpField.php - * BRIEF: Read-only field that displays the current user's IP address - */ - -namespace Moko\Plugin\System\MokoWaaS\Field; - -defined('_JEXEC') or die; - -use Joomla\CMS\Form\FormField; - -class CurrentIpField extends FormField -{ - protected $type = 'CurrentIp'; - - protected function getInput() - { - $currentIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - return '
' - . 'Your current IP: ' - . '' . htmlspecialchars($currentIp) . ' ' - . '— add this to the table below to keep your session alive.' - . '
'; - } - - protected function getLabel() - { - return ''; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php b/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php deleted file mode 100644 index ac499748..00000000 --- a/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php +++ /dev/null @@ -1,237 +0,0 @@ -getQuery(true) - ->select('*') - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $task = $db->loadAssoc(); - } - catch (\Throwable $e) - { - $task = null; - } - - $newTaskLink = Route::_('index.php?option=com_scheduler&task=task.add'); - - if (!$task) - { - return '
' - . 'No demo reset task configured. ' - . 'Create a Scheduled Task ' - . 'and select MokoWaaS Demo Reset to enable demo mode.
'; - } - - $taskId = (int) $task['id']; - $state = (int) $task['state']; - $siteTimezone = Factory::getApplication()->get('offset', 'UTC'); - - // Parse schedule from execution_rules - $rules = json_decode($task['execution_rules'] ?? '{}', true); - $ruleType = $rules['rule-type'] ?? ''; - - switch ($ruleType) - { - case 'cron-expression': - $schedule = $rules['cron-expression'] ?? ''; - $friendlySchedule = $this->friendlySchedule($schedule); - break; - - case 'interval-minutes': - $mins = (int) ($rules['interval-minutes'] ?? 0); - - if ($mins >= 1440 && $mins % 1440 === 0) - { - $days = $mins / 1440; - $schedule = 'Every ' . $days . ' day' . ($days > 1 ? 's' : ''); - } - elseif ($mins >= 60 && $mins % 60 === 0) - { - $hours = $mins / 60; - $schedule = 'Every ' . $hours . ' hour' . ($hours > 1 ? 's' : ''); - } - else - { - $schedule = 'Every ' . $mins . ' minute' . ($mins !== 1 ? 's' : ''); - } - - $friendlySchedule = $schedule; - break; - - case 'interval-hours': - $hours = (int) ($rules['interval-hours'] ?? 0); - $schedule = 'Every ' . $hours . ' hour' . ($hours !== 1 ? 's' : ''); - $friendlySchedule = $schedule; - break; - - case 'interval-days': - $days = (int) ($rules['interval-days'] ?? 0); - $schedule = 'Every ' . $days . ' day' . ($days !== 1 ? 's' : ''); - $friendlySchedule = $schedule; - break; - - default: - $schedule = $ruleType ?: 'Not set'; - $friendlySchedule = 'Custom'; - } - - // Next execution - $nextExec = $task['next_execution'] ?? ''; - $nextFormatted = 'Not scheduled'; - $nextBadge = ''; - - if (!empty($nextExec) && $nextExec !== '0000-00-00 00:00:00') - { - try - { - $dt = new \DateTime($nextExec, new \DateTimeZone('UTC')); - $dt->setTimezone(new \DateTimeZone($siteTimezone)); - $nextFormatted = $dt->format('M j, Y g:i A T'); - } - catch (\Throwable $e) - { - $nextFormatted = $nextExec; - } - - $diff = strtotime($nextExec . ' UTC') - time(); - - if ($diff <= 0) - { - $nextBadge = 'DUE'; - } - elseif ($diff < 3600) - { - $nextBadge = 'in ' . (int) ceil($diff / 60) . ' min'; - } - elseif ($diff < 86400) - { - $nextBadge = 'in ' . round($diff / 3600, 1) . 'h'; - } - else - { - $nextBadge = 'in ' . round($diff / 86400, 1) . 'd'; - } - } - - // Last execution - $lastExec = $task['last_execution'] ?? ''; - $lastFormatted = 'Never'; - - if (!empty($lastExec) && $lastExec !== '0000-00-00 00:00:00') - { - try - { - $dt = new \DateTime($lastExec, new \DateTimeZone('UTC')); - $dt->setTimezone(new \DateTimeZone($siteTimezone)); - $lastFormatted = $dt->format('M j, Y g:i A T'); - } - catch (\Throwable $e) - { - $lastFormatted = $lastExec; - } - } - - // State badge - $stateBadge = $state === 1 - ? 'Enabled' - : 'Disabled'; - - // Link to edit the task - $editLink = Route::_('index.php?option=com_scheduler&task=task.edit&id=' . $taskId); - - // Task params — default to On when keys are missing (matches form defaults) - $taskParams = json_decode($task['params'] ?? '{}', true) ?: []; - $bannerOn = !isset($taskParams['banner_enabled']) || (int) $taskParams['banner_enabled'] === 1; - $mediaOn = !isset($taskParams['include_media']) || (int) $taskParams['include_media'] === 1; - $countdownOn = !isset($taskParams['show_countdown']) || (int) $taskParams['show_countdown'] === 1; - - // Check if snapshot exists - $snapshotExists = is_dir(JPATH_ROOT . '/mokowaas-snapshots/default'); - - // Build info card - return '
' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '
Status' . $stateBadge . '
Schedule' . htmlspecialchars($friendlySchedule) . '
Next Reset' . htmlspecialchars($nextFormatted) . ' ' . $nextBadge . '
Last Reset' . htmlspecialchars($lastFormatted) . '
Runs' . (int) ($task['times_executed'] ?? 0) . ' executed, ' . (int) ($task['times_failed'] ?? 0) . ' failed
Baseline' . ($snapshotExists ? 'Saved' : 'Not taken yet') . '
Banner' . ($bannerOn ? 'On' : 'Off') . ($countdownOn ? ' + countdown' : '') . '
Images' . ($mediaOn ? 'Included' : 'Excluded') . '
' - . '' - . ' Manage Scheduled Task' - . '
'; - } - - protected function getLabel() - { - return ''; - } - - /** - * Convert a cron expression to a human-readable string. - * - * @param string $cron Cron expression - * - * @return string - */ - private function friendlySchedule(string $cron): string - { - $map = [ - '* * * * *' => 'Every minute', - '*/5 * * * *' => 'Every 5 minutes', - '*/15 * * * *' => 'Every 15 minutes', - '*/30 * * * *' => 'Every 30 minutes', - '0 */1 * * *' => 'Every hour', - '0 */4 * * *' => 'Every 4 hours', - '0 */6 * * *' => 'Every 6 hours', - '0 */12 * * *' => 'Every 12 hours', - '0 0 * * *' => 'Daily at midnight', - '0 6 * * *' => 'Daily at 6:00 AM', - '0 0 * * 0' => 'Weekly (Sunday)', - '0 0 1 * *' => 'Monthly (1st)', - ]; - - return $map[$cron] ?? 'Custom'; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/NextResetField.php b/src/packages/plg_system_mokowaas/Field/NextResetField.php deleted file mode 100644 index 9bbba13c..00000000 --- a/src/packages/plg_system_mokowaas/Field/NextResetField.php +++ /dev/null @@ -1,156 +0,0 @@ -form) - { - $demoEnabled = (int) $this->form->getValue('demo_mode_enabled', 'params', 0) === 1; - } - - if (!$demoEnabled) - { - return 'Demo mode is off' - . ''; - } - - // Query the actual next_execution from the scheduled task - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([ - $db->quoteName('next_execution'), - $db->quoteName('last_execution'), - $db->quoteName('state'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $task = $db->loadAssoc(); - } - catch (\Throwable $e) - { - $task = null; - } - - if (!$task) - { - return '
No scheduled task found — save to create one automatically.
' - . ''; - } - - if ((int) $task['state'] !== 1) - { - return '
Scheduled task is disabled.
' - . ''; - } - - $nextExec = $task['next_execution']; - $lastExec = $task['last_execution']; - - if (empty($nextExec) || $nextExec === '0000-00-00 00:00:00') - { - return '
Waiting for first run...
' - . ''; - } - - // Convert to site timezone - $utcTimestamp = strtotime($nextExec); - $siteTimezone = Factory::getApplication()->get('offset', 'UTC'); - - try - { - $dt = new \DateTime('@' . $utcTimestamp); - $dt->setTimezone(new \DateTimeZone($siteTimezone)); - $formatted = $dt->format('l, F j, Y \a\t g:i A T'); - } - catch (\Throwable $e) - { - $formatted = $nextExec . ' UTC'; - } - - // Relative time - $diff = $utcTimestamp - time(); - $relative = ''; - - if ($diff <= 0) - { - $relative = 'overdue'; - } - elseif ($diff < 3600) - { - $mins = (int) ceil($diff / 60); - $relative = 'in ' . $mins . ' min'; - } - elseif ($diff < 86400) - { - $hours = round($diff / 3600, 1); - $relative = 'in ' . $hours . 'h'; - } - else - { - $days = round($diff / 86400, 1); - $relative = 'in ' . $days . 'd'; - } - - // Last run info - $lastInfo = ''; - - if (!empty($lastExec) && $lastExec !== '0000-00-00 00:00:00') - { - try - { - $lastDt = new \DateTime($lastExec); - $lastDt->setTimezone(new \DateTimeZone($siteTimezone)); - $lastInfo = 'Last run: ' . $lastDt->format('M j, g:i A') . ''; - } - catch (\Throwable $e) - { - // skip - } - } - - return '
' - . '' - . ' ' - . htmlspecialchars($formatted) . ' ' - . $relative - . $lastInfo - . '' - . '
'; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php b/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php deleted file mode 100644 index 396bf83e..00000000 --- a/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php +++ /dev/null @@ -1,175 +0,0 @@ - ['content', 'categories', 'fields', 'fields_values', 'fields_groups', 'tags', 'contentitem_tag_map', 'ucm_content', 'ucm_history'], - 'Users' => ['users', 'user_usergroup_map', 'user_profiles', 'usergroups', 'user_keys', 'user_mfa'], - 'Menus' => ['menu', 'menu_types'], - 'Modules' => ['modules', 'modules_menu'], - 'Assets' => ['assets'], - ]; - - protected function getInput() - { - $db = Factory::getDbo(); - $prefix = $db->getPrefix(); - $tables = $db->getTableList(); - - // Resolve selected values - $selected = $this->value; - - if ($selected === null || $selected === '') - { - $selected = self::DEFAULT_TABLES; - } - elseif (is_string($selected)) - { - $selected = array_filter(array_map('trim', explode("\n", $selected))); - } - - $selected = (array) $selected; - - // Flatten nested arrays from broken save format [["#__content"],["#__categories"]] - $selected = array_map(function ($v) { - return is_array($v) ? reset($v) : $v; - }, $selected); - - // Group tables - $grouped = []; - - foreach ($tables as $table) - { - if (strpos($table, $prefix) !== 0) - { - continue; - } - - $suffix = substr($table, strlen($prefix)); - $logical = '#__' . $suffix; - $group = 'Other'; - - foreach (self::TABLE_GROUPS as $groupName => $patterns) - { - if (in_array($suffix, $patterns, true)) - { - $group = $groupName; - break; - } - } - - $grouped[$group][] = $logical; - } - - // Build HTML select with optgroups - $size = (int) ($this->element['size'] ?? 15); - $html = ''; - - // "Reset to defaults" link - $defaultsJson = htmlspecialchars(json_encode(self::DEFAULT_TABLES), ENT_QUOTES, 'UTF-8'); - $html .= '
' - . ' Reset to defaults' - . '
'; - - return $html; - } -} diff --git a/src/packages/plg_system_mokowaas/mokowaas.xml b/src/packages/plg_system_mokowaas/mokowaas.xml deleted file mode 100644 index 63af1c34..00000000 --- a/src/packages/plg_system_mokowaas/mokowaas.xml +++ /dev/null @@ -1,260 +0,0 @@ - - - - System - MokoWaaS - mokowaas - Moko Consulting - 2026-05-22 - Copyright (C) 2025 Moko Consulting. All rights reserved. - GNU General Public License version 3 or later; see LICENSE.md - hello@mokoconsulting.tech - https://mokoconsulting.tech - 02.34.00 - This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform. - Moko\Plugin\System\MokoWaaS - script.php - - - script.php - Extension - Field - Helper - Service - forms - payload - services - language - administrator - - - - index.html - favicon.ico - favicon.svg - favicon_256.png - logo.png - - - - en-GB/plg_system_mokowaas.ini - en-US/plg_system_mokowaas.ini - - - - en-GB/plg_system_mokowaas.sys.ini - en-US/plg_system_mokowaas.sys.ini - - - - - language - - - - - -
- - - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-