Files
MokoSuiteClient/source/packages/com_mokosuite/admin/src/Model/ExtensionsModel.php
T
Jonathan Miller 00d44256b4 refactor: rename MokoWaaS to MokoSuite across entire codebase
Rebrand all 17 sub-extensions from mokowaas to mokosuite naming,
including component, plugins, modules, task plugins, and webservices.
Updates package manifest, workflows, docs, wiki, and issue templates.
Adds new plg_system_mokosuite_license extension.
2026-06-07 09:25:45 -05:00

392 lines
9.3 KiB
PHP

<?php
/**
* @package MokoSuite
* @subpackage com_mokosuite
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuite\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
/**
* 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
{
/**
* Parsed catalog entries (cached per request).
*
* @var array|null
*/
private ?array $catalogCache = null;
/**
* Get the full catalog with install status and release info.
*
* @return array Array of catalog entry objects
*/
public function getCatalog(): array
{
$catalog = $this->loadCatalog();
$installed = $this->getInstalledVersions($catalog);
$packages = [];
foreach ($catalog as $entry)
{
$release = $this->fetchFromUpdateServer($entry['updateserver'] ?? '');
$localVersion = $installed[$entry['element']] ?? null;
$remoteVersion = $release['version'] ?? '';
$downloadUrl = $release['download_url'] ?? '';
// Skip extensions with no release available and not installed
if (empty($remoteVersion) && $localVersion === null)
{
continue;
}
$status = 'not_installed';
if ($localVersion !== null)
{
$status = 'installed';
if ($remoteVersion !== '' && version_compare($remoteVersion, $localVersion, '>'))
{
$status = 'update_available';
}
}
$extensionId = $this->getExtensionId($entry['element']);
$needsDlid = $release['needs_dlid'] ?? false;
$hasDlid = $needsDlid && $extensionId ? $this->hasDownloadKey($entry['element']) : false;
$packages[] = (object) [
'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' => $entry['article'] ?? '',
'protected' => ($entry['protected'] ?? 'false') === 'true',
'extension_id' => $extensionId,
'needs_dlid' => $needsDlid,
'has_dlid' => $hasDlid,
'has_stable' => $release['has_stable'] ?? false,
];
}
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 . '/mokosuite_install_' . md5($url) . '.zip';
try
{
$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);
$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()];
}
}
/**
* 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_mokosuite/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 [];
}
// Determine site's update channel preference
$channel = 'dev'; // default to dev — show everything
$hasStable = false;
$hasDev = false;
// Find the best version entry, preferring the site's channel
$bestVersion = '0.0.0';
$downloadUrl = '';
$needsDlid = false;
foreach ($xml->update as $update)
{
$ver = (string) ($update->version ?? '');
$tag = '';
// Check for <tags><tag> element
if (isset($update->tags->tag))
{
$tag = (string) $update->tags->tag;
}
if ($tag === 'stable')
{
$hasStable = true;
}
if ($tag === 'dev')
{
$hasDev = true;
}
if ($ver === '' || version_compare($ver, $bestVersion, '<='))
{
continue;
}
$bestVersion = $ver;
if (isset($update->downloads->downloadurl))
{
$downloadUrl = (string) $update->downloads->downloadurl;
// Check if download URL contains dlid placeholder
if (str_contains($downloadUrl, 'dlid='))
{
$needsDlid = true;
}
}
}
if ($bestVersion === '0.0.0')
{
return [];
}
return [
'version' => $bestVersion,
'download_url' => $downloadUrl,
'has_stable' => $hasStable,
'has_dev' => $hasDev,
'needs_dlid' => $needsDlid,
];
}
/**
* Get installed versions of catalog extensions.
*
* @param array $catalog The parsed catalog entries
*
* @return array element => version
*/
private function getInstalledVersions(array $catalog): array
{
if (empty($catalog))
{
return [];
}
$db = $this->getDatabase();
$elements = [];
foreach ($catalog as $entry)
{
$elements[] = $db->quote($entry['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;
}
/**
* Check if an extension has a download key configured.
*/
private function hasDownloadKey(string $element): bool
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('us.extra_query'))
->from($db->quoteName('#__update_sites', 'us'))
->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON us.update_site_id = use.update_site_id')
->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id')
->where($db->quoteName('e.element') . ' = ' . $db->quote($element));
$db->setQuery($query, 0, 1);
$extraQuery = (string) $db->loadResult();
return !empty($extraQuery) && str_contains($extraQuery, 'dlid=');
}
catch (\Throwable $e)
{
return false;
}
}
/**
* Get the extension_id for an element (for uninstall links).
*
* @param string $element Extension element name
*
* @return int
*/
private function getExtensionId(string $element): int
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('extension_id'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->setLimit(1);
$db->setQuery($query);
return (int) $db->loadResult();
}
}