Files
MokoSuiteClient/src/Extension/MokoWaaS.php
T
Jonathan Miller cc907a5aa2
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 3s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 4s
Joomla: Extension CI / Release Readiness Check (pull_request) Successful in 4s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been skipped
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been skipped
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 3s
Universal: PR Check / Changelog Updated (pull_request) Successful in 3s
fix(heartbeat): only send heartbeat for primary domain, not aliases
Alias domains were creating separate Grafana datasources with unique
UIDs, causing provisioning failures (duplicate UID) when Grafana
restarts. Only the primary domain should be registered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 18:34:57 -05:00

3374 lines
77 KiB
PHP

<?php
/**
* Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
*
* 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.01.08
* 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\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;
/**
* 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
{
/**
* Obfuscated Grafana URL (XOR + base64).
*
* @var string
* @since 02.01.26
*/
private const HEARTBEAT_URL = 'https://bench.mokoconsulting.tech/api/waas-heartbeat';
/**
* Shared secret for heartbeat authentication.
*
* @var string
* @since 02.01.36
*/
private const HEARTBEAT_KEY = 'moko-waas-hb-2026-x9k4m';
/**
* 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;
/**
* 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
$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();
}
if (!$this->params->get('enable_branding', 1))
{
return;
}
$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;
}
$masterUsername = $this->params->get(
'master_username', 'mokoconsulting'
);
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
if ($username !== $masterUsername)
{
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);
$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,
"<?php die('MokoWaaS emergency verification."
. " Delete this file to proceed.'); ?>\n"
);
file_put_contents($flagFile, date('Y-m-d H:i:s'));
$session->set('mokowaas.emergency_pending', true);
$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);
$masterUsername = $this->params->get(
'master_username', 'mokoconsulting'
);
$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()
{
if (!$this->params->get('enforce_master_user', 1))
{
return;
}
$username = $this->params->get('master_username', 'mokoconsulting');
$email = $this->params->get('master_email', 'webmaster@mokoconsulting.tech');
$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();
$userData = (object) [
'name' => 'Webmaster',
'username' => $username,
'email' => $email,
'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()
{
$config = Factory::getConfig();
$allowedRaw = $config->get('mokowaas_allowed_ips', '');
if (empty($allowedRaw))
{
return false;
}
$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}}' => $this->params->get('brand_name', 'MokoWaaS'),
'{{COMPANY_NAME}}' => $this->params->get('company_name', 'Moko Consulting'),
'{{SUPPORT_URL}}' => $this->params->get('support_url', 'https://mokoconsulting.tech'),
];
}
/**
* 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'
);
}
// 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'
);
}
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->enforceAdminRestrictions();
}
/**
* Inject visual branding into the document head.
*
* Fires just before <head> 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);
}
if (!$this->app->isClient('administrator'))
{
return;
}
$this->injectFavicon($doc);
}
/**
* 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');
}
if (!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;
default:
$this->sendHealthResponse(400, [
'error' => 'Unknown action',
'action' => $action,
'available' => ['health', 'install', 'update', 'cache', 'backup', 'info'],
]);
break;
}
}
// ------------------------------------------------------------------
// API Actions
// ------------------------------------------------------------------
/**
* 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' => $this->params->get('brand_name', 'MokoWaaS'),
'plugin_version' => '02.01.39',
]);
}
/**
* 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' => $this->params->get('brand_name', 'MokoWaaS'),
'plugin_version' => '02.01.22',
'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 <token>
* 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);
// Install using Joomla's installer
$installer = \Joomla\CMS\Installer\Installer::getInstance();
$result = $installer->install($tmpFile);
@unlink($tmpFile);
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 ?? '');
$this->sendHealthResponse(500, [
'error' => 'Install exception',
'message' => $e->getMessage(),
'url' => $url,
]);
}
}
// ------------------------------------------------------------------
// 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
*/
protected function getCurrentAlias()
{
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
$primaryHost = parse_url(Uri::root(), PHP_URL_HOST);
if (empty($currentHost) || strcasecmp($currentHost, $primaryHost) === 0)
{
return null;
}
$aliases = $this->params->get('site_aliases', '');
if (empty($aliases))
{
return null;
}
// Subform returns JSON string or array
if (is_string($aliases))
{
$aliases = json_decode($aliases, false);
}
if (!is_array($aliases))
{
return null;
}
foreach ($aliases as $alias)
{
$alias = (object) $alias;
if (isset($alias->domain) && strcasecmp(trim($alias->domain), $currentHost) === 0)
{
return $alias;
}
}
return null;
}
/**
* Handle site alias logic: offline page and backend redirect.
*
* Runs early in onAfterInitialise before routing occurs.
*
* @return void
*
* @since 02.01.43
*/
protected function handleSiteAlias()
{
$alias = $this->getCurrentAlias();
if ($alias === null)
{
return;
}
// Backend redirect: send admin requests to the primary domain
if (!empty($alias->redirect_backend) && $alias->redirect_backend === '1'
&& $this->app->isClient('administrator'))
{
$primaryUrl = rtrim(Uri::root(), '/') . '/administrator' . Uri::getInstance()->toString(['path', 'query']);
$adminPath = str_replace(Uri::root() . 'administrator', '', Uri::getInstance()->toString(['path', 'query']));
$primaryUrl = rtrim(Uri::root(), '/') . '/administrator' . $adminPath;
$this->app->redirect($primaryUrl, 301);
}
// Offline: show maintenance page for frontend requests
if (!empty($alias->offline) && $alias->offline === '1'
&& $this->app->isClient('site'))
{
// Allow health API to still respond
if ($this->app->input->get('mokowaas', '') !== '')
{
return;
}
$message = $alias->offline_message ?? 'This site is currently offline for maintenance.';
$brandName = $this->params->get('brand_name', 'MokoWaaS');
header('HTTP/1.1 503 Service Unavailable');
header('Retry-After: 3600');
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html><html><head><meta charset="utf-8">';
echo '<meta name="robots" content="noindex, nofollow">';
echo '<title>' . htmlspecialchars($brandName) . ' - Maintenance</title>';
echo '<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5;color:#333}';
echo '.container{text-align:center;padding:2rem;max-width:600px}h1{color:#1a2744;margin-bottom:1rem}p{font-size:1.1rem;line-height:1.6}</style>';
echo '</head><body><div class="container">';
echo '<h1>' . htmlspecialchars($brandName) . '</h1>';
echo '<p>' . htmlspecialchars($message) . '</p>';
echo '</div></body></html>';
$this->app->close();
}
}
/**
* 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)
{
$alias = $this->getCurrentAlias();
if ($alias === null)
{
return;
}
$robots = $alias->robots ?? 'index, follow';
if ($robots !== 'index, follow')
{
$doc->setMetaData('robots', $robots);
}
}
// ------------------------------------------------------------------
// Heartbeat (called from onExtensionAfterSave)
// ------------------------------------------------------------------
/**
* 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 only — aliases should not get separate datasources
$this->sendHeartbeat($siteUrl, $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
*/
/**
* Disable caching when development mode is active.
*
* Sets the Joomla caching config to 0 at runtime so no page
* or component cache is used. Does not modify configuration.php.
*
* @return void
*
* @since 02.01.15
*/
protected function enforceDevMode()
{
if (!$this->params->get('dev_mode', 0))
{
return;
}
$config = Factory::getConfig();
$config->set('caching', 0);
}
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;
}
$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);
}
/**
* 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()
{
$input = $this->app->input;
$option = $input->get('option', '');
$view = $input->get('view', '');
$task = $input->get('task', '');
// Disable install-from-URL for ALL users (safety net)
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;
}
// Remaining restrictions only apply to non-master users
if ($this->isMasterUser())
{
return;
}
$blocked = [];
if ($this->params->get('restrict_installer', 1))
{
$blocked[] = ['option' => 'com_installer'];
}
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;
}
$masterUsername = $this->params->get(
'master_username', 'mokoconsulting'
);
return $user->username === $masterUsername;
}
/**
* 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
if ($this->params->get('restrict_installer', 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',
];
// Color params — map plugin fields to Atum template params
$primary = $this->params->get('color_primary', '');
$sidebar = $this->params->get('color_sidebar', '');
$link = $this->params->get('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']
);
}
}