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.
284 lines
6.5 KiB
PHP
284 lines
6.5 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\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/mokosuite/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 . '/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();
|
|
}
|
|
}
|