00d44256b4
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.
392 lines
9.3 KiB
PHP
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();
|
|
}
|
|
}
|