Files
MokoSuite/source/script.php
T
Jonathan Miller 5ab496b399
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
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 6s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 26s
fix: backup download keys in preflight, not postflight
Joomla's package installer deletes and recreates update site rows
from the manifest BETWEEN preflight and postflight. By the time
postflight ran backupDownloadKeys(), the extra_query values were
already empty.

Moved the backup to preflight() via a class property. The restore
in postflight() now uses keys saved before Joomla touched them.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:14:18 -05:00

1606 lines
47 KiB
PHP

<?php
/**
* @package MokoWaaS
* @subpackage pkg_mokowaas
* @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 MokoWaaS.
*
* Handles migration from standalone plugin to package, enables plugins,
* and triggers heartbeat registration on install/update.
*
* @since 2.2.0
*/
class Pkg_MokowaasInstallerScript
{
/**
* 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 array Download keys saved before Joomla wipes update sites */
private array $savedDownloadKeys = [];
public function preflight($type, $parent)
{
// CRITICAL: backup download keys BEFORE Joomla's installer wipes update sites.
// Joomla deletes and recreates #__update_sites rows from the manifest
// between preflight and postflight, clearing extra_query (dlid).
$this->savedDownloadKeys = $this->backupDownloadKeys();
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)
{
// Remove legacy extensions and migrate settings before retiring
$this->cleanupLegacyExtensions();
$this->migrateStandalonePlugins();
$this->removeRetiredExtensions();
$this->enablePlugin('system', 'mokowaas');
$this->enablePlugin('system', 'mokowaas_firewall');
$this->enablePlugin('system', 'mokowaas_tenant');
$this->enablePlugin('system', 'mokowaas_devtools');
$this->enablePlugin('system', 'mokowaas_offline');
$this->enablePlugin('webservices', 'mokowaas');
$this->enablePlugin('task', 'mokowaasdemo');
$this->enablePlugin('task', 'mokowaassync');
$this->enablePlugin('task', 'mokowaas_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 MokoWaaS guided tours and unpublish Joomla defaults
$this->setupGuidedTours();
// Mark MokoWaaS 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 keys saved in preflight (before Joomla wiped them)
$this->restoreDownloadKeys($this->savedDownloadKeys);
// 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 "mokowaasbrand" (plg_system_mokowaasbrand).
* After the rewrite into the pkg_mokowaas 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('mokowaasbrand'),
$db->quote('plg_system_mokowaasbrand'),
];
// 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/mokowaasbrand',
];
foreach ($legacyDirs as $dir)
{
if (is_dir($dir))
{
$this->rmdirRecursive($dir);
}
}
if ($count > 0)
{
Factory::getApplication()->enqueueMessage(
sprintf('Removed %d legacy MokoWaaS extension(s).', $count),
'message'
);
Log::add(
sprintf('Cleaned up %d legacy MokoWaaS extension entries', $count),
Log::INFO,
'mokowaas'
);
}
}
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_mokowaas_monitor was merged into the core plugin in 02.32.00.
* Health monitoring is now built into plg_system_mokowaas directly.
*
* @return void
*
* @since 02.32.00
*/
private function migrateStandalonePlugins(): void
{
// Migrate standalone MokoJoomTOS plugin to MokoWaaS Offline Bypass
$migrations = [
['old_element' => 'mokojoomtos', 'old_folder' => 'system', 'new_element' => 'mokowaas_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,
'mokowaas'
);
}
}
catch (\Throwable $e)
{
Log::add('Standalone plugin migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* 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' => 'mokowaas_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,
'mokowaas'
);
}
}
catch (\Throwable $e)
{
Log::add('Retired extension cleanup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* 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
{
$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();
}
catch (\Throwable $e)
{
Log::add('Error enabling plugin ' . $group . '/' . $element . ': ' . $e->getMessage(), Log::WARNING, 'jerror');
}
}
/**
* Set the protected flag on all MokoWaaS 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 MokoWaaS elements: package, system plugin, component,
// webservices plugins, task plugin
$elements = [
$db->quote('pkg_mokowaas'),
$db->quote('mokowaas'),
$db->quote('mokowaas_firewall'),
$db->quote('mokowaas_tenant'),
$db->quote('mokowaas_devtools'),
$db->quote('mokowaas_offline'),
$db->quote('com_mokowaas'),
$db->quote('mod_mokowaas_cpanel'),
$db->quote('mokowaasdemo'),
$db->quote('mokowaassync'),
$db->quote('mokowaas_tickets'),
$db->quote('perfectpublisher'),
$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 MokoWaaS 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, 'mokowaas');
}
}
/**
* Remove stale and duplicate MokoWaaS 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('%mokowaas%')
);
$db->execute();
}
catch (\Throwable $e)
{
// Non-critical
}
}
private function cleanupStaleUpdateSites(): void
{
try
{
$db = Factory::getDbo();
$dynamicUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml';
// Find all MokoWaaS update sites
$query = $db->getQuery(true)
->select($db->quoteName(['update_site_id', 'location']))
->from($db->quoteName('#__update_sites'))
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%')
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')');
$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 MokoWaaS 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 backupDownloadKeys(): array
{
$keys = [];
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query'), $db->quoteName('location')])
->from($db->quoteName('#__update_sites'))
->where($db->quoteName('extra_query') . ' != ' . $db->quote(''))
);
$rows = $db->loadObjectList() ?: [];
foreach ($rows as $row)
{
// Key by location so we can match after IDs change
$keys[$row->location] = $row->extra_query;
$keys['id_' . $row->update_site_id] = $row->extra_query;
}
// Also save to file backup for the preserveDownloadKeys() runtime guard
$backupFile = JPATH_ADMINISTRATOR . '/cache/mokowaas_dlkeys.json';
$existing = [];
if (file_exists($backupFile))
{
$existing = json_decode(file_get_contents($backupFile), true) ?: [];
}
foreach ($rows as $row)
{
$existing[$row->update_site_id] = $row->extra_query;
}
file_put_contents($backupFile, json_encode($existing, JSON_PRETTY_PRINT));
}
catch (\Throwable $e)
{
// Non-critical
}
return $keys;
}
/**
* Restore download keys that were cleared by update site cleanup.
*
* @param array $savedKeys Map from backupDownloadKeys()
*/
private function restoreDownloadKeys(array $savedKeys): void
{
if (empty($savedKeys))
{
return;
}
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query'), $db->quoteName('location')])
->from($db->quoteName('#__update_sites'))
->where($db->quoteName('extra_query') . ' = ' . $db->quote(''))
);
$sites = $db->loadObjectList() ?: [];
$restored = 0;
foreach ($sites as $site)
{
// Try to match by location URL first, then by old ID
$key = $savedKeys[$site->location] ?? $savedKeys['id_' . $site->update_site_id] ?? '';
if (!empty($key))
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__update_sites'))
->set($db->quoteName('extra_query') . ' = ' . $db->quote($key))
->where($db->quoteName('update_site_id') . ' = ' . (int) $site->update_site_id)
)->execute();
$restored++;
}
}
if ($restored > 0)
{
Factory::getApplication()->enqueueMessage(
sprintf('Restored %d download key(s) after update site cleanup.', $restored),
'message'
);
}
}
catch (\Throwable $e)
{
// Non-critical
}
}
/**
* Ensure the MokoWaaS 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/MokoWaaS/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('%MokoWaaS%')
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')')
->where($db->quoteName('location') . ' != ' . $db->quote($staticUrl))
);
$db->execute();
// Enable all MokoWaaS update sites
$query = $db->getQuery(true)
->update($db->quoteName('#__update_sites'))
->set($db->quoteName('enabled') . ' = 1')
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%')
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')');
$db->setQuery($query);
$db->execute();
}
catch (\Throwable $e)
{
Log::add('Error enabling update server: ' . $e->getMessage(), Log::WARNING, 'jerror');
}
}
/**
* Send heartbeat to the MokoWaaS monitoring receiver.
*
* @return void
*
* @since 02.03.08
*/
private function sendHeartbeat(): void
{
try
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
$params = json_decode((string) $db->setQuery($query)->loadResult());
$healthToken = $params->health_api_token ?? '';
if (empty($healthToken))
{
return;
}
$siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/');
$siteName = Factory::getConfig()->get('sitename', 'Joomla');
$payload = json_encode([
'site_url' => $siteUrl,
'site_name' => $siteName,
'health_token' => $healthToken,
'action' => 'register',
], JSON_UNESCAPED_SLASHES);
$ch = curl_init('https://bench.mokoconsulting.tech/api/waas-heartbeat/register');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-MokoWaaS-Key: moko-waas-hb-2026-x9k4m',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300)
{
Factory::getApplication()->enqueueMessage('Grafana heartbeat: site registered', 'message');
}
}
catch (\Throwable $e)
{
// Silent failure — heartbeat is non-critical
}
}
/**
* 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
{
try
{
$db = Factory::getDbo();
// Enable the module
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokowaas_cpanel'));
$db->setQuery($query);
$db->execute();
// Check if a module instance already exists in #__modules
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__modules'))
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel'));
$db->setQuery($query);
if ((int) $db->loadResult() > 0)
{
return;
}
// Create the module instance on the cpanel position
$module = (object) [
'title' => 'MokoWaaS',
'note' => '',
'content' => '',
'ordering' => 0,
'position' => 'top',
'checked_out' => null,
'checked_out_time' => null,
'publish_up' => null,
'publish_down' => null,
'published' => 1,
'module' => 'mod_mokowaas_cpanel',
'access' => 6, // Super Users only
'showtitle' => 0,
'params' => '{"show_health":"1","show_plugins":"1"}',
'client_id' => 1, // Administrator
'language' => '*',
];
$db->insertObject('#__modules', $module, 'id');
$moduleId = (int) $module->id;
if ($moduleId)
{
// Assign to all admin pages
$map = (object) [
'moduleid' => $moduleId,
'menuid' => 0, // 0 = all pages
];
$db->insertObject('#__modules_menu', $map);
}
}
catch (\Throwable $e)
{
Log::add('CPanel module setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* Set up the MokoWaaS admin sidebar menu module at position 0.
*/
private function setupAdminMenuModule(): void
{
try
{
$db = Factory::getDbo();
// Enable the module extension
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokowaas_menu'))
)->execute();
// Check if module instance exists
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__modules'))
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_menu'))
);
if ((int) $db->loadResult() > 0)
{
return;
}
$module = (object) [
'title' => 'MokoWaaS Menu',
'note' => '',
'content' => '',
'ordering' => 0,
'position' => 'menu',
'checked_out' => null,
'checked_out_time' => null,
'publish_up' => null,
'publish_down' => null,
'published' => 1,
'module' => 'mod_mokowaas_menu',
'access' => 3,
'showtitle' => 0,
'params' => '{}',
'client_id' => 1,
'language' => '*',
];
$db->insertObject('#__modules', $module, 'id');
if ((int) $module->id)
{
$db->insertObject('#__modules_menu', (object) ['moduleid' => (int) $module->id, 'menuid' => 0]);
}
}
catch (\Throwable $e)
{
Log::add('Admin menu module setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* Set up the cache cleaner module in the admin status bar position.
*/
private function setupCacheModule(): void
{
try
{
$db = Factory::getDbo();
// Enable the module extension
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokowaas_cache'))
)->execute();
// Check if module instance exists
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__modules'))
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cache'))
);
if ((int) $db->loadResult() > 0)
{
return;
}
$module = (object) [
'title' => 'MokoWaaS Cache Cleaner',
'note' => '',
'content' => '',
'ordering' => 8,
'position' => 'status',
'checked_out' => null,
'checked_out_time' => null,
'publish_up' => null,
'publish_down' => null,
'published' => 1,
'module' => 'mod_mokowaas_cache',
'access' => 3,
'showtitle' => 0,
'params' => '{}',
'client_id' => 1,
'language' => '*',
];
$db->insertObject('#__modules', $module, 'id');
if ((int) $module->id)
{
$mm = (object) ['moduleid' => (int) $module->id, 'menuid' => 0];
$db->insertObject('#__modules_menu', $mm, 'moduleid');
}
}
catch (\Throwable $e)
{
Log::add('Cache module setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* 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 MokoWaaS 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_mokowaas%') . ')')
);
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, 'mokowaas');
}
}
/**
* Unpublish default Joomla guided tours and create MokoWaaS 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 = 'MokoWaaS 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'] = 'MokoWaaS Tours';
$overrides['MOD_GUIDEDTOURS_TITLE'] = 'MokoWaaS 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 MokoWaaS tours
$tours = [
[
'uid' => 'mokowaas-welcome',
'title' => 'Welcome to MokoWaaS',
'desc' => 'Get started with the MokoWaaS Admin Tools Suite. This tour shows you the key areas of your admin dashboard.',
'url' => 'administrator/index.php?option=com_mokowaas',
'steps' => [
['title' => 'MokoWaaS Dashboard', 'desc' => 'This is your MokoWaaS control center. You can see site info, feature plugins, WAF activity, and quick actions all in one place.', 'target' => '#mokowaas-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' => '.mokowaas-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' => '#mokowaas-btn-cache', 'type' => 0],
['title' => 'Feature Plugins', 'desc' => 'MokoWaaS features are split into toggleable plugins. Enable or disable security, tenant restrictions, developer tools, and more from here.', 'target' => '.mokowaas-plugin-grid', 'type' => 0],
['title' => 'MokoWaaS Menu', 'desc' => 'The MokoWaaS sidebar menu gives you quick access to all admin tools — Helpdesk, Extensions, WAF Log, Database Tools, and more.', 'target' => '.mokowaas-admin-menu, [class*="mokowaas"]', 'type' => 0],
],
],
[
'uid' => 'mokowaas-firewall',
'title' => 'MokoWaaS 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]=mokowaas_firewall',
'steps' => [
['title' => 'Firewall Plugin', 'desc' => 'The MokoWaaS 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' => 'mokowaas-helpdesk',
'title' => 'MokoWaaS Helpdesk',
'desc' => 'Learn how to manage support tickets, categories, and automation rules.',
'url' => 'administrator/index.php?option=com_mokowaas&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' => 'mokowaas-extensions',
'title' => 'Moko Extensions Manager',
'desc' => 'Browse and install Moko Consulting extensions from the built-in catalog.',
'url' => 'administrator/index.php?option=com_mokowaas&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' => 'MokoWaaS',
'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, 'mokowaas');
}
}
/**
* 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_mokowaas&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_mokowaas'))
->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_mokowaas&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_mokowaas&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, 'mokowaas');
}
}
/**
* 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('mokowaas'))
->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 = [
'mokowaas_firewall' => $firewallKeys,
'mokowaas_tenant' => $tenantKeys,
'mokowaas_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('mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
)->execute();
Factory::getApplication()->enqueueMessage(
'MokoWaaS: migrated settings to feature plugins (Firewall, Tenant, DevTools).',
'message'
);
}
catch (\Throwable $e)
{
Log::add('Feature param migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* 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('%MokoWaaS%')
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')')
->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
}
}
}