feat: license key warning + heartbeat validation + stale update site cleanup

- Persistent admin warning when no download key is set on the MokoWaaS
  update site, with link to System → Update Sites
- Daily heartbeat validates the key against MokoGitea's dynamic endpoint;
  shows error if key is invalid or expired
- Package postflight removes stale/duplicate update site entries and
  orphaned #__updates rows

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-05-31 11:23:54 -05:00
parent 47bfdb9206
commit e8d494d590
3 changed files with 211 additions and 0 deletions
+3
View File
@@ -24,6 +24,9 @@
- License key support via Joomla's native Update Sites download key system (dlid)
- Update server URL migrated from static XML to MokoGitea's dynamic update feed endpoint
- Legacy static update site URLs auto-migrated to dynamic endpoint on install/update
- Persistent admin warning when no license key is configured in Update Sites
- Daily heartbeat validation of license key against MokoGitea — warns if key is invalid or expired
- Stale/duplicate update site cleanup on install/update (removes old static URL entries and orphaned records)
### Removed
- Static `updates.xml` — update feed is now generated dynamically by MokoGitea from git releases
@@ -1000,6 +1000,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return;
}
$this->warnMissingLicenseKey();
$this->enforceAdminRestrictions();
$this->protectPlugin();
}
@@ -3926,6 +3927,124 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
// ------------------------------------------------------------------
// Heartbeat (called from onExtensionAfterSave)
// ------------------------------------------------------------------
// License key check (called from onAfterRoute)
// ------------------------------------------------------------------
/**
* Show a persistent admin warning if no license key is set on the
* MokoWaaS update site.
*
* Checks the extra_query column in #__update_sites for a dlid value.
* Also validates the key against MokoGitea on a heartbeat interval
* (once per day) and warns if the key is invalid or expired.
*
* @return void
*
* @since 02.30.00
*/
protected function warnMissingLicenseKey(): void
{
// Only show to master users
if (!$this->isMasterUser())
{
return;
}
try
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($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);
$extraQuery = (string) $db->loadResult();
if (empty($extraQuery) || strpos($extraQuery, 'dlid=') === false)
{
$this->app->enqueueMessage(
'<strong>MokoWaaS License Key Required</strong> — '
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
. 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> '
. 'and enter your license key (MOKO-XXXX-XXXX-XXXX-XXXX) in the Download Key field for the MokoWaaS update site.',
'warning'
);
return;
}
// Extract the key value from extra_query
parse_str($extraQuery, $parsed);
$licenseKey = $parsed['dlid'] ?? '';
if (empty($licenseKey))
{
return;
}
// Heartbeat validation — check once per day
$session = Factory::getSession();
$lastCheck = (int) $session->get('mokowaas.license_check', 0);
$now = time();
if (($now - $lastCheck) < 86400)
{
// Show cached warning if key was invalid last check
if ($session->get('mokowaas.license_invalid', false))
{
$this->app->enqueueMessage(
'<strong>MokoWaaS License Key Invalid</strong> — '
. 'Your license key could not be validated. Please verify your key in '
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
'error'
);
}
return;
}
// Validate against MokoGitea
$session->set('mokowaas.license_check', $now);
$validateUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml'
. '?dlid=' . urlencode($licenseKey)
. '&domain=' . urlencode(Uri::root());
$ch = curl_init($validateUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Empty <updates></updates> or non-200 means invalid key
$isValid = ($httpCode === 200 && $response && strpos($response, '<update>') !== false);
$session->set('mokowaas.license_invalid', !$isValid);
if (!$isValid)
{
$this->app->enqueueMessage(
'<strong>MokoWaaS License Key Invalid</strong> — '
. 'Your license key could not be validated. Updates will not be available. '
. 'Please verify your key in '
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
'error'
);
}
}
catch (\Throwable $e)
{
// Silent — license check is non-critical
}
}
// ------------------------------------------------------------------
/**
+89
View File
@@ -45,6 +45,9 @@ class Pkg_MokowaasInstallerScript
// Mark MokoWaaS extensions as protected (prevents disable/uninstall at framework level)
$this->protectExtensions();
// Clean up stale/duplicate update sites
$this->cleanupStaleUpdateSites();
// Trigger heartbeat registration
$this->sendHeartbeat();
}
@@ -218,6 +221,92 @@ class Pkg_MokowaasInstallerScript
}
}
/**
* 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.30.00
*/
private function cleanupStaleUpdateSites(): void
{
try
{
$db = Factory::getDbo();
$dynamicUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/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');
}
}
/**
* Ensure the MokoWaaS update server entry stays enabled and points
* to the correct dynamic endpoint with the license key attached.