Files
Ferdinand Thiessen faf1a0cacf fix(appstore): only show appstore entries if actual from appstore
- resolves https://github.com/nextcloud/server/issues/61273

Shipped apps are no longer published to the appstore,
so the appstore data is outdated and confusing.
So ignore them from the app fetcher and only use the local data.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-06-24 22:44:18 +02:00

520 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Appstore\Controller;
use OC\App\AppManager;
use OC\App\AppStore\Bundles\BundleFetcher;
use OC\App\AppStore\Fetcher\AppFetcher;
use OC\App\AppStore\Fetcher\CategoryFetcher;
use OC\App\AppStore\Version\VersionParser;
use OC\App\DependencyAnalyzer;
use OC\Installer;
use OCA\Appstore\AppInfo\Application;
use OCP\App\AppPathNotFoundException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\L10N\IFactory;
use OCP\Server;
use OCP\Support\Subscription\IRegistry;
use Psr\Log\LoggerInterface;
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION)]
class ApiController extends OCSController {
/** @var array */
private $allApps = [];
public function __construct(
IRequest $request,
private readonly IConfig $config,
private readonly IAppConfig $appConfig,
private readonly AppManager $appManager,
private readonly DependencyAnalyzer $dependencyAnalyzer,
private readonly CategoryFetcher $categoryFetcher,
private readonly AppFetcher $appFetcher,
private readonly IFactory $l10nFactory,
private readonly BundleFetcher $bundleFetcher,
private readonly Installer $installer,
private readonly IRegistry $subscriptionRegistry,
private readonly LoggerInterface $logger,
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* Get all available categories
*
* @return DataResponse<Http::STATUS_OK, list<array{id: string, displayName: string}>, 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<Http::STATUS_OK, list<array{id: string, name: string, groups: list<string>, internal: bool, isCompatible: bool, missingDependencies?: list<string>, ...<array-key, mixed>}>, 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<string>|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>|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['isCompatible'] = $this->dependencyAnalyzer->isMarkedCompatible($appData);
$appData['internal'] = in_array($appData['id'], $this->appManager->getAlwaysEnabledApps());
return $appData;
}, $apps);
/**
* @var list<array{id: string, name: string, groups: list<string>, internal: bool, isCompatible: bool, missingDependencies?: list<string>, ...<array-key, mixed>}> $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<string> $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<Http::STATUS_OK, array{update_required: bool}, array{}>
* @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<Http::STATUS_OK, array{}, array{}>
* @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<Http::STATUS_OK, array{}, array{}>
* @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<Http::STATUS_OK, array{}, array{}>
* @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<Http::STATUS_OK, array{}, array{}>
* @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();
$shippedApps = $this->appManager->getAlwaysEnabledApps();
foreach ($apps as $app) {
if (in_array($app['id'], $shippedApps)) {
// shipped apps are no longer published on the appstore
// so skip them to avoid confusion with outdated data
continue;
}
$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,
'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,
'appstoreData' => $app,
];
}
return $formattedApps;
}
/**
* @param string[] $groups - The group ids to fetch
* @return list<IGroup> - 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'];
}
}