From 0fb82306bb2efb597e0400f4364f31828f7fcd18 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 6 Jun 2026 06:49:52 -0500 Subject: [PATCH] feat: XML-based extension catalog with update server discovery (#186) 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) --- src/packages/com_mokowaas/admin/catalog.xml | 92 +++++ .../admin/src/Model/ExtensionsModel.php | 344 +++++++++--------- .../admin/tmpl/extensions/default.php | 19 +- src/packages/com_mokowaas/mokowaas.xml | 1 + 4 files changed, 284 insertions(+), 172 deletions(-) create mode 100644 src/packages/com_mokowaas/admin/catalog.xml diff --git a/src/packages/com_mokowaas/admin/catalog.xml b/src/packages/com_mokowaas/admin/catalog.xml new file mode 100644 index 00000000..2122651b --- /dev/null +++ b/src/packages/com_mokowaas/admin/catalog.xml @@ -0,0 +1,92 @@ + + + + + MokoWaaS + pkg_mokowaas + package + Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API. + icon-shield-alt + Platform +
https://mokoconsulting.tech/support/products/mokowaas-platform
+ true + https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/dev/updates.xml +
+ + MokoOnyx + mokoonyx + template + Modern Joomla site template with dark mode, custom layouts, and MokoWaaS integration. + icon-paint-brush + Templates +
https://mokoconsulting.tech/support/products/mokoonyx-template
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/dev/updates.xml +
+ + MokoJoomTOS + com_mokojoomtos + component + Terms of Service and privacy policy component with consent tracking. + icon-file-contract + Components +
https://mokoconsulting.tech/support/products/mokojoomtos
+ https://git.mokoconsulting.tech/MokoConsulting/MokoJoomTOS/raw/branch/dev/updates.xml +
+ + MokoJoomHero + mod_mokojoomhero + module + Random hero image module from a configurable folder. + icon-image + Modules +
https://mokoconsulting.tech/support/products/mokojoomhero
+ https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/dev/updates.xml +
+ + MokoWaaS Announce + mod_mokowaas_announce + module + Centralized announcement system via admin module. + icon-bullhorn + Modules +
https://mokoconsulting.tech/support/products/mokowaas-announce
+ https://git.mokoconsulting.tech/MokoConsulting/MokoWaaSAnnounce/raw/branch/dev/updates.xml +
+ + DPCalendar API + mokodpcalendarapi + plugin + Web Services plugin exposing DPCalendar events and calendars via REST API. + icon-calendar + Plugins +
https://mokoconsulting.tech/support/products/mokodpcalendarapi
+ https://git.mokoconsulting.tech/MokoConsulting/MokoDPCalendarAPI/raw/branch/dev/updates.xml +
+ + Gallery Calendar + mokogallerycalendar + plugin + JoomGallery and DPCalendar integration — link galleries to events. + icon-images + Plugins +
https://mokoconsulting.tech/support/products/mokogallerycalendar
+ https://git.mokoconsulting.tech/MokoConsulting/MokoGalleryCalendar/raw/branch/dev/updates.xml +
+ + MokoJoomOpenGraph + pkg_mokoog + package + Open Graph, Twitter Card, and social sharing meta tags for articles, categories, and pages. + icon-share-alt + Components +
https://mokoconsulting.tech/support/products/mokojoomopengraph
+ https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml +
+
diff --git a/src/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php b/src/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php index 7618f479..cc42dd3e 100644 --- a/src/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php +++ b/src/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php @@ -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 + 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 { diff --git a/src/packages/com_mokowaas/admin/tmpl/extensions/default.php b/src/packages/com_mokowaas/admin/tmpl/extensions/default.php index e5a14684..4ffca746 100644 --- a/src/packages/com_mokowaas/admin/tmpl/extensions/default.php +++ b/src/packages/com_mokowaas/admin/tmpl/extensions/default.php @@ -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 = [
local_version): ?> vlocal_version); ?> + remote_version && $pkg->status === 'update_available'): ?> + → remote_version); ?> + remote_version): ?> Latest: remote_version); ?> @@ -73,7 +77,16 @@ $statusBadge = [ - download_url && $pkg->status === 'not_installed'): ?> + download_url && $pkg->status === 'update_available'): ?> + + download_url && $pkg->status === 'not_installed'): ?>