Files
MokoSuiteClient/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php
T
2026-06-23 19:27:16 +00:00

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;
}
}