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 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(); } }