feat: XML-based extension catalog with update server discovery (#186)
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled

Replace hardcoded CATALOG constant with catalog.xml that points to each
extension's updates.xml. The model fetches update servers at runtime to
resolve latest version and download URL. Adds update_available status
with Update button in the extensions view.

Closes #186

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-06 06:49:52 -05:00
parent b170894228
commit 0fb82306bb
4 changed files with 284 additions and 172 deletions
@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Extension catalog for MokoWaaS Extension Manager.
Each entry points to the extension's own updates.xml — the installer
resolves the latest version and download URL at runtime.
To add an extension: copy an <extension> block and fill in the fields.
The updateserver URL must point to a valid Joomla updates.xml file.
-->
<catalog>
<extension>
<name>MokoWaaS</name>
<element>pkg_mokowaas</element>
<type>package</type>
<description>Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API.</description>
<icon>icon-shield-alt</icon>
<category>Platform</category>
<article>https://mokoconsulting.tech/support/products/mokowaas-platform</article>
<protected>true</protected>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>MokoOnyx</name>
<element>mokoonyx</element>
<type>template</type>
<description>Modern Joomla site template with dark mode, custom layouts, and MokoWaaS integration.</description>
<icon>icon-paint-brush</icon>
<category>Templates</category>
<article>https://mokoconsulting.tech/support/products/mokoonyx-template</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>MokoJoomTOS</name>
<element>com_mokojoomtos</element>
<type>component</type>
<description>Terms of Service and privacy policy component with consent tracking.</description>
<icon>icon-file-contract</icon>
<category>Components</category>
<article>https://mokoconsulting.tech/support/products/mokojoomtos</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>MokoJoomHero</name>
<element>mod_mokojoomhero</element>
<type>module</type>
<description>Random hero image module from a configurable folder.</description>
<icon>icon-image</icon>
<category>Modules</category>
<article>https://mokoconsulting.tech/support/products/mokojoomhero</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>MokoWaaS Announce</name>
<element>mod_mokowaas_announce</element>
<type>module</type>
<description>Centralized announcement system via admin module.</description>
<icon>icon-bullhorn</icon>
<category>Modules</category>
<article>https://mokoconsulting.tech/support/products/mokowaas-announce</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoWaaSAnnounce/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>DPCalendar API</name>
<element>mokodpcalendarapi</element>
<type>plugin</type>
<description>Web Services plugin exposing DPCalendar events and calendars via REST API.</description>
<icon>icon-calendar</icon>
<category>Plugins</category>
<article>https://mokoconsulting.tech/support/products/mokodpcalendarapi</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoDPCalendarAPI/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>Gallery Calendar</name>
<element>mokogallerycalendar</element>
<type>plugin</type>
<description>JoomGallery and DPCalendar integration — link galleries to events.</description>
<icon>icon-images</icon>
<category>Plugins</category>
<article>https://mokoconsulting.tech/support/products/mokogallerycalendar</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoGalleryCalendar/raw/branch/dev/updates.xml</updateserver>
</extension>
<extension>
<name>MokoJoomOpenGraph</name>
<element>pkg_mokoog</element>
<type>package</type>
<description>Open Graph, Twitter Card, and social sharing meta tags for articles, categories, and pages.</description>
<icon>icon-share-alt</icon>
<category>Components</category>
<article>https://mokoconsulting.tech/support/products/mokojoomopengraph</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml</updateserver>
</extension>
</catalog>
@@ -14,140 +14,67 @@ use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
/**
* Extension manager model — fetches Moko Consulting Joomla packages
* from the Gitea API and checks local install status.
* Extension catalog model — reads catalog.xml, fetches each extension's
* updates.xml to resolve latest version and download URL, and checks
* local install status.
*
* @since 02.32.00
*/
class ExtensionsModel extends BaseDatabaseModel
{
/**
* Curated catalog of Moko Consulting Joomla packages.
* Each entry maps a Gitea repo name to local extension metadata.
* Parsed catalog entries (cached per request).
*
* @var array|null
*/
private const CATALOG = [
'MokoWaaS' => [
'label' => 'MokoWaaS',
'description' => 'Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API.',
'element' => 'pkg_mokowaas',
'type' => 'package',
'icon' => 'icon-shield-alt',
'category' => 'Platform',
'article' => 'https://mokoconsulting.tech/support/products/mokowaas-platform',
'protected' => true,
],
'MokoOnyx' => [
'label' => 'MokoOnyx',
'description' => 'Modern Joomla site template with dark mode, custom layouts, and MokoWaaS integration.',
'element' => 'mokoonyx',
'type' => 'template',
'icon' => 'icon-paint-brush',
'category' => 'Templates',
'article' => 'https://mokoconsulting.tech/support/products/mokoonyx-template',
'protected' => false,
],
'MokoJoomTOS' => [
'label' => 'MokoJoomTOS',
'description' => 'Terms of Service and privacy policy component with consent tracking.',
'element' => 'com_mokojoomtos',
'type' => 'component',
'icon' => 'icon-file-contract',
'category' => 'Components',
'article' => 'https://mokoconsulting.tech/support/products/mokojoomtos',
'protected' => false,
],
'MokoJoomHero' => [
'label' => 'MokoJoomHero',
'description' => 'Random hero image module from a configurable folder.',
'element' => 'mod_mokojoomhero',
'type' => 'module',
'icon' => 'icon-image',
'category' => 'Modules',
'article' => 'https://mokoconsulting.tech/support/products/mokojoomhero',
'protected' => false,
],
'MokoWaaSAnnounce' => [
'label' => 'MokoWaaS Announce',
'description' => 'Centralized announcement system via admin module.',
'element' => 'mod_mokowaas_announce',
'type' => 'module',
'icon' => 'icon-bullhorn',
'category' => 'Modules',
'article' => 'https://mokoconsulting.tech/support/products/mokowaas-announce',
'protected' => false,
],
'MokoDPCalendarAPI' => [
'label' => 'DPCalendar API',
'description' => 'Web Services plugin exposing DPCalendar events and calendars via REST API.',
'element' => 'mokodpcalendarapi',
'type' => 'plugin',
'icon' => 'icon-calendar',
'category' => 'Plugins',
'article' => 'https://mokoconsulting.tech/support/products/mokodpcalendarapi',
'protected' => false,
],
'MokoGalleryCalendar' => [
'label' => 'Gallery Calendar',
'description' => 'JoomGallery and DPCalendar integration — link galleries to events.',
'element' => 'mokogallerycalendar',
'type' => 'plugin',
'icon' => 'icon-images',
'category' => 'Plugins',
'article' => 'https://mokoconsulting.tech/support/products/mokogallerycalendar',
'protected' => false,
],
'MokoJoomOpenGraph' => [
'label' => 'MokoJoomOpenGraph',
'description' => 'Open Graph meta tags for articles, categories, and pages. Controls Facebook, Twitter, and LinkedIn link previews.',
'element' => 'pkg_mokoog',
'type' => 'package',
'icon' => 'icon-share-alt',
'category' => 'Components',
'article' => 'https://mokoconsulting.tech/support/products/mokojoomopengraph',
'protected' => false,
],
];
private const GITEA_URL = 'https://git.mokoconsulting.tech';
private const GITEA_ORG = 'MokoConsulting';
private ?array $catalogCache = null;
/**
* Get the full catalog with install status and release info.
*
* @return array
* @return array Array of catalog entry objects
*/
public function getCatalog(): array
{
$installed = $this->getInstalledVersions();
$catalog = $this->loadCatalog();
$installed = $this->getInstalledVersions($catalog);
$packages = [];
foreach (self::CATALOG as $repo => $meta)
foreach ($catalog as $entry)
{
$release = $this->fetchLatestRelease($repo);
$release = $this->fetchFromUpdateServer($entry['updateserver'] ?? '');
$localVersion = $installed[$meta['element']] ?? null;
$localVersion = $installed[$entry['element']] ?? null;
$remoteVersion = $release['version'] ?? '';
$downloadUrl = $release['download_url'] ?? '';
$status = ($localVersion !== null) ? 'installed' : 'not_installed';
$status = 'not_installed';
// Get extension_id for uninstall link
$extensionId = $this->getExtensionId($meta['element']);
if ($localVersion !== null)
{
$status = 'installed';
if ($remoteVersion !== '' && version_compare($remoteVersion, $localVersion, '>'))
{
$status = 'update_available';
}
}
$extensionId = $this->getExtensionId($entry['element']);
$packages[] = (object) [
'repo' => $repo,
'label' => $meta['label'],
'description' => $meta['description'],
'element' => $meta['element'],
'type' => $meta['type'],
'icon' => $meta['icon'],
'category' => $meta['category'],
'label' => $entry['name'],
'description' => $entry['description'],
'element' => $entry['element'],
'type' => $entry['type'],
'icon' => $entry['icon'],
'category' => $entry['category'],
'local_version' => $localVersion ?? '',
'remote_version' => $remoteVersion,
'download_url' => $downloadUrl,
'status' => $status,
'article_url' => $meta['article'] ?? '',
'protected' => $meta['protected'] ?? false,
'article_url' => $entry['article'] ?? '',
'protected' => ($entry['protected'] ?? 'false') === 'true',
'extension_id' => $extensionId,
];
}
@@ -158,9 +85,9 @@ class ExtensionsModel extends BaseDatabaseModel
/**
* Install an extension from a remote ZIP URL.
*
* @param string $url The download URL.
* @param string $url The download URL
*
* @return array Result with success, message, and extension info.
* @return array Result with success, message, and extension info
*/
public function installFromUrl(string $url): array
{
@@ -169,14 +96,13 @@ class ExtensionsModel extends BaseDatabaseModel
try
{
// Download
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$data = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$data = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
@@ -187,7 +113,6 @@ class ExtensionsModel extends BaseDatabaseModel
file_put_contents($tmpFile, $data);
// Install via Joomla Installer
$installer = new \Joomla\CMS\Installer\Installer();
$result = $installer->install($tmpFile);
@@ -212,18 +137,148 @@ class ExtensionsModel extends BaseDatabaseModel
}
/**
* Get installed versions of all Moko extensions.
* Load and parse the catalog.xml file.
*
* @return array Array of associative arrays, one per extension
*/
private function loadCatalog(): array
{
if ($this->catalogCache !== null)
{
return $this->catalogCache;
}
$catalogFile = JPATH_ADMINISTRATOR . '/components/com_mokowaas/catalog.xml';
if (!file_exists($catalogFile))
{
$this->catalogCache = [];
return [];
}
$xml = @simplexml_load_file($catalogFile);
if (!$xml)
{
$this->catalogCache = [];
return [];
}
$entries = [];
foreach ($xml->extension as $ext)
{
$entries[] = [
'name' => (string) $ext->name,
'element' => (string) $ext->element,
'type' => (string) $ext->type,
'description' => (string) $ext->description,
'icon' => (string) $ext->icon,
'category' => (string) $ext->category,
'article' => (string) $ext->article,
'protected' => (string) $ext->protected,
'updateserver' => (string) $ext->updateserver,
];
}
$this->catalogCache = $entries;
return $entries;
}
/**
* Fetch the latest version and download URL from an extension's updates.xml.
*
* Parses the standard Joomla update server XML format and returns
* the highest version entry with its download URL.
*
* @param string $updateServerUrl URL to the updates.xml file
*
* @return array [version, download_url] or empty array
*/
private function fetchFromUpdateServer(string $updateServerUrl): array
{
if (empty($updateServerUrl))
{
return [];
}
$ch = curl_init($updateServerUrl);
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);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || empty($response))
{
return [];
}
$xml = @simplexml_load_string($response);
if (!$xml)
{
return [];
}
// Find the highest version entry
$bestVersion = '0.0.0';
$downloadUrl = '';
foreach ($xml->update as $update)
{
$ver = (string) ($update->version ?? '');
if ($ver === '' || version_compare($ver, $bestVersion, '<='))
{
continue;
}
$bestVersion = $ver;
// Get download URL from <downloads><downloadurl>
if (isset($update->downloads->downloadurl))
{
$downloadUrl = (string) $update->downloads->downloadurl;
}
}
if ($bestVersion === '0.0.0')
{
return [];
}
return [
'version' => $bestVersion,
'download_url' => $downloadUrl,
];
}
/**
* Get installed versions of catalog extensions.
*
* @param array $catalog The parsed catalog entries
*
* @return array element => version
*/
private function getInstalledVersions(): array
private function getInstalledVersions(array $catalog): array
{
if (empty($catalog))
{
return [];
}
$db = $this->getDatabase();
$elements = [];
foreach (self::CATALOG as $meta)
foreach ($catalog as $entry)
{
$elements[] = $db->quote($meta['element']);
$elements[] = $db->quote($entry['element']);
}
$query = $db->getQuery(true)
@@ -244,61 +299,12 @@ class ExtensionsModel extends BaseDatabaseModel
return $versions;
}
/**
* Fetch the latest release from Gitea for a repo.
*
* @param string $repo Repository name.
*
* @return array [version, download_url] or empty.
*/
private function fetchLatestRelease(string $repo): array
{
$url = self::GITEA_URL . '/api/v1/repos/' . self::GITEA_ORG . '/' . $repo . '/releases?limit=1';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json']);
$response = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || empty($response))
{
return [];
}
$releases = json_decode($response, true);
if (empty($releases[0]))
{
return [];
}
$release = $releases[0];
$version = $release['tag_name'] ?? '';
// Find the first .zip asset
$downloadUrl = '';
foreach ($release['assets'] ?? [] as $asset)
{
if (str_ends_with(strtolower($asset['name'] ?? ''), '.zip'))
{
$downloadUrl = $asset['browser_download_url'] ?? '';
break;
}
}
return [
'version' => $version,
'download_url' => $downloadUrl,
];
}
/**
* Get the extension_id for an element (for uninstall links).
*
* @param string $element Extension element name
*
* @return int
*/
private function getExtensionId(string $element): int
{
@@ -25,8 +25,9 @@ foreach ($packages as $pkg)
}
$statusBadge = [
'installed' => ['bg-success', 'Installed'],
'not_installed' => ['bg-secondary', 'Not Installed'],
'installed' => ['bg-success', 'Installed'],
'update_available' => ['bg-warning text-dark', 'Update Available'],
'not_installed' => ['bg-secondary', 'Not Installed'],
];
?>
@@ -63,6 +64,9 @@ $statusBadge = [
<div class="small text-muted">
<?php if ($pkg->local_version): ?>
v<?php echo htmlspecialchars($pkg->local_version); ?>
<?php if ($pkg->remote_version && $pkg->status === 'update_available'): ?>
&rarr; <?php echo htmlspecialchars($pkg->remote_version); ?>
<?php endif; ?>
<?php elseif ($pkg->remote_version): ?>
Latest: <?php echo htmlspecialchars($pkg->remote_version); ?>
<?php endif; ?>
@@ -73,7 +77,16 @@ $statusBadge = [
<span class="icon-book" aria-hidden="true"></span>
</a>
<?php endif; ?>
<?php if ($pkg->download_url && $pkg->status === 'not_installed'): ?>
<?php if ($pkg->download_url && $pkg->status === 'update_available'): ?>
<button type="button" class="btn btn-sm btn-warning mokowaas-install-btn"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.installExtension&format=json'); ?>"
data-download="<?php echo htmlspecialchars($pkg->download_url); ?>"
data-token="<?php echo $token; ?>"
data-label="<?php echo htmlspecialchars($pkg->label); ?>">
<span class="icon-refresh" aria-hidden="true"></span>
Update to <?php echo htmlspecialchars($pkg->remote_version); ?>
</button>
<?php elseif ($pkg->download_url && $pkg->status === 'not_installed'): ?>
<button type="button" class="btn btn-sm btn-primary mokowaas-install-btn"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.installExtension&format=json'); ?>"
data-download="<?php echo htmlspecialchars($pkg->download_url); ?>"
+1
View File
@@ -43,6 +43,7 @@
</submenu>
<files folder="admin">
<filename>access.xml</filename>
<filename>catalog.xml</filename>
<filename>config.xml</filename>
<folder>language</folder>
<folder>services</folder>