feat: Moko Extensions manager - browse and install packages

New view at index.php?option=com_mokowaas&view=extensions showing:
- Curated catalog of Moko Consulting Joomla packages
- Install status (installed, update available, not installed)
- Local vs remote version comparison
- One-click install/update from Gitea releases
- Repo link for each package
- Grouped by category (Platform, Templates, Components, Modules, Plugins)
- Quick access button on the dashboard

Catalog includes: MokoWaaS, MokoOnyx, MokoCassiopeia, MokoJoomTOS,
MokoJoomHero, MokoWaaSAnnounce, MokoDPCalendarAPI, MokoGalleryCalendar

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-02 11:13:13 -05:00
parent 7632acfbd8
commit 885b24bfa9
6 changed files with 523 additions and 0 deletions
@@ -16,3 +16,6 @@ COM_MOKOWAAS_CONFIGURE="Configure"
COM_MOKOWAAS_TOGGLE_SUCCESS="Plugin state updated."
COM_MOKOWAAS_TOGGLE_FAIL="Failed to update plugin state."
COM_MOKOWAAS_CACHE_CLEARED="Cache cleared successfully."
COM_MOKOWAAS_EXTENSIONS_TITLE="Moko Extensions"
COM_MOKOWAAS_EXTENSIONS_INFO="Install and manage Moko Consulting Joomla packages. Extensions are downloaded from the official Gitea release server."
COM_MOKOWAAS_EXTENSIONS_LINK="Moko Extensions"
@@ -88,4 +88,38 @@ class DisplayController extends BaseController
echo json_encode($result);
$app->close();
}
/**
* Install a Moko extension from a download URL.
*/
public function installExtension()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$user = $app->getIdentity();
if (!$user->authorise('core.admin'))
{
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
$app->close();
}
$downloadUrl = $app->getInput()->getString('download_url', '');
if (empty($downloadUrl))
{
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => 'Missing download URL.']);
$app->close();
}
$model = $this->getModel('Extensions');
$result = $model->installFromUrl($downloadUrl);
$app->setHeader('Content-Type', 'application/json');
echo json_encode($result);
$app->close();
}
}
@@ -0,0 +1,292 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
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.
*
* @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.
*/
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',
],
'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',
],
'MokoCassiopeia' => [
'label' => 'MokoCassiopeia',
'description' => 'Enhancement layer for Joomla\'s Cassiopeia template.',
'element' => 'mokocassiopeia',
'type' => 'template',
'icon' => 'icon-paint-brush',
'category' => 'Templates',
],
'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',
],
'MokoJoomHero' => [
'label' => 'MokoJoomHero',
'description' => 'Random hero image module from a configurable folder.',
'element' => 'mod_mokojoomhero',
'type' => 'module',
'icon' => 'icon-image',
'category' => 'Modules',
],
'MokoWaaSAnnounce' => [
'label' => 'MokoWaaS Announce',
'description' => 'Centralized announcement system via admin module.',
'element' => 'mod_mokowaas_announce',
'type' => 'module',
'icon' => 'icon-bullhorn',
'category' => 'Modules',
],
'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',
],
'MokoGalleryCalendar' => [
'label' => 'Gallery Calendar',
'description' => 'JoomGallery and DPCalendar integration — link galleries to events.',
'element' => 'mokogallerycalendar',
'type' => 'plugin',
'icon' => 'icon-images',
'category' => 'Plugins',
],
];
private const GITEA_URL = 'https://git.mokoconsulting.tech';
private const GITEA_ORG = 'MokoConsulting';
/**
* Get the full catalog with install status and release info.
*
* @return array
*/
public function getCatalog(): array
{
$installed = $this->getInstalledVersions();
$packages = [];
foreach (self::CATALOG as $repo => $meta)
{
$release = $this->fetchLatestRelease($repo);
$localVersion = $installed[$meta['element']] ?? null;
$remoteVersion = $release['version'] ?? '';
$downloadUrl = $release['download_url'] ?? '';
$status = 'not_installed';
if ($localVersion !== null)
{
$status = 'installed';
if ($remoteVersion && version_compare(
preg_replace('/[^0-9.]/', '', $remoteVersion),
preg_replace('/[^0-9.]/', '', $localVersion),
'>'
))
{
$status = 'update_available';
}
}
$packages[] = (object) [
'repo' => $repo,
'label' => $meta['label'],
'description' => $meta['description'],
'element' => $meta['element'],
'type' => $meta['type'],
'icon' => $meta['icon'],
'category' => $meta['category'],
'local_version' => $localVersion ?? '',
'remote_version' => $remoteVersion,
'download_url' => $downloadUrl,
'status' => $status,
'repo_url' => self::GITEA_URL . '/' . self::GITEA_ORG . '/' . $repo,
];
}
return $packages;
}
/**
* Install an extension from a remote ZIP URL.
*
* @param string $url The download URL.
*
* @return array Result with success, message, and extension info.
*/
public function installFromUrl(string $url): array
{
$tmpPath = Factory::getConfig()->get('tmp_path', JPATH_ROOT . '/tmp');
$tmpFile = $tmpPath . '/mokowaas_install_' . md5($url) . '.zip';
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);
$error = curl_error($ch);
curl_close($ch);
if ($error || $code !== 200 || empty($data))
{
return ['success' => false, 'message' => 'Download failed: ' . ($error ?: "HTTP {$code}")];
}
file_put_contents($tmpFile, $data);
// Install via Joomla Installer
$installer = new \Joomla\CMS\Installer\Installer();
$result = $installer->install($tmpFile);
@unlink($tmpFile);
if (!$result)
{
return ['success' => false, 'message' => 'Installation failed.'];
}
return [
'success' => true,
'message' => 'Installed successfully.',
];
}
catch (\Throwable $e)
{
@unlink($tmpFile);
return ['success' => false, 'message' => 'Error: ' . $e->getMessage()];
}
}
/**
* Get installed versions of all Moko extensions.
*
* @return array element => version
*/
private function getInstalledVersions(): array
{
$db = $this->getDatabase();
$elements = [];
foreach (self::CATALOG as $meta)
{
$elements[] = $db->quote($meta['element']);
}
$query = $db->getQuery(true)
->select([$db->quoteName('element'), $db->quoteName('manifest_cache')])
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' IN (' . implode(',', $elements) . ')');
$db->setQuery($query);
$rows = $db->loadObjectList() ?: [];
$versions = [];
foreach ($rows as $row)
{
$mc = json_decode($row->manifest_cache ?? '{}');
$versions[$row->element] = $mc->version ?? '0.0.0';
}
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,
];
}
}
@@ -0,0 +1,41 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Extensions;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $packages = [];
public function display($tpl = null)
{
$model = $this->getModel();
$this->packages = $model->getCatalog();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOWAAS_EXTENSIONS_TITLE'), 'puzzle-piece');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -79,6 +79,10 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<span class="icon-refresh" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOWAAS_CHECK_UPDATES'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=extensions'); ?>" class="btn btn-outline-primary btn-sm">
<span class="icon-puzzle-piece" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOWAAS_EXTENSIONS_LINK'); ?>
</a>
</div>
</div>
@@ -0,0 +1,149 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_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\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
/** @var \Moko\Component\MokoWaaS\Administrator\View\Extensions\HtmlView $this */
$packages = $this->packages;
$token = Session::getFormToken();
// Group by category
$grouped = [];
foreach ($packages as $pkg)
{
$grouped[$pkg->category][] = $pkg;
}
$statusBadge = [
'installed' => ['bg-success', 'Installed'],
'update_available' => ['bg-warning text-dark', 'Update Available'],
'not_installed' => ['bg-secondary', 'Not Installed'],
];
?>
<div id="mokowaas-extensions">
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOWAAS_EXTENSIONS_INFO'); ?>
</div>
<?php foreach ($grouped as $category => $pkgs): ?>
<h3 class="mb-3"><?php echo htmlspecialchars($category); ?></h3>
<div class="row g-3 mb-4">
<?php foreach ($pkgs as $pkg): ?>
<?php
$badge = $statusBadge[$pkg->status] ?? $statusBadge['not_installed'];
?>
<div class="col-12 col-md-6 col-xl-4">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-start justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<span class="<?php echo htmlspecialchars($pkg->icon); ?>" aria-hidden="true" style="font-size:1.5rem;color:#1a2744"></span>
<div>
<h5 class="card-title mb-0"><?php echo htmlspecialchars($pkg->label); ?></h5>
<small class="text-muted"><?php echo htmlspecialchars($pkg->type); ?></small>
</div>
</div>
<span class="badge <?php echo $badge[0]; ?>"><?php echo $badge[1]; ?></span>
</div>
<p class="card-text text-muted flex-grow-1"><?php echo htmlspecialchars($pkg->description); ?></p>
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
<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'): ?>
<span class="icon-arrow-right" aria-hidden="true"></span>
<span class="text-warning"><?php echo htmlspecialchars($pkg->remote_version); ?></span>
<?php endif; ?>
<?php elseif ($pkg->remote_version): ?>
Latest: <?php echo htmlspecialchars($pkg->remote_version); ?>
<?php endif; ?>
</div>
<div class="d-flex gap-1">
<a href="<?php echo htmlspecialchars($pkg->repo_url); ?>" target="_blank" class="btn btn-sm btn-outline-secondary" title="View on Gitea">
<span class="icon-code-branch" aria-hidden="true"></span>
</a>
<?php if ($pkg->download_url && $pkg->status !== '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); ?>"
data-token="<?php echo $token; ?>"
data-label="<?php echo htmlspecialchars($pkg->label); ?>">
<span class="icon-download" aria-hidden="true"></span>
<?php echo $pkg->status === 'update_available' ? 'Update' : 'Install'; ?>
</button>
<?php elseif ($pkg->status === 'installed'): ?>
<span class="btn btn-sm btn-outline-success disabled">
<span class="icon-check" aria-hidden="true"></span> Installed
</span>
<?php else: ?>
<span class="btn btn-sm btn-outline-secondary disabled">No release</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.mokowaas-install-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
var url = el.dataset.url;
var downloadUrl = el.dataset.download;
var token = el.dataset.token;
var label = el.dataset.label;
if (!confirm('Install ' + label + '?')) return;
el.disabled = true;
var origHtml = el.textContent;
el.textContent = ' Installing...';
var fd = new FormData();
fd.append('download_url', downloadUrl);
fd.append(token, '1');
fetch(url, {
method: 'POST',
body: fd,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
Joomla.renderMessages({message: [label + ': ' + d.message]});
location.reload();
} else {
Joomla.renderMessages({error: [label + ': ' + (d.message || 'Failed')]});
el.disabled = false;
el.textContent = origHtml;
}
})
.catch(function() {
Joomla.renderMessages({error: ['Network error']});
el.disabled = false;
el.textContent = origHtml;
});
});
});
});
</script>