$enabled */ private array $installedAppsCache = []; /** @var string[]|null */ private ?array $shippedApps = null; private array $alwaysEnabled = []; private array $defaultEnabled = []; /** @var array */ private array $appInfos = []; /** @var array */ private array $appVersions = []; /** @var array */ private array $autoDisabledApps = []; private array $appTypes = []; /** @var array */ private array $loadedApps = []; private ?AppConfig $appConfig = null; private ?IURLGenerator $urlGenerator = null; private ?INavigationManager $navigationManager = null; /** * Be extremely careful when injecting classes here. The AppManager is used by the installer, * so it needs to work before installation. See how AppConfig and IURLGenerator are injected for reference */ public function __construct( private IUserSession $userSession, private IConfig $config, private IGroupManager $groupManager, private ICacheFactory $memCacheFactory, private IEventDispatcher $dispatcher, private LoggerInterface $logger, ) { } private function getNavigationManager(): INavigationManager { if ($this->navigationManager === null) { $this->navigationManager = \OCP\Server::get(INavigationManager::class); } return $this->navigationManager; } public function getAppIcon(string $appId, bool $dark = false): ?string { $possibleIcons = $dark ? [$appId . '-dark.svg', 'app-dark.svg'] : [$appId . '.svg', 'app.svg']; $icon = null; foreach ($possibleIcons as $iconName) { try { $icon = $this->getUrlGenerator()->imagePath($appId, $iconName); break; } catch (\RuntimeException $e) { // ignore } } return $icon; } private function getAppConfig(): AppConfig { if ($this->appConfig !== null) { return $this->appConfig; } if (!$this->config->getSystemValueBool('installed', false)) { throw new \Exception('Nextcloud is not installed yet, AppConfig is not available'); } $this->appConfig = \OCP\Server::get(AppConfig::class); return $this->appConfig; } private function getUrlGenerator(): IURLGenerator { if ($this->urlGenerator !== null) { return $this->urlGenerator; } if (!$this->config->getSystemValueBool('installed', false)) { throw new \Exception('Nextcloud is not installed yet, AppConfig is not available'); } $this->urlGenerator = \OCP\Server::get(IURLGenerator::class); return $this->urlGenerator; } /** * @return string[] $appId => $enabled */ private function getInstalledAppsValues(): array { if (!$this->installedAppsCache) { $values = $this->getAppConfig()->getValues(false, 'enabled'); $alwaysEnabledApps = $this->getAlwaysEnabledApps(); foreach ($alwaysEnabledApps as $appId) { $values[$appId] = 'yes'; } $this->installedAppsCache = array_filter($values, function ($value) { return $value !== 'no'; }); ksort($this->installedAppsCache); } return $this->installedAppsCache; } /** * List all installed apps * * @return string[] */ public function getInstalledApps() { return array_keys($this->getInstalledAppsValues()); } /** * Get a list of all apps in the apps folder * * @return list an array of app names (string IDs) */ public function getAllAppsInAppsFolders(): array { $apps = []; foreach (\OC::$APPSROOTS as $apps_dir) { if (!is_readable($apps_dir['path'])) { $this->logger->warning('unable to read app folder : ' . $apps_dir['path'], ['app' => 'core']); continue; } $dh = opendir($apps_dir['path']); if (is_resource($dh)) { while (($file = readdir($dh)) !== false) { if ( $file[0] != '.' && is_dir($apps_dir['path'] . '/' . $file) && is_file($apps_dir['path'] . '/' . $file . '/appinfo/info.xml') ) { $apps[] = $file; } } } } return array_values(array_unique($apps)); } /** * List all apps enabled for a user * * @param \OCP\IUser $user * @return string[] */ public function getEnabledAppsForUser(IUser $user) { $apps = $this->getInstalledAppsValues(); $appsForUser = array_filter($apps, function ($enabled) use ($user) { return $this->checkAppForUser($enabled, $user); }); return array_keys($appsForUser); } public function getEnabledAppsForGroup(IGroup $group): array { $apps = $this->getInstalledAppsValues(); $appsForGroups = array_filter($apps, function ($enabled) use ($group) { return $this->checkAppForGroups($enabled, $group); }); return array_keys($appsForGroups); } /** * Loads all apps * * @param string[] $types * @return bool * * This function walks through the Nextcloud directory and loads all apps * it can find. A directory contains an app if the file /appinfo/info.xml * exists. * * if $types is set to non-empty array, only apps of those types will be loaded */ public function loadApps(array $types = []): bool { if ($this->config->getSystemValueBool('maintenance', false)) { return false; } // Load the enabled apps here $apps = \OC_App::getEnabledApps(); // Add each apps' folder as allowed class path foreach ($apps as $app) { // If the app is already loaded then autoloading it makes no sense if (!$this->isAppLoaded($app)) { $path = \OC_App::getAppPath($app); if ($path !== false) { \OC_App::registerAutoloading($app, $path); } } } // prevent app.php from printing output ob_start(); foreach ($apps as $app) { if (!$this->isAppLoaded($app) && ($types === [] || $this->isType($app, $types))) { try { $this->loadApp($app); } catch (\Throwable $e) { $this->logger->emergency('Error during app loading: ' . $e->getMessage(), [ 'exception' => $e, 'app' => $app, ]); } } } ob_end_clean(); return true; } /** * check if an app is of a specific type * * @param string $app * @param array $types * @return bool */ public function isType(string $app, array $types): bool { $appTypes = $this->getAppTypes($app); foreach ($types as $type) { if (in_array($type, $appTypes, true)) { return true; } } return false; } /** * get the types of an app * * @param string $app * @return string[] */ private function getAppTypes(string $app): array { //load the cache if (count($this->appTypes) === 0) { $this->appTypes = $this->getAppConfig()->getValues(false, 'types') ?: []; } if (isset($this->appTypes[$app])) { return explode(',', $this->appTypes[$app]); } return []; } /** * @return array */ public function getAutoDisabledApps(): array { return $this->autoDisabledApps; } public function getAppRestriction(string $appId): array { $values = $this->getInstalledAppsValues(); if (!isset($values[$appId])) { return []; } if ($values[$appId] === 'yes' || $values[$appId] === 'no') { return []; } return json_decode($values[$appId], true); } /** * Check if an app is enabled for user * * @param string $appId * @param \OCP\IUser|null $user (optional) if not defined, the currently logged in user will be used * @return bool */ public function isEnabledForUser($appId, $user = null) { if ($this->isAlwaysEnabled($appId)) { return true; } if ($user === null) { $user = $this->userSession->getUser(); } $installedApps = $this->getInstalledAppsValues(); if (isset($installedApps[$appId])) { return $this->checkAppForUser($installedApps[$appId], $user); } else { return false; } } private function checkAppForUser(string $enabled, ?IUser $user): bool { if ($enabled === 'yes') { return true; } elseif ($user === null) { return false; } else { if (empty($enabled)) { return false; } $groupIds = json_decode($enabled); if (!is_array($groupIds)) { $jsonError = json_last_error(); $jsonErrorMsg = json_last_error_msg(); // this really should never happen (if it does, the admin should check the `enabled` key value via `occ config:list` because it's bogus for some reason) $this->logger->warning('AppManager::checkAppForUser - can\'t decode group IDs listed in app\'s enabled config key: ' . print_r($enabled, true) . ' - JSON error (' . $jsonError . ') ' . $jsonErrorMsg); return false; } $userGroups = $this->groupManager->getUserGroupIds($user); foreach ($userGroups as $groupId) { if (in_array($groupId, $groupIds, true)) { return true; } } return false; } } private function checkAppForGroups(string $enabled, IGroup $group): bool { if ($enabled === 'yes') { return true; } else { if (empty($enabled)) { return false; } $groupIds = json_decode($enabled); if (!is_array($groupIds)) { $jsonError = json_last_error(); $jsonErrorMsg = json_last_error_msg(); // this really should never happen (if it does, the admin should check the `enabled` key value via `occ config:list` because it's bogus for some reason) $this->logger->warning('AppManager::checkAppForGroups - can\'t decode group IDs listed in app\'s enabled config key: ' . print_r($enabled, true) . ' - JSON error (' . $jsonError . ') ' . $jsonErrorMsg); return false; } return in_array($group->getGID(), $groupIds); } } /** * Check if an app is enabled in the instance * * Notice: This actually checks if the app is enabled and not only if it is installed. * * @param string $appId * @param IGroup[]|String[] $groups * @return bool */ public function isInstalled($appId) { $installedApps = $this->getInstalledAppsValues(); return isset($installedApps[$appId]); } /** * Overwrite the `max-version` requirement for this app. */ public function overwriteNextcloudRequirement(string $appId): void { $ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []); if (!in_array($appId, $ignoreMaxApps, true)) { $ignoreMaxApps[] = $appId; } $this->config->setSystemValue('app_install_overwrite', $ignoreMaxApps); } /** * Remove the `max-version` overwrite for this app. * This means this app now again can not be enabled if the `max-version` is smaller than the current Nextcloud version. */ public function removeOverwriteNextcloudRequirement(string $appId): void { $ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []); $ignoreMaxApps = array_filter($ignoreMaxApps, fn (string $id) => $id !== $appId); $this->config->setSystemValue('app_install_overwrite', $ignoreMaxApps); } public function loadApp(string $app): void { if (isset($this->loadedApps[$app])) { return; } $this->loadedApps[$app] = true; $appPath = \OC_App::getAppPath($app); if ($appPath === false) { return; } $eventLogger = \OC::$server->get(IEventLogger::class); $eventLogger->start("bootstrap:load_app:$app", "Load app: $app"); // in case someone calls loadApp() directly \OC_App::registerAutoloading($app, $appPath); /** @var Coordinator $coordinator */ $coordinator = \OC::$server->get(Coordinator::class); $isBootable = $coordinator->isBootable($app); $hasAppPhpFile = is_file($appPath . '/appinfo/app.php'); if ($isBootable && $hasAppPhpFile) { $this->logger->error('/appinfo/app.php is not loaded when \OCP\AppFramework\Bootstrap\IBootstrap on the application class is used. Migrate everything from app.php to the Application class.', [ 'app' => $app, ]); } elseif ($hasAppPhpFile) { $eventLogger->start("bootstrap:load_app:$app:app.php", "Load legacy app.php app $app"); $this->logger->debug('/appinfo/app.php is deprecated, use \OCP\AppFramework\Bootstrap\IBootstrap on the application class instead.', [ 'app' => $app, ]); try { self::requireAppFile($appPath); } catch (\Throwable $ex) { if ($ex instanceof ServerNotAvailableException) { throw $ex; } if (!$this->isShipped($app) && !$this->isType($app, ['authentication'])) { $this->logger->error("App $app threw an error during app.php load and will be disabled: " . $ex->getMessage(), [ 'exception' => $ex, ]); // Only disable apps which are not shipped and that are not authentication apps $this->disableApp($app, true); } else { $this->logger->error("App $app threw an error during app.php load: " . $ex->getMessage(), [ 'exception' => $ex, ]); } } $eventLogger->end("bootstrap:load_app:$app:app.php"); } $coordinator->bootApp($app); $eventLogger->start("bootstrap:load_app:$app:info", "Load info.xml for $app and register any services defined in it"); $info = $this->getAppInfo($app); if (!empty($info['activity'])) { $activityManager = \OC::$server->get(IActivityManager::class); if (!empty($info['activity']['filters'])) { foreach ($info['activity']['filters'] as $filter) { $activityManager->registerFilter($filter); } } if (!empty($info['activity']['settings'])) { foreach ($info['activity']['settings'] as $setting) { $activityManager->registerSetting($setting); } } if (!empty($info['activity']['providers'])) { foreach ($info['activity']['providers'] as $provider) { $activityManager->registerProvider($provider); } } } if (!empty($info['settings'])) { $settingsManager = \OC::$server->get(ISettingsManager::class); if (!empty($info['settings']['admin'])) { foreach ($info['settings']['admin'] as $setting) { $settingsManager->registerSetting('admin', $setting); } } if (!empty($info['settings']['admin-section'])) { foreach ($info['settings']['admin-section'] as $section) { $settingsManager->registerSection('admin', $section); } } if (!empty($info['settings']['personal'])) { foreach ($info['settings']['personal'] as $setting) { $settingsManager->registerSetting('personal', $setting); } } if (!empty($info['settings']['personal-section'])) { foreach ($info['settings']['personal-section'] as $section) { $settingsManager->registerSection('personal', $section); } } } if (!empty($info['collaboration']['plugins'])) { // deal with one or many plugin entries $plugins = isset($info['collaboration']['plugins']['plugin']['@value']) ? [$info['collaboration']['plugins']['plugin']] : $info['collaboration']['plugins']['plugin']; $collaboratorSearch = null; $autoCompleteManager = null; foreach ($plugins as $plugin) { if ($plugin['@attributes']['type'] === 'collaborator-search') { $pluginInfo = [ 'shareType' => $plugin['@attributes']['share-type'], 'class' => $plugin['@value'], ]; $collaboratorSearch ??= \OC::$server->get(ICollaboratorSearch::class); $collaboratorSearch->registerPlugin($pluginInfo); } elseif ($plugin['@attributes']['type'] === 'autocomplete-sort') { $autoCompleteManager ??= \OC::$server->get(IAutoCompleteManager::class); $autoCompleteManager->registerSorter($plugin['@value']); } } } $eventLogger->end("bootstrap:load_app:$app:info"); $eventLogger->end("bootstrap:load_app:$app"); } /** * Check if an app is loaded * @param string $app app id * @since 26.0.0 */ public function isAppLoaded(string $app): bool { return isset($this->loadedApps[$app]); } /** * Load app.php from the given app * * @param string $app app name * @throws \Error */ private static function requireAppFile(string $app): void { // encapsulated here to avoid variable scope conflicts require_once $app . '/appinfo/app.php'; } /** * Enable an app for every user * * @param string $appId * @param bool $forceEnable * @throws AppPathNotFoundException */ public function enableApp(string $appId, bool $forceEnable = false): void { // Check if app exists $this->getAppPath($appId); if ($forceEnable) { $this->overwriteNextcloudRequirement($appId); } $this->installedAppsCache[$appId] = 'yes'; $this->getAppConfig()->setValue($appId, 'enabled', 'yes'); $this->dispatcher->dispatchTyped(new AppEnableEvent($appId)); $this->dispatcher->dispatch(ManagerEvent::EVENT_APP_ENABLE, new ManagerEvent( ManagerEvent::EVENT_APP_ENABLE, $appId )); $this->clearAppsCache(); } /** * Whether a list of types contains a protected app type * * @param string[] $types * @return bool */ public function hasProtectedAppType($types) { if (empty($types)) { return false; } $protectedTypes = array_intersect($this->protectedAppTypes, $types); return !empty($protectedTypes); } /** * Enable an app only for specific groups * * @param string $appId * @param IGroup[] $groups * @param bool $forceEnable * @throws \InvalidArgumentException if app can't be enabled for groups * @throws AppPathNotFoundException */ public function enableAppForGroups(string $appId, array $groups, bool $forceEnable = false): void { // Check if app exists $this->getAppPath($appId); $info = $this->getAppInfo($appId); if (!empty($info['types']) && $this->hasProtectedAppType($info['types'])) { throw new \InvalidArgumentException("$appId can't be enabled for groups."); } if ($forceEnable) { $this->overwriteNextcloudRequirement($appId); } /** @var string[] $groupIds */ $groupIds = array_map(function ($group) { /** @var IGroup $group */ return ($group instanceof IGroup) ? $group->getGID() : $group; }, $groups); $this->installedAppsCache[$appId] = json_encode($groupIds); $this->getAppConfig()->setValue($appId, 'enabled', json_encode($groupIds)); $this->dispatcher->dispatchTyped(new AppEnableEvent($appId, $groupIds)); $this->dispatcher->dispatch(ManagerEvent::EVENT_APP_ENABLE_FOR_GROUPS, new ManagerEvent( ManagerEvent::EVENT_APP_ENABLE_FOR_GROUPS, $appId, $groups )); $this->clearAppsCache(); } /** * Disable an app for every user * * @param string $appId * @param bool $automaticDisabled * @throws \Exception if app can't be disabled */ public function disableApp($appId, $automaticDisabled = false): void { if ($this->isAlwaysEnabled($appId)) { throw new \Exception("$appId can't be disabled."); } if ($automaticDisabled) { $previousSetting = $this->getAppConfig()->getValue($appId, 'enabled', 'yes'); if ($previousSetting !== 'yes' && $previousSetting !== 'no') { $previousSetting = json_decode($previousSetting, true); } $this->autoDisabledApps[$appId] = $previousSetting; } unset($this->installedAppsCache[$appId]); $this->getAppConfig()->setValue($appId, 'enabled', 'no'); // run uninstall steps $appData = $this->getAppInfo($appId); if (!is_null($appData)) { \OC_App::executeRepairSteps($appId, $appData['repair-steps']['uninstall']); } $this->dispatcher->dispatchTyped(new AppDisableEvent($appId)); $this->dispatcher->dispatch(ManagerEvent::EVENT_APP_DISABLE, new ManagerEvent( ManagerEvent::EVENT_APP_DISABLE, $appId )); $this->clearAppsCache(); } /** * Get the directory for the given app. * * @throws AppPathNotFoundException if app folder can't be found */ public function getAppPath(string $appId): string { $appPath = \OC_App::getAppPath($appId); if ($appPath === false) { throw new AppPathNotFoundException('Could not find path for ' . $appId); } return $appPath; } /** * Get the web path for the given app. * * @param string $appId * @return string * @throws AppPathNotFoundException if app path can't be found */ public function getAppWebPath(string $appId): string { $appWebPath = \OC_App::getAppWebPath($appId); if ($appWebPath === false) { throw new AppPathNotFoundException('Could not find web path for ' . $appId); } return $appWebPath; } /** * Clear the cached list of apps when enabling/disabling an app */ public function clearAppsCache(): void { $this->appInfos = []; } /** * Returns a list of apps that need upgrade * * @param string $version Nextcloud version as array of version components * @return array list of app info from apps that need an upgrade * * @internal */ public function getAppsNeedingUpgrade($version) { $appsToUpgrade = []; $apps = $this->getInstalledApps(); foreach ($apps as $appId) { $appInfo = $this->getAppInfo($appId); $appDbVersion = $this->getAppConfig()->getValue($appId, 'installed_version'); if ($appDbVersion && isset($appInfo['version']) && version_compare($appInfo['version'], $appDbVersion, '>') && \OC_App::isAppCompatible($version, $appInfo) ) { $appsToUpgrade[] = $appInfo; } } return $appsToUpgrade; } /** * Returns the app information from "appinfo/info.xml". * * @param string|null $lang * @return array|null app info */ public function getAppInfo(string $appId, bool $path = false, $lang = null) { if ($path) { throw new \InvalidArgumentException('Calling IAppManager::getAppInfo() with a path is no longer supported. Please call IAppManager::getAppInfoByPath() instead and verify that the path is good before calling.'); } if ($lang === null && isset($this->appInfos[$appId])) { return $this->appInfos[$appId]; } try { $appPath = $this->getAppPath($appId); } catch (AppPathNotFoundException) { return null; } $file = $appPath . '/appinfo/info.xml'; $data = $this->getAppInfoByPath($file, $lang); if ($lang === null) { $this->appInfos[$appId] = $data; } return $data; } public function getAppInfoByPath(string $path, ?string $lang = null): ?array { if (!str_ends_with($path, '/appinfo/info.xml')) { return null; } $parser = new InfoParser($this->memCacheFactory->createLocal('core.appinfo')); $data = $parser->parse($path); if (is_array($data)) { $data = \OC_App::parseAppInfo($data, $lang); } return $data; } public function getAppVersion(string $appId, bool $useCache = true): string { if (!$useCache || !isset($this->appVersions[$appId])) { $appInfo = $this->getAppInfo($appId); $this->appVersions[$appId] = ($appInfo !== null && isset($appInfo['version'])) ? $appInfo['version'] : '0'; } return $this->appVersions[$appId]; } /** * Returns a list of apps incompatible with the given version * * @param string $version Nextcloud version as array of version components * * @return array list of app info from incompatible apps * * @internal */ public function getIncompatibleApps(string $version): array { $apps = $this->getInstalledApps(); $incompatibleApps = []; foreach ($apps as $appId) { $info = $this->getAppInfo($appId); if ($info === null) { $incompatibleApps[] = ['id' => $appId, 'name' => $appId]; } elseif (!\OC_App::isAppCompatible($version, $info)) { $incompatibleApps[] = $info; } } return $incompatibleApps; } /** * @inheritdoc * In case you change this method, also change \OC\App\CodeChecker\InfoChecker::isShipped() */ public function isShipped($appId) { $this->loadShippedJson(); return in_array($appId, $this->shippedApps, true); } private function isAlwaysEnabled(string $appId): bool { $alwaysEnabled = $this->getAlwaysEnabledApps(); return in_array($appId, $alwaysEnabled, true); } /** * In case you change this method, also change \OC\App\CodeChecker\InfoChecker::loadShippedJson() * @throws \Exception */ private function loadShippedJson(): void { if ($this->shippedApps === null) { $shippedJson = \OC::$SERVERROOT . '/core/shipped.json'; if (!file_exists($shippedJson)) { throw new \Exception("File not found: $shippedJson"); } $content = json_decode(file_get_contents($shippedJson), true); $this->shippedApps = $content['shippedApps']; $this->alwaysEnabled = $content['alwaysEnabled']; $this->defaultEnabled = $content['defaultEnabled']; } } /** * @inheritdoc */ public function getAlwaysEnabledApps() { $this->loadShippedJson(); return $this->alwaysEnabled; } /** * @inheritdoc */ public function isDefaultEnabled(string $appId): bool { return (in_array($appId, $this->getDefaultEnabledApps())); } /** * @inheritdoc */ public function getDefaultEnabledApps(): array { $this->loadShippedJson(); return $this->defaultEnabled; } /** * @inheritdoc */ public function getDefaultAppForUser(?IUser $user = null, bool $withFallbacks = true): string { $id = $this->getNavigationManager()->getDefaultEntryIdForUser($user, $withFallbacks); $entry = $this->getNavigationManager()->get($id); return (string)$entry['app']; } /** * @inheritdoc */ public function getDefaultApps(): array { $ids = $this->getNavigationManager()->getDefaultEntryIds(); return array_values(array_unique(array_map(function (string $id) { $entry = $this->getNavigationManager()->get($id); return (string)$entry['app']; }, $ids))); } /** * @inheritdoc */ public function setDefaultApps(array $defaultApps): void { $entries = $this->getNavigationManager()->getAll(); $ids = []; foreach ($defaultApps as $defaultApp) { foreach ($entries as $entry) { if ((string)$entry['app'] === $defaultApp) { $ids[] = (string)$entry['id']; break; } } } $this->getNavigationManager()->setDefaultEntryIds($ids); } public function isBackendRequired(string $backend): bool { foreach ($this->appInfos as $appInfo) { foreach ($appInfo['dependencies']['backend'] as $appBackend) { if ($backend === $appBackend) { return true; } } } return false; } public function cleanAppId(string $app): string { // FIXME should list allowed characters instead return str_replace(['<', '>', '"', "'", '\0', '/', '\\', '..'], '', $app); } }