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>
284 lines
6.4 KiB
PHP
284 lines
6.4 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\Api\Controller;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\Installer\Installer;
|
|
use Joomla\CMS\MVC\Controller\BaseController;
|
|
|
|
/**
|
|
* Extension install-from-URL API controller.
|
|
*
|
|
* POST /api/index.php/v1/mokowaas/install
|
|
* Body: {"url": "https://example.com/path/to/extension.zip"}
|
|
*
|
|
* Downloads a ZIP from the given URL and installs it via Joomla's Installer.
|
|
* Requires a Joomla API token with core.manage on com_installer.
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
class InstallController extends BaseController
|
|
{
|
|
/**
|
|
* Maximum allowed download size in bytes (64 MB).
|
|
*
|
|
* @var int
|
|
* @since 02.21.00
|
|
*/
|
|
private const MAX_DOWNLOAD_BYTES = 67108864;
|
|
|
|
/**
|
|
* Install an extension from a remote ZIP URL.
|
|
*
|
|
* @return void
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
public function execute($task = 'install'): void
|
|
{
|
|
$app = Factory::getApplication();
|
|
|
|
if ($app->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 . '/mokowaas_install_' . bin2hex(random_bytes(8)) . '.zip';
|
|
|
|
// Download
|
|
$this->downloadFile($url, $zipFile);
|
|
|
|
try
|
|
{
|
|
// Extract
|
|
$extractDir = $tmpPath . '/mokowaas_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 => 'MokoWaaS-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();
|
|
}
|
|
}
|