03e0b6d13b
The receiver returns 'updated' when re-registering an existing site, but the code only accepted 'registered', causing false 'HTTP 200 — Unknown' warnings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3241 lines
73 KiB
PHP
3241 lines
73 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();
|
|
|
|
// 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()
|
|
{
|
|
if (!$this->app->isClient('administrator'))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$doc = $this->app->getDocument();
|
|
|
|
if ($doc->getType() !== 'html')
|
|
{
|
|
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,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Heartbeat (called from onExtensionAfterSave)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Send heartbeat to the MokoWaaS monitoring receiver.
|
|
*
|
|
* Registers this site (and any aliases) with the Grafana provisioning system.
|
|
* The receiver writes a datasource YAML file and restarts Grafana.
|
|
*
|
|
* @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 any alias domains
|
|
$aliases = $params->get('site_aliases', '');
|
|
|
|
if (!empty($aliases))
|
|
{
|
|
foreach (array_filter(array_map('trim', explode(',', $aliases))) as $alias)
|
|
{
|
|
$aliasUrl = 'https://' . ltrim($alias, 'https://');
|
|
$aliasUrl = rtrim($aliasUrl, '/');
|
|
$this->sendHeartbeat($aliasUrl, $siteName . ' (' . $alias . ')', $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']
|
|
);
|
|
}
|
|
|
|
}
|