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
-
-
-
-
- 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 .= '';
-
- 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
-
-
-
-
-
-
-
-
-
-
-
-