Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prototype: Check module requirements before install/update #6816

Merged
merged 8 commits into from Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
25 changes: 25 additions & 0 deletions protected/humhub/components/ModuleManager.php
Expand Up @@ -27,6 +27,7 @@
use yii\db\StaleObjectException;
use yii\helpers\ArrayHelper;
use yii\helpers\FileHelper;
use yii\web\ServerErrorHttpException;

/**
* ModuleManager handles all installed modules.
Expand Down Expand Up @@ -623,6 +624,8 @@ public function removeModule($moduleId, $disableBeforeRemove = true): ?string
*/
public function enable(Module $module)
{
$this->checkRequirements($module);

$this->trigger(static::EVENT_BEFORE_MODULE_ENABLE, new ModuleEvent(['module' => $module]));

if (!ModuleEnabled::findOne(['module_id' => $module->id])) {
Expand Down Expand Up @@ -689,4 +692,26 @@ public function disableModules($modules = [])
}
}
}

/**
* Check module requirements
*
* @param Module $module
* @throws ServerErrorHttpException
* @since 1.16
*/
private function checkRequirements(Module $module)
{
$requirementsPath = $module->getBasePath() . DIRECTORY_SEPARATOR . 'requirements.php';
if (!file_exists($requirementsPath)) {
return;
}

$requirementCheckResult = include($requirementsPath);

if (is_string($requirementCheckResult)) {
Yii::error('Error enabling the "' . $module->id . '" module: ' . $requirementCheckResult, 'module');
throw new ServerErrorHttpException($requirementCheckResult);
}
}
}
11 changes: 9 additions & 2 deletions protected/humhub/components/bootstrap/ModuleAutoLoader.php
Expand Up @@ -84,7 +84,7 @@ private static function findModules(iterable $paths): array

foreach ($folders as $folder) {
try {
$moduleConfig = include $folder . DIRECTORY_SEPARATOR . self::CONFIGURATION_FILE;
$moduleConfig = static::getModuleConfigByPath($folder);
if ($preventDuplicatedModules && isset($moduleIdFolders[$moduleConfig['id']])) {
Yii::error('Duplicated module "' . $moduleConfig['id'] . '"(' . $folder . ') is already loaded from the folder "' . $moduleIdFolders[$moduleConfig['id']] . '"');
} else {
Expand All @@ -101,7 +101,8 @@ private static function findModules(iterable $paths): array
foreach (Yii::$app->moduleManager->overwriteModuleBasePath as $overwriteModuleId => $overwriteModulePath) {
if (isset($moduleIdFolders[$overwriteModuleId]) && $moduleIdFolders[$overwriteModuleId] !== $overwriteModulePath) {
try {
$moduleConfig = include $overwriteModulePath . DIRECTORY_SEPARATOR . self::CONFIGURATION_FILE;
$moduleConfig = static::getModuleConfigByPath($overwriteModulePath);

Yii::info('Overwrite path of the module "' . $overwriteModuleId . '" to the folder "' . $overwriteModulePath . '"');
// Remove original config
unset($modules[$moduleIdFolders[$overwriteModuleId]]);
Expand All @@ -118,6 +119,12 @@ private static function findModules(iterable $paths): array
return $modules;
}

private static function getModuleConfigByPath(string $modulePath): array
{
return include $modulePath . DIRECTORY_SEPARATOR . self::CONFIGURATION_FILE;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After running some tests for this addition I'm getting the following error;

TypeError: humhub\components\bootstrap\ModuleAutoLoader::getModuleConfigByPath(): Return value must be of type array, int returned in /home/LOCAL/protected/humhub/components/bootstrap/ModuleAutoLoader.php:124
Stack trace:
#0 /home/LOCAL/protected/humhub/components/bootstrap/ModuleAutoLoader.php(87): humhub\components\bootstrap\ModuleAutoLoader::getModuleConfigByPath()
#1 /home/LOCAL/protected/humhub/components/bootstrap/ModuleAutoLoader.php(55): humhub\components\bootstrap\ModuleAutoLoader::findModules()
#2 /home/LOCAL/protected/humhub/components/bootstrap/ModuleAutoLoader.php(39): humhub\components\bootstrap\ModuleAutoLoader::locateModules()
#3 /home/LOCAL/protected/vendor/yiisoft/yii2/base/Application.php(325): humhub\components\bootstrap\ModuleAutoLoader->bootstrap()
#4 /home/LOCAL/protected/vendor/yiisoft/yii2/web/Application.php(69): yii\base\Application->bootstrap()
#5 /home/LOCAL/protected/humhub/components/Application.php(61): yii\web\Application->bootstrap()
#6 /home/LOCAL/protected/vendor/yiisoft/yii2/base/Application.php(271): humhub\components\Application->bootstrap()
#7 /home/LOCAL/protected/humhub/components/Application.php(42): yii\base\Application->init()
#8 /home/LOCAL/protected/vendor/yiisoft/yii2/base/BaseObject.php(109): humhub\components\Application->init()
#9 /home/LOCAL/protected/vendor/yiisoft/yii2/base/Application.php(204): yii\base\BaseObject->__construct()
#10 /home/LOCAL/protected/humhub/components/ApplicationTrait.php(38): yii\base\Application->__construct()
#11 /home/LOCAL/index.php(25): humhub\components\Application->__construct()
#12 {main}

To fix this and display better error handling here's a fix that will show exactly which config.php that causes any issues so that they can be fixed;

    /**
     * Retrieves the module configuration from the specified path.
     *
     * @param string $modulePath The path to the module directory.
     * @return array The module configuration.
     * @throws \RuntimeException If the configuration file is not found or is invalid.
     */
    private static function getModuleConfigByPath(string $modulePath): array
    {
        $configFilePath = $modulePath . DIRECTORY_SEPARATOR . self::CONFIGURATION_FILE;

        // Check if the configuration file exists
        if (!file_exists($configFilePath)) {
            throw new \RuntimeException('Configuration file not found: ' . $configFilePath);
        }

        // Include the configuration file and ensure it returns an array
        $config = include $configFilePath;

        if (!is_array($config)) {
            throw new \RuntimeException('Invalid configuration file format: ' . $configFilePath);
        }

        return $config;
    }

If this method isn't wanted then I'd suggest only check config.php of enabled modules.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reproduce the error you must have a module that has a blank or missing config.php file.

}


/**
* Find all directories with a configuration file inside
*
Expand Down
Expand Up @@ -22,6 +22,7 @@
use yii\web\HttpException;
use yii\base\Exception;
use yii\helpers\FileHelper;
use yii\web\ServerErrorHttpException;
use ZipArchive;

/**
Expand All @@ -40,10 +41,11 @@ class OnlineModuleManager extends Component
* Installs latest compatible module version
*
* @param string $moduleId
* @return void
* @throws Exception
* @throws HttpException
* @throws ErrorException
* @throws InvalidConfigException
* @throws ServerErrorHttpException
*/
public function install($moduleId)
{
Expand All @@ -52,42 +54,28 @@ public function install($moduleId)
$modulesPath = Yii::getAlias($marketplaceModule->modulesPath);

if (!is_writable($modulesPath)) {
throw new Exception(Yii::t('MarketplaceModule.base', 'Module directory %modulePath% is not writeable!', ['%modulePath%' => $modulesPath]));
$this->throwError($moduleId, Yii::t('MarketplaceModule.base', 'Module directory %modulePath% is not writeable!', ['%modulePath%' => $modulesPath]));
}

$moduleInfo = $this->getModuleInfo($moduleId);

if (!isset($moduleInfo['latestCompatibleVersion'])) {
throw new Exception(Yii::t('MarketplaceModule.base', 'No compatible module version found!'));
$this->throwError($moduleId, Yii::t('MarketplaceModule.base', 'No compatible module version found!'));
}

// Check Module Folder exists
$moduleDownloadFolder = Yii::getAlias($marketplaceModule->modulesDownloadPath);
FileHelper::createDirectory($moduleDownloadFolder);

// Download
$downloadUrl = $moduleInfo['latestCompatibleVersion']['downloadUrl'];
$downloadTargetFileName = $moduleDownloadFolder . DIRECTORY_SEPARATOR . basename($downloadUrl);
try {
$hashSha256 = $moduleInfo['latestCompatibleVersion']['downloadFileSha256'];
$this->downloadFile($downloadTargetFileName, $downloadUrl, $hashSha256);
} catch (\Exception $ex) {
throw new HttpException('500', Yii::t('MarketplaceModule.base', 'Module download failed! (%error%)', ['%error%' => $ex->getMessage()]));
}
$downloadTargetFileName = $this->downloadModule($moduleId);
$this->checkRequirements($moduleId, $downloadTargetFileName);

// Remove old module path
if (!$this->removeModuleDir($modulesPath . DIRECTORY_SEPARATOR . $moduleId)) {
throw new HttpException('500', Yii::t('MarketplaceModule.base', 'Could not remove old module path!'));
}

// Extract Package
if (!file_exists($downloadTargetFileName)) {
throw new HttpException('500', Yii::t('MarketplaceModule.base', 'Download of module failed!'));
$this->throwError($moduleId, Yii::t('MarketplaceModule.base', 'Could not remove old module path!'));
}

if (!$this->unzip($downloadTargetFileName, $modulesPath)) {
Yii::error('Could not unzip ' . $downloadTargetFileName . ' to ' . $modulesPath, 'marketplace');
throw new HttpException('500', Yii::t('MarketplaceModule.base', 'Could not extract module!'));
$this->throwError($moduleId,
'Could not unzip ' . $downloadTargetFileName . ' to ' . $modulesPath,
Yii::t('MarketplaceModule.base', 'Could not extract module!')
);
}

Yii::$app->moduleManager->flushCache();
Expand Down Expand Up @@ -127,7 +115,50 @@ private function unzip($file, $folder)
return true;
}

private function downloadFile($fileName, $url, $sha256 = null)
private function checkRequirements($moduleId, $moduleZipFile)
{
$zip = new ZipArchive();
$zip->open($moduleZipFile);
if ($zip->locateName($moduleId . '/requirements.php')) {
$requirementCheckResult = include('zip://' . $moduleZipFile . '#' . $moduleId . '/requirements.php');
if (is_string($requirementCheckResult)) {
$this->throwError($moduleId, $requirementCheckResult);
}
}
}

private function downloadModule($moduleId): string
{
$moduleInfo = $this->getModuleInfo($moduleId);

/** @var Module $marketplaceModule */
$marketplaceModule = Yii::$app->getModule('marketplace');

// Check Module Folder exists
$moduleDownloadFolder = Yii::getAlias($marketplaceModule->modulesDownloadPath);
FileHelper::createDirectory($moduleDownloadFolder);


// Download
$downloadUrl = $moduleInfo['latestCompatibleVersion']['downloadUrl'];
$downloadTargetFileName = $moduleDownloadFolder . DIRECTORY_SEPARATOR . basename($downloadUrl);
try {
$hashSha256 = $moduleInfo['latestCompatibleVersion']['downloadFileSha256'];
$this->downloadFile($moduleId, $downloadTargetFileName, $downloadUrl, $hashSha256);
} catch (\Exception $ex) {
$this->throwError($moduleId, Yii::t('MarketplaceModule.base', 'Module download failed! (%error%)', ['%error%' => $ex->getMessage()]));
}

// Extract Package
if (!file_exists($downloadTargetFileName)) {
$this->throwError($moduleId, Yii::t('MarketplaceModule.base', 'Download of module failed!'));
}

return $downloadTargetFileName;
}


private function downloadFile(string $moduleId, $fileName, $url, $sha256 = null)
{
if (is_file($fileName) && !empty($sha256) && hash_file('sha256', $fileName) === $sha256) {
// File already downloaded
Expand All @@ -140,15 +171,15 @@ private function downloadFile($fileName, $url, $sha256 = null)
$httpClient->get($url)->addOptions(['timeout' => 300])->setOutputFile($fp)->send();
fclose($fp);
} catch (\yii\httpclient\Exception $e) {
throw new \Exception('Download failed.' . $e->getMessage());
$this->throwError($moduleId, 'Download failed.' . $e->getMessage());
}

if (!is_file($fileName)) {
throw new \Exception('Download failed. Could not write file! ' . $fileName);
$this->throwError($moduleId, 'Download failed. Could not write file! ' . $fileName);
}

if (!empty($sha256) && hash_file('sha256', $fileName) !== $sha256) {
throw new \Exception('File verification failed. Could not download file! ' . $fileName);
$this->throwError($moduleId, 'File verification failed. Could not download file! ' . $fileName);
}

return true;
Expand All @@ -158,16 +189,22 @@ private function downloadFile($fileName, $url, $sha256 = null)
/**
* Updates a given module
*
* @param string $moduleId
* @param $moduleId
* @return void
* @throws Exception
* @throws HttpException
* @throws InvalidConfigException
* @throws ServerErrorHttpException
* @throws ErrorException
* @throws HttpException
* @throws InvalidConfigException
*/
public function update($moduleId)
{
$this->trigger(static::EVENT_BEFORE_UPDATE, new ModuleEvent(['module' => Yii::$app->moduleManager->getModule($moduleId)]));

$moduleZipFile = $this->downloadModule($moduleId);
$this->checkRequirements($moduleId, $moduleZipFile);

// Temporary disable module if enabled
if (Yii::$app->hasModule($moduleId)) {
Yii::$app->setModule($moduleId, null);
Expand Down Expand Up @@ -401,4 +438,13 @@ public function getModule(string $id): ?ModelModule
return isset($modules[$id]) ? new ModelModule($modules[$id]) : null;
}

/**
* @throws ServerErrorHttpException
*/
private function throwError(string $moduleId, string $errorMsg, string $displayedErrorMsg = null): void
{
Yii::error('Error installing or updating the "' . $moduleId . '" module: ' . $errorMsg, 'marketplace');
throw new ServerErrorHttpException($displayedErrorMsg ?? $errorMsg);
}

}
@@ -0,0 +1,14 @@
<?php
/**
* @link https://www.humhub.org/
* @copyright Copyright (c) HumHub GmbH & Co. KG
* @license https://www.humhub.com/licences
*/

namespace Some\Name\Space\moduleWithRequirements;

class Module extends \humhub\components\Module
{
public const ID = 'moduleWithRequirements';
public const NAMESPACE = __NAMESPACE__;
}
@@ -0,0 +1,26 @@
<?php
/**
* @link https://www.humhub.org/
* @copyright Copyright (c) 2023 HumHub GmbH & Co. KG
* @license https://www.humhub.com/licences
*/

/** @noinspection MissedFieldInspection */

require_once __DIR__ . "/Module.php";

return [
'id' => 'moduleWithRequirements',
'class' => \Some\Name\Space\moduleWithRequirements\Module::class,
'namespace' => "Some\\Name\\Space\\moduleWithRequirements",
'events' => [
[
'class' => \humhub\tests\codeception\unit\components\ModuleManagerTest::class,
'event' => 'valid',
'callback' => [
\humhub\tests\codeception\unit\components\ModuleManagerTest::class,
'handleEvent',
],
],
]
];
@@ -0,0 +1,12 @@
{
"id": "moduleWithRequirements",
"version": "1.0",
"name": "My Example Module With Requirements",
"description": "My testing module with Requirements",
"humhub": {
"minVersion": "1.16"
},
"keywords": ["module", "requirements"],
"homepage": "https://www.example.com",
"licence": "AGPL-3.0-or-later"
}
@@ -0,0 +1,7 @@
<?php

if (Yii::$app->getModule('module1') === null) {
return 'This module cannot work without enabled module "module1"';
}

return null;
Expand Up @@ -32,6 +32,7 @@
use yii\db\StaleObjectException;
use yii\helpers\FileHelper;
use yii\log\Logger;
use yii\web\ServerErrorHttpException;

require_once __DIR__ . '/bootstrap/ModuleAutoLoaderTest.php';

Expand Down Expand Up @@ -734,6 +735,24 @@ public function testEnableModulesWithMigration()
static::logFlush();
}

/**
* @noinspection MissedFieldInspection
*/
public function testEnableModulesWithRequirements()
{
Yii::$app->set('moduleManager', $this->moduleManager);

$moduleWithRequirements = $this->moduleManager->getModule(static::$testModuleRoot . '/moduleWithRequirements');
$module1 = $this->moduleManager->getModule(static::$testModuleRoot . '/module1');

$this->expectException(ServerErrorHttpException::class);
$this->expectExceptionMessage('This module cannot work without enabled module "module1"');
static::assertFalse($moduleWithRequirements->enable());

static::assertTrue($module1->enable());
static::assertTrue($moduleWithRequirements->enable());
}

/**
* @throws InvalidConfigException
*/
Expand Down Expand Up @@ -1116,6 +1135,7 @@ public function reset(): void
'module1',
'module2',
'moduleWithMigration',
'moduleWithRequirements',
'coreModule',
'installerModule',
'invalidModule1',
Expand Down