e3c15979b8
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 21s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 26s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Failing after 7s
Rename top-level src/ directory to source/ and update all references in .gitignore, CLAUDE.md, manifest.xml, docs, and PATH comments. Internal namespace path="src" attributes within extension packages are unchanged (they refer to the package-internal src/ folder). Closes #188 Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
322 lines
7.5 KiB
PHP
322 lines
7.5 KiB
PHP
<?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 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'] ?? '';
|
|
|
|
$status = 'not_installed';
|
|
|
|
if ($localVersion !== null)
|
|
{
|
|
$status = 'installed';
|
|
|
|
if ($remoteVersion !== '' && version_compare($remoteVersion, $localVersion, '>'))
|
|
{
|
|
$status = 'update_available';
|
|
}
|
|
}
|
|
|
|
$extensionId = $this->getExtensionId($entry['element']);
|
|
|
|
$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,
|
|
];
|
|
}
|
|
|
|
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
|
|
{
|
|
$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_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 $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;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|