config = $config; $this->paths = $paths; $this->container = $container; $this->migrator = $migrator; $this->dispatcher = $dispatcher; $this->filesystem = $filesystem; } /** * @return Collection */ public function getExtensions() { if (is_null($this->extensions) && $this->filesystem->exists($this->paths->vendor.'/composer/installed.json')) { $extensions = new Collection(); // Load all packages installed by composer. $installed = json_decode($this->filesystem->get($this->paths->vendor.'/composer/installed.json'), true); // 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. // We do the same for enabled extensions, for optional dependencies. $installedSet = []; $enabledIds = array_flip($this->getEnabled()); 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'); // Instantiates an Extension object using the package path and composer.json file. $extension = new Extension($path, $package); // Per default all extensions are installed if they are registered in composer. $extension->setInstalled(true); $extension->setVersion(Arr::get($package, 'version')); $extensions->put($extension->getId(), $extension); } foreach ($extensions as $extension) { $extension->calculateDependencies($installedSet, $enabledIds); } $needsReset = false; $enabledExtensions = []; foreach ($this->getEnabled() as $enabledKey) { $extension = $extensions->get($enabledKey); if (is_null($extension)) { $needsReset = true; } else { $enabledExtensions[] = $extension; } } if ($needsReset) { $this->setEnabledExtensions($enabledExtensions); } $this->extensions = $extensions->sortBy(function ($extension, $name) { return $extension->getTitle(); }); } return $this->extensions; } /** * Loads an Extension with all information. * * @param string $name * @return Extension|null */ public function getExtension($name) { return $this->getExtensions()->get($name); } /** * Enables the extension. * * @param string $name */ public function enable($name) { if ($this->isEnabled($name)) { return; } $extension = $this->getExtension($name); $missingDependencies = []; $enabledIds = $this->getEnabled(); foreach ($extension->getExtensionDependencyIds() as $dependencyId) { if (! in_array($dependencyId, $enabledIds)) { $missingDependencies[] = $this->getExtension($dependencyId); } } if (! empty($missingDependencies)) { throw new Exception\MissingDependenciesException($extension, $missingDependencies); } $this->dispatcher->dispatch(new Enabling($extension)); $this->migrate($extension); $this->publishAssets($extension); $enabledExtensions = $this->getEnabledExtensions(); $enabledExtensions[] = $extension; $this->setEnabledExtensions($enabledExtensions); $extension->enable($this->container); $this->dispatcher->dispatch(new Enabled($extension)); } /** * Disables an extension. * * @param string $name */ public function disable($name) { $extension = $this->getExtension($name); $enabledExtensions = $this->getEnabledExtensions(); if (($k = array_search($extension, $enabledExtensions)) === false) { return; } $dependentExtensions = []; foreach ($enabledExtensions 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($enabledExtensions[$k]); $this->setEnabledExtensions($enabledExtensions); $extension->disable($this->container); $this->dispatcher->dispatch(new Disabled($extension)); } /** * Uninstalls an extension. * * @param string $name */ public function uninstall($name) { $extension = $this->getExtension($name); $this->disable($name); $this->migrateDown($extension); $this->unpublishAssets($extension); $extension->setInstalled(false); $this->dispatcher->dispatch(new Uninstalled($extension)); } /** * Copy the assets from an extension's assets directory into public view. * * @param Extension $extension */ protected function publishAssets(Extension $extension) { if ($extension->hasAssets()) { $this->filesystem->copyDirectory( $extension->getPath().'/assets', $this->paths->public.'/assets/extensions/'.$extension->getId() ); } } /** * Delete an extension's assets from public view. * * @param Extension $extension */ protected function unpublishAssets(Extension $extension) { $this->filesystem->deleteDirectory($this->paths->public.'/assets/extensions/'.$extension->getId()); } /** * Get the path to an extension's published asset. * * @param Extension $extension * @param string $path * @return string */ public function getAsset(Extension $extension, $path) { return $this->paths->public.'/assets/extensions/'.$extension->getId().$path; } /** * Runs the database migrations for the extension. * * @param Extension $extension * @param string $direction * @return void */ public function migrate(Extension $extension, $direction = 'up') { $this->container->bind(Builder::class, function ($container) { return $container->make(ConnectionInterface::class)->getSchemaBuilder(); }); $extension->migrate($this->migrator, $direction); } /** * Runs the database migrations to reset the database to its old state. * * @param Extension $extension * @return array Notes from the migrator. */ public function migrateDown(Extension $extension) { return $this->migrate($extension, 'down'); } /** * The database migrator. * * @return Migrator */ public function getMigrator() { return $this->migrator; } /** * Get only enabled extensions. * * @return array|Extension[] */ public function getEnabledExtensions() { $enabled = []; $extensions = $this->getExtensions(); foreach ($this->getEnabled() as $id) { if (isset($extensions[$id])) { $enabled[$id] = $extensions[$id]; } } return $enabled; } /** * Call on all enabled extensions to extend the Flarum application. * * @param Container $app */ public function extend(Container $app) { foreach ($this->getEnabledExtensions() as $extension) { $extension->extend($app); } } /** * The id's of the enabled extensions. * * @return array */ public function getEnabled() { return json_decode($this->config->get('extensions_enabled'), true) ?? []; } /** * Persist the currently enabled extensions. * * @param array $enabledExtensions */ protected function setEnabledExtensions(array $enabledExtensions) { $sortedEnabled = static::resolveExtensionOrder($enabledExtensions)['valid']; $sortedEnabledIds = array_map(function (Extension $extension) { return $extension->getId(); }, $sortedEnabled); $this->config->set('extensions_enabled', json_encode($sortedEnabledIds)); } /** * Whether the extension is enabled. * * @param $extension * @return bool */ public function isEnabled($extension) { $enabled = $this->getEnabledExtensions(); return isset($enabled[$extension]); } /** * Returns the titles of the extensions passed. * * @param array $exts * @return string[] */ public static function pluckTitles(array $exts) { return array_map(function (Extension $extension) { return $extension->getTitle(); }, $exts); } /** * Sort a list of extensions so that they are properly resolved in respect to order. * Effectively just topological sorting. * * @param Extension[] $extensionList: an array of \Flarum\Extension\Extension objects * * @return array with 2 keys: 'valid' points to an ordered array of \Flarum\Extension\Extension * 'missingDependencies' points to an associative array of extensions that could not be resolved due * to missing dependencies, in the format extension id => array of missing dependency IDs. * 'circularDependencies' points to an array of extensions ids of extensions * that cannot be processed due to circular dependencies */ public static function resolveExtensionOrder($extensionList) { $extensionIdMapping = []; // Used for caching so we don't rerun ->getExtensions every time. // This is an implementation of Kahn's Algorithm (https://dl.acm.org/doi/10.1145/368996.369025) $extensionGraph = []; $output = []; $missingDependencies = []; // Extensions are invalid if they are missing dependencies, or have circular dependencies. $circularDependencies = []; $pendingQueue = []; $inDegreeCount = []; // How many extensions are dependent on a given extension? foreach ($extensionList as $extension) { $extensionIdMapping[$extension->getId()] = $extension; } foreach ($extensionList as $extension) { $optionalDependencies = array_filter($extension->getOptionalDependencyIds(), function ($id) use ($extensionIdMapping) { return array_key_exists($id, $extensionIdMapping); }); $extensionGraph[$extension->getId()] = array_merge($extension->getExtensionDependencyIds(), $optionalDependencies); foreach ($extensionGraph[$extension->getId()] as $dependency) { $inDegreeCount[$dependency] = array_key_exists($dependency, $inDegreeCount) ? $inDegreeCount[$dependency] + 1 : 1; } } foreach ($extensionList as $extension) { if (! array_key_exists($extension->getId(), $inDegreeCount)) { $inDegreeCount[$extension->getId()] = 0; $pendingQueue[] = $extension->getId(); } } while (! empty($pendingQueue)) { $activeNode = array_shift($pendingQueue); $output[] = $activeNode; foreach ($extensionGraph[$activeNode] as $dependency) { $inDegreeCount[$dependency] -= 1; if ($inDegreeCount[$dependency] === 0) { if (! array_key_exists($dependency, $extensionGraph)) { // Missing Dependency $missingDependencies[$activeNode] = array_merge( Arr::get($missingDependencies, $activeNode, []), [$dependency] ); } else { $pendingQueue[] = $dependency; } } } } $validOutput = array_filter($output, function ($extension) use ($missingDependencies) { return ! array_key_exists($extension, $missingDependencies); }); $validExtensions = array_reverse(array_map(function ($extensionId) use ($extensionIdMapping) { return $extensionIdMapping[$extensionId]; }, $validOutput)); // Reversed as required by Kahn's algorithm. foreach ($inDegreeCount as $id => $count) { if ($count != 0) { $circularDependencies[] = $id; } } return [ 'valid' => $validExtensions, 'missingDependencies' => $missingDependencies, 'circularDependencies' => $circularDependencies ]; } }