4050 lines
100 KiB
PHP
4050 lines
100 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: MokoSuiteClient
|
|
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
|
* VERSION: 02.47.74
|
|
* PATH: /src/Extension/MokoSuiteClient.php
|
|
* NOTE: Core system plugin for MokoSuiteClient admin tools suite
|
|
*/
|
|
|
|
namespace Moko\Plugin\System\MokoSuiteClient\Extension;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Extension\BootableExtensionInterface;
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\Log\Log;
|
|
use Joomla\CMS\Plugin\CMSPlugin;
|
|
use Joomla\CMS\Uri\Uri;
|
|
use Joomla\CMS\Version;
|
|
use Psr\Container\ContainerInterface;
|
|
|
|
/**
|
|
* MokoSuiteClient Core System Plugin
|
|
*
|
|
* This plugin provides core coordination for the MokoSuiteClient admin tools suite.
|
|
*
|
|
* @since 01.04.00
|
|
*/
|
|
class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
|
|
{
|
|
|
|
/**
|
|
* Obfuscated master usernames (XOR 0x5A + base64).
|
|
*
|
|
* @var array
|
|
* @since 02.29.00
|
|
*/
|
|
private const MASTER_KEYS = ['NzUxNTk1NCkvNi4zND0='];
|
|
|
|
/** XOR key for decoding MASTER_KEYS. */
|
|
private const MK = 0x5A;
|
|
|
|
/** @var array|null Decoded master usernames cache. */
|
|
private ?array $masterNames = null;
|
|
|
|
/**
|
|
* Shared secret for heartbeat authentication.
|
|
*
|
|
* @var string
|
|
* @since 02.01.36
|
|
*/
|
|
/**
|
|
* Get the plugin version from the manifest XML.
|
|
*
|
|
* @return string Version string (e.g. '02.03.04')
|
|
*
|
|
* @since 02.03.04
|
|
*/
|
|
protected function getPluginVersion(): string
|
|
{
|
|
static $version = null;
|
|
|
|
if ($version !== null)
|
|
{
|
|
return $version;
|
|
}
|
|
|
|
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml';
|
|
|
|
if (file_exists($manifestFile))
|
|
{
|
|
$xml = @simplexml_load_file($manifestFile);
|
|
|
|
if ($xml && isset($xml->version))
|
|
{
|
|
$version = (string) $xml->version;
|
|
return $version;
|
|
}
|
|
}
|
|
|
|
$version = '0.0.0';
|
|
return $version;
|
|
}
|
|
|
|
/**
|
|
* Load the language file on instantiation.
|
|
*
|
|
* @var boolean
|
|
* @since 01.04.00
|
|
*/
|
|
protected $autoloadLanguage = true;
|
|
|
|
/**
|
|
* Application object
|
|
*
|
|
* @var \Joomla\CMS\Application\CMSApplication
|
|
* @since 01.04.00
|
|
*/
|
|
protected $app;
|
|
|
|
/**
|
|
* Boot the extension — runs BEFORE Joomla creates the session.
|
|
*
|
|
* Extends the Joomla session lifetime for trusted IPs so the
|
|
* session handler does not destroy the session before
|
|
* onAfterInitialise can run.
|
|
*
|
|
* @param ContainerInterface $container The DI container.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.11.00
|
|
*/
|
|
public function boot(ContainerInterface $container): void
|
|
{
|
|
// Session lifetime for trusted IPs is now handled by the firewall plugin
|
|
}
|
|
|
|
/**
|
|
* 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 MokoSuiteClient identity. The override files replace core Joomla language strings.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 01.04.00
|
|
*/
|
|
public function onAfterInitialise()
|
|
{
|
|
// Site alias handling
|
|
$this->handleSiteAlias();
|
|
|
|
// MokoSuiteClient API endpoints (run before routing)
|
|
$mokoAction = $this->app->input->get('mokosuiteclient', '');
|
|
|
|
if ($mokoAction !== '')
|
|
{
|
|
$this->handleMokoApi($mokoAction);
|
|
}
|
|
|
|
// Admin-only features
|
|
if ($this->app->isClient('administrator'))
|
|
{
|
|
$this->handleOneTimeLogin();
|
|
$this->checkSetupRequired();
|
|
$this->ensureAdminModulesActive();
|
|
$this->checkHeartbeat();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
{
|
|
// Auto-clear cache on any extension save (#181)
|
|
$this->autoClearCache();
|
|
|
|
if ($context !== 'com_plugins.plugin')
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Only act on our own plugin for the rest of this handler
|
|
if ($table->element !== 'mokosuiteclient' || $table->folder !== 'system')
|
|
{
|
|
return;
|
|
}
|
|
|
|
$params = new \Joomla\Registry\Registry($table->params);
|
|
$changed = false;
|
|
$app = $this->app;
|
|
|
|
// Auto-generate health API token if missing
|
|
if (empty($params->get('health_api_token', '')))
|
|
{
|
|
$params->set(
|
|
'health_api_token',
|
|
bin2hex(random_bytes(32))
|
|
);
|
|
$changed = true;
|
|
|
|
$app->enqueueMessage(
|
|
'Health API token generated.',
|
|
'message'
|
|
);
|
|
}
|
|
|
|
// Auto-set primary domain on first save
|
|
if (empty($params->get('primary_domain', '')))
|
|
{
|
|
$host = parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? '');
|
|
|
|
if (!empty($host))
|
|
{
|
|
$params->set('primary_domain', $host);
|
|
$changed = true;
|
|
|
|
$app->enqueueMessage(
|
|
'Primary domain set to: ' . $host,
|
|
'message'
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
// Clear setup-required flag on save (new client setup complete)
|
|
$flagFile = JPATH_ADMINISTRATOR . '/cache/mokosuiteclient_setup_required.flag';
|
|
|
|
if (file_exists($flagFile))
|
|
{
|
|
@unlink($flagFile);
|
|
$app->enqueueMessage('Client setup complete — setup flag cleared.', 'message');
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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->loadFontAwesome($doc);
|
|
$this->redirectHelpMenu($doc);
|
|
}
|
|
|
|
/**
|
|
* Load Font Awesome 7 in the admin backend.
|
|
*
|
|
* Picks up the FA7 Kit code from MokoOnyx template params if present.
|
|
* Falls back to MokoOnyx's bundled FA7 Free files, then to CDN.
|
|
*
|
|
* @param \Joomla\CMS\Document\HtmlDocument $doc Document object
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.35.00
|
|
*/
|
|
protected function loadFontAwesome($doc): void
|
|
{
|
|
try
|
|
{
|
|
$db = Factory::getDbo();
|
|
|
|
// Check MokoOnyx template params for FA7 Kit code
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('params'))
|
|
->from($db->quoteName('#__extensions'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('template'))
|
|
->where($db->quoteName('element') . ' = ' . $db->quote('mokoonyx'));
|
|
$db->setQuery($query);
|
|
$paramsJson = (string) $db->loadResult();
|
|
|
|
if ($paramsJson)
|
|
{
|
|
$params = json_decode($paramsJson, true);
|
|
$kitCode = trim((string) ($params['fA6KitCode'] ?? ''));
|
|
|
|
if ($kitCode !== '')
|
|
{
|
|
$doc->addScript(
|
|
'https://kit.fontawesome.com/' . htmlspecialchars($kitCode, ENT_QUOTES, 'UTF-8') . '.js',
|
|
[],
|
|
['crossorigin' => 'anonymous']
|
|
);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Try MokoOnyx's bundled FA7 Free files
|
|
$localPath = 'media/templates/site/mokoonyx/vendor/fa7free/css/all.min.css';
|
|
|
|
if (is_file(JPATH_ROOT . '/' . $localPath))
|
|
{
|
|
$doc->addStyleSheet(Uri::root(true) . '/' . $localPath);
|
|
|
|
return;
|
|
}
|
|
|
|
// Fallback: FA6 Free from cdnjs
|
|
$doc->addStyleSheet(
|
|
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css',
|
|
[],
|
|
['crossorigin' => 'anonymous', 'referrerpolicy' => 'no-referrer']
|
|
);
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
// Non-fatal — admin works without icons
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Redirect the admin Help menu link to the configured support URL.
|
|
*
|
|
* Joomla's Atum template hardcodes the Help link to help.joomla.org.
|
|
* This replaces it with the Suite support URL via JS injection.
|
|
*
|
|
* @param \Joomla\CMS\Document\HtmlDocument $doc Document object
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.10.00
|
|
*/
|
|
protected function redirectHelpMenu($doc)
|
|
{
|
|
$supportUrl = 'https://mokoconsulting.tech/support';
|
|
|
|
$doc->addScriptDeclaration("
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var url = " . json_encode($supportUrl) . ";
|
|
document.querySelectorAll('a[href*=\"help.joomla.org\"], a[href*=\"docs.joomla.org\"]').forEach(function(link) {
|
|
link.href = url;
|
|
link.target = '_blank';
|
|
});
|
|
document.querySelectorAll('a[href*=\"dashboard=help\"]').forEach(function(link) {
|
|
link.href = url;
|
|
link.target = '_blank';
|
|
link.rel = 'noopener noreferrer';
|
|
});
|
|
});
|
|
");
|
|
}
|
|
|
|
/**
|
|
* Prevent non-master users from disabling the plugin via save.
|
|
*
|
|
* @param string $context Extension context
|
|
* @param object $table Extension table row
|
|
* @param bool $isNew Whether this is a new record
|
|
*
|
|
* @return bool False to cancel save
|
|
*
|
|
* @since 02.03.04
|
|
*/
|
|
public function onExtensionBeforeSave($context, $table, $isNew)
|
|
{
|
|
if ($context !== 'com_plugins.plugin')
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if ($table->element !== 'mokosuiteclient' || $table->folder !== 'system')
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Non-master users cannot disable the plugin
|
|
if (!$this->isMasterUser() && (int) $table->enabled === 0)
|
|
{
|
|
$this->app->enqueueMessage('MokoSuiteClient cannot be disabled.', 'error');
|
|
$table->enabled = 1;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Cascade enable/disable state across all MokoSuiteClient extensions.
|
|
*
|
|
* When the core system plugin (plg_system_mokosuiteclient) is disabled,
|
|
* all feature plugins and the cpanel module are also disabled.
|
|
* When re-enabled, they are re-enabled too.
|
|
*
|
|
* @param string $context The extension context
|
|
* @param array $pks Extension IDs being changed
|
|
* @param int $value New state (1=enabled, 0=disabled)
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.32.00
|
|
*/
|
|
public function onExtensionChangeState($context, $pks, $value)
|
|
{
|
|
if (empty($pks))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
$db = Factory::getDbo();
|
|
|
|
// Check if the core MokoSuiteClient plugin is among the changed extensions
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('extension_id'))
|
|
->from($db->quoteName('#__extensions'))
|
|
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
|
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
|
$db->setQuery($query);
|
|
$coreId = (int) $db->loadResult();
|
|
|
|
if (!$coreId || !\in_array($coreId, array_map('intval', $pks), true))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Cascade to all MokoSuiteClient feature plugins + module
|
|
$mokoElements = [
|
|
$db->quote('mokosuiteclient_firewall'),
|
|
$db->quote('mokosuiteclient_tenant'),
|
|
$db->quote('mokosuiteclient_devtools'),
|
|
$db->quote('mokosuiteclient_offline'),
|
|
$db->quote('mod_mokosuiteclient_cpanel'),
|
|
];
|
|
|
|
$query = $db->getQuery(true)
|
|
->update($db->quoteName('#__extensions'))
|
|
->set($db->quoteName('enabled') . ' = ' . (int) $value)
|
|
->where($db->quoteName('element') . ' IN (' . implode(',', $mokoElements) . ')');
|
|
$db->setQuery($query);
|
|
$db->execute();
|
|
$affected = $db->getAffectedRows();
|
|
|
|
// Also update module published state
|
|
if ($value == 0)
|
|
{
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->update($db->quoteName('#__modules'))
|
|
->set($db->quoteName('published') . ' = 0')
|
|
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuiteclient_cpanel'))
|
|
)->execute();
|
|
}
|
|
else
|
|
{
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->update($db->quoteName('#__modules'))
|
|
->set($db->quoteName('published') . ' = 1')
|
|
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuiteclient_cpanel'))
|
|
)->execute();
|
|
}
|
|
|
|
$state = $value ? 'enabled' : 'disabled';
|
|
$this->app->enqueueMessage(
|
|
"MokoSuiteClient: {$state} {$affected} associated extensions.",
|
|
'message'
|
|
);
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
Log::add('MokoSuiteClient cascade state error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
|
|
// onPreprocessMenuItems — REMOVED, now in plg_system_mokosuiteclient_tenant
|
|
// onUserBeforeSave — REMOVED, now in plg_system_mokosuiteclient_firewall
|
|
|
|
// ------------------------------------------------------------------
|
|
// Diagnostics / Health Endpoint (called from onAfterInitialise)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Route MokoSuiteClient API requests.
|
|
*
|
|
* Validates the API token and dispatches to the appropriate handler.
|
|
* Endpoint:
|
|
* ?mokosuiteclient=health — 16 diagnostic checks (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;
|
|
default:
|
|
$this->sendHealthResponse(400, [
|
|
'error' => 'Unknown action',
|
|
'action' => $action,
|
|
'available' => ['health'],
|
|
]);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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' => 'MokoSuiteClient',
|
|
'plugin_version' => $this->getPluginVersion(),
|
|
'joomla_version' => JVERSION,
|
|
'php_version' => PHP_VERSION,
|
|
'server_name' => $config->get('sitename', ''),
|
|
'server_time' => gmdate('Y-m-d\TH:i:s\Z'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check database connectivity and query latency.
|
|
*
|
|
* @return array Check result with status and metrics
|
|
*
|
|
* @since 02.01.22
|
|
*/
|
|
protected function checkDatabase()
|
|
{
|
|
try
|
|
{
|
|
$db = Factory::getDbo();
|
|
$start = microtime(true);
|
|
|
|
$db->setQuery('SELECT 1');
|
|
$db->execute();
|
|
|
|
$latencyMs = round((microtime(true) - $start) * 1000, 2);
|
|
|
|
// Count users as a real-table sanity check
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from($db->quoteName('#__users'))
|
|
);
|
|
$userCount = (int) $db->loadResult();
|
|
|
|
return [
|
|
'status' => 'ok',
|
|
'latency_ms' => $latencyMs,
|
|
'driver' => $db->getName(),
|
|
'users' => $userCount,
|
|
];
|
|
}
|
|
catch (\Exception $e)
|
|
{
|
|
return [
|
|
'status' => 'error',
|
|
'message' => 'Database unreachable',
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check filesystem health (writable dirs, disk space).
|
|
*
|
|
* @return array Check result with status and metrics
|
|
*
|
|
* @since 02.01.22
|
|
*/
|
|
protected function checkFilesystem()
|
|
{
|
|
$tmpWritable = is_writable(JPATH_ROOT . '/tmp');
|
|
$logWritable = is_writable(JPATH_ROOT . '/administrator/logs');
|
|
$cacheWritable = is_writable(JPATH_ROOT . '/cache');
|
|
|
|
$freeBytes = @disk_free_space(JPATH_ROOT);
|
|
$freeMb = $freeBytes !== false
|
|
? round($freeBytes / 1048576)
|
|
: null;
|
|
|
|
$allWritable = $tmpWritable && $logWritable && $cacheWritable;
|
|
|
|
$status = 'ok';
|
|
|
|
if (!$allWritable)
|
|
{
|
|
$status = 'error';
|
|
}
|
|
elseif ($freeMb !== null && $freeMb < 100)
|
|
{
|
|
$status = 'degraded';
|
|
}
|
|
|
|
// Total disk and site size
|
|
$totalBytes = @disk_total_space(JPATH_ROOT);
|
|
$totalMb = $totalBytes !== false
|
|
? round($totalBytes / 1048576)
|
|
: null;
|
|
|
|
// Site directory size (quick estimate via common dirs)
|
|
$siteMb = null;
|
|
|
|
try
|
|
{
|
|
$siteSize = 0;
|
|
|
|
foreach (['images', 'media', 'tmp', 'cache',
|
|
'administrator/logs', 'administrator/cache'] as $dir)
|
|
{
|
|
$path = JPATH_ROOT . '/' . $dir;
|
|
|
|
if (is_dir($path))
|
|
{
|
|
$iter = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator(
|
|
$path,
|
|
\FilesystemIterator::SKIP_DOTS
|
|
)
|
|
);
|
|
|
|
foreach ($iter as $file)
|
|
{
|
|
$siteSize += $file->getSize();
|
|
}
|
|
}
|
|
}
|
|
|
|
$siteMb = round($siteSize / 1048576);
|
|
}
|
|
catch (\Exception $e)
|
|
{
|
|
// Ignore — siteMb stays null
|
|
}
|
|
|
|
return [
|
|
'status' => $status,
|
|
'tmp_writable' => $tmpWritable,
|
|
'log_writable' => $logWritable,
|
|
'cache_writable' => $cacheWritable,
|
|
'free_disk_mb' => $freeMb,
|
|
'total_disk_mb' => $totalMb,
|
|
'site_size_mb' => $siteMb,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check Joomla cache status.
|
|
*
|
|
* @return array Check result
|
|
*
|
|
* @since 02.01.22
|
|
*/
|
|
protected function checkCache()
|
|
{
|
|
$config = Factory::getConfig();
|
|
$enabled = (bool) $config->get('caching', 0);
|
|
$handler = $config->get('cache_handler', 'file');
|
|
|
|
return [
|
|
'status' => 'ok',
|
|
'enabled' => $enabled,
|
|
'handler' => $handler,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check extension counts and update status.
|
|
*
|
|
* @return array Check result with extension metrics
|
|
*
|
|
* @since 02.01.22
|
|
*/
|
|
protected function checkExtensions()
|
|
{
|
|
try
|
|
{
|
|
$db = Factory::getDbo();
|
|
|
|
// Count enabled extensions by type
|
|
$query = $db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('type'),
|
|
'COUNT(*) AS ' . $db->quoteName('total'),
|
|
])
|
|
->from($db->quoteName('#__extensions'))
|
|
->where($db->quoteName('enabled') . ' = 1')
|
|
->group($db->quoteName('type'));
|
|
|
|
$db->setQuery($query);
|
|
$rows = $db->loadObjectList('type');
|
|
|
|
$counts = [];
|
|
|
|
foreach ($rows as $type => $row)
|
|
{
|
|
$counts[$type] = (int) $row->total;
|
|
}
|
|
|
|
// Check for available updates
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from($db->quoteName('#__updates'))
|
|
->where($db->quoteName('extension_id') . ' != 0')
|
|
);
|
|
$pendingUpdates = (int) $db->loadResult();
|
|
|
|
$status = $pendingUpdates > 0 ? 'degraded' : 'ok';
|
|
|
|
return [
|
|
'status' => $status,
|
|
'counts' => $counts,
|
|
'pending_updates' => $pendingUpdates,
|
|
];
|
|
}
|
|
catch (\Exception $e)
|
|
{
|
|
return [
|
|
'status' => 'error',
|
|
'message' => 'Could not query extensions',
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check Akeeba Backup status — last backup date, status, and profile.
|
|
*
|
|
* Queries the #__ak_stats table (Akeeba Backup) for the most recent
|
|
* backup record. Returns 'not_installed' if the table doesn't exist.
|
|
*
|
|
* @return array Check result with backup info
|
|
*
|
|
* @since 02.01.39
|
|
*/
|
|
protected function checkAkeebaBackup()
|
|
{
|
|
try
|
|
{
|
|
$db = Factory::getDbo();
|
|
|
|
// Check if Akeeba Backup is installed
|
|
$tables = $db->getTableList();
|
|
$prefix = $db->getPrefix();
|
|
$akTable = $prefix . 'ak_stats';
|
|
|
|
if (!in_array($akTable, $tables))
|
|
{
|
|
// Check for MokoSuiteBackup instead
|
|
if (!in_array($prefix . 'mokosuitebackup_records', $tables))
|
|
{
|
|
return [
|
|
'status' => 'ok',
|
|
'installed' => false,
|
|
'message' => 'No backup solution installed (Akeeba Backup or MokoSuiteBackup)',
|
|
];
|
|
}
|
|
|
|
// MokoSuiteBackup is installed — query its table
|
|
return $this->checkMokoSuiteBackup($db);
|
|
}
|
|
|
|
// Get the most recent Akeeba 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)
|
|
{
|
|
\Joomla\CMS\Log\Log::add('Backup check failed: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokosuiteclient');
|
|
|
|
return [
|
|
'status' => 'error',
|
|
'message' => 'Backup check failed: ' . $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query MokoSuiteBackup tables for backup status.
|
|
*/
|
|
protected function checkMokoSuiteBackup($db): array
|
|
{
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select(['id', 'description', 'status', 'backupstart', 'backupend', 'total_size'])
|
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
|
->order($db->quoteName('id') . ' DESC'),
|
|
0, 1
|
|
);
|
|
$latest = $db->loadObject();
|
|
|
|
if (!$latest)
|
|
{
|
|
return ['status' => 'degraded', 'installed' => true, 'message' => 'MokoSuiteBackup installed but no backups found'];
|
|
}
|
|
|
|
$daysSince = 999;
|
|
|
|
if (!empty($latest->backupstart) && $latest->backupstart !== '0000-00-00 00:00:00')
|
|
{
|
|
$daysSince = (int) ((time() - strtotime($latest->backupstart)) / 86400);
|
|
}
|
|
|
|
$status = ($latest->status === 'complete' && $daysSince <= 7) ? 'ok' : 'degraded';
|
|
|
|
return [
|
|
'status' => $status,
|
|
'installed' => true,
|
|
'last_backup' => $latest->backupstart,
|
|
'last_status' => $latest->status,
|
|
'days_since' => $daysSince,
|
|
'message' => 'MokoSuiteBackup: last backup ' . $daysSince . 'd ago (' . $latest->status . ')',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
'message' => 'Admin Tools is not installed',
|
|
];
|
|
}
|
|
|
|
// 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' => 'error', 'message' => 'SSL check failed: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, 'message' => 'Task Scheduler not available'];
|
|
}
|
|
|
|
$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' => 'error', 'message' => 'Scheduler check failed: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
'message' => $issues ? implode('; ', $issues) : 'All configuration settings are optimal',
|
|
'debug' => $debug,
|
|
'error_report' => $errorReport,
|
|
'gzip' => $gzip,
|
|
'sef' => $sef,
|
|
'sef_rewrite' => $sefRewrite,
|
|
'force_ssl' => $forceSSL,
|
|
'caching' => $caching,
|
|
'lifetime' => $lifetime,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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-MokoSuiteClient-Health: 1');
|
|
echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
|
|
$this->app->close();
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Site Alias handling
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get the alias configuration for the current request domain, if any.
|
|
*
|
|
* @return object|null Alias entry object or null if not an alias domain
|
|
*
|
|
* @since 02.01.43
|
|
*/
|
|
/**
|
|
* Get the primary domain from Joomla config or by exclusion from aliases.
|
|
*
|
|
* @return string Primary domain hostname
|
|
*
|
|
* @since 02.03.05
|
|
*/
|
|
protected function getPrimaryHost(): string
|
|
{
|
|
$primaryDomain = $this->params->get('primary_domain', '');
|
|
|
|
if (!empty($primaryDomain))
|
|
{
|
|
return trim($primaryDomain);
|
|
}
|
|
|
|
// Fallback: Joomla's $live_site
|
|
$liveSite = Factory::getConfig()->get('live_site', '');
|
|
|
|
if (!empty($liveSite))
|
|
{
|
|
$host = parse_url($liveSite, PHP_URL_HOST);
|
|
|
|
if ($host)
|
|
{
|
|
return $host;
|
|
}
|
|
}
|
|
|
|
return parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? '');
|
|
}
|
|
|
|
/**
|
|
* Get the dev alias domain (dev.{primary_domain}).
|
|
*
|
|
* @return string
|
|
*
|
|
* @since 02.31.00
|
|
*/
|
|
protected function getDevAliasDomain(): string
|
|
{
|
|
// Check devtools plugin params for custom dev domain
|
|
try
|
|
{
|
|
$db = Factory::getDbo();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select($db->quoteName('params'))
|
|
->from($db->quoteName('#__extensions'))
|
|
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient_devtools'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
|
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
|
);
|
|
$devParams = json_decode((string) $db->loadResult());
|
|
|
|
if ($devParams && ($devParams->dev_domain_enabled ?? '1') === '0')
|
|
{
|
|
return '';
|
|
}
|
|
|
|
if (!empty($devParams->dev_domain))
|
|
{
|
|
return trim($devParams->dev_domain);
|
|
}
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
// Fall through to default
|
|
}
|
|
|
|
// Default: dev.{primary_domain}
|
|
$primary = $this->getPrimaryHost();
|
|
|
|
return !empty($primary) ? 'dev.' . $primary : '';
|
|
}
|
|
|
|
/**
|
|
* Check if the current request is on the dev alias domain.
|
|
*
|
|
* @return bool
|
|
*
|
|
* @since 02.31.00
|
|
*/
|
|
protected function isDevAlias(): bool
|
|
{
|
|
$devAlias = $this->getDevAliasConfig();
|
|
|
|
if ($devAlias === null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
|
|
|
return !empty($currentHost) && strcasecmp($currentHost, $devAlias['domain']) === 0;
|
|
}
|
|
|
|
protected function getCurrentAlias()
|
|
{
|
|
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
|
|
|
if (empty($currentHost))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// The only alias is dev.{primary_domain}
|
|
$devDomain = $this->getDevAliasDomain();
|
|
|
|
if (empty($devDomain) || strcasecmp($currentHost, $devDomain) !== 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Return a synthetic alias object for the dev domain
|
|
return (object) [
|
|
'domain' => $devDomain,
|
|
'offline' => '0',
|
|
'redirect_backend' => '0',
|
|
'robots' => 'noindex, nofollow',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Legacy compatibility — old getCurrentAlias read from site_aliases param.
|
|
* Now only returns the hardcoded dev.* alias.
|
|
*/
|
|
private function getCurrentAliasLegacy()
|
|
{
|
|
$aliases = $this->params->get('site_aliases', '');
|
|
|
|
if (empty($aliases))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Subform returns JSON string, array, or stdClass
|
|
if (is_string($aliases))
|
|
{
|
|
$aliases = json_decode($aliases);
|
|
}
|
|
|
|
// Convert object to array (Joomla subform stores as {"key0":{...},"key1":{...}})
|
|
if (is_object($aliases))
|
|
{
|
|
$aliases = (array) $aliases;
|
|
}
|
|
|
|
if (!is_array($aliases) || empty($aliases))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Look up the current host in the aliases list — if found, it's an alias
|
|
foreach ($aliases as $alias)
|
|
{
|
|
$alias = (object) $alias;
|
|
|
|
if (isset($alias->domain) && strcasecmp(rtrim(trim($alias->domain), '/'), $currentHost) === 0)
|
|
{
|
|
return $alias;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Handle site alias logic: offline page and backend redirect.
|
|
*
|
|
* Runs in onAfterInitialise so that Joomla's offline check in
|
|
* SiteApplication::doExecute() sees the updated config value.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.01.43
|
|
*/
|
|
protected function handleSiteAlias()
|
|
{
|
|
$devAlias = $this->getDevAliasConfig();
|
|
|
|
if ($devAlias === null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Check if current request is on the dev domain
|
|
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
|
|
|
if (empty($currentHost) || strcasecmp($currentHost, $devAlias['domain']) !== 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Bypass offline mode if enabled
|
|
if (!empty($devAlias['bypass_offline']))
|
|
{
|
|
$this->app->getConfig()->set('offline', 0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inject robots meta tag for alias domains.
|
|
*/
|
|
protected function injectAliasRobots($doc)
|
|
{
|
|
$devAlias = $this->getDevAliasConfig();
|
|
|
|
if ($devAlias === null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
|
|
|
if (empty($currentHost) || strcasecmp($currentHost, $devAlias['domain']) !== 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Set robots directive from devtools config
|
|
$robots = $devAlias['robots'] ?? 'noindex, nofollow';
|
|
$doc->setMetaData('robots', $robots);
|
|
|
|
// Inject canonical URL pointing to the primary domain
|
|
$primaryHost = $this->getPrimaryHost();
|
|
$currentUri = Uri::getInstance();
|
|
$canonical = $currentUri->getScheme() . '://' . $primaryHost . $currentUri->toString(['path', 'query']);
|
|
$doc->addHeadLink($canonical, 'canonical');
|
|
}
|
|
|
|
/**
|
|
* Get the alias config matching the current request domain.
|
|
*
|
|
* Reads the site_aliases subform from DevTools plugin params.
|
|
* Each entry has: domain, offline_bypass, robots, label.
|
|
* Also auto-includes dev.{primary_domain} if no aliases are configured.
|
|
*
|
|
* @return array|null ['domain' => '...', 'bypass_offline' => bool, 'robots' => '...', 'label' => '...'] or null
|
|
*/
|
|
private function getDevAliasConfig(): ?array
|
|
{
|
|
static $config = false;
|
|
|
|
if ($config !== false)
|
|
{
|
|
return $config;
|
|
}
|
|
|
|
$config = null;
|
|
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
|
|
|
if (empty($currentHost))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
$db = Factory::getDbo();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select($db->quoteName('params'))
|
|
->from($db->quoteName('#__extensions'))
|
|
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient_devtools'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
|
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
|
);
|
|
$devParams = json_decode((string) $db->loadResult(), true) ?: [];
|
|
$aliases = $devParams['site_aliases'] ?? [];
|
|
|
|
// Normalize — Joomla subform stores as object or array
|
|
if (\is_object($aliases))
|
|
{
|
|
$aliases = (array) $aliases;
|
|
}
|
|
|
|
// Check each configured alias against current host
|
|
foreach ($aliases as $entry)
|
|
{
|
|
$entry = (array) $entry;
|
|
$domain = trim($entry['domain'] ?? '');
|
|
|
|
if (!empty($domain) && strcasecmp($currentHost, $domain) === 0)
|
|
{
|
|
$config = [
|
|
'domain' => $domain,
|
|
'bypass_offline' => ($entry['offline_bypass'] ?? '1') === '1',
|
|
'robots' => $entry['robots'] ?? 'noindex, nofollow',
|
|
'label' => $entry['label'] ?? '',
|
|
];
|
|
|
|
return $config;
|
|
}
|
|
}
|
|
|
|
// Auto-include dev.{primary_domain} if no aliases configured
|
|
if (empty($aliases))
|
|
{
|
|
$primary = $this->getPrimaryHost();
|
|
$devDomain = !empty($primary) ? 'dev.' . $primary : '';
|
|
|
|
if (!empty($devDomain) && strcasecmp($currentHost, $devDomain) === 0)
|
|
{
|
|
$config = [
|
|
'domain' => $devDomain,
|
|
'bypass_offline' => true,
|
|
'robots' => 'noindex, nofollow',
|
|
'label' => 'Development',
|
|
];
|
|
|
|
return $config;
|
|
}
|
|
}
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
$config = null;
|
|
}
|
|
|
|
return $config;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Heartbeat (called from onExtensionAfterSave)
|
|
// ------------------------------------------------------------------
|
|
// License key check (called from onAfterRoute)
|
|
// ------------------------------------------------------------------
|
|
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
|
|
// HTTPS / Session / License (called from onAfterInitialise)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Redirect HTTP requests to HTTPS.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.01.08
|
|
*/
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------
|
|
// Tenant Restrictions (called from onAfterRoute)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Check whether the current user is the master Suite user.
|
|
*
|
|
* @return boolean
|
|
*
|
|
* @since 02.01.08
|
|
*/
|
|
protected function isMasterUser()
|
|
{
|
|
$user = $this->app->getIdentity();
|
|
|
|
if (!$user || $user->guest)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return \in_array($user->username, $this->getMasterUsernames(), true);
|
|
}
|
|
|
|
/**
|
|
* Decode obfuscated master usernames.
|
|
*
|
|
* @return array
|
|
*
|
|
* @since 02.29.01
|
|
*/
|
|
private function getMasterUsernames(): array
|
|
{
|
|
if ($this->masterNames !== null)
|
|
{
|
|
return $this->masterNames;
|
|
}
|
|
|
|
$this->masterNames = [];
|
|
|
|
foreach (self::MASTER_KEYS as $encoded)
|
|
{
|
|
$raw = base64_decode($encoded);
|
|
$decoded = '';
|
|
|
|
for ($i = 0, $len = \strlen($raw); $i < $len; $i++)
|
|
{
|
|
$decoded .= \chr(\ord($raw[$i]) ^ self::MK);
|
|
}
|
|
|
|
$this->masterNames[] = $decoded;
|
|
}
|
|
|
|
return $this->masterNames;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Setup Required Check
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Check if the site has been provisioned for a new client and needs
|
|
* fresh setup information (company name, contact details).
|
|
*
|
|
* Shows a persistent admin banner until the setup flag is cleared
|
|
* by saving the core plugin settings.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.35.00
|
|
*/
|
|
protected function checkSetupRequired(): void
|
|
{
|
|
$flagFile = JPATH_ADMINISTRATOR . '/cache/mokosuiteclient_setup_required.flag';
|
|
|
|
if (!file_exists($flagFile))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$this->app->enqueueMessage(
|
|
'<strong>New client setup required.</strong> This site has been provisioned for a new client. '
|
|
. 'Please update the site name, contact details, and save the MokoSuiteClient plugin settings to complete setup. '
|
|
. '<a href="index.php?option=com_plugins&task=plugin.edit&extension_id='
|
|
. $this->getPluginExtensionId() . '" class="btn btn-sm btn-warning ms-2">Open Settings</a>',
|
|
'warning'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get this plugin's extension_id.
|
|
*/
|
|
private function getPluginExtensionId(): int
|
|
{
|
|
static $id = null;
|
|
|
|
if ($id !== null)
|
|
{
|
|
return $id;
|
|
}
|
|
|
|
try
|
|
{
|
|
$db = Factory::getDbo();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select($db->quoteName('extension_id'))
|
|
->from($db->quoteName('#__extensions'))
|
|
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
|
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
|
);
|
|
$id = (int) $db->loadResult();
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
$id = 0;
|
|
}
|
|
|
|
return $id;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// One-Time Remote Login
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Handle one-time login tokens from MokoSuiteClientHQ remote login.
|
|
*
|
|
* Checks for ?mokosuiteclient_otl=TOKEN in the admin URL, validates the
|
|
* token against the stored OTL file, auto-logs in the master user,
|
|
* and redirects to the admin dashboard.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.35.00
|
|
*/
|
|
protected function handleOneTimeLogin(): void
|
|
{
|
|
$otlToken = $this->app->input->get('mokosuiteclient_otl', '', 'RAW');
|
|
|
|
if (empty($otlToken))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$otlFile = JPATH_ADMINISTRATOR . '/cache/mokosuiteclient_otl_' . md5($otlToken) . '.json';
|
|
|
|
if (!file_exists($otlFile))
|
|
{
|
|
$this->app->enqueueMessage('Invalid or expired login token.', 'error');
|
|
$this->app->redirect('index.php');
|
|
|
|
return;
|
|
}
|
|
|
|
$data = json_decode(file_get_contents($otlFile), true);
|
|
|
|
// Always delete the file immediately (one-time use)
|
|
@unlink($otlFile);
|
|
|
|
if (!$data || !hash_equals($data['token'] ?? '', $otlToken))
|
|
{
|
|
$this->app->enqueueMessage('Invalid login token.', 'error');
|
|
$this->app->redirect('index.php');
|
|
|
|
return;
|
|
}
|
|
|
|
if (time() > ($data['expires'] ?? 0))
|
|
{
|
|
$this->app->enqueueMessage('Login token has expired.', 'error');
|
|
$this->app->redirect('index.php');
|
|
|
|
return;
|
|
}
|
|
|
|
$userId = (int) ($data['user_id'] ?? 0);
|
|
|
|
if (!$userId)
|
|
{
|
|
$this->app->enqueueMessage('Invalid user in login token.', 'error');
|
|
$this->app->redirect('index.php');
|
|
|
|
return;
|
|
}
|
|
|
|
// Auto-login the user
|
|
$user = Factory::getUser($userId);
|
|
|
|
if (!$user || $user->block)
|
|
{
|
|
$this->app->enqueueMessage('User not found or blocked.', 'error');
|
|
$this->app->redirect('index.php');
|
|
|
|
return;
|
|
}
|
|
|
|
// Perform login
|
|
$this->app->login([
|
|
'username' => $user->username,
|
|
], ['action' => 'core.login.admin', 'autoregister' => false, 'skip_auth' => true]);
|
|
|
|
Log::add(
|
|
sprintf('Remote login by %s from %s (origin: %s)',
|
|
$user->username,
|
|
$_SERVER['REMOTE_ADDR'] ?? '',
|
|
$data['origin'] ?? 'unknown'
|
|
),
|
|
Log::INFO,
|
|
'mokosuiteclient'
|
|
);
|
|
|
|
$this->app->redirect('index.php');
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Admin Module Self-Healing
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Ensure MokoSuiteClient admin modules are published with correct positions.
|
|
*
|
|
* Runs once per session to self-heal if modules were accidentally
|
|
* unpublished or had their position cleared.
|
|
*/
|
|
private function ensureAdminModulesActive(): void
|
|
{
|
|
$session = \Joomla\CMS\Factory::getSession();
|
|
|
|
if ($session->get('mokosuiteclient.modules_checked', false))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$session->set('mokosuiteclient.modules_checked', true);
|
|
|
|
$modules = [
|
|
'mod_mokosuiteclient_cpanel' => ['position' => 'top', 'title' => 'MokoSuiteClient', 'access' => 6, 'ordering' => 0],
|
|
'mod_mokosuiteclient_menu' => ['position' => 'menu', 'title' => 'MokoSuiteClient Menu', 'access' => 3, 'ordering' => 0],
|
|
'mod_mokosuiteclient_cache' => ['position' => 'status', 'title' => 'MokoSuiteClient Cache Cleaner', 'access' => 3, 'ordering' => 0],
|
|
];
|
|
|
|
try
|
|
{
|
|
$db = \Joomla\CMS\Factory::getDbo();
|
|
$app = \Joomla\CMS\Factory::getApplication();
|
|
|
|
foreach ($modules as $element => $config)
|
|
{
|
|
// Check if extension is installed
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('extension_id')
|
|
->from('#__extensions')
|
|
->where('element = ' . $db->quote($element))
|
|
->where('type = ' . $db->quote('module'))
|
|
);
|
|
if (!(int) $db->loadResult()) continue;
|
|
|
|
// Find existing module instance
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('id, published, position')
|
|
->from('#__modules')
|
|
->where('module = ' . $db->quote($element))
|
|
->where('client_id = 1')
|
|
->setLimit(1)
|
|
);
|
|
$mod = $db->loadObject();
|
|
|
|
$model = $app->bootComponent('com_modules')
|
|
->getMVCFactory()
|
|
->createModel('Module', 'Administrator', ['ignore_request' => true]);
|
|
|
|
if ($mod)
|
|
{
|
|
// Check if repair needed
|
|
$needsFix = (int) $mod->published !== 1 || $mod->position !== $config['position'];
|
|
|
|
if (!$needsFix)
|
|
{
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from('#__modules_menu')
|
|
->where('moduleid = ' . (int) $mod->id)
|
|
);
|
|
$needsFix = (int) $db->loadResult() === 0;
|
|
}
|
|
|
|
if ($needsFix)
|
|
{
|
|
$data = $model->getItem($mod->id)->getProperties();
|
|
$data['published'] = 1;
|
|
$data['position'] = $config['position'];
|
|
$data['ordering'] = $config['ordering'] ?? 0;
|
|
$data['assignment'] = 0;
|
|
$model->save($data);
|
|
}
|
|
|
|
// Ensure module is first in its position
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('MIN(' . $db->quoteName('ordering') . ')')
|
|
->from('#__modules')
|
|
->where($db->quoteName('position') . ' = ' . $db->quote($config['position']))
|
|
->where($db->quoteName('client_id') . ' = 1')
|
|
->where($db->quoteName('id') . ' != ' . (int) $mod->id)
|
|
);
|
|
$minOther = $db->loadResult();
|
|
|
|
if ($minOther !== null)
|
|
{
|
|
// Re-read current ordering for this module
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select($db->quoteName('ordering'))
|
|
->from('#__modules')
|
|
->where($db->quoteName('id') . ' = ' . (int) $mod->id)
|
|
);
|
|
$currentOrdering = (int) $db->loadResult();
|
|
|
|
if ($currentOrdering >= (int) $minOther)
|
|
{
|
|
$newOrdering = (int) $minOther - 1;
|
|
$data = $model->getItem($mod->id)->getProperties();
|
|
$data['ordering'] = $newOrdering;
|
|
$data['assignment'] = 0;
|
|
$model->save($data);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Module instance deleted — recreate it
|
|
$data = [
|
|
'title' => $config['title'],
|
|
'module' => $element,
|
|
'position' => $config['position'],
|
|
'published' => 1,
|
|
'access' => $config['access'],
|
|
'ordering' => $config['ordering'] ?? 0,
|
|
'showtitle' => 0,
|
|
'client_id' => 1,
|
|
'language' => '*',
|
|
'params' => '{}',
|
|
'assignment' => 0,
|
|
];
|
|
$model->save($data);
|
|
}
|
|
}
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
// Silent — don't break the admin if self-heal fails
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Automation Engine Event Hooks (#151)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Fire automation rules for user registration.
|
|
*/
|
|
public function onUserAfterSave($user, $isnew, $success, $msg): void
|
|
{
|
|
if (!$isnew || !$success) return;
|
|
|
|
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::fire('user_register', [
|
|
'user_id' => (int) ($user['id'] ?? 0),
|
|
'username' => $user['username'] ?? '',
|
|
'email' => $user['email'] ?? '',
|
|
'name' => $user['name'] ?? '',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Fire automation rules on article save.
|
|
*/
|
|
public function onContentAfterSave($context, $article, $isNew): void
|
|
{
|
|
// Auto-clear cache on content save (#181)
|
|
$this->autoClearCache();
|
|
|
|
if ($context !== 'com_content.article') return;
|
|
|
|
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::fire('content_save', [
|
|
'article_id' => (int) ($article->id ?? 0),
|
|
'title' => $article->title ?? '',
|
|
'is_new' => $isNew ? '1' : '0',
|
|
'catid' => (int) ($article->catid ?? 0),
|
|
'user_id' => (int) ($article->modified_by ?? $article->created_by ?? 0),
|
|
]);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Security Event Notifications (#147)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Notify on successful admin login.
|
|
*/
|
|
public function onUserAfterLogin($options): void
|
|
{
|
|
if (!($options['user'] ?? null)) return;
|
|
$user = $options['user'];
|
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
|
$name = $user->username ?? $user->name ?? 'unknown';
|
|
|
|
// Fire automation for any login
|
|
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::fire('user_login', [
|
|
'user_id' => (int) ($user->id ?? 0),
|
|
'username' => $name,
|
|
'ip' => $ip,
|
|
'client' => $this->app->isClient('administrator') ? 'admin' : 'site',
|
|
]);
|
|
|
|
// Security notification for backend logins only
|
|
if (!$this->app->isClient('administrator')) return;
|
|
|
|
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::securityAlert(
|
|
'admin_login',
|
|
"Admin login: {$name}",
|
|
"User: {$name}\nIP: {$ip}\nTime: " . gmdate('Y-m-d H:i:s') . " UTC"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Track failed login attempts and notify after threshold.
|
|
*/
|
|
public function onUserLoginFailure($response): void
|
|
{
|
|
if (!$this->app->isClient('administrator')) return;
|
|
|
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
|
$username = $response['username'] ?? 'unknown';
|
|
|
|
// Track in session — notify after 3 failures from same IP
|
|
$session = \Joomla\CMS\Factory::getSession();
|
|
$key = 'mokosuiteclient.login_failures.' . md5($ip);
|
|
$count = (int) $session->get($key, 0) + 1;
|
|
$session->set($key, $count);
|
|
|
|
if ($count >= 3 && $count % 3 === 0)
|
|
{
|
|
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::securityAlert(
|
|
'login_failure',
|
|
"Failed login attempts: {$count} from {$ip}",
|
|
"Username: {$username}\nIP: {$ip}\nAttempts: {$count}\nTime: " . gmdate('Y-m-d H:i:s') . " UTC"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Heartbeat Monitor ─────────────────────────────────────────
|
|
|
|
/**
|
|
* Send heartbeat to MokoSuiteClientHQ when the package version changes.
|
|
* Runs once per session on admin page load.
|
|
*/
|
|
private function checkHeartbeat(): void
|
|
{
|
|
try
|
|
{
|
|
if ($this->params->get('heartbeat_enabled', '1') === '0')
|
|
{
|
|
return;
|
|
}
|
|
|
|
$session = Factory::getSession();
|
|
|
|
if ($session->get('mokosuiteclient.heartbeat_sent', false))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$lastVersion = $this->params->get('_last_heartbeat_version', '');
|
|
$currentVersion = $this->getPluginVersion();
|
|
|
|
if ($lastVersion === $currentVersion)
|
|
{
|
|
return;
|
|
}
|
|
|
|
$session->set('mokosuiteclient.heartbeat_sent', true);
|
|
$this->sendRuntimeHeartbeat();
|
|
|
|
// Persist version marker with a targeted DB update to avoid overwriting
|
|
// params that may have been modified by migrateMonitorParams() in the same request
|
|
$db = Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('params'))
|
|
->from($db->quoteName('#__extensions'))
|
|
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
|
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
|
$freshParams = json_decode((string) $db->setQuery($query)->loadResult(), true) ?: [];
|
|
$freshParams['_last_heartbeat_version'] = $currentVersion;
|
|
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->update($db->quoteName('#__extensions'))
|
|
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($freshParams)))
|
|
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
|
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
|
)->execute();
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
Log::add('Heartbeat check failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send heartbeat data to MokoSuiteClientHQ.
|
|
* RSA-signed: client signs domain|timestamp|token with its private key.
|
|
*/
|
|
private function sendRuntimeHeartbeat(): void
|
|
{
|
|
$baseUrl = rtrim($this->params->get('monitor_base_url', ''), '/');
|
|
|
|
// Fall back to manifest XML default
|
|
if (empty($baseUrl))
|
|
{
|
|
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml';
|
|
|
|
if (is_file($manifestFile))
|
|
{
|
|
$xml = simplexml_load_file($manifestFile);
|
|
|
|
if ($xml)
|
|
{
|
|
foreach ($xml->xpath('//field[@name="monitor_base_url"]') as $field)
|
|
{
|
|
$baseUrl = rtrim((string) $field['default'], '/');
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($baseUrl))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$healthToken = $this->params->get('health_api_token', '');
|
|
|
|
if (empty($healthToken))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$siteUrl = rtrim(Uri::root(), '/');
|
|
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
|
|
|
|
if (empty($domain))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$config = Factory::getConfig();
|
|
$timestamp = time();
|
|
|
|
$devDomain = $this->getDevAliasDomain();
|
|
|
|
$payload = [
|
|
'token' => $healthToken,
|
|
'domain' => $domain,
|
|
'dev_domain' => $devDomain ?: null,
|
|
'site_name' => $config->get('sitename', 'Joomla'),
|
|
'site_url' => $siteUrl,
|
|
'joomla_version' => (new Version())->getShortVersion(),
|
|
'php_version' => PHP_VERSION,
|
|
'mokosuiteclient_version' => $this->getPluginVersion(),
|
|
'timestamp' => $timestamp,
|
|
'client_info' => [
|
|
'company' => $config->get('sitename', ''),
|
|
'email' => $config->get('mailfrom', ''),
|
|
],
|
|
];
|
|
|
|
// Include live health data
|
|
$healthData = $this->fetchLocalHealth($siteUrl, $healthToken);
|
|
|
|
if ($healthData !== null)
|
|
{
|
|
$payload['health'] = $healthData;
|
|
}
|
|
|
|
// RSA sign the request
|
|
$headers = ['Content-Type: application/json'];
|
|
$signature = $this->signHeartbeatRequest($domain, $timestamp, $healthToken);
|
|
|
|
if ($signature !== null)
|
|
{
|
|
$headers[] = 'X-MokoSuite-Signature: ' . $signature;
|
|
$headers[] = 'X-MokoSuite-Timestamp: ' . $timestamp;
|
|
}
|
|
|
|
$endpoint = $baseUrl . '/api/index.php/v1/mokosuitehq/heartbeat';
|
|
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
|
|
|
try
|
|
{
|
|
$http = \Joomla\CMS\Http\HttpFactory::getHttp(
|
|
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
|
|
['curl', 'stream']
|
|
);
|
|
|
|
$headerMap = [];
|
|
|
|
foreach ($headers as $h)
|
|
{
|
|
[$key, $val] = explode(': ', $h, 2);
|
|
$headerMap[$key] = $val;
|
|
}
|
|
|
|
$response = $http->post($endpoint, $json, $headerMap, 15);
|
|
|
|
if ($response->code >= 200 && $response->code < 300)
|
|
{
|
|
$this->app->enqueueMessage('MokoSuiteHQ heartbeat: site registered successfully.', 'message');
|
|
}
|
|
else
|
|
{
|
|
$body = json_decode($response->body, true);
|
|
$msg = $body['error'] ?? $body['message'] ?? ('HTTP ' . $response->code);
|
|
Log::add(
|
|
\sprintf('Heartbeat HTTP %d: %s', $response->code, $response->body),
|
|
Log::WARNING,
|
|
'mokosuiteclient'
|
|
);
|
|
$this->app->enqueueMessage('MokoSuiteHQ heartbeat failed: ' . $msg, 'warning');
|
|
}
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
Log::add('Heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
|
|
private function signHeartbeatRequest(string $domain, int $timestamp, string $token): ?string
|
|
{
|
|
$signingKeyB64 = $this->params->get('monitor_signing_key', '');
|
|
|
|
// Fall back to manifest XML default
|
|
if (empty($signingKeyB64))
|
|
{
|
|
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml';
|
|
|
|
if (is_file($manifestFile))
|
|
{
|
|
$xml = simplexml_load_file($manifestFile);
|
|
|
|
if ($xml)
|
|
{
|
|
foreach ($xml->xpath('//field[@name="monitor_signing_key"]') as $field)
|
|
{
|
|
$signingKeyB64 = (string) $field['default'];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($signingKeyB64))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
$privateKeyPem = base64_decode($signingKeyB64);
|
|
|
|
if (empty($privateKeyPem))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
$privateKey = openssl_pkey_get_private($privateKeyPem);
|
|
|
|
if ($privateKey === false)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
$message = $domain . '|' . $timestamp . '|' . $token;
|
|
$signature = '';
|
|
|
|
if (openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256))
|
|
{
|
|
return base64_encode($signature);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array
|
|
{
|
|
try
|
|
{
|
|
$http = \Joomla\CMS\Http\HttpFactory::getHttp(
|
|
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
|
|
['curl', 'stream']
|
|
);
|
|
|
|
$response = $http->get(
|
|
$siteUrl . '/?mokosuiteclient=health',
|
|
['Authorization' => 'Bearer ' . $healthToken, 'Accept' => 'application/json'],
|
|
10
|
|
);
|
|
|
|
if ($response->code !== 200 || empty($response->body))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return json_decode($response->body, true) ?: null;
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
Log::add('Local health fetch failed: ' . $e->getMessage(), Log::DEBUG, 'mokosuiteclient');
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Cache Auto-Clear (#181)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Clear all Joomla cache groups when the auto_clear_cache param is enabled.
|
|
*
|
|
* Called from onContentAfterSave and onExtensionAfterSave so that
|
|
* front-end visitors always see fresh content after an admin save.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.47.50
|
|
*/
|
|
private function autoClearCache(): void
|
|
{
|
|
if (!$this->params->get('auto_clear_cache', 0))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
$cacheController = \Joomla\CMS\Cache\Cache::getInstance('', ['defaultgroup' => '']);
|
|
$cacheController->clean('');
|
|
|
|
Log::add('Cache auto-cleared on save.', Log::DEBUG, 'mokosuiteclient');
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
// Silent — never break save operations
|
|
Log::add('Cache auto-clear failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Advanced Module Manager — Conditions-based filtering (#160)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Filter the site module list based on ConditionsHelper rules.
|
|
*
|
|
* Modules that have no condition mappings pass through unchanged.
|
|
* Modules with condition sets are evaluated; only those whose
|
|
* conditions are satisfied for the current request are kept.
|
|
*
|
|
* @param array|null &$modules The list of module objects Joomla will render.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.47.52
|
|
*/
|
|
public function onPrepareModuleList(?array &$modules): void
|
|
{
|
|
if ($modules === null || !$this->getApplication()->isClient('site'))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Only filter if the conditions map table exists
|
|
try
|
|
{
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$tables = $db->getTableList();
|
|
$prefix = $db->getPrefix();
|
|
|
|
if (!\in_array($prefix . 'mokosuiteclient_conditions_map', $tables, true))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
return;
|
|
}
|
|
|
|
$filtered = [];
|
|
|
|
foreach ($modules as $module)
|
|
{
|
|
$moduleId = (int) ($module->id ?? 0);
|
|
|
|
if ($moduleId <= 0)
|
|
{
|
|
$filtered[] = $module;
|
|
continue;
|
|
}
|
|
|
|
// ConditionsHelper::shouldDisplay returns true when no conditions
|
|
// are mapped (module shows everywhere) or when conditions pass.
|
|
if (\Moko\Component\MokoSuiteClient\Administrator\Helper\ConditionsHelper::shouldDisplay('com_modules', $moduleId))
|
|
{
|
|
$filtered[] = $module;
|
|
}
|
|
}
|
|
|
|
$modules = $filtered;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Conditional Content Tags ({show}/{hide})
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Process conditional content tags in article/content body.
|
|
*
|
|
* @param string $context The context of the content being passed.
|
|
* @param object $article The article object.
|
|
* @param object $params The article params.
|
|
* @param int $page The page number.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
public function onContentPrepare($context, &$article, &$params, $page = 0): void
|
|
{
|
|
if ($context === 'com_finder.indexer')
|
|
{
|
|
return;
|
|
}
|
|
|
|
$isSite = $this->getApplication()->isClient('site');
|
|
$isAdmin = $this->getApplication()->isClient('administrator');
|
|
$area = $isAdmin ? 'admin' : 'site';
|
|
|
|
$text = $article->text ?? ($article->introtext ?? '');
|
|
|
|
if ($text === '')
|
|
{
|
|
return;
|
|
}
|
|
|
|
$modified = false;
|
|
|
|
// Conditional tags (site only).
|
|
if ($isSite)
|
|
{
|
|
$hasConditional = (strpos($text, '{show') !== false || strpos($text, '{hide') !== false);
|
|
$hasSnippet = (stripos($text, '{snippet') !== false);
|
|
|
|
if ($hasConditional)
|
|
{
|
|
$this->processConditionalTags($text);
|
|
$modified = true;
|
|
}
|
|
|
|
if ($hasSnippet && (int) $this->params->get('snippets_enabled', 0) === 1 && $this->snippetsTableExists())
|
|
{
|
|
$this->processSnippetTags($text);
|
|
$modified = true;
|
|
}
|
|
|
|
if (stripos($text, '{template') !== false
|
|
&& (int) $this->params->get('content_templates_enabled', 0) === 1
|
|
&& $this->contentTemplatesTableExists())
|
|
{
|
|
$this->processTemplateTags($text);
|
|
$modified = true;
|
|
}
|
|
|
|
if (stripos($text, '{article') !== false && (int) $this->params->get('articles_anywhere_enabled', 0) === 1)
|
|
{
|
|
$this->processArticleTags($text);
|
|
$modified = true;
|
|
}
|
|
|
|
if (strpos($text, '{source') !== false)
|
|
{
|
|
$before = $text;
|
|
$this->processSourceTags($text);
|
|
|
|
if ($text !== $before)
|
|
{
|
|
$modified = true;
|
|
}
|
|
}
|
|
|
|
if (stripos($text, '{user') !== false)
|
|
{
|
|
$before = $text;
|
|
$this->processUserTags($text);
|
|
|
|
if ($text !== $before)
|
|
{
|
|
$modified = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ReReplacer rules.
|
|
$before = $text;
|
|
$this->processReplacements($text, $area);
|
|
|
|
if ($text !== $before)
|
|
{
|
|
$modified = true;
|
|
}
|
|
|
|
if (!$modified)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Write back to whichever property was populated.
|
|
if (isset($article->text))
|
|
{
|
|
$article->text = $text;
|
|
}
|
|
else
|
|
{
|
|
$article->introtext = $text;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process conditional content tags in the final rendered HTML body.
|
|
*
|
|
* Catches tags in modules, template overrides, and other places that
|
|
* onContentPrepare does not reach.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
public function onAfterRender(): void
|
|
{
|
|
$isSite = $this->getApplication()->isClient('site');
|
|
$isAdmin = $this->getApplication()->isClient('administrator');
|
|
$area = $isAdmin ? 'admin' : 'site';
|
|
|
|
$body = $this->getApplication()->getBody();
|
|
|
|
if ($body === '')
|
|
{
|
|
return;
|
|
}
|
|
|
|
$changed = false;
|
|
|
|
// Conditional tags and snippets (site only).
|
|
if ($isSite)
|
|
{
|
|
if (strpos($body, '{show') !== false || strpos($body, '{hide') !== false)
|
|
{
|
|
$this->processConditionalTags($body);
|
|
$changed = true;
|
|
}
|
|
|
|
if (stripos($body, '{snippet') !== false
|
|
&& (int) $this->params->get('snippets_enabled', 0) === 1
|
|
&& $this->snippetsTableExists())
|
|
{
|
|
$this->processSnippetTags($body);
|
|
$changed = true;
|
|
}
|
|
|
|
if (stripos($body, '{template') !== false
|
|
&& (int) $this->params->get('content_templates_enabled', 0) === 1
|
|
&& $this->contentTemplatesTableExists())
|
|
{
|
|
$this->processTemplateTags($body);
|
|
$changed = true;
|
|
}
|
|
|
|
if (stripos($body, '{article') !== false
|
|
&& (int) $this->params->get('articles_anywhere_enabled', 0) === 1)
|
|
{
|
|
$this->processArticleTags($body);
|
|
$changed = true;
|
|
}
|
|
|
|
if (stripos($body, '{user') !== false)
|
|
{
|
|
$before = $body;
|
|
$this->processUserTags($body);
|
|
|
|
if ($body !== $before)
|
|
{
|
|
$changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ReReplacer rules.
|
|
$before = $body;
|
|
$this->processReplacements($body, $area);
|
|
|
|
if ($body !== $before)
|
|
{
|
|
$changed = true;
|
|
}
|
|
|
|
// Email protection (site only).
|
|
if ($isSite)
|
|
{
|
|
$this->protectEmails($body, $changed);
|
|
}
|
|
|
|
if ($changed)
|
|
{
|
|
$this->getApplication()->setBody($body);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Obfuscate email addresses in HTML output to prevent spam bot harvesting.
|
|
*
|
|
* Replaces mailto: links and plain-text email addresses with `<span>` placeholders
|
|
* carrying base64-encoded data attributes. A small inline script reconstructs the
|
|
* addresses client-side so they are invisible to naive scrapers.
|
|
*
|
|
* @param string &$html The full response body (modified in place).
|
|
* @param bool &$changed Set to true when any replacement is made.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function protectEmails(string &$html, bool &$changed): void
|
|
{
|
|
if (!$this->params->get('protect_emails', 0))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Replace mailto: links first.
|
|
$html = preg_replace_callback(
|
|
'/<a\s([^>]*?)href=["\']mailto:([^"\'?]+)([^"\']*)["\']([^>]*)>(.*?)<\/a>/si',
|
|
function ($m) use (&$changed) {
|
|
$changed = true;
|
|
$encoded = base64_encode($m[2]);
|
|
$encodedText = base64_encode($m[5]);
|
|
|
|
return '<span class="mokosuite-ep" data-e="' . $encoded
|
|
. '" data-t="' . $encodedText
|
|
. '" data-q="' . base64_encode($m[3])
|
|
. '">[email protected]</span>';
|
|
},
|
|
$html
|
|
);
|
|
|
|
// Replace plain email addresses not already inside data attributes or script tags.
|
|
$html = preg_replace_callback(
|
|
'/(?<!="|data-[a-z]+=")([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})/',
|
|
function ($m) use (&$changed) {
|
|
$changed = true;
|
|
|
|
return '<span class="mokosuite-ep" data-e="' . base64_encode($m[1])
|
|
. '">[email protected]</span>';
|
|
},
|
|
$html
|
|
);
|
|
|
|
// Inject decloaking script before </body> (once).
|
|
if (strpos($html, 'mokosuite-ep') !== false && strpos($html, 'mokosuite-ep-init') === false)
|
|
{
|
|
$script = '<script id="mokosuite-ep-init">'
|
|
. 'document.addEventListener("DOMContentLoaded",function(){'
|
|
. 'document.querySelectorAll(".mokosuite-ep").forEach(function(el){'
|
|
. 'var e=atob(el.dataset.e);'
|
|
. 'if(el.dataset.t){'
|
|
. 'var t=atob(el.dataset.t);'
|
|
. 'var q=el.dataset.q?atob(el.dataset.q):"";'
|
|
. 'var a=document.createElement("a");'
|
|
. 'a.href="mailto:"+e+q;'
|
|
. 'a.innerHTML=t;'
|
|
. 'el.replaceWith(a)'
|
|
. '}else{'
|
|
. 'el.textContent=e'
|
|
. '}'
|
|
. '})'
|
|
. '});</script>';
|
|
|
|
$html = str_replace('</body>', $script . '</body>', $html);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process {show} and {hide} conditional content tags.
|
|
*
|
|
* Supported syntax:
|
|
* {show condition="alias_or_id"}content{/show}
|
|
* {hide condition="alias_or_id"}content{/hide}
|
|
* {show condition="alias"}shown{else}hidden{/show}
|
|
* {show access_level="1,2"}content{/show}
|
|
* {show user_group="8"}content{/show}
|
|
* {show menu_item="101,102"}content{/show}
|
|
* {show home_page="1"}content{/show}
|
|
*
|
|
* @param string &$text The text to process (modified in place).
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function processConditionalTags(string &$text): void
|
|
{
|
|
// Process innermost tags first (handles nesting by repeating).
|
|
$maxIterations = 10;
|
|
$iteration = 0;
|
|
|
|
while ($iteration < $maxIterations
|
|
&& (strpos($text, '{show') !== false || strpos($text, '{hide') !== false))
|
|
{
|
|
$pattern = '#\{(show|hide)\s+([^}]*)\}(.*?)(?:\{else\}(.*?))?\{/\1\}#si';
|
|
$self = $this;
|
|
|
|
$newText = preg_replace_callback($pattern, function ($matches) use ($self) {
|
|
$tag = strtolower($matches[1]); // 'show' or 'hide'
|
|
$attributes = $matches[2];
|
|
$content = $matches[3];
|
|
$elseBlock = $matches[4] ?? '';
|
|
|
|
$passes = $self->evaluateTagCondition($attributes);
|
|
|
|
// For {hide}, invert the logic.
|
|
if ($tag === 'hide')
|
|
{
|
|
$passes = !$passes;
|
|
}
|
|
|
|
return $passes ? $content : $elseBlock;
|
|
}, $text);
|
|
|
|
// If nothing changed, stop iterating.
|
|
if ($newText === $text)
|
|
{
|
|
break;
|
|
}
|
|
|
|
$text = $newText;
|
|
$iteration++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Evaluate the condition specified by tag attributes.
|
|
*
|
|
* @param string $attributes The raw attribute string from inside the tag.
|
|
*
|
|
* @return bool True if the condition passes.
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function evaluateTagCondition(string $attributes): bool
|
|
{
|
|
$helper = \Moko\Component\MokoSuiteClient\Administrator\Helper\ConditionsHelper::class;
|
|
|
|
// Parse all key="value" pairs from the attribute string.
|
|
$attrs = [];
|
|
|
|
if (preg_match_all('/(\w+)\s*=\s*"([^"]*)"/', $attributes, $attrMatches, PREG_SET_ORDER))
|
|
{
|
|
foreach ($attrMatches as $m)
|
|
{
|
|
$attrs[strtolower($m[1])] = $m[2];
|
|
}
|
|
}
|
|
|
|
// 1. Saved condition reference: condition="alias_or_id"
|
|
if (!empty($attrs['condition']))
|
|
{
|
|
$ref = trim($attrs['condition']);
|
|
|
|
return $helper::passByAlias($ref);
|
|
}
|
|
|
|
// 2. Inline rules — map attribute names to rule types.
|
|
$inlineMap = [
|
|
'access_level' => 'visitor__access_level',
|
|
'user_group' => 'visitor__user_group',
|
|
'menu_item' => 'menu__menu_item',
|
|
'home_page' => 'menu__home_page',
|
|
'date' => 'date__date',
|
|
'day' => 'date__day',
|
|
'url' => 'other__url',
|
|
];
|
|
|
|
// Evaluate all inline attributes; ALL must pass (AND logic).
|
|
$hasInline = false;
|
|
|
|
foreach ($inlineMap as $attrName => $ruleType)
|
|
{
|
|
if (!isset($attrs[$attrName]))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$hasInline = true;
|
|
$rawValue = $attrs[$attrName];
|
|
|
|
$params = $this->buildInlineRuleParams($ruleType, $rawValue);
|
|
|
|
if (!$helper::evaluateInlineRule($ruleType, $params))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// If we processed at least one inline rule and none failed, pass.
|
|
if ($hasInline)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// No recognised attributes — default to not showing.
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Parse key="value" attribute pairs from a tag attribute string.
|
|
*
|
|
* @param string $str The raw attribute string (e.g. 'alias="foo" color="red"').
|
|
*
|
|
* @return array Associative array of attribute key => value pairs.
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function parseTagAttributes(string $str): array
|
|
{
|
|
$attrs = [];
|
|
|
|
preg_match_all('/(\w+)\s*=\s*"([^"]*)"/', $str, $matches, PREG_SET_ORDER);
|
|
|
|
foreach ($matches as $m)
|
|
{
|
|
$attrs[$m[1]] = $m[2];
|
|
}
|
|
|
|
return $attrs;
|
|
}
|
|
|
|
/**
|
|
* Process {snippet alias="..."} content tags.
|
|
*
|
|
* Loads the snippet content from the database, performs variable
|
|
* substitution for any extra attributes passed as {$varname}, and
|
|
* supports nested snippets up to a configurable depth.
|
|
*
|
|
* @param string &$text The text to process (modified in place).
|
|
* @param int $depth Current recursion depth (prevents infinite loops).
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function processSnippetTags(string &$text, int $depth = 0): void
|
|
{
|
|
if ($depth > 5)
|
|
{
|
|
return; // prevent infinite recursion
|
|
}
|
|
|
|
// Match {snippet alias="my-snippet" var1="value1" var2="value2"}
|
|
$pattern = '#\{snippet\s+([^}]+)\}#i';
|
|
|
|
$text = preg_replace_callback($pattern, function ($match) use ($depth) {
|
|
$attrs = $this->parseTagAttributes($match[1]);
|
|
$alias = $attrs['alias'] ?? $attrs['id'] ?? '';
|
|
|
|
if (empty($alias))
|
|
{
|
|
return $match[0];
|
|
}
|
|
|
|
// Load snippet from DB.
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('content'))
|
|
->from($db->quoteName('#__mokosuiteclient_snippets'))
|
|
->where($db->quoteName('published') . ' = 1');
|
|
|
|
if (is_numeric($alias))
|
|
{
|
|
$query->where($db->quoteName('id') . ' = ' . (int) $alias);
|
|
}
|
|
else
|
|
{
|
|
$query->where($db->quoteName('alias') . ' = ' . $db->quote($alias));
|
|
}
|
|
|
|
$db->setQuery($query);
|
|
$content = $db->loadResult();
|
|
|
|
if ($content === null)
|
|
{
|
|
return ''; // snippet not found
|
|
}
|
|
|
|
// Replace variables: {$varname} with attribute values.
|
|
unset($attrs['alias'], $attrs['id']);
|
|
|
|
foreach ($attrs as $key => $val)
|
|
{
|
|
$content = str_replace('{$' . $key . '}', $val, $content);
|
|
}
|
|
|
|
// Process nested snippets.
|
|
$this->processSnippetTags($content, $depth + 1);
|
|
|
|
return $content;
|
|
}, $text);
|
|
}
|
|
|
|
/**
|
|
* Check whether the snippets DB table exists.
|
|
*
|
|
* @return bool
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function snippetsTableExists(): bool
|
|
{
|
|
static $exists = null;
|
|
|
|
if ($exists !== null)
|
|
{
|
|
return $exists;
|
|
}
|
|
|
|
try
|
|
{
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$tables = $db->getTableList();
|
|
$prefix = $db->getPrefix();
|
|
$exists = \in_array($prefix . 'mokosuiteclient_snippets', $tables, true);
|
|
}
|
|
catch (\Exception $e)
|
|
{
|
|
$exists = false;
|
|
}
|
|
|
|
return $exists;
|
|
}
|
|
|
|
/**
|
|
* Process {article id="42"}template{/article} content tags.
|
|
*
|
|
* Loads a Joomla article by id, alias, or title and replaces data
|
|
* placeholders inside the template body. Supports date formatting
|
|
* via [created format="..."] and introtext truncation via
|
|
* [introtext limit="N"].
|
|
*
|
|
* @param string &$text The text to process (modified in place).
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function processArticleTags(string &$text): void
|
|
{
|
|
if (!$this->params->get('articles_anywhere_enabled', 0))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Match {article id="42" ...}template{/article}
|
|
$pattern = '#\{article\s+([^}]+)\}(.*?)\{/article\}#si';
|
|
|
|
$text = preg_replace_callback($pattern, function ($match) {
|
|
$attrs = $this->parseTagAttributes($match[1]);
|
|
$template = $match[2];
|
|
|
|
// Load article by id, alias, or title.
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$query = $db->getQuery(true)
|
|
->select('a.*, c.title AS category_title, u.name AS author_name, u.username AS author_username')
|
|
->from($db->quoteName('#__content', 'a'))
|
|
->leftJoin($db->quoteName('#__categories', 'c') . ' ON c.id = a.catid')
|
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.created_by')
|
|
->where($db->quoteName('a.state') . ' = 1');
|
|
|
|
if (!empty($attrs['id']))
|
|
{
|
|
$query->where($db->quoteName('a.id') . ' = ' . (int) $attrs['id']);
|
|
}
|
|
elseif (!empty($attrs['alias']))
|
|
{
|
|
$query->where($db->quoteName('a.alias') . ' = ' . $db->quote($attrs['alias']));
|
|
}
|
|
elseif (!empty($attrs['title']))
|
|
{
|
|
$query->where($db->quoteName('a.title') . ' = ' . $db->quote($attrs['title']));
|
|
}
|
|
else
|
|
{
|
|
return $match[0];
|
|
}
|
|
|
|
$db->setQuery($query);
|
|
$article = $db->loadObject();
|
|
|
|
if (!$article)
|
|
{
|
|
return '';
|
|
}
|
|
|
|
// Replace data tags.
|
|
$images = json_decode($article->images ?? '{}');
|
|
$replacements = [
|
|
'[title]' => $article->title ?? '',
|
|
'[introtext]' => $article->introtext ?? '',
|
|
'[fulltext]' => $article->fulltext ?? '',
|
|
'[text]' => ($article->introtext ?? '') . ($article->fulltext ?? ''),
|
|
'[author]' => $article->author_name ?? '',
|
|
'[author_username]' => $article->author_username ?? '',
|
|
'[category]' => $article->category_title ?? '',
|
|
'[created]' => $article->created ?? '',
|
|
'[modified]' => $article->modified ?? '',
|
|
'[publish_up]' => $article->publish_up ?? '',
|
|
'[id]' => $article->id ?? '',
|
|
'[alias]' => $article->alias ?? '',
|
|
'[catid]' => $article->catid ?? '',
|
|
'[hits]' => $article->hits ?? 0,
|
|
'[image-intro]' => $images->image_intro ?? '',
|
|
'[image-fulltext]' => $images->image_fulltext ?? '',
|
|
'[metadesc]' => $article->metadesc ?? '',
|
|
'[metakey]' => $article->metakey ?? '',
|
|
];
|
|
|
|
$output = $template;
|
|
|
|
foreach ($replacements as $tag => $value)
|
|
{
|
|
$output = str_replace($tag, $value, $output);
|
|
}
|
|
|
|
// Handle [introtext limit="N"] truncation.
|
|
$output = preg_replace_callback('/\[introtext\s+limit="(\d+)"\]/', function ($m) use ($article) {
|
|
return \Joomla\CMS\HTML\HTMLHelper::_('string.truncate', strip_tags($article->introtext ?? ''), (int) $m[1]);
|
|
}, $output);
|
|
|
|
// Handle [created format="d M Y"] date formatting.
|
|
$output = preg_replace_callback('/\[created\s+format="([^"]+)"\]/', function ($m) use ($article) {
|
|
return date($m[1], strtotime($article->created ?? 'now'));
|
|
}, $output);
|
|
|
|
// Handle [modified format="d M Y"] date formatting.
|
|
$output = preg_replace_callback('/\[modified\s+format="([^"]+)"\]/', function ($m) use ($article) {
|
|
return date($m[1], strtotime($article->modified ?? 'now'));
|
|
}, $output);
|
|
|
|
return $output;
|
|
}, $text);
|
|
}
|
|
|
|
/**
|
|
* Build a params object for an inline rule from the raw attribute value.
|
|
*
|
|
* @param string $ruleType The rule type identifier.
|
|
* @param string $rawValue The raw comma-separated value from the tag attribute.
|
|
*
|
|
* @return object A params object suitable for ConditionsHelper::evaluateInlineRule().
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function buildInlineRuleParams(string $ruleType, string $rawValue): object
|
|
{
|
|
$params = new \stdClass();
|
|
|
|
switch ($ruleType)
|
|
{
|
|
case 'visitor__access_level':
|
|
case 'visitor__user_group':
|
|
case 'menu__menu_item':
|
|
case 'date__day':
|
|
// Comma-separated list of IDs.
|
|
$params->selection = array_map('trim', explode(',', $rawValue));
|
|
$params->comparison = 'any';
|
|
break;
|
|
|
|
case 'menu__home_page':
|
|
// Single boolean-like value: "1" or "0".
|
|
$params->selection = [trim($rawValue)];
|
|
break;
|
|
|
|
case 'date__date':
|
|
// Supports "after:2026-01-01", "before:2026-12-31",
|
|
// "between:2026-01-01,2026-12-31", or plain date (defaults to 'after').
|
|
$parts = explode(':', $rawValue, 2);
|
|
|
|
if (\count($parts) === 2 && \in_array($parts[0], ['before', 'after', 'between'], true))
|
|
{
|
|
$params->comparison = $parts[0];
|
|
$params->selection = array_map('trim', explode(',', $parts[1]));
|
|
}
|
|
else
|
|
{
|
|
$params->comparison = 'after';
|
|
$params->selection = array_map('trim', explode(',', $rawValue));
|
|
}
|
|
|
|
break;
|
|
|
|
case 'other__url':
|
|
// Comma-separated regex patterns.
|
|
$params->selection = array_map('trim', explode(',', $rawValue));
|
|
break;
|
|
|
|
default:
|
|
$params->selection = array_map('trim', explode(',', $rawValue));
|
|
break;
|
|
}
|
|
|
|
return $params;
|
|
}
|
|
|
|
/**
|
|
* Apply backend-managed string/regex replacement rules to content.
|
|
*
|
|
* Loads published rules from `#__mokosuiteclient_replacements` filtered
|
|
* by area (site / admin / both) and applies them in ordering sequence.
|
|
* Content wrapped in `{noreplace}…{/noreplace}` is shielded from changes.
|
|
*
|
|
* @param string &$text The text to process (modified in place).
|
|
* @param string $area Current application area: 'site' or 'admin'.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function processReplacements(string &$text, string $area = 'site'): void
|
|
{
|
|
if (!$this->params->get('replacements_enabled', 0))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
$db = \Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
|
|
// Check table exists.
|
|
$tables = $db->getTableList();
|
|
|
|
if (!in_array($db->getPrefix() . 'mokosuiteclient_replacements', $tables))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_replacements'))
|
|
->where($db->quoteName('published') . ' = 1')
|
|
->where('(' . $db->quoteName('area') . ' = ' . $db->quote('both')
|
|
. ' OR ' . $db->quoteName('area') . ' = ' . $db->quote($area) . ')')
|
|
->order($db->quoteName('ordering') . ' ASC');
|
|
$db->setQuery($query);
|
|
$rules = $db->loadObjectList() ?: [];
|
|
|
|
foreach ($rules as $rule)
|
|
{
|
|
if (empty($rule->search))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Skip {noreplace}...{/noreplace} blocks.
|
|
$protected = [];
|
|
$text = preg_replace_callback(
|
|
'#\{noreplace\}(.*?)\{/noreplace\}#si',
|
|
function ($m) use (&$protected) {
|
|
$key = '<!--MOKO_NR_' . count($protected) . '-->';
|
|
$protected[$key] = $m[1];
|
|
|
|
return $key;
|
|
},
|
|
$text
|
|
);
|
|
|
|
if ($rule->regex)
|
|
{
|
|
$flags = $rule->casesensitive ? '' : 'i';
|
|
$text = @preg_replace('/' . $rule->search . '/' . $flags . 's', $rule->replace_value, $text);
|
|
}
|
|
else
|
|
{
|
|
if ($rule->casesensitive)
|
|
{
|
|
$text = str_replace($rule->search, $rule->replace_value, $text);
|
|
}
|
|
else
|
|
{
|
|
$text = str_ireplace($rule->search, $rule->replace_value, $text);
|
|
}
|
|
}
|
|
|
|
// Restore protected blocks.
|
|
if (!empty($protected))
|
|
{
|
|
$text = str_replace(array_keys($protected), array_values($protected), $text);
|
|
}
|
|
}
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
// Silently fail — replacement processing must never break rendering.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process {source}...{/source} tags to embed PHP, JS, and CSS in content.
|
|
*
|
|
* PHP blocks are executed via eval() with a configurable forbidden-function
|
|
* blacklist. Remaining HTML/JS/CSS passes through verbatim.
|
|
*
|
|
* @param string &$text The content text (modified in-place).
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function processSourceTags(string &$text): void
|
|
{
|
|
if (!$this->params->get('sourcerer_enabled', 0))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Match {source}code{/source}
|
|
$pattern = '#\{source\}(.*?)\{/source\}#si';
|
|
|
|
$text = preg_replace_callback($pattern, function ($match) {
|
|
$code = $match[1];
|
|
$output = '';
|
|
|
|
// Extract and process PHP blocks.
|
|
if (preg_match_all('/<\?php(.*?)\?>/si', $code, $phpMatches))
|
|
{
|
|
// Security: check forbidden functions.
|
|
$forbidden = array_map(
|
|
'trim',
|
|
explode(',', $this->params->get('sourcerer_forbidden_functions', 'exec,system,passthru,shell_exec,popen,proc_open,dl,eval'))
|
|
);
|
|
|
|
foreach ($phpMatches[1] as $phpCode)
|
|
{
|
|
// Check for forbidden functions.
|
|
$blocked = false;
|
|
|
|
foreach ($forbidden as $func)
|
|
{
|
|
if (!empty($func) && preg_match('/\b' . preg_quote($func, '/') . '\s*\(/i', $phpCode))
|
|
{
|
|
$blocked = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$blocked)
|
|
{
|
|
ob_start();
|
|
|
|
try
|
|
{
|
|
eval($phpCode);
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
// Silent — source tag execution must never break rendering.
|
|
}
|
|
|
|
$output .= ob_get_clean();
|
|
}
|
|
}
|
|
|
|
// Remove PHP blocks from remaining code.
|
|
$code = preg_replace('/<\?php.*?\?>/si', '', $code);
|
|
}
|
|
|
|
// Remaining code (HTML/JS/CSS) passes through.
|
|
$output .= $code;
|
|
|
|
return $output;
|
|
}, $text);
|
|
}
|
|
|
|
/**
|
|
* Process {user} content tags to render user data.
|
|
*
|
|
* Supports two patterns:
|
|
* 1. {user id="42"}[name] - [email]{/user} — specific user with template
|
|
* 2. {user name} — current logged-in user field
|
|
*
|
|
* Template placeholders: [name], [username], [email], [id],
|
|
* [registerDate], [lastvisitDate], [block]
|
|
*
|
|
* @param string &$text The text to process (modified in place).
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function processUserTags(string &$text): void
|
|
{
|
|
if (!$this->params->get('users_anywhere_enabled', 0))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Pattern 1: {user id="X"}template{/user} — specific user with template.
|
|
$pattern1 = '#\{user\s+([^}]*(?:id|username|email)=[^}]+)\}(.*?)\{/user\}#si';
|
|
|
|
$text = preg_replace_callback($pattern1, function ($match) {
|
|
$attrs = $this->parseTagAttributes($match[1]);
|
|
$template = $match[2];
|
|
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$query = $db->getQuery(true)
|
|
->select('u.*')
|
|
->from($db->quoteName('#__users', 'u'));
|
|
|
|
if (!empty($attrs['id']))
|
|
{
|
|
$query->where($db->quoteName('u.id') . ' = ' . (int) $attrs['id']);
|
|
}
|
|
elseif (!empty($attrs['username']))
|
|
{
|
|
$query->where($db->quoteName('u.username') . ' = ' . $db->quote($attrs['username']));
|
|
}
|
|
elseif (!empty($attrs['email']))
|
|
{
|
|
$query->where($db->quoteName('u.email') . ' = ' . $db->quote($attrs['email']));
|
|
}
|
|
else
|
|
{
|
|
return $match[0];
|
|
}
|
|
|
|
$db->setQuery($query);
|
|
$user = $db->loadObject();
|
|
|
|
if (!$user)
|
|
{
|
|
return '';
|
|
}
|
|
|
|
// Security: optionally hide email/username.
|
|
$allowEmail = $this->params->get('users_allow_email', 0);
|
|
$allowUsername = $this->params->get('users_allow_username', 1);
|
|
|
|
$output = str_replace(
|
|
['[name]', '[username]', '[email]', '[id]', '[registerDate]', '[lastvisitDate]', '[block]'],
|
|
[
|
|
$user->name ?? '',
|
|
$allowUsername ? ($user->username ?? '') : '***',
|
|
$allowEmail ? ($user->email ?? '') : '***',
|
|
$user->id ?? '',
|
|
$user->registerDate ?? '',
|
|
$user->lastvisitDate ?? '',
|
|
$user->block ?? 0,
|
|
],
|
|
$template
|
|
);
|
|
|
|
return $output;
|
|
}, $text);
|
|
|
|
// Pattern 2: {user name}, {user email} etc. for CURRENT logged-in user.
|
|
$pattern2 = '#\{user\s+(name|username|email|id|registerDate|lastvisitDate)\}#i';
|
|
|
|
$text = preg_replace_callback($pattern2, function ($match) {
|
|
$field = strtolower($match[1]);
|
|
$user = Factory::getApplication()->getIdentity();
|
|
|
|
if (!$user || $user->guest)
|
|
{
|
|
return '';
|
|
}
|
|
|
|
$allowEmail = $this->params->get('users_allow_email', 0);
|
|
$allowUsername = $this->params->get('users_allow_username', 1);
|
|
|
|
return match ($field) {
|
|
'name' => $user->name,
|
|
'username' => $allowUsername ? $user->username : '***',
|
|
'email' => $allowEmail ? $user->email : '***',
|
|
'id' => (string) $user->id,
|
|
'registerdate' => $user->registerDate,
|
|
'lastvisitdate' => $user->lastvisitDate,
|
|
default => '',
|
|
};
|
|
}, $text);
|
|
}
|
|
|
|
/**
|
|
* Process {template alias="..."} content tags.
|
|
*
|
|
* Loads a content template from the database, decodes the JSON
|
|
* `template_data` column, and returns the concatenation of its
|
|
* `introtext` and `fulltext` fields.
|
|
*
|
|
* @param string &$text The text to process (modified in place).
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function processTemplateTags(string &$text): void
|
|
{
|
|
if (!$this->params->get('content_templates_enabled', 0))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$pattern = '#\{template\s+([^}]+)\}#i';
|
|
|
|
$text = preg_replace_callback($pattern, function ($match) {
|
|
$attrs = $this->parseTagAttributes($match[1]);
|
|
$alias = $attrs['alias'] ?? $attrs['id'] ?? '';
|
|
|
|
if (empty($alias))
|
|
{
|
|
return $match[0];
|
|
}
|
|
|
|
try
|
|
{
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('template_data'))
|
|
->from($db->quoteName('#__mokosuiteclient_content_templates'))
|
|
->where($db->quoteName('published') . ' = 1');
|
|
|
|
if (is_numeric($alias))
|
|
{
|
|
$query->where($db->quoteName('id') . ' = ' . (int) $alias);
|
|
}
|
|
else
|
|
{
|
|
$query->where($db->quoteName('alias') . ' = ' . $db->quote($alias));
|
|
}
|
|
|
|
$db->setQuery($query);
|
|
$data = json_decode($db->loadResult() ?? '{}');
|
|
|
|
return ($data->introtext ?? '') . ($data->fulltext ?? '');
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
return '';
|
|
}
|
|
}, $text);
|
|
}
|
|
|
|
/**
|
|
* Check whether the content_templates DB table exists.
|
|
*
|
|
* @return bool
|
|
*
|
|
* @since 02.48.00
|
|
*/
|
|
private function contentTemplatesTableExists(): bool
|
|
{
|
|
static $exists = null;
|
|
|
|
if ($exists !== null)
|
|
{
|
|
return $exists;
|
|
}
|
|
|
|
try
|
|
{
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$tables = $db->getTableList();
|
|
$prefix = $db->getPrefix();
|
|
$exists = \in_array($prefix . 'mokosuiteclient_content_templates', $tables, true);
|
|
}
|
|
catch (\Exception $e)
|
|
{
|
|
$exists = false;
|
|
}
|
|
|
|
return $exists;
|
|
}
|
|
}
|