input->getMethod() !== 'POST') { $this->sendJson(405, ['error' => 'POST required']); return; } $user = $app->getIdentity(); if (!$user->authorise('core.manage', 'com_installer')) { $this->sendJson(403, ['error' => 'Not authorized — requires core.manage on com_installer']); return; } // Parse JSON body $body = json_decode($app->input->json->getRaw(), true); $url = $body['url'] ?? ''; if ($url === '') { $this->sendJson(400, ['error' => 'Missing "url" in request body']); return; } // Validate URL scheme if (!preg_match('#^https?://#i', $url)) { $this->sendJson(400, ['error' => 'URL must use http or https scheme']); return; } // Must point to a .zip file $path = parse_url($url, PHP_URL_PATH); if (!$path || !str_ends_with(strtolower($path), '.zip')) { $this->sendJson(400, ['error' => 'URL must point to a .zip file']); return; } try { $result = $this->downloadAndInstall($url); $this->sendJson(200, $result); } catch (\Throwable $e) { $this->sendJson(500, [ 'error' => 'Installation failed', 'message' => $e->getMessage(), ]); } } /** * Download ZIP from URL, extract, and install via Joomla Installer. * * @param string $url The remote ZIP URL * * @return array Result payload * * @throws \RuntimeException on failure * * @since 02.21.00 */ private function downloadAndInstall(string $url): array { $config = Factory::getConfig(); $tmpPath = $config->get('tmp_path', JPATH_ROOT . '/tmp'); $zipFile = $tmpPath . '/mokosuite_install_' . bin2hex(random_bytes(8)) . '.zip'; // Download $this->downloadFile($url, $zipFile); try { // Extract $extractDir = $tmpPath . '/mokosuite_extract_' . bin2hex(random_bytes(8)); if (!mkdir($extractDir, 0755, true)) { throw new \RuntimeException('Failed to create extraction directory'); } $archive = new \Joomla\Archive\Archive; $archive->extract($zipFile, $extractDir); // Install $installer = Installer::getInstance(); $result = $installer->install($extractDir); if (!$result) { throw new \RuntimeException('Joomla Installer returned failure — check server logs for details'); } // Read installed extension info from the installer $manifest = $installer->getManifest(); $name = $manifest ? (string) $manifest->name : 'Unknown'; $version = $manifest ? (string) $manifest->version : 'Unknown'; $type = $installer->get('extension.type', 'Unknown'); return [ 'status' => 'ok', 'message' => 'Extension installed successfully', 'extension' => [ 'name' => $name, 'version' => $version, 'type' => $type, ], 'source_url' => $url, ]; } finally { // Clean up temp files @unlink($zipFile); if (isset($extractDir) && is_dir($extractDir)) { $this->removeDirectory($extractDir); } } } /** * Download a file from a URL with size limit enforcement. * * @param string $url Remote URL * @param string $destPath Local destination path * * @return void * * @throws \RuntimeException on failure * * @since 02.21.00 */ private function downloadFile(string $url, string $destPath): void { $ch = curl_init($url); if ($ch === false) { throw new \RuntimeException('Failed to initialise cURL'); } $fp = fopen($destPath, 'wb'); if ($fp === false) { curl_close($ch); throw new \RuntimeException('Failed to open temp file for writing'); } curl_setopt_array($ch, [ CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_TIMEOUT => 120, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_FAILONERROR => true, CURLOPT_USERAGENT => 'MokoSuite-Installer/1.0', ]); $success = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); $fileSize = curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD); curl_close($ch); fclose($fp); if (!$success) { @unlink($destPath); throw new \RuntimeException('Download failed (HTTP ' . $httpCode . '): ' . $error); } if ($fileSize > self::MAX_DOWNLOAD_BYTES) { @unlink($destPath); throw new \RuntimeException('Download exceeds maximum size of ' . (self::MAX_DOWNLOAD_BYTES / 1048576) . ' MB'); } } /** * Recursively remove a directory and its contents. * * @param string $dir Directory path * * @return void * * @since 02.21.00 */ private function removeDirectory(string $dir): void { $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($items as $item) { if ($item->isDir()) { @rmdir($item->getPathname()); } else { @unlink($item->getPathname()); } } @rmdir($dir); } /** * Send a JSON response and close. * * @param int $code HTTP status code * @param array $payload Response data * * @return void * * @since 02.21.00 */ private function sendJson(int $code, array $payload): void { $app = Factory::getApplication(); $app->setHeader('Content-Type', 'application/json', true); $app->setHeader('Status', (string) $code, true); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); $app->close(); } }