Files
MokoSuiteClient/source/script.php
T
Jonathan Miller ab21d17563
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 10s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 22s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 35s
Generic: Project CI / Lint & Validate (pull_request) Successful in 35s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 38s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 40s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 33s
fix(install): skip enablePlugin when plugin files not on disk
Prevents enabling plugins whose extension record exists in the DB
but whose files were never deployed (e.g. backup bridge when package
build fails). Avoids fatal class-not-found errors on next page load.
2026-06-20 23:58:41 -05:00

1985 lines
61 KiB
PHP

<?php
/**
* @package MokoSuiteClient
* @subpackage pkg_mokosuiteclient
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Installer\InstallerAdapter;
use Joomla\CMS\Log\Log;
/**
* Package installation script for MokoSuiteClient.
*
* Handles migration from standalone plugin to package, enables plugins,
* and triggers heartbeat registration on install/update.
*
* @since 2.2.0
*/
class Pkg_MokosuiteclientInstallerScript
{
/**
* Runs after package installation/update.
*
* @param string $type Installation type
* @param InstallerAdapter $parent Parent installer
*
* @return void
*
* @since 2.2.0
*/
/**
* Runs before package installation/update.
*
* Fixes MySQL strict mode incompatibility: #__extensions.element is NOT NULL
* with no default, causing INSERT failures when Joomla's package installer
* creates placeholder rows before processing sub-extension manifests.
*/
/** @var string|null Download key saved before Joomla wipes update sites */
private ?string $savedDownloadKey = null;
public function preflight($type, $parent)
{
$this->saveDownloadKey();
try
{
$db = Factory::getDbo();
$db->setQuery("ALTER TABLE " . $db->quoteName('#__extensions')
. " MODIFY " . $db->quoteName('element') . " VARCHAR(100) NOT NULL DEFAULT ''");
$db->execute();
}
catch (\Throwable $e)
{
// Non-fatal — column may already have a default
}
}
public function postflight($type, $parent)
{
// Migrate MokoWaaS database tables to MokoSuiteClient naming
$this->migrateWaasTables();
// Migrate params from old mokowaas extensions to mokosuiteclient equivalents
$this->migrateWaasExtensionParams();
// Remove legacy extensions and migrate settings before retiring
$this->cleanupLegacyExtensions();
$this->migrateStandalonePlugins();
// Migrate monitor params into core plugin BEFORE monitor row is deleted
$this->migrateMonitorParams();
$this->removeRetiredExtensions();
$this->enablePlugin('system', 'mokosuiteclient');
$this->enablePlugin('system', 'mokosuiteclient_firewall');
$this->enablePlugin('system', 'mokosuiteclient_tenant');
$this->enablePlugin('system', 'mokosuiteclient_devtools');
$this->enablePlugin('system', 'mokosuiteclient_offline');
$this->enablePlugin('system', 'mokosuiteclient_dbip');
$this->enablePlugin('system', 'mokosuiteclient_backup');
$this->enablePlugin('webservices', 'mokosuiteclient');
$this->enablePlugin('task', 'mokosuiteclientdemo');
$this->enablePlugin('task', 'mokosuiteclientsync');
$this->enablePlugin('task', 'mokosuiteclient_tickets');
// Migrate params from core plugin to feature plugins (one-time)
$this->migrateFeatureParams();
// Set up cpanel module on the admin dashboard
$this->setupCpanelModule();
// Set up admin sidebar menu module
$this->setupAdminMenuModule();
// Set up cache cleaner status bar module
$this->setupCacheModule();
// Create Support portal menu item on frontend
$this->setupSupportMenuItem();
// Set menu_icon params on submenu items (Joomla only renders img on level 1)
$this->fixMenuIcons();
// Set up MokoSuiteClient guided tours and unpublish Joomla defaults
$this->setupGuidedTours();
// Mark MokoSuiteClient extensions as protected (prevents disable/uninstall at framework level)
$this->protectExtensions();
// Migrate all Moko update server URLs to new format
$this->migrateUpdateServerUrls();
// Clean up stale/duplicate update sites
$this->cleanupStaleUpdateSites();
// Restore download key saved in preflight
$this->restoreDownloadKey();
// Fix orphaned update records (extension_id=0)
$this->fixUpdateRecords();
// Trigger heartbeat registration
$this->sendHeartbeat();
// Warn if no license key is configured
$this->warnMissingLicenseKey();
}
/**
* Remove legacy/stale extension entries and filesystem remnants.
*
* The old standalone plugin was named "mokosuiteclientbrand" (plg_system_mokosuiteclientbrand).
* After the rewrite into the pkg_mokosuiteclient package, the old entries and files
* may linger — especially on sites restored from old backups.
*
* @return void
*
* @since 02.21.00
*/
private function cleanupLegacyExtensions(): void
{
try
{
$db = Factory::getDbo();
// Legacy element names to remove from #__extensions
$legacy = [
$db->quote('mokosuiteclientbrand'),
$db->quote('plg_system_mokosuiteclientbrand'),
];
// Delete from #__extensions
$query = $db->getQuery(true)
->delete($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' IN (' . implode(',', $legacy) . ')');
$db->setQuery($query);
$affected = $db->execute();
$count = $db->getAffectedRows();
// Remove legacy plugin files from the filesystem
$legacyDirs = [
JPATH_PLUGINS . '/system/mokosuiteclientbrand',
];
foreach ($legacyDirs as $dir)
{
if (is_dir($dir))
{
$this->rmdirRecursive($dir);
}
}
if ($count > 0)
{
Factory::getApplication()->enqueueMessage(
sprintf('Removed %d legacy MokoSuiteClient extension(s).', $count),
'message'
);
Log::add(
sprintf('Cleaned up %d legacy MokoSuiteClient extension entries', $count),
Log::INFO,
'mokosuiteclient'
);
}
}
catch (\Throwable $e)
{
Log::add('Legacy cleanup error: ' . $e->getMessage(), Log::WARNING, 'jerror');
}
}
/**
* Remove extensions that have been retired and merged into core.
*
* plg_system_mokosuiteclient_monitor was merged into the core plugin in 02.32.00.
* Health monitoring is now built into plg_system_mokosuiteclient directly.
*
* @return void
*
* @since 02.32.00
*/
private function migrateStandalonePlugins(): void
{
// Migrate standalone MokoJoomTOS plugin to MokoSuiteClient Offline Bypass
$migrations = [
['old_element' => 'mokojoomtos', 'old_folder' => 'system', 'new_element' => 'mokosuiteclient_offline', 'new_folder' => 'system'],
];
try
{
$db = Factory::getDbo();
foreach ($migrations as $m)
{
// Check if old plugin exists
$query = $db->getQuery(true)
->select([$db->quoteName('extension_id'), $db->quoteName('params')])
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($m['old_element']))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($m['old_folder']));
$db->setQuery($query);
$old = $db->loadObject();
if (!$old)
{
continue;
}
$oldParams = $old->params ?? '{}';
// Copy params to new plugin (only if new plugin has empty params)
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element']))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder']));
$db->setQuery($query);
$newParams = (string) $db->loadResult();
if (empty($newParams) || $newParams === '{}' || $newParams === '[]')
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($oldParams))
->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element']))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder']))
)->execute();
Factory::getApplication()->enqueueMessage(
sprintf('Migrated settings from %s to %s.', $m['old_element'], $m['new_element']),
'message'
);
}
// Unprotect old plugin
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('protected') . ' = 0')
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
)->execute();
// Remove old extension record
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__extensions'))
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
)->execute();
// Remove old update site entries
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__update_sites_extensions'))
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
)->execute();
// Remove old files
$dir = JPATH_PLUGINS . '/' . $m['old_folder'] . '/' . $m['old_element'];
if (is_dir($dir))
{
$this->rmdirRecursive($dir);
}
Factory::getApplication()->enqueueMessage(
sprintf('Removed standalone %s plugin (replaced by %s).', $m['old_element'], $m['new_element']),
'message'
);
Log::add(
sprintf('Migrated %s → %s and removed old plugin', $m['old_element'], $m['new_element']),
Log::INFO,
'mokosuiteclient'
);
}
}
catch (\Throwable $e)
{
Log::add('Standalone plugin migration error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Remove extensions that have been retired and merged into core.
*
* @return void
*
* @since 02.32.00
*/
private function removeRetiredExtensions(): void
{
$retired = [
['type' => 'plugin', 'folder' => 'system', 'element' => 'mokosuiteclient_monitor'],
['type' => 'plugin', 'folder' => 'system', 'element' => 'mokojoomtos'],
['type' => 'plugin', 'folder' => 'system', 'element' => 'mokoatsautomation'],
['type' => 'plugin', 'folder' => 'webservices', 'element' => 'mokodpcalendarapi'],
['type' => 'plugin', 'folder' => 'system', 'element' => 'mokogallerycalendar'],
];
try
{
$db = Factory::getDbo();
foreach ($retired as $ext)
{
// Check if installed
$query = $db->getQuery(true)
->select($db->quoteName('extension_id'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('type') . ' = ' . $db->quote($ext['type']))
->where($db->quoteName('folder') . ' = ' . $db->quote($ext['folder']))
->where($db->quoteName('element') . ' = ' . $db->quote($ext['element']));
$db->setQuery($query);
$extId = (int) $db->loadResult();
if (!$extId)
{
continue;
}
// Unprotect so Joomla allows removal
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('protected') . ' = 0')
->where($db->quoteName('extension_id') . ' = ' . $extId)
)->execute();
// Remove update site links and update sites
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('update_site_id'))
->from($db->quoteName('#__update_sites_extensions'))
->where($db->quoteName('extension_id') . ' = ' . $extId)
);
$siteIds = $db->loadColumn();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__update_sites_extensions'))
->where($db->quoteName('extension_id') . ' = ' . $extId)
)->execute();
if (!empty($siteIds))
{
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__updates'))
->where($db->quoteName('update_site_id') . ' IN (' . implode(',', $siteIds) . ')')
)->execute();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__update_sites'))
->where($db->quoteName('update_site_id') . ' IN (' . implode(',', $siteIds) . ')')
)->execute();
}
// Remove extension record
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__extensions'))
->where($db->quoteName('extension_id') . ' = ' . $extId)
)->execute();
// Remove files
$dir = JPATH_PLUGINS . '/' . $ext['folder'] . '/' . $ext['element'];
if (is_dir($dir))
{
$this->rmdirRecursive($dir);
}
Factory::getApplication()->enqueueMessage(
sprintf('Removed retired extension: %s/%s', $ext['folder'], $ext['element']),
'message'
);
Log::add(
sprintf('Removed retired extension %s/%s (ID %d)', $ext['folder'], $ext['element'], $extId),
Log::INFO,
'mokosuiteclient'
);
}
}
catch (\Throwable $e)
{
Log::add('Retired extension cleanup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Recursively remove a directory.
*
* @param string $dir Directory path
*
* @return void
*
* @since 02.21.00
*/
private function rmdirRecursive(string $dir): void
{
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item)
{
if ($item->isDir())
{
@rmdir($item->getPathname());
}
else
{
@unlink($item->getPathname());
}
}
@rmdir($dir);
}
/**
* Enable a plugin by group and element.
*
* @param string $group Plugin group
* @param string $element Plugin element name
*
* @return void
*
* @since 2.2.0
*/
private function enablePlugin(string $group, string $element): void
{
try
{
// Only enable if the plugin files actually exist on disk
$pluginDir = JPATH_PLUGINS . '/' . $group . '/' . $element;
if (!is_dir($pluginDir))
{
Log::add('Skipping enable for ' . $group . '/' . $element . ' — files not installed', Log::DEBUG, 'mokosuiteclient');
return;
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($group))
->where($db->quoteName('element') . ' = ' . $db->quote($element));
$db->setQuery($query);
$db->execute();
if ($db->getAffectedRows() > 0)
{
return;
}
// Row may exist with empty element (DEFAULT '' from preflight ALTER).
// Fix the element value and enable in one pass.
$manifestName = 'plg_' . $group . '_' . $element;
$fix = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('element') . ' = ' . $db->quote($element))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($group))
->where('(' . $db->quoteName('element') . ' = ' . $db->quote('')
. ' OR ' . $db->quoteName('element') . ' IS NULL)')
->where($db->quoteName('name') . ' = ' . $db->quote($manifestName));
$db->setQuery($fix);
$db->execute();
if ($db->getAffectedRows() > 0)
{
Log::add('Fixed empty element for plugin ' . $group . '/' . $element, Log::NOTICE, 'mokosuiteclient');
}
}
catch (\Throwable $e)
{
Log::add('Error enabling plugin ' . $group . '/' . $element . ': ' . $e->getMessage(), Log::WARNING, 'jerror');
}
}
/**
* Set the protected flag on all MokoSuiteClient extensions.
*
* Joomla's protected flag prevents disabling and uninstalling at the
* framework level — no plugin-side interception needed.
*
* @return void
*
* @since 02.03.10
*/
private function protectExtensions(): void
{
try
{
$db = Factory::getDbo();
// All MokoSuiteClient elements: package, system plugin, component,
// webservices plugins, task plugin
$elements = [
$db->quote('pkg_mokosuiteclient'),
$db->quote('mokosuiteclient'),
$db->quote('mokosuiteclient_firewall'),
$db->quote('mokosuiteclient_tenant'),
$db->quote('mokosuiteclient_devtools'),
$db->quote('mokosuiteclient_offline'),
$db->quote('com_mokosuiteclient'),
$db->quote('mod_mokosuiteclient_cpanel'),
$db->quote('mokosuiteclientdemo'),
$db->quote('mokosuiteclientsync'),
$db->quote('mokosuiteclient_tickets'),
$db->quote('mokosuiteclient_backup'),
$db->quote('mokoonyx'),
];
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('protected') . ' = 1')
->set($db->quoteName('locked') . ' = 0')
->where($db->quoteName('element') . ' IN (' . implode(',', $elements) . ')');
$db->setQuery($query);
$db->execute();
// Ensure update server stays enabled
$this->enableUpdateServer();
}
catch (\Throwable $e)
{
Log::add('Error protecting MokoSuiteClient extensions: ' . $e->getMessage(), Log::WARNING, 'jerror');
}
}
/**
* Rewrite all Moko Consulting update server URLs from the old
* raw/branch/main pattern to the new clean /updates.xml pattern.
*
* Old: https://git.mokoconsulting.tech/MokoConsulting/{repo}/raw/branch/main/updates.xml
* New: https://git.mokoconsulting.tech/MokoConsulting/{repo}/updates.xml
*/
private function migrateUpdateServerUrls(): void
{
try
{
$db = Factory::getDbo();
$db->setQuery(
"UPDATE " . $db->quoteName('#__update_sites')
. " SET " . $db->quoteName('location') . " = REPLACE("
. $db->quoteName('location') . ", '/raw/branch/main/updates.xml', '/updates.xml')"
. " WHERE " . $db->quoteName('location') . " LIKE " . $db->quote('%mokoconsulting.tech%/raw/branch/main/updates.xml')
);
$db->execute();
$count = $db->getAffectedRows();
if ($count > 0)
{
Factory::getApplication()->enqueueMessage(
sprintf('Migrated %d Moko update server URL(s) to new format.', $count),
'message'
);
}
}
catch (\Throwable $e)
{
Log::add('Update server URL migration error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Remove stale and duplicate MokoSuiteClient update site entries.
*
* Keeps only the package-level update site pointing to the dynamic
* MokoGitea endpoint. Removes plugin-level entries, old static URLs,
* and orphaned #__updates rows tied to deleted update sites.
*
* @return void
*
* @since 02.31.00
*/
private function fixUpdateRecords(): void
{
try
{
$db = Factory::getDbo();
// Link orphaned #__updates records to the installed extension
$db->setQuery(
"UPDATE " . $db->quoteName('#__updates') . " u"
. " JOIN " . $db->quoteName('#__extensions') . " e"
. " ON u.element = e.element AND u.type = e.type"
. " SET u.extension_id = e.extension_id"
. " WHERE u.extension_id = 0"
. " AND u.element LIKE " . $db->quote('%mokosuiteclient%')
);
$db->execute();
}
catch (\Throwable $e)
{
// Non-critical
}
}
private function cleanupStaleUpdateSites(): void
{
try
{
$db = Factory::getDbo();
$dynamicUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/updates.xml';
// Find MokoSuiteClient update sites (exclude MokoSuiteClientHQ and other Moko extensions)
$query = $db->getQuery(true)
->select($db->quoteName(['update_site_id', 'location']))
->from($db->quoteName('#__update_sites'))
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoSuiteClient%')
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteClient%') . ')')
->where($db->quoteName('name') . ' NOT LIKE ' . $db->quote('%MokoSuiteClientHQ%'))
->where($db->quoteName('location') . ' NOT LIKE ' . $db->quote('%MokoSuiteClientHQ%'));
$db->setQuery($query);
$sites = $db->loadObjectList();
$keepId = null;
$removeIds = [];
foreach ($sites as $site)
{
if ($site->location === $dynamicUrl && $keepId === null)
{
$keepId = (int) $site->update_site_id;
}
else
{
$removeIds[] = (int) $site->update_site_id;
}
}
if (empty($removeIds))
{
return;
}
$idList = implode(',', $removeIds);
// Remove orphaned #__updates rows
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__updates'))
->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')')
)->execute();
// Remove link rows
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__update_sites_extensions'))
->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')')
)->execute();
// Remove stale update sites
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__update_sites'))
->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')')
)->execute();
$count = count($removeIds);
if ($count > 0)
{
Factory::getApplication()->enqueueMessage(
sprintf('Cleaned up %d stale MokoSuiteClient update site(s).', $count),
'message'
);
}
}
catch (\Throwable $e)
{
Log::add('Error cleaning up stale update sites: ' . $e->getMessage(), Log::WARNING, 'jerror');
}
}
/**
* Backup all non-empty extra_query values from update sites.
*
* @return array Map of update_site_id => extra_query
*/
private function saveDownloadKey(): void
{
try
{
$db = Factory::getDbo();
// Check pkg_mokosuiteclient first, then fall back to old pkg_mokowaas
foreach (['pkg_mokosuiteclient', 'pkg_mokowaas'] as $element)
{
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('us.extra_query'))
->from($db->quoteName('#__update_sites', 'us'))
->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON use.update_site_id = us.update_site_id')
->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id')
->where($db->quoteName('e.element') . ' = ' . $db->quote($element))
->setLimit(1)
);
$key = $db->loadResult();
if (!empty($key) && strpos($key, 'dlid=') !== false)
{
$this->savedDownloadKey = $key;
break;
}
}
}
catch (\Throwable $e) {}
}
private function restoreDownloadKey(): void
{
if ($this->savedDownloadKey === null)
{
return;
}
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('us.update_site_id'))
->from($db->quoteName('#__update_sites', 'us'))
->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON use.update_site_id = us.update_site_id')
->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id')
->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokosuiteclient'))
->setLimit(1)
);
$siteId = (int) $db->loadResult();
if ($siteId > 0)
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__update_sites'))
->set($db->quoteName('extra_query') . ' = ' . $db->quote($this->savedDownloadKey))
->where($db->quoteName('update_site_id') . ' = ' . $siteId)
)->execute();
}
}
catch (\Throwable $e) {}
}
/**
* Ensure the MokoSuiteClient update server entry stays enabled and points
* to the correct dynamic endpoint with the license key attached.
*
* Migrates legacy static URLs (raw/branch/main/updates.xml) to the
* dynamic MokoGitea update feed, and syncs the license key from
* plugin params into extra_query so Joomla sends it as dlid.
*
* @return void
*
* @since 02.21.00
*/
private function enableUpdateServer(): void
{
try
{
$db = Factory::getDbo();
$staticUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/raw/branch/main/updates.xml';
// Migrate old dynamic URL to static raw file URL
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__update_sites'))
->set($db->quoteName('location') . ' = ' . $db->quote($staticUrl))
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoSuiteClient%')
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteClient%') . ')')
->where($db->quoteName('location') . ' != ' . $db->quote($staticUrl))
);
$db->execute();
// Enable all MokoSuiteClient update sites
$query = $db->getQuery(true)
->update($db->quoteName('#__update_sites'))
->set($db->quoteName('enabled') . ' = 1')
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoSuiteClient%')
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteClient%') . ')');
$db->setQuery($query);
$db->execute();
}
catch (\Throwable $e)
{
Log::add('Error enabling update server: ' . $e->getMessage(), Log::WARNING, 'jerror');
}
}
/**
* Send heartbeat to the MokoSuiteClient monitoring receiver.
*
* @return void
*
* @since 02.03.08
*/
private function sendHeartbeat(): void
{
try
{
$db = Factory::getDbo();
// All heartbeat config now lives in the core plugin params
$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'));
$rawParams = (string) $db->setQuery($query)->loadResult();
$coreParams = json_decode($rawParams);
if (!$coreParams)
{
Log::add('Heartbeat skipped: core plugin params empty or not found', Log::WARNING, 'mokosuiteclient');
return;
}
$healthToken = $coreParams->health_api_token ?? '';
if (empty($healthToken))
{
Log::add('Heartbeat skipped: health_api_token not configured', Log::INFO, 'mokosuiteclient');
return;
}
if (($coreParams->heartbeat_enabled ?? '1') === '0')
{
return;
}
$baseUrl = rtrim($coreParams->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))
{
Log::add('Heartbeat skipped: monitor_base_url not configured and manifest fallback failed', Log::WARNING, 'mokosuiteclient');
return;
}
Log::add('Heartbeat sending to: ' . $baseUrl, Log::INFO, 'mokosuiteclient');
$siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/');
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
$timestamp = time();
$payload = json_encode([
'token' => $healthToken,
'domain' => $domain,
'site_name' => Factory::getConfig()->get('sitename', 'Joomla'),
'site_url' => $siteUrl,
'joomla_version' => (new \Joomla\CMS\Version())->getShortVersion(),
'php_version' => PHP_VERSION,
'timestamp' => $timestamp,
], JSON_UNESCAPED_SLASHES);
$headers = ['Content-Type: application/json'];
$signingKeyB64 = $coreParams->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))
{
$privateKeyPem = base64_decode($signingKeyB64);
$privateKey = openssl_pkey_get_private($privateKeyPem);
if ($privateKey !== false)
{
$message = $domain . '|' . $timestamp . '|' . $healthToken;
$signature = '';
if (openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256))
{
$headers[] = 'X-MokoSuite-Signature: ' . base64_encode($signature);
$headers[] = 'X-MokoSuite-Timestamp: ' . $timestamp;
}
}
}
$endpoint = $baseUrl . '/api/index.php/v1/mokosuitehq/heartbeat';
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error)
{
Log::add('Heartbeat connection failed: ' . $error, Log::WARNING, 'mokosuiteclient');
Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat failed: ' . $error, 'warning');
}
elseif ($code >= 200 && $code < 300)
{
Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat: site registered successfully.', 'message');
}
else
{
$body = json_decode($response, true);
$msg = $body['error'] ?? $body['message'] ?? ('HTTP ' . $code);
Log::add(sprintf('Heartbeat HTTP %d: %s', $code, $response), Log::WARNING, 'mokosuiteclient');
Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat failed: ' . $msg, 'warning');
}
}
catch (\Throwable $e)
{
Log::add('Heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat failed: ' . $e->getMessage(), 'warning');
}
}
/**
* One-time migration of params from the monolithic core plugin to
* the new feature plugins. Copies security, tenant, and dev params.
*
* @return void
*
* @since 02.32.00
*/
private function setupCpanelModule(): void
{
$this->ensureAdminModule('mod_mokosuiteclient_cpanel', 'MokoSuiteClient', 'top', 3, 0, '{"show_health":"1","show_plugins":"1"}');
}
private function setupAdminMenuModule(): void
{
$this->ensureAdminModule('mod_mokosuiteclient_menu', 'MokoSuiteClient Menu', 'menu', 3, -1);
}
private function setupCacheModule(): void
{
$this->ensureAdminModule('mod_mokosuiteclient_cache', 'MokoSuiteClient Cache Cleaner', 'status', 3, 8);
}
/**
* Ensure an admin module is published at the correct position using Joomla's ModuleModel.
*
* Uses the Joomla MVC save pipeline so that #__modules_menu mappings,
* checked_out, and all internal bookkeeping are handled correctly.
*/
private function ensureAdminModule(string $element, string $title, string $position, int $access = 3, int $ordering = 0, string $params = '{}'): void
{
try
{
$db = Factory::getDbo();
// Enable the extension entry
$db->setQuery(
$db->getQuery(true)
->update('#__extensions')
->set('enabled = 1')
->where('type = ' . $db->quote('module'))
->where('element = ' . $db->quote($element))
)->execute();
// Find existing module instance
$db->setQuery(
$db->getQuery(true)
->select('id')
->from('#__modules')
->where('module = ' . $db->quote($element))
->setLimit(1)
);
$moduleId = (int) $db->loadResult();
if ($moduleId > 0)
{
// Module exists — ensure it stays published with correct position
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__modules'))
->set($db->quoteName('published') . ' = 1')
->set($db->quoteName('position') . ' = ' . $db->quote($position))
->set($db->quoteName('ordering') . ' = ' . (int) $ordering)
->set($db->quoteName('access') . ' = ' . (int) $access)
->set($db->quoteName('checked_out') . ' = NULL')
->set($db->quoteName('checked_out_time') . ' = NULL')
->where($db->quoteName('id') . ' = ' . $moduleId)
)->execute();
// Ensure module-menu mapping exists (0 = all pages)
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__modules_menu'))
->where($db->quoteName('moduleid') . ' = ' . $moduleId)
);
if ((int) $db->loadResult() === 0)
{
$db->setQuery(
"INSERT IGNORE INTO " . $db->quoteName('#__modules_menu')
. " (moduleid, menuid) VALUES (" . $moduleId . ", 0)"
)->execute();
}
return;
}
// Module doesn't exist — create via ModuleModel
$data = [
'title' => $title,
'module' => $element,
'position' => $position,
'published' => 1,
'access' => $access,
'ordering' => $ordering,
'showtitle' => 0,
'client_id' => 1,
'language' => '*',
'params' => $params,
'assignment' => 0,
];
$app = Factory::getApplication();
/** @var \Joomla\Component\Modules\Administrator\Model\ModuleModel $model */
$model = $app->bootComponent('com_modules')
->getMVCFactory()
->createModel('Module', 'Administrator', ['ignore_request' => true]);
if (!$model->save($data))
{
Log::add("Module setup ({$element}): " . $model->getError(), Log::WARNING, 'mokosuiteclient');
}
}
catch (\Throwable $e)
{
Log::add("Module setup ({$element}): " . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Joomla only renders the img column icon for level-1 menu items.
* Submenu items (level 2) need menu_icon set in the params JSON.
*/
private function fixMenuIcons(): void
{
try
{
$db = Factory::getDbo();
$iconMap = [
'class:cogs' => 'icon-cogs',
'class:puzzle-piece' => 'icon-puzzle-piece',
'class:headphones' => 'fa-solid fa-handshake-angle',
'class:file-code' => 'fa-solid fa-file-code',
'class:lock' => 'icon-lock',
'class:shield-alt' => 'icon-shield-alt',
'class:database' => 'icon-database',
'class:trash' => 'icon-trash',
'class:power-off' => 'icon-power-off',
'class:refresh' => 'icon-refresh',
'class:check-square' => 'icon-check-square',
'class:bolt' => 'icon-bolt',
];
// Find all MokoSuiteClient component submenu items (including those linking to other components)
$db->setQuery(
$db->getQuery(true)
->select(['m.id', 'm.img', 'm.params'])
->from($db->quoteName('#__menu', 'm'))
->where('m.client_id = 1')
->where('m.level >= 2')
->where('m.parent_id IN (SELECT id FROM ' . $db->quoteName('#__menu')
. ' WHERE client_id = 1 AND level = 1 AND link LIKE ' . $db->quote('%com_mokosuiteclient%') . ')')
);
foreach ($db->loadObjectList() as $item)
{
$icon = $iconMap[$item->img] ?? '';
if (!$icon)
{
continue;
}
$params = json_decode($item->params ?: '{}', true) ?: [];
if (!empty($params['menu_icon']))
{
continue;
}
$params['menu_icon'] = $icon;
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__menu'))
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
->where($db->quoteName('id') . ' = ' . (int) $item->id)
)->execute();
}
}
catch (\Throwable $e)
{
Log::add('Menu icon fix error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Unpublish default Joomla guided tours and create MokoSuiteClient tours.
* Re-enables the guided tours plugin if disabled.
*/
private function setupGuidedTours(): void
{
try
{
$db = Factory::getDbo();
// Re-enable guided tours plugin (may have been disabled)
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('element') . ' = ' . $db->quote('guidedtours'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
)->execute();
// Re-enable the guided tours module (shows our tours, not Joomla's)
$db->setQuery(
"UPDATE " . $db->quoteName('#__modules')
. " SET published = 1, title = 'MokoSuiteClient Tours'"
. " WHERE module = 'mod_guidedtours'"
);
$db->execute();
// Override the guided tours module language string
$overridePath = JPATH_ADMINISTRATOR . '/language/overrides/en-GB.override.ini';
$overrides = file_exists($overridePath) ? parse_ini_file($overridePath) : [];
if (empty($overrides['MOD_GUIDEDTOURS']))
{
$overrides['MOD_GUIDEDTOURS'] = 'MokoSuiteClient Tours';
$overrides['MOD_GUIDEDTOURS_TITLE'] = 'MokoSuiteClient Tours';
$lines = [];
foreach ($overrides as $k => $v)
{
$lines[] = $k . '="' . str_replace('"', '\"', $v) . '"';
}
file_put_contents($overridePath, implode("\n", $lines) . "\n");
}
// Unpublish all default Joomla tours
$db->setQuery(
"UPDATE " . $db->quoteName('#__guidedtours')
. " SET published = 0"
. " WHERE " . $db->quoteName('uid') . " LIKE 'joomla-%'"
);
$db->execute();
// Define MokoSuiteClient tours
$tours = [
[
'uid' => 'mokosuiteclient-welcome',
'title' => 'Welcome to MokoSuiteClient',
'desc' => 'Get started with the MokoSuiteClient Admin Tools Suite. This tour shows you the key areas of your admin dashboard.',
'url' => 'administrator/index.php?option=com_mokosuiteclient',
'steps' => [
['title' => 'MokoSuiteClient Dashboard', 'desc' => 'This is your MokoSuiteClient control center. You can see site info, feature plugins, WAF activity, and quick actions all in one place.', 'target' => '#mokosuiteclient-dashboard', 'type' => 0],
['title' => 'Site Information', 'desc' => 'The info bar shows your Joomla version, PHP version, database type, and debug/offline status at a glance.', 'target' => '.mokosuiteclient-info-bar', 'type' => 0],
['title' => 'Quick Actions', 'desc' => 'Use these buttons to clear cache, check updates, manage extensions, and perform common admin tasks with one click.', 'target' => '#mokosuiteclient-btn-cache', 'type' => 0],
['title' => 'Feature Plugins', 'desc' => 'MokoSuiteClient features are split into toggleable plugins. Enable or disable security, tenant restrictions, developer tools, and more from here.', 'target' => '.mokosuiteclient-plugin-grid', 'type' => 0],
['title' => 'MokoSuiteClient Menu', 'desc' => 'The MokoSuiteClient sidebar menu gives you quick access to all admin tools — Helpdesk, Extensions, WAF Log, Database Tools, and more.', 'target' => '.mokosuiteclient-admin-menu, [class*="mokosuiteclient"]', 'type' => 0],
],
],
[
'uid' => 'mokosuiteclient-firewall',
'title' => 'MokoSuiteClient Firewall Setup',
'desc' => 'Configure the Web Application Firewall to protect your site from common attacks.',
'url' => 'administrator/index.php?option=com_plugins&task=plugin.edit&filter[search]=mokosuiteclient_firewall',
'steps' => [
['title' => 'Firewall Plugin', 'desc' => 'The MokoSuiteClient Firewall provides 10 security shields including SQL injection, XSS, and malicious user agent detection.', 'target' => '', 'type' => 0],
['title' => 'WAF Shields', 'desc' => 'Enable or disable individual WAF shields. Each shield protects against a specific attack vector. All shields are enabled by default.', 'target' => '', 'type' => 0],
['title' => 'Security Headers', 'desc' => 'Configure HTTP security headers like X-Frame-Options, Content-Security-Policy, and HSTS to harden your site against browser-based attacks.', 'target' => '', 'type' => 0],
['title' => 'IP Blocklist', 'desc' => 'Block specific IP addresses, CIDR ranges, or wildcard patterns. The auto-ban feature automatically blocks IPs that trigger too many WAF alerts.', 'target' => '', 'type' => 0],
],
],
[
'uid' => 'mokosuiteclient-helpdesk',
'title' => 'MokoSuiteClient Helpdesk',
'desc' => 'Learn how to manage support tickets, categories, and automation rules.',
'url' => 'administrator/index.php?option=com_mokosuiteclient&view=tickets',
'steps' => [
['title' => 'Ticket List', 'desc' => 'View all support tickets with status, priority, SLA tracking, and assignment. Filter by status or search to find specific tickets.', 'target' => '', 'type' => 0],
['title' => 'Create a Ticket', 'desc' => 'Click the New button to create a support ticket. Assign a category, priority, and optional SLA deadline.', 'target' => '', 'type' => 0],
['title' => 'Ticket Automation', 'desc' => 'Set up automation rules that trigger on ticket events (new ticket, status change) or Joomla events (user login, registration). Automate assignment, notifications, and status changes.', 'target' => '', 'type' => 0],
],
],
[
'uid' => 'mokosuiteclient-extensions',
'title' => 'Moko Extensions Manager',
'desc' => 'Browse and install Moko Consulting extensions from the built-in catalog.',
'url' => 'administrator/index.php?option=com_mokosuiteclient&view=extensions',
'steps' => [
['title' => 'Extension Catalog', 'desc' => 'Browse all available Moko Consulting extensions. Each card shows the extension name, description, install status, and current version.', 'target' => '', 'type' => 0],
['title' => 'Install Extensions', 'desc' => 'Click Install to add an extension from the Moko Consulting repository. Updates are handled through Joomla\'s standard update system.', 'target' => '', 'type' => 0],
],
],
];
foreach ($tours as $tourDef)
{
// Check if tour already exists
$db->setQuery(
$db->getQuery(true)
->select('id')
->from($db->quoteName('#__guidedtours'))
->where($db->quoteName('uid') . ' = ' . $db->quote($tourDef['uid']))
);
if ($db->loadResult())
{
continue;
}
$tour = (object) [
'title' => $tourDef['title'],
'uid' => $tourDef['uid'],
'description' => $tourDef['desc'],
'extensions' => '',
'url' => $tourDef['url'],
'created' => date('Y-m-d H:i:s'),
'created_by' => 0,
'modified' => date('Y-m-d H:i:s'),
'modified_by' => 0,
'published' => 1,
'language' => '*',
'note' => 'MokoSuiteClient',
'access' => 3,
'ordering' => 0,
'autostart' => 0,
];
$db->insertObject('#__guidedtours', $tour, 'id');
$tourId = (int) $tour->id;
foreach ($tourDef['steps'] as $i => $stepDef)
{
$step = (object) [
'tour_id' => $tourId,
'title' => $stepDef['title'],
'description' => $stepDef['desc'],
'target' => $stepDef['target'],
'type' => $stepDef['type'],
'interactive_type' => 1,
'url' => '',
'position' => 'bottom',
'ordering' => $i + 1,
'published' => 1,
'created' => date('Y-m-d H:i:s'),
'created_by' => 0,
'modified' => date('Y-m-d H:i:s'),
'modified_by' => 0,
'language' => '*',
'note' => '',
'params' => '{}',
];
$db->insertObject('#__guidedtour_steps', $step, 'id');
}
}
}
catch (\Throwable $e)
{
Log::add('Guided tours setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Create a "Support" menu item on the frontend main menu.
*/
private function setupSupportMenuItem(): void
{
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__menu'))
->where($db->quoteName('link') . ' LIKE ' . $db->quote('%com_mokosuiteclient&view=tickets%'))
->where($db->quoteName('client_id') . ' = 0')
);
if ((int) $db->loadResult() > 0)
{
return;
}
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('extension_id'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
);
$componentId = (int) $db->loadResult();
if (!$componentId)
{
return;
}
$db->setQuery("SELECT id FROM #__menu WHERE menutype = '' AND level = 0 AND client_id = 0 LIMIT 1");
$rootId = (int) $db->loadResult() ?: 1;
$db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0');
$maxRgt = (int) $db->loadResult();
$item = (object) [
'menutype' => 'mainmenu',
'title' => 'Support',
'alias' => 'support',
'note' => '',
'path' => 'support',
'link' => 'index.php?option=com_mokosuiteclient&view=tickets',
'type' => 'component',
'published' => 1,
'parent_id' => $rootId,
'level' => 1,
'component_id' => $componentId,
'checked_out' => null,
'checked_out_time' => null,
'browserNav' => 0,
'access' => 2,
'img' => '',
'template_style_id' => 0,
'params' => '{}',
'lft' => $maxRgt + 1,
'rgt' => $maxRgt + 2,
'home' => 0,
'language' => '*',
'client_id' => 0,
];
$db->insertObject('#__menu', $item, 'id');
$supportId = (int) $item->id;
// Create "Submit a Ticket" child menu item
if ($supportId)
{
$db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0');
$maxRgt2 = (int) $db->loadResult();
$child = (object) [
'menutype' => 'mainmenu',
'title' => 'Submit a Ticket',
'alias' => 'submit-ticket',
'note' => '',
'path' => 'support/submit-ticket',
'link' => 'index.php?option=com_mokosuiteclient&view=tickets&layout=submit',
'type' => 'component',
'published' => 1,
'parent_id' => $supportId,
'level' => 2,
'component_id' => $componentId,
'checked_out' => null,
'checked_out_time' => null,
'browserNav' => 0,
'access' => 2,
'img' => '',
'template_style_id' => 0,
'params' => '{}',
'lft' => $maxRgt2 + 1,
'rgt' => $maxRgt2 + 2,
'home' => 0,
'language' => '*',
'client_id' => 0,
];
$db->insertObject('#__menu', $child, 'id');
}
}
catch (\Throwable $e)
{
Log::add('Support menu setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* One-time migration of params from the monolithic core plugin to
* the new feature plugins. Copies security, tenant, and dev params.
*
* @return void
*
* @since 02.32.00
*/
private function migrateFeatureParams(): void
{
try
{
$db = Factory::getDbo();
// Read core plugin params
$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'));
$db->setQuery($query);
$coreParamsJson = (string) $db->loadResult();
if (empty($coreParamsJson) || $coreParamsJson === '{}')
{
return;
}
$core = json_decode($coreParamsJson, true);
if (empty($core))
{
return;
}
// Check migration marker
if (!empty($core['_params_migrated_032']))
{
return;
}
// Firewall params
$firewallKeys = [
'force_https', 'admin_session_timeout', 'trusted_ips',
'password_min_length', 'password_require_uppercase',
'password_require_number', 'password_require_special',
'upload_allowed_types', 'upload_max_size_mb',
];
// Tenant params
$tenantKeys = [
'restrict_installer', 'allow_extension_updates', 'hide_sysinfo',
'restrict_global_config', 'restrict_template_editing',
'disable_install_url', 'hidden_menu_items',
];
// DevTools params
$devtoolsKeys = ['dev_mode', 'reset_hits', 'delete_versions'];
$migrations = [
'mokosuiteclient_firewall' => $firewallKeys,
'mokosuiteclient_tenant' => $tenantKeys,
'mokosuiteclient_devtools' => $devtoolsKeys,
];
foreach ($migrations as $element => $keys)
{
$featureParams = [];
foreach ($keys as $key)
{
if (isset($core[$key]))
{
$featureParams[$key] = $core[$key];
}
}
if (empty($featureParams))
{
continue;
}
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($featureParams)))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
)->execute();
}
// Set migration marker on core plugin
$core['_params_migrated_032'] = 1;
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($core)))
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
)->execute();
Factory::getApplication()->enqueueMessage(
'MokoSuiteClient: migrated settings to feature plugins (Firewall, Tenant, DevTools).',
'message'
);
}
catch (\Throwable $e)
{
Log::add('Feature param migration error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Migrate monitor plugin params (base_url, signing_key) into the core plugin.
* The monitor plugin is retired but its config must survive in the core plugin.
*/
private function migrateMonitorParams(): void
{
try
{
$db = Factory::getDbo();
// Read core plugin params
$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'));
$coreParams = json_decode((string) $db->setQuery($query)->loadResult(), true) ?: [];
if (!empty($coreParams['_monitor_migrated']))
{
return;
}
// Read monitor plugin params (may already be gone)
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient_monitor'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
$monitorJson = (string) $db->setQuery($query)->loadResult();
$monitorParams = json_decode($monitorJson, true) ?: [];
$keyMap = [
'base_url' => 'monitor_base_url',
'signing_key' => 'monitor_signing_key',
'heartbeat_enabled' => 'heartbeat_enabled',
];
foreach ($keyMap as $old => $new)
{
if (!empty($monitorParams[$old]) && empty($coreParams[$new]))
{
$coreParams[$new] = $monitorParams[$old];
}
}
$coreParams['_monitor_migrated'] = 1;
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($coreParams)))
->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('Monitor param migration error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Warn after install/update if no license key (dlid) is configured on the update site.
*/
private function warnMissingLicenseKey(): void
{
try
{
$db = Factory::getDbo();
$app = Factory::getApplication();
$query = $db->getQuery(true)
->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')])
->from($db->quoteName('#__update_sites'))
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoSuiteClient%')
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteClient%') . ')')
->setLimit(1);
$db->setQuery($query);
$site = $db->loadObject();
if ($site)
{
$extraQuery = (string) ($site->extra_query ?? '');
if (!empty($extraQuery) && strpos($extraQuery, 'dlid=') !== false)
{
parse_str($extraQuery, $parsed);
if (!empty($parsed['dlid']))
{
return;
}
}
$editUrl = 'index.php?option=com_installer&task=updatesite.edit&update_site_id=' . (int) $site->update_site_id;
}
else
{
$editUrl = 'index.php?option=com_installer&view=updatesites';
}
$app->enqueueMessage(
'<strong>Moko Consulting License Key Required</strong> — '
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
. '<a href="' . $editUrl . '" class="btn btn-sm btn-warning ms-2">Enter License Key</a>',
'warning'
);
}
catch (\Throwable $e)
{
// Silent
}
}
/**
* Migrate MokoWaaS database tables to MokoSuiteClient naming.
*
* For each table: create new mokosuiteclient_* table → copy data from mokowaas_* → drop old table.
* Safe to run multiple times — skips tables that don't exist or are already migrated.
*
* @return void
*
* @since 02.35.00
*/
private function migrateWaasTables(): void
{
$tableMap = [
'mokowaas_ticket_categories' => 'mokosuiteclient_ticket_categories',
'mokowaas_tickets' => 'mokosuiteclient_tickets',
'mokowaas_ticket_replies' => 'mokosuiteclient_ticket_replies',
'mokowaas_ticket_canned' => 'mokosuiteclient_ticket_canned',
'mokowaas_ticket_automation' => 'mokosuiteclient_ticket_automation',
'mokowaas_consent_log' => 'mokosuiteclient_consent_log',
'mokowaas_data_requests' => 'mokosuiteclient_data_requests',
'mokowaas_retention_policies' => 'mokosuiteclient_retention_policies',
'mokowaas_waf_log' => 'mokosuiteclient_waf_log',
];
try
{
$db = Factory::getDbo();
$prefix = $db->getPrefix();
$migrated = 0;
foreach ($tableMap as $oldSuffix => $newSuffix)
{
$oldTable = $prefix . $oldSuffix;
$newTable = $prefix . $newSuffix;
// Check if old table exists
$db->setQuery("SHOW TABLES LIKE " . $db->quote($oldTable));
if (!$db->loadResult())
{
continue;
}
// Create new table with same structure if it doesn't exist
$db->setQuery("SHOW TABLES LIKE " . $db->quote($newTable));
if (!$db->loadResult())
{
$db->setQuery("CREATE TABLE " . $db->quoteName('#__' . $newSuffix)
. " LIKE " . $db->quoteName('#__' . $oldSuffix));
$db->execute();
}
// Copy data from old to new (skip duplicates on primary key)
$db->setQuery("INSERT IGNORE INTO " . $db->quoteName('#__' . $newSuffix)
. " SELECT * FROM " . $db->quoteName('#__' . $oldSuffix));
$db->execute();
$copied = $db->getAffectedRows();
// Drop old table
$db->setQuery("DROP TABLE IF EXISTS " . $db->quoteName('#__' . $oldSuffix));
$db->execute();
$migrated++;
Log::add(
sprintf('Migrated table %s → %s (%d rows)', $oldSuffix, $newSuffix, $copied),
Log::INFO,
'mokosuiteclient'
);
}
if ($migrated > 0)
{
Factory::getApplication()->enqueueMessage(
sprintf('Migrated %d MokoWaaS database table(s) to MokoSuiteClient naming.', $migrated),
'message'
);
}
}
catch (\Throwable $e)
{
Log::add('Table migration error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Migrate params from old mokowaas extension entries to mokosuiteclient equivalents.
*
* Copies params where the new extension has empty/default params, then deletes
* the old extension entries and their filesystem remnants.
*
* @return void
*
* @since 02.35.00
*/
private function migrateWaasExtensionParams(): void
{
// [old_element, old_folder, new_element, new_folder, type]
$map = [
['mokowaas', 'system', 'mokosuiteclient', 'system', 'plugin'],
['mokowaas_firewall', 'system', 'mokosuiteclient_firewall', 'system', 'plugin'],
['mokowaas_tenant', 'system', 'mokosuiteclient_tenant', 'system', 'plugin'],
['mokowaas_devtools', 'system', 'mokosuiteclient_devtools', 'system', 'plugin'],
['mokowaas_offline', 'system', 'mokosuiteclient_offline', 'system', 'plugin'],
['mokowaas_monitor', 'system', 'mokosuiteclient_monitor', 'system', 'plugin'],
['mokowaas', 'webservices', 'mokosuiteclient', 'webservices', 'plugin'],
['mokowaassync', 'task', 'mokosuiteclientsync', 'task', 'plugin'],
['mokowaasdemo', 'task', 'mokosuiteclientdemo', 'task', 'plugin'],
['mokowaas_tickets', 'task', 'mokosuiteclient_tickets', 'task', 'plugin'],
['com_mokowaas', '', 'com_mokosuiteclient', '', 'component'],
['mod_mokowaas_cpanel', '', 'mod_mokosuiteclient_cpanel', '', 'module'],
['mod_mokowaas_menu', '', 'mod_mokosuiteclient_menu', '', 'module'],
['mod_mokowaas_cache', '', 'mod_mokosuiteclient_cache', '', 'module'],
['mod_mokowaas_categories', '', 'mod_mokosuiteclient_categories', '', 'module'],
['pkg_mokowaas', '', 'pkg_mokosuiteclient', '', 'package'],
];
try
{
$db = Factory::getDbo();
$migrated = 0;
foreach ($map as [$oldEl, $oldFolder, $newEl, $newFolder, $type])
{
// Find old extension
$query = $db->getQuery(true)
->select([$db->quoteName('extension_id'), $db->quoteName('params')])
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($oldEl))
->where($db->quoteName('type') . ' = ' . $db->quote($type));
if ($type === 'plugin')
{
$query->where($db->quoteName('folder') . ' = ' . $db->quote($oldFolder));
}
$db->setQuery($query);
$old = $db->loadObject();
if (!$old)
{
continue;
}
$oldParams = (string) ($old->params ?? '{}');
// Copy params to new extension only if new has empty params
if ($oldParams !== '' && $oldParams !== '{}' && $oldParams !== '[]')
{
$newQuery = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($newEl))
->where($db->quoteName('type') . ' = ' . $db->quote($type));
if ($type === 'plugin')
{
$newQuery->where($db->quoteName('folder') . ' = ' . $db->quote($newFolder));
}
$db->setQuery($newQuery);
$newParams = (string) $db->loadResult();
if (empty($newParams) || $newParams === '{}' || $newParams === '[]')
{
$updateQuery = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($oldParams))
->where($db->quoteName('element') . ' = ' . $db->quote($newEl))
->where($db->quoteName('type') . ' = ' . $db->quote($type));
if ($type === 'plugin')
{
$updateQuery->where($db->quoteName('folder') . ' = ' . $db->quote($newFolder));
}
$db->setQuery($updateQuery)->execute();
Log::add(
sprintf('Migrated params from %s to %s', $oldEl, $newEl),
Log::INFO,
'mokosuiteclient'
);
}
}
// Unprotect old extension
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('protected') . ' = 0')
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
)->execute();
// Remove old update site links
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('update_site_id'))
->from($db->quoteName('#__update_sites_extensions'))
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
);
$siteIds = $db->loadColumn();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__update_sites_extensions'))
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
)->execute();
if (!empty($siteIds))
{
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__updates'))
->where($db->quoteName('update_site_id') . ' IN (' . implode(',', array_map('intval', $siteIds)) . ')')
)->execute();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__update_sites'))
->where($db->quoteName('update_site_id') . ' IN (' . implode(',', array_map('intval', $siteIds)) . ')')
)->execute();
}
// Delete old extension entry
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__extensions'))
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
)->execute();
// Remove old plugin/module filesystem remnants
$dir = null;
if ($type === 'plugin')
{
$dir = JPATH_PLUGINS . '/' . $oldFolder . '/' . $oldEl;
}
elseif ($type === 'module')
{
$dir = JPATH_ADMINISTRATOR . '/modules/' . $oldEl;
}
elseif ($type === 'component')
{
// Components have admin + site dirs
foreach ([JPATH_ADMINISTRATOR . '/components/' . $oldEl, JPATH_SITE . '/components/' . $oldEl] as $cDir)
{
if (is_dir($cDir))
{
$this->rmdirRecursive($cDir);
}
}
}
if ($dir && is_dir($dir))
{
$this->rmdirRecursive($dir);
}
$migrated++;
}
if ($migrated > 0)
{
Factory::getApplication()->enqueueMessage(
sprintf('Migrated params from %d MokoWaaS extension(s) and removed old entries.', $migrated),
'message'
);
}
}
catch (\Throwable $e)
{
Log::add('Extension param migration error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
}