, array{}> * * 200: The categories were found successfully */ #[ApiRoute(verb: 'GET', url: '/api/v1/apps/categories')] public function listCategories(): DataResponse { $currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2); $categories = $this->categoryFetcher->get(); $categories = array_map(fn (array $category): array => [ 'id' => $category['id'], 'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'], ], $categories); return new DataResponse($categories); } /** * Get all available apps * * @param bool $details - Whether to include detailed appstore information about the app * @return DataResponse, internal: bool, isCompatible: bool, missingDependencies?: list, missingMaxNextcloudVersion: bool, missingMinNextcloudVersion: bool, ...}>, array{}> * * 200: The apps were found successfully */ #[ApiRoute(verb: 'GET', url: '/api/v1/apps')] public function listApps(bool $details = false): DataResponse { $apps = $this->getAllApps(); /** @var array|mixed $ignoreMaxApps */ $ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []); if (!is_array($ignoreMaxApps)) { $this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...'); $ignoreMaxApps = []; } // Extend existing app details $apps = array_map(function (array $appData) use ($ignoreMaxApps, $details): array { if (isset($appData['appstoreData'])) { $appstoreData = $appData['appstoreData']; $appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? ''); $appData['category'] = $appstoreData['categories']; $appData['releases'] = $appstoreData['releases']; if (!$details) { unset($appData['appstoreData']); } } $newVersion = $this->installer->isUpdateAvailable($appData['id']); if ($newVersion !== false) { $appData['update'] = $newVersion; } // fix groups to be an array $groups = []; if (is_string($appData['groups'])) { /** @var list|string $groups */ $groups = json_decode($appData['groups']); // ensure 'groups' is an array if (!is_array($groups)) { $groups = [$groups]; } } $appData['groups'] = $groups; // analyze dependencies $ignoreMax = in_array($appData['id'], $ignoreMaxApps); $missing = $this->dependencyAnalyzer->analyze($appData, $ignoreMax); $appData['missingDependencies'] = $missing; $appData['missingMinNextcloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']); $appData['missingMaxNextcloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']); $appData['isCompatible'] = $this->dependencyAnalyzer->isMarkedCompatible($appData); $appData['internal'] = in_array($appData['id'], $this->appManager->getAlwaysEnabledApps()); return $appData; }, $apps); /** * @var list, internal: bool, isCompatible: bool, missingDependencies?: list, missingMaxNextcloudVersion: bool, missingMinNextcloudVersion: bool, ...}> $apps */ usort($apps, $this->sortApps(...)); return new DataResponse($apps); } /** * Enable one apps * * App will be enabled for specific groups only if $groups is defined * * @param string $appId - The app to enable * @param list $groups - The groups to enable the app for * @param bool $force - Whether to force enable the app even if Nextcloud version requirements are not met * * @return DataResponse * @throws OCSException - if the app could not be enabled * * 200: App successfully enabled */ #[PasswordConfirmationRequired(strict: true)] #[ApiRoute(verb: 'POST', url: '/api/v1/apps/enable')] public function enableApp(string $appId, array $groups = [], bool $force = false): DataResponse { try { $appId = $this->appManager->cleanAppId($appId); if ($force) { $this->appManager->overwriteNextcloudRequirement($appId); } // Check if app is already downloaded if (!$this->installer->isDownloaded($appId)) { $this->installer->downloadApp($appId); } $this->installer->installApp($appId); if ($groups !== []) { $this->appManager->enableAppForGroups($appId, $this->getGroupList($groups)); } else { $this->appManager->enableApp($appId); } $updateRequired = $this->appManager->isUpgradeRequired($appId); return new DataResponse(['update_required' => $updateRequired]); } catch (\Throwable $throwable) { $this->logger->error('could not enable app', ['exception' => $throwable]); throw new OCSException('could not enable app', Http::STATUS_INTERNAL_SERVER_ERROR, $throwable); } } /** * Disable an app * * @param string $appId - The app to disable * * @return DataResponse * @throws OCSException - if the app could not be disabled * * 200: App successfully disabled */ #[PasswordConfirmationRequired(strict: false)] #[ApiRoute(verb: 'POST', url: '/api/v1/apps/disable')] public function disableApp(string $appId): DataResponse { try { $appId = $this->appManager->cleanAppId($appId); $this->appManager->removeOverwriteNextcloudRequirement($appId); $this->appManager->disableApp($appId); return new DataResponse([]); } catch (\Exception $exception) { $this->logger->error('could not disable app', ['exception' => $exception]); throw new OCSException('could not disable app', Http::STATUS_INTERNAL_SERVER_ERROR, $exception); } } /** * Uninstall an app. * * @param string $appId - The app to uninstall * @return DataResponse * @throws OCSException - if the app could not be uninstalled * * 200: App successfully uninstalled */ #[PasswordConfirmationRequired(strict: true)] #[ApiRoute(verb: 'POST', url: '/api/v1/apps/uninstall')] public function uninstallApp(string $appId): DataResponse { $appId = $this->appManager->cleanAppId($appId); if ($this->appManager->isEnabledForAnyone($appId)) { $this->disableApp($appId); } $result = $this->installer->removeApp($appId); if ($result !== false) { // If this app was force enabled, remove the force-enabled-state $this->appManager->removeOverwriteNextcloudRequirement($appId); $this->appManager->clearAppsCache(); return new DataResponse([]); } throw new OCSException('could not remove app', Http::STATUS_INTERNAL_SERVER_ERROR); } /** * Update an app * * @param string $appId - The app to update * @return DataResponse * @throws OCSException - if the app could not be updated * * 200: App successfully updated */ #[PasswordConfirmationRequired(strict: true)] #[ApiRoute(verb: 'POST', url: '/api/v1/apps/update')] public function updateApp(string $appId): DataResponse { $appId = $this->appManager->cleanAppId($appId); $this->config->setSystemValue('maintenance', true); try { $result = $this->installer->updateAppstoreApp($appId); $this->config->setSystemValue('maintenance', false); if ($result === false) { throw new \Exception('Update failed'); } } catch (\Exception $exception) { $this->config->setSystemValue('maintenance', false); throw new OCSException('could not update app', Http::STATUS_INTERNAL_SERVER_ERROR, $exception); } return new DataResponse([]); } /** * Enable all apps of a bundle * * @param string $bundleId - The bundle to enable * @return DataResponse * @throws OCSException - if the bundle, or one app within, could not be enabled * * 200: Bundle successfully enabled */ #[PasswordConfirmationRequired(strict: true)] #[ApiRoute(verb: 'POST', url: '/api/v1/bundles/enable')] public function enableBundle(string $bundleId): DataResponse { try { $bundle = $this->bundleFetcher->getBundleByIdentifier($bundleId); $this->config->setSystemValue('maintenance', true); $this->installer->installAppBundle($bundle); } catch (\BadMethodCallException $e) { throw new OCSNotFoundException('Bundle not found', $e); } catch (\Exception $exception) { $this->logger->error('could not enable bundle', ['bundleId' => $bundleId, 'exception' => $exception]); throw new OCSException('could not enable bundle', Http::STATUS_INTERNAL_SERVER_ERROR, $exception); } finally { $this->config->setSystemValue('maintenance', false); } return new DataResponse([]); } /** * Convert URL to proxied URL so CSP is no problem */ private function createProxyPreviewUrl(string $url): string { if ($url === '') { return ''; } return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url); } private function fetchApps(): void { $appClass = new \OC_App(); $apps = $appClass->listAllApps(); foreach ($apps as $app) { $app['installed'] = true; if (isset($app['screenshot'][0])) { $appScreenshot = $app['screenshot'][0] ?? null; if (is_array($appScreenshot)) { // Screenshot with thumbnail $appScreenshot = $appScreenshot['@value']; } $app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot); } $this->allApps[$app['id']] = $app; } $apps = $this->getAppsForCategory(''); $supportedApps = $this->subscriptionRegistry->delegateGetSupportedApps(); foreach ($apps as $app) { $app['appstore'] = true; if (!array_key_exists($app['id'], $this->allApps)) { $this->allApps[$app['id']] = $app; } else { $this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]); } if (in_array($app['id'], $supportedApps)) { $this->allApps[$app['id']]['level'] = \OC_App::supportedApp; } } // add bundle information $bundles = $this->bundleFetcher->getBundles(); foreach ($bundles as $bundle) { foreach ($bundle->getAppIdentifiers() as $identifier) { foreach ($this->allApps as &$app) { if ($app['id'] === $identifier) { $app['bundleIds'][] = $bundle->getIdentifier(); continue; } } } } } private function getAllApps() { if (empty($this->allApps)) { $this->fetchApps(); } return $this->allApps; } /** * Get all apps for a category from the app store * * @throws \Exception */ private function getAppsForCategory(string $requestedCategory = ''): array { $versionParser = new VersionParser(); $formattedApps = []; $apps = $this->appFetcher->get(); foreach ($apps as $app) { // Skip all apps not in the requested category if ($requestedCategory !== '') { $isInCategory = false; foreach ($app['categories'] as $category) { if ($category === $requestedCategory) { $isInCategory = true; } } if (!$isInCategory) { continue; } } if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) { continue; } $nextcloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']); $nextcloudVersionDependencies = []; if ($nextcloudVersion->getMinimumVersion() !== '') { $nextcloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextcloudVersion->getMinimumVersion(); } if ($nextcloudVersion->getMaximumVersion() !== '') { $nextcloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextcloudVersion->getMaximumVersion(); } $phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']); try { $this->appManager->getAppPath($app['id']); $existsLocally = true; } catch (AppPathNotFoundException) { $existsLocally = false; } $phpDependencies = []; if ($phpVersion->getMinimumVersion() !== '') { $phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion(); } if ($phpVersion->getMaximumVersion() !== '') { $phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion(); } if (isset($app['releases'][0]['minIntSize'])) { $phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize']; } $authors = ''; foreach ($app['authors'] as $key => $author) { $authors .= $author['name']; if ($key !== count($app['authors']) - 1) { $authors .= ', '; } } $currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2); $enabledValue = $this->appConfig->getValueString($app['id'], 'enabled', 'no'); $groups = null; if ($enabledValue !== 'no' && $enabledValue !== 'yes') { $groups = $enabledValue; } $currentVersion = ''; if ($this->appManager->isEnabledForAnyone($app['id'])) { $currentVersion = $this->appManager->getAppVersion($app['id']); } else { $currentVersion = $app['releases'][0]['version']; } $formattedApps[] = [ 'id' => $app['id'], 'app_api' => false, 'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'], 'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'], 'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'], 'license' => $app['releases'][0]['licenses'], 'author' => $authors, 'shipped' => $this->appManager->isShipped($app['id']), 'internal' => in_array($app['id'], $this->appManager->getAlwaysEnabledApps()), 'version' => $currentVersion, 'types' => [], 'documentation' => [ 'admin' => $app['adminDocs'], 'user' => $app['userDocs'], 'developer' => $app['developerDocs'] ], 'website' => $app['website'], 'bugs' => $app['issueTracker'], 'dependencies' => array_merge( $nextcloudVersionDependencies, $phpDependencies ), 'level' => ($app['isFeatured'] === true) ? 200 : 100, 'missingMaxNextcloudVersion' => false, 'missingMinNextcloudVersion' => false, 'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '', 'ratingOverall' => $app['ratingOverall'], 'ratingNumOverall' => $app['ratingNumOverall'], 'removable' => $existsLocally, 'active' => $this->appManager->isEnabledForUser($app['id']), 'needsDownload' => !$existsLocally, 'groups' => $groups, 'fromAppStore' => true, 'appstoreData' => $app, ]; } return $formattedApps; } /** * @param string[] $groups - The group ids to fetch * @return list - The list of groups matching the given group ids */ private function getGroupList(array $groups): array { $groupManager = Server::get(IGroupManager::class); $groupsList = []; foreach ($groups as $group) { $groupItem = $groupManager->get($group); if ($groupItem instanceof IGroup) { $groupsList[] = $groupItem; } } return $groupsList; } /** * @param array{name: string, ...} $a * @param array{name: string, ...} $b */ private function sortApps(array $a, array $b): int { return $a['name'] <=> $b['name']; } }