diff --git a/js/src/admin/components/ExtensionsPage.js b/js/src/admin/components/ExtensionsPage.js index 5a50fea6ea..0ef4e9f9c4 100644 --- a/js/src/admin/components/ExtensionsPage.js +++ b/js/src/admin/components/ExtensionsPage.js @@ -123,6 +123,7 @@ export default class ExtensionsPage extends Page { url: app.forum.attribute('apiUrl') + '/extensions/' + id, method: 'PATCH', body: { enabled: !enabled }, + errorHandler: this.onerror.bind(this), }) .then(() => { if (!enabled) localStorage.setItem('enabledExtension', id); @@ -131,4 +132,23 @@ export default class ExtensionsPage extends Page { app.modal.show(LoadingModal); } + + onerror(e) { + // We need to give the modal animation time to start; if we close the modal too early, + // it breaks the bootstrap modal library. + // TODO: This workaround should be removed when we move away from bootstrap JS for modals. + setTimeout(() => { + app.modal.close(); + + const error = JSON.parse(e.responseText).errors[0]; + + app.alerts.show( + { type: 'error' }, + app.translator.trans(`core.lib.error.${error.code}_message`, { + extension: error.extension, + extensions: error.extensions.join(', '), + }) + ); + }, 250); + } } diff --git a/src/Extension/Exception/DependentExtensionsException.php b/src/Extension/Exception/DependentExtensionsException.php new file mode 100644 index 0000000000..ab26347061 --- /dev/null +++ b/src/Extension/Exception/DependentExtensionsException.php @@ -0,0 +1,47 @@ +extension = $extension; + $this->dependent_extensions = $dependent_extensions; + + parent::__construct($extension->getId().' could not be disabled, because it is a dependency of: '.implode(', ', $this->getDependentExtensionIds())); + } + + /** + * Get array of IDs for extensions that depend on this extension. + * + * @return array + */ + public function getDependentExtensionIds() + { + return array_map(function (Extension $extension) { + return $extension->getId(); + }, $this->dependent_extensions); + } +} diff --git a/src/Extension/Exception/DependentExtensionsExceptionHandler.php b/src/Extension/Exception/DependentExtensionsExceptionHandler.php new file mode 100644 index 0000000000..a8ad877274 --- /dev/null +++ b/src/Extension/Exception/DependentExtensionsExceptionHandler.php @@ -0,0 +1,34 @@ +withDetails($this->errorDetails($e)); + } + + protected function errorDetails(DependentExtensionsException $e): array + { + return [ + [ + 'extension' => $e->extension->getId(), + 'extensions' => $e->getDependentExtensionIds(), + ] + ]; + } +} diff --git a/src/Extension/Exception/MissingDependenciesException.php b/src/Extension/Exception/MissingDependenciesException.php new file mode 100644 index 0000000000..114348ca40 --- /dev/null +++ b/src/Extension/Exception/MissingDependenciesException.php @@ -0,0 +1,47 @@ +extension = $extension; + $this->missing_dependencies = $missing_dependencies; + + parent::__construct($extension->getId().' could not be enabled, because it depends on: '.implode(', ', $this->getMissingDependencyIds())); + } + + /** + * Get array of IDs for missing (disabled) extensions that this extension depends on. + * + * @return array + */ + public function getMissingDependencyIds() + { + return array_map(function (Extension $extension) { + return $extension->getId(); + }, $this->missing_dependencies); + } +} diff --git a/src/Extension/Exception/MissingDependenciesExceptionHandler.php b/src/Extension/Exception/MissingDependenciesExceptionHandler.php new file mode 100644 index 0000000000..39f8980cc2 --- /dev/null +++ b/src/Extension/Exception/MissingDependenciesExceptionHandler.php @@ -0,0 +1,34 @@ +withDetails($this->errorDetails($e)); + } + + protected function errorDetails(MissingDependenciesException $e): array + { + return [ + [ + 'extension' => $e->extension->getId(), + 'extensions' => $e->getMissingDependencyIds(), + ] + ]; + } +} diff --git a/src/Extension/Extension.php b/src/Extension/Extension.php index 3707bfae08..ea7b90ed04 100644 --- a/src/Extension/Extension.php +++ b/src/Extension/Extension.php @@ -15,6 +15,7 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use League\Flysystem\Adapter\Local; use League\Flysystem\Filesystem; @@ -51,6 +52,14 @@ class Extension implements Arrayable 'jpg' => 'image/jpeg', ]; + protected static function nameToId($name) + { + list($vendor, $package) = explode('/', $name); + $package = str_replace(['flarum-ext-', 'flarum-'], '', $package); + + return "$vendor-$package"; + } + /** * Unique Id of the extension. * @@ -60,6 +69,7 @@ class Extension implements Arrayable * @var string */ protected $id; + /** * The directory of this extension. * @@ -74,6 +84,13 @@ class Extension implements Arrayable */ protected $composerJson; + /** + * The IDs of all Flarum extensions that this extension depends on. + * + * @var string[] + */ + protected $extensionDependencyIds; + /** * Whether the extension is installed. * @@ -104,9 +121,7 @@ public function __construct($path, $composerJson) */ protected function assignId() { - list($vendor, $package) = explode('/', $this->name); - $package = str_replace(['flarum-ext-', 'flarum-'], '', $package); - $this->id = "$vendor-$package"; + $this->id = static::nameToId($this->name); } public function extend(Container $app) @@ -182,6 +197,24 @@ public function setVersion($version) return $this; } + /** + * Get the list of flarum extensions that this extension depends on. + * + * @param array $extensionSet: An associative array where keys are the composer package names + * of installed extensions. Used to figure out which dependencies + * are flarum extensions. + */ + public function calculateDependencies($extensionSet) + { + $this->extensionDependencyIds = (new Collection(Arr::get($this->composerJson, 'require', []))) + ->keys() + ->filter(function ($key) use ($extensionSet) { + return array_key_exists($key, $extensionSet); + })->map(function ($key) { + return static::nameToId($key); + })->toArray(); + } + /** * @return string */ @@ -253,6 +286,16 @@ public function getPath() return $this->path; } + /** + * The IDs of all Flarum extensions that this extension depends on. + * + * @return array + */ + public function getExtensionDependencyIds() + { + return $this->extensionDependencyIds; + } + private function getExtenders(): array { $extenderFile = $this->getExtenderFile(); @@ -363,12 +406,13 @@ public function migrate(Migrator $migrator, $direction = 'up') public function toArray() { return (array) array_merge([ - 'id' => $this->getId(), - 'version' => $this->getVersion(), - 'path' => $this->path, - 'icon' => $this->getIcon(), - 'hasAssets' => $this->hasAssets(), - 'hasMigrations' => $this->hasMigrations(), + 'id' => $this->getId(), + 'version' => $this->getVersion(), + 'path' => $this->getPath(), + 'icon' => $this->getIcon(), + 'hasAssets' => $this->hasAssets(), + 'hasMigrations' => $this->hasMigrations(), + 'extensionDependencyIds' => $this->getExtensionDependencyIds(), ], $this->composerJson); } } diff --git a/src/Extension/ExtensionManager.php b/src/Extension/ExtensionManager.php index 40136e3484..7b393dc5aa 100644 --- a/src/Extension/ExtensionManager.php +++ b/src/Extension/ExtensionManager.php @@ -83,11 +83,18 @@ public function getExtensions() // Composer 2.0 changes the structure of the installed.json manifest $installed = $installed['packages'] ?? $installed; + // We calculate and store a set of composer package names for all installed Flarum extensions, + // so we know what is and isn't a flarum extension in `calculateDependencies`. + // Using keys of an associative array allows us to do these checks in constant time. + $installedSet = []; + foreach ($installed as $package) { if (Arr::get($package, 'type') != 'flarum-extension' || empty(Arr::get($package, 'name'))) { continue; } + $installedSet[Arr::get($package, 'name')] = true; + $path = isset($package['install-path']) ? $this->paths->vendor.'/composer/'.$package['install-path'] : $this->paths->vendor.'/'.Arr::get($package, 'name'); @@ -101,6 +108,11 @@ public function getExtensions() $extensions->put($extension->getId(), $extension); } + + foreach ($extensions as $extension) { + $extension->calculateDependencies($installedSet); + } + $this->extensions = $extensions->sortBy(function ($extension, $name) { return $extension->composerJsonAttribute('extra.flarum-extension.title'); }); @@ -133,17 +145,27 @@ public function enable($name) $extension = $this->getExtension($name); - $this->dispatcher->dispatch(new Enabling($extension)); + $missingDependencies = []; + $enabledIds = $this->getEnabled(); + foreach ($extension->getExtensionDependencyIds() as $dependencyId) { + if (! in_array($dependencyId, $enabledIds)) { + $missingDependencies[] = $this->getExtension($dependencyId); + } + } - $enabled = $this->getEnabled(); + if (! empty($missingDependencies)) { + throw new Exception\MissingDependenciesException($extension, $missingDependencies); + } - $enabled[] = $name; + $this->dispatcher->dispatch(new Enabling($extension)); + + $enabledIds[] = $name; $this->migrate($extension); $this->publishAssets($extension); - $this->setEnabled($enabled); + $this->setEnabled($enabledIds); $extension->enable($this->container); @@ -165,6 +187,18 @@ public function disable($name) $extension = $this->getExtension($name); + $dependentExtensions = []; + + foreach ($this->getEnabledExtensions() as $possibleDependent) { + if (in_array($extension->getId(), $possibleDependent->getExtensionDependencyIds())) { + $dependentExtensions[] = $possibleDependent; + } + } + + if (! empty($dependentExtensions)) { + throw new Exception\DependentExtensionsException($extension, $dependentExtensions); + } + $this->dispatcher->dispatch(new Disabling($extension)); unset($enabled[$k]); diff --git a/src/Foundation/ErrorServiceProvider.php b/src/Foundation/ErrorServiceProvider.php index 58c827f7fe..02483841de 100644 --- a/src/Foundation/ErrorServiceProvider.php +++ b/src/Foundation/ErrorServiceProvider.php @@ -9,6 +9,10 @@ namespace Flarum\Foundation; +use Flarum\Extension\Exception\DependentExtensionsException; +use Flarum\Extension\Exception\DependentExtensionsExceptionHandler; +use Flarum\Extension\Exception\MissingDependenciesException; +use Flarum\Extension\Exception\MissingDependenciesExceptionHandler; use Flarum\Foundation\ErrorHandling\ExceptionHandler; use Flarum\Foundation\ErrorHandling\LogReporter; use Flarum\Foundation\ErrorHandling\Registry; @@ -57,6 +61,8 @@ public function register() return [ IlluminateValidationException::class => ExceptionHandler\IlluminateValidationExceptionHandler::class, ValidationException::class => ExceptionHandler\ValidationExceptionHandler::class, + DependentExtensionsException::class => DependentExtensionsExceptionHandler::class, + MissingDependenciesException::class => MissingDependenciesExceptionHandler::class, ]; });