refactor(appstore): split controllers and use proper root

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-12-27 13:50:13 +01:00
parent 5b756ad8bc
commit f6a37dc608
31 changed files with 3194 additions and 2334 deletions

16
apps/appstore/REUSE.toml Normal file
View file

@ -0,0 +1,16 @@
version = 1
SPDX-PackageName = "nextcloud"
SPDX-PackageSupplier = "Nextcloud <info@nextcloud.com>"
SPDX-PackageDownloadLocation = "https://github.com/nextcloud/server"
[[annotations]]
path = ["tests/fixtures/categories.json", "tests/fixtures/categories-api-response.json"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "CC-BY-SA-4.0"
[[annotations]]
path = ["img/app.svg"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2018-2024 Google LLC"
SPDX-License-Identifier = "Apache-2.0"

View file

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
return [
'routes' => [
['name' => 'AppSettings#getAppDiscoverJSON', 'url' => '/settings/api/apps/discover', 'verb' => 'GET', 'root' => ''],
['name' => 'AppSettings#getAppDiscoverMedia', 'url' => '/settings/api/apps/media', 'verb' => 'GET', 'root' => ''],
['name' => 'AppSettings#listCategories', 'url' => '/settings/apps/categories', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#listApps', 'url' => '/settings/apps/list', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#enableApp', 'url' => '/settings/apps/enable/{appId}', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#enableApp', 'url' => '/settings/apps/enable/{appId}', 'verb' => 'POST' , 'root' => ''],
['name' => 'AppSettings#enableApps', 'url' => '/settings/apps/enable', 'verb' => 'POST' , 'root' => ''],
['name' => 'AppSettings#disableApp', 'url' => '/settings/apps/disable/{appId}', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#disableApps', 'url' => '/settings/apps/disable', 'verb' => 'POST' , 'root' => ''],
['name' => 'AppSettings#updateApp', 'url' => '/settings/apps/update/{appId}', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#uninstallApp', 'url' => '/settings/apps/uninstall/{appId}', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps/{category}', 'verb' => 'GET', 'defaults' => ['category' => ''] , 'root' => ''],
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps/{category}/{id}', 'verb' => 'GET', 'defaults' => ['category' => '', 'id' => ''] , 'root' => ''],
['name' => 'AppSettings#force', 'url' => '/settings/apps/force', 'verb' => 'POST' , 'root' => ''],
],
];

View file

@ -8,7 +8,8 @@ $baseDir = $vendorDir;
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'OCA\\Appstore\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
'OCA\\Appstore\\Controller\\AppSettingsController' => $baseDir . '/../lib/Controller/AppSettingsController.php',
'OCA\\Appstore\\Controller\\ApiController' => $baseDir . '/../lib/Controller/ApiController.php',
'OCA\\Appstore\\Controller\\DiscoverController' => $baseDir . '/../lib/Controller/DiscoverController.php',
'OCA\\Appstore\\Controller\\PageController' => $baseDir . '/../lib/Controller/PageController.php',
'OCA\\Appstore\\Search\\AppSearch' => $baseDir . '/../lib/Search/AppSearch.php',
'OCA\\Settings\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
);

View file

@ -7,14 +7,14 @@ namespace Composer\Autoload;
class ComposerStaticInitAppstore
{
public static $prefixLengthsPsr4 = array (
'O' =>
'O' =>
array (
'OCA\\Appstore\\' => 13,
),
);
public static $prefixDirsPsr4 = array (
'OCA\\Appstore\\' =>
'OCA\\Appstore\\' =>
array (
0 => __DIR__ . '/..' . '/../lib',
),
@ -23,9 +23,10 @@ class ComposerStaticInitAppstore
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'OCA\\Appstore\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
'OCA\\Appstore\\Controller\\AppSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AppSettingsController.php',
'OCA\\Appstore\\Controller\\ApiController' => __DIR__ . '/..' . '/../lib/Controller/ApiController.php',
'OCA\\Appstore\\Controller\\DiscoverController' => __DIR__ . '/..' . '/../lib/Controller/DiscoverController.php',
'OCA\\Appstore\\Controller\\PageController' => __DIR__ . '/..' . '/../lib/Controller/PageController.php',
'OCA\\Appstore\\Search\\AppSearch' => __DIR__ . '/..' . '/../lib/Search/AppSearch.php',
'OCA\\Settings\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
);
public static function getInitializer(ClassLoader $loader)

View file

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 321 B

View file

@ -23,10 +23,12 @@ class Application extends App implements IBootstrap {
parent::__construct(self::APP_ID, $urlParams);
}
#[\Override]
public function register(IRegistrationContext $context): void {
$context->registerSearchProvider(AppSearch::class);
}
#[\Override]
public function boot(IBootContext $context): void {
}
}

View file

@ -0,0 +1,497 @@
<?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 IConfig $config,
private IAppConfig $appConfig,
private AppManager $appManager,
private DependencyAnalyzer $dependencyAnalyzer,
private CategoryFetcher $categoryFetcher,
private AppFetcher $appFetcher,
private IFactory $l10nFactory,
private BundleFetcher $bundleFetcher,
private Installer $installer,
private IRegistry $subscriptionRegistry,
private 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 ($category) => [
'id' => $category['id'],
'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
], $categories);
return new DataResponse($categories);
}
/**
* Get all available apps
*
* @return DataResponse<Http::STATUS_OK, list<array{id: string, name: string, description: string, ...}>, array{}>
*
* 200: The apps were found successfully
*/
#[ApiRoute(verb: 'GET', url: '/api/v1/apps')]
public function listApps(): DataResponse {
$apps = $this->getAllApps();
$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) {
if (isset($appData['appstoreData'])) {
$appstoreData = $appData['appstoreData'];
$appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
$appData['category'] = $appstoreData['categories'];
$appData['releases'] = $appstoreData['releases'];
}
$newVersion = $this->installer->isUpdateAvailable($appData['id']);
if ($newVersion) {
$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;
$appData['canUninstall'] = !$appData['active'] && $appData['removable'];
// analyze dependencies
$ignoreMax = in_array($appData['id'], $ignoreMaxApps);
$missing = $this->dependencyAnalyzer->analyze($appData, $ignoreMax);
$appData['canInstall'] = empty($missing);
$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);
return $appData;
}, $apps);
usort($apps, $this->sortApps(...));
/**
* @var list<array{id: string, name: string, description: string, ...}> $apps
*/
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 (count($groups) > 0) {
$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 $e) {
$this->logger->error('could not enable app', ['exception' => $e]);
throw new OCSException('could not enable app', Http::STATUS_INTERNAL_SERVER_ERROR, $e);
}
}
/**
* 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->disableApp($appId);
return new DataResponse([]);
} catch (\Exception $e) {
$this->logger->error('could not disable app', ['exception' => $e]);
throw new OCSException('could not disable app', Http::STATUS_INTERNAL_SERVER_ERROR, $e);
}
}
/**
* Uninstall an app.
* This will disable the app - if needed - and then remove the app from the system
*
* @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);
$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 $ex) {
$this->config->setSystemValue('maintenance', false);
throw new OCSException('could not update app', Http::STATUS_INTERNAL_SERVER_ERROR, $ex);
}
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() {
$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
*
* @param string $requestedCategory
* @return array
* @throws \Exception
*/
private function getAppsForCategory($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']),
'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,
'canInstall' => true,
'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
'score' => $app['ratingOverall'],
'ratingNumOverall' => $app['ratingNumOverall'],
'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
'removable' => $existsLocally,
'active' => $this->appManager->isEnabledForUser($app['id']),
'needsDownload' => !$existsLocally,
'groups' => $groups,
'fromAppStore' => true,
'appstoreData' => $app,
];
}
return $formattedApps;
}
private function getGroupList(array $groups) {
$groupManager = Server::get(IGroupManager::class);
$groupsList = [];
foreach ($groups as $group) {
$groupItem = $groupManager->get($group);
if ($groupItem instanceof IGroup) {
$groupsList[] = $groupManager->get($group);
}
}
return $groupsList;
}
private function sortApps($a, $b) {
$a = (string)$a['name'];
$b = (string)$b['name'];
if ($a === $b) {
return 0;
}
return ($a < $b) ? -1 : 1;
}
}

View file

@ -1,689 +0,0 @@
<?php
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Appstore\Controller;
use OC\App\AppManager;
use OC\App\AppStore\Bundles\BundleFetcher;
use OC\App\AppStore\Fetcher\AppDiscoverFetcher;
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\AppAPI\Service\ExAppsPageService;
use OCP\App\AppPathNotFoundException;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\INavigationManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Security\RateLimiting\ILimiter;
use OCP\Server;
use OCP\Util;
use Psr\Log\LoggerInterface;
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class AppSettingsController extends Controller {
/** @var array */
private $allApps = [];
private IAppData $appData;
public function __construct(
string $appName,
IRequest $request,
IAppDataFactory $appDataFactory,
private IL10N $l10n,
private IConfig $config,
private INavigationManager $navigationManager,
private AppManager $appManager,
private CategoryFetcher $categoryFetcher,
private AppFetcher $appFetcher,
private IFactory $l10nFactory,
private IGroupManager $groupManager,
private BundleFetcher $bundleFetcher,
private Installer $installer,
private IURLGenerator $urlGenerator,
private LoggerInterface $logger,
private IInitialState $initialState,
private AppDiscoverFetcher $discoverFetcher,
private IClientService $clientService,
) {
parent::__construct($appName, $request);
$this->appData = $appDataFactory->get('appstore');
}
/**
* @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
*
* @return TemplateResponse
*/
#[NoCSRFRequired]
public function viewApps(): TemplateResponse {
$this->navigationManager->setActiveEntry('core_apps');
$this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
$this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
$this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
$this->initialState->provideInitialState('isAllInOne', filter_var(getenv('THIS_IS_AIO'), FILTER_VALIDATE_BOOL));
$groups = array_map(static fn (IGroup $group): array => [
'id' => $group->getGID(),
'name' => $group->getDisplayName(),
], $this->groupManager->search('', 5));
$this->initialState->provideInitialState('usersSettings', [ 'systemGroups' => $groups]);
if ($this->appManager->isEnabledForAnyone('app_api')) {
try {
Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
} catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
}
}
$policy = new ContentSecurityPolicy();
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
$templateResponse = new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]);
$templateResponse->setContentSecurityPolicy($policy);
Util::addScript('appstore', 'main');
return $templateResponse;
}
/**
* Get all active entries for the app discover section
*/
#[NoCSRFRequired]
public function getAppDiscoverJSON(): JSONResponse {
$data = $this->discoverFetcher->get(true);
return new JSONResponse($data);
}
/**
* Get a image for the app discover section - this is proxied for privacy and CSP reasons
*
* @param string $image
* @throws \Exception
*/
#[NoCSRFRequired]
public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): Response {
$getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
$etag = trim($getEtag, '"');
$folder = null;
try {
$folder = $this->appData->getFolder('app-discover-cache');
$this->cleanUpImageCache($folder, $etag);
} catch (\Throwable $e) {
$folder = $this->appData->newFolder('app-discover-cache');
}
// Get the current cache folder
try {
$folder = $folder->getFolder($etag);
} catch (NotFoundException $e) {
$folder = $folder->newFolder($etag);
}
$info = pathinfo($fileName);
$hashName = md5($fileName);
$allFiles = $folder->getDirectoryListing();
// Try to find the file
$file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
return str_starts_with($file->getName(), $hashName);
});
// Get the first entry
$file = reset($file);
// If not found request from Web
if ($file === false) {
$user = $session->getUser();
// this route is not public thus we can assume a user is logged-in
assert($user !== null);
// Register a user request to throttle fetching external data
// this will prevent using the server for DoS of other systems.
$limiter->registerUserRequest(
'settings-discover-media',
// allow up to 24 media requests per hour
// this should be a sane default when a completely new section is loaded
// keep in mind browsers request all files from a source-set
24,
60 * 60,
$user,
);
if (!$this->checkCanDownloadMedia($fileName)) {
$this->logger->warning('Tried to load media files for app discover section from untrusted source');
return new NotFoundResponse(Http::STATUS_BAD_REQUEST);
}
try {
$client = $this->clientService->newClient();
$fileResponse = $client->get($fileName);
$contentType = $fileResponse->getHeader('Content-Type');
$extension = $info['extension'] ?? '';
$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
} catch (\Throwable $e) {
$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
return new NotFoundResponse();
}
} else {
// File was found so we can get the content type from the file name
$contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
}
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
// cache for 7 days
$response->cacheFor(604800, false, true);
return $response;
}
private function checkCanDownloadMedia(string $filename): bool {
$urlInfo = parse_url($filename);
if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
return false;
}
// Always allowed hosts
if ($urlInfo['host'] === 'nextcloud.com') {
return true;
}
// Hosts that need further verification
// Github is only allowed if from our organization
$ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) {
return false;
}
if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) {
return true;
}
return false;
}
/**
* Remove orphaned folders from the image cache that do not match the current etag
* @param ISimpleFolder $folder The folder to clear
* @param string $etag The etag (directory name) to keep
*/
private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
// Cleanup old cache folders
$allFiles = $folder->getDirectoryListing();
foreach ($allFiles as $dir) {
try {
if ($dir->getName() !== $etag) {
$dir->delete();
}
} catch (NotPermittedException $e) {
// ignore folder for now
}
}
}
private function getAppsWithUpdates() {
$appClass = new \OC_App();
$apps = $appClass->listAllApps();
foreach ($apps as $key => $app) {
$newVersion = $this->installer->isUpdateAvailable($app['id']);
if ($newVersion === false) {
unset($apps[$key]);
}
}
return $apps;
}
private function getBundles() {
$result = [];
$bundles = $this->bundleFetcher->getBundles();
foreach ($bundles as $bundle) {
$result[] = [
'name' => $bundle->getName(),
'id' => $bundle->getIdentifier(),
'appIdentifiers' => $bundle->getAppIdentifiers()
];
}
return $result;
}
/**
* Get all available categories
*
* @return JSONResponse
*/
public function listCategories(): JSONResponse {
return new JSONResponse($this->getAllCategories());
}
private function getAllCategories() {
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
$categories = $this->categoryFetcher->get();
return array_map(fn ($category) => [
'id' => $category['id'],
'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
], $categories);
}
/**
* 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() {
$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 = $appClass->getSupportedApps();
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() {
return $this->allApps;
}
/**
* Get all available apps in a category
*
* @return JSONResponse
* @throws \Exception
*/
public function listApps(): JSONResponse {
$this->fetchApps();
$apps = $this->getAllApps();
$dependencyAnalyzer = Server::get(DependencyAnalyzer::class);
$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 ($dependencyAnalyzer, $ignoreMaxApps) {
if (isset($appData['appstoreData'])) {
$appstoreData = $appData['appstoreData'];
$appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
$appData['category'] = $appstoreData['categories'];
$appData['releases'] = $appstoreData['releases'];
}
$newVersion = $this->installer->isUpdateAvailable($appData['id']);
if ($newVersion) {
$appData['update'] = $newVersion;
}
// fix groups to be an array
$groups = [];
if (is_string($appData['groups'])) {
$groups = json_decode($appData['groups']);
// ensure 'groups' is an array
if (!is_array($groups)) {
$groups = [$groups];
}
}
$appData['groups'] = $groups;
$appData['canUnInstall'] = !$appData['active'] && $appData['removable'];
// fix licence vs license
if (isset($appData['license']) && !isset($appData['licence'])) {
$appData['licence'] = $appData['license'];
}
$ignoreMax = in_array($appData['id'], $ignoreMaxApps);
// analyse dependencies
$missing = $dependencyAnalyzer->analyze($appData, $ignoreMax);
$appData['canInstall'] = empty($missing);
$appData['missingDependencies'] = $missing;
$appData['missingMinOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
$appData['missingMaxOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
$appData['isCompatible'] = $dependencyAnalyzer->isMarkedCompatible($appData);
return $appData;
}, $apps);
usort($apps, [$this, 'sortApps']);
return new JSONResponse(['apps' => $apps, 'status' => 'success']);
}
/**
* Get all apps for a category from the app store
*
* @param string $requestedCategory
* @return array
* @throws \Exception
*/
private function getAppsForCategory($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->config->getAppValue($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']),
'version' => $currentVersion,
'default_enable' => '',
'types' => [],
'documentation' => [
'admin' => $app['adminDocs'],
'user' => $app['userDocs'],
'developer' => $app['developerDocs']
],
'website' => $app['website'],
'bugs' => $app['issueTracker'],
'detailpage' => $app['website'],
'dependencies' => array_merge(
$nextCloudVersionDependencies,
$phpDependencies
),
'level' => ($app['isFeatured'] === true) ? 200 : 100,
'missingMaxOwnCloudVersion' => false,
'missingMinOwnCloudVersion' => false,
'canInstall' => true,
'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
'score' => $app['ratingOverall'],
'ratingNumOverall' => $app['ratingNumOverall'],
'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
'removable' => $existsLocally,
'active' => $this->appManager->isEnabledForUser($app['id']),
'needsDownload' => !$existsLocally,
'groups' => $groups,
'fromAppStore' => true,
'appstoreData' => $app,
];
}
return $formattedApps;
}
/**
* @param string $appId
* @param array $groups
* @return JSONResponse
*/
#[PasswordConfirmationRequired]
public function enableApp(string $appId, array $groups = []): JSONResponse {
return $this->enableApps([$appId], $groups);
}
/**
* Enable one or more apps
*
* apps will be enabled for specific groups only if $groups is defined
*
* @param array $appIds
* @param array $groups
* @return JSONResponse
*/
#[PasswordConfirmationRequired(strict: true)]
public function enableApps(array $appIds, array $groups = []): JSONResponse {
try {
$updateRequired = false;
foreach ($appIds as $appId) {
$appId = $this->appManager->cleanAppId($appId);
// Check if app is already downloaded
if (!$this->installer->isDownloaded($appId)) {
$this->installer->downloadApp($appId);
}
$this->installer->installApp($appId);
if (count($groups) > 0) {
$this->appManager->enableAppForGroups($appId, $this->getGroupList($groups));
} else {
$this->appManager->enableApp($appId);
}
$updateRequired = $updateRequired || $this->appManager->isUpgradeRequired($appId);
}
return new JSONResponse(['data' => ['update_required' => $updateRequired]]);
} catch (\Throwable $e) {
$this->logger->error('could not enable apps', ['exception' => $e]);
return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
private function getGroupList(array $groups) {
$groupManager = Server::get(IGroupManager::class);
$groupsList = [];
foreach ($groups as $group) {
$groupItem = $groupManager->get($group);
if ($groupItem instanceof IGroup) {
$groupsList[] = $groupManager->get($group);
}
}
return $groupsList;
}
/**
* @param string $appId
* @return JSONResponse
*/
#[PasswordConfirmationRequired]
public function disableApp(string $appId): JSONResponse {
return $this->disableApps([$appId]);
}
/**
* @param array $appIds
* @return JSONResponse
*/
#[PasswordConfirmationRequired]
public function disableApps(array $appIds): JSONResponse {
try {
foreach ($appIds as $appId) {
$appId = $this->appManager->cleanAppId($appId);
$this->appManager->disableApp($appId);
}
return new JSONResponse([]);
} catch (\Exception $e) {
$this->logger->error('could not disable app', ['exception' => $e]);
return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* @param string $appId
* @return JSONResponse
*/
#[PasswordConfirmationRequired]
public function uninstallApp(string $appId): JSONResponse {
$appId = $this->appManager->cleanAppId($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 JSONResponse(['data' => ['appid' => $appId]]);
}
return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not remove app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
/**
* @param string $appId
* @return JSONResponse
*/
public function updateApp(string $appId): JSONResponse {
$appId = $this->appManager->cleanAppId($appId);
$this->config->setSystemValue('maintenance', true);
try {
$result = $this->installer->updateAppstoreApp($appId);
$this->config->setSystemValue('maintenance', false);
} catch (\Exception $ex) {
$this->config->setSystemValue('maintenance', false);
return new JSONResponse(['data' => ['message' => $ex->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
if ($result !== false) {
return new JSONResponse(['data' => ['appid' => $appId]]);
}
return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not update app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
private function sortApps($a, $b) {
$a = (string)$a['name'];
$b = (string)$b['name'];
if ($a === $b) {
return 0;
}
return ($a < $b) ? -1 : 1;
}
public function force(string $appId): JSONResponse {
$appId = $this->appManager->cleanAppId($appId);
$this->appManager->overwriteNextcloudRequirement($appId);
return new JSONResponse();
}
}

View file

@ -0,0 +1,197 @@
<?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\AppStore\Fetcher\AppDiscoverFetcher;
use OC\App\AppStore\Fetcher\ResponseDefinitions;
use OCA\Appstore\AppInfo\Application;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\OCS\OCSBadRequestException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\Http\Client\IClientService;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Security\RateLimiting\ILimiter;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type AppStoreFetcherDiscoverElement from ResponseDefinitions
*/
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class DiscoverController extends OCSController {
private IAppData $appData;
public function __construct(
IRequest $request,
IAppDataFactory $appDataFactory,
private IClientService $clientService,
private AppDiscoverFetcher $discoverFetcher,
private LoggerInterface $logger,
) {
parent::__construct(Application::APP_ID, $request);
$this->appData = $appDataFactory->get(Application::APP_ID);
}
/**
* Get all active entries for the app discover section
*
* @return DataResponse<Http::STATUS_OK, list<AppStoreFetcherDiscoverElement>, array{}>
*
* 200: List of active entries for the app discover section
*/
#[NoCSRFRequired]
#[ApiRoute(verb: 'GET', url:'/api/v1/discover')]
public function getAppDiscoverJSON(): DataResponse {
$data = $this->discoverFetcher->get(true);
return new DataResponse($data);
}
/**
* Get a image for the app discover section - this is proxied for privacy and CSP reasons
*
* @param string $fileName - The image file name
* @return FileDisplayResponse<Http::STATUS_OK, array{'Content-Type': string}>
* @throws OCSBadRequestException - if the media source is not trusted
* @throws OCSNotFoundException - if the media file could not be found
*
* 200: The media file was found and is returned
* 400: The media source is not trusted
* 404: The media file could not be found
*/
#[NoCSRFRequired]
#[ApiRoute(verb: 'GET', url: '/api/v1/discover/media')]
public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): FileDisplayResponse {
$getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
$etag = trim($getEtag, '"');
$folder = null;
try {
$folder = $this->appData->getFolder('app-discover-cache');
$this->cleanUpImageCache($folder, $etag);
} catch (\Throwable $e) {
$folder = $this->appData->newFolder('app-discover-cache');
}
// Get the current cache folder
try {
$folder = $folder->getFolder($etag);
} catch (NotFoundException $e) {
$folder = $folder->newFolder($etag);
}
$info = pathinfo($fileName);
$hashName = md5($fileName);
$allFiles = $folder->getDirectoryListing();
// Try to find the file
$file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
return str_starts_with($file->getName(), $hashName);
});
// Get the first entry
$file = reset($file);
// If not found request from Web
if ($file === false) {
$user = $session->getUser();
// this route is not public thus we can assume a user is logged-in
assert($user !== null);
// Register a user request to throttle fetching external data
// this will prevent using the server for DoS of other systems.
$limiter->registerUserRequest(
'settings-discover-media',
// allow up to 24 media requests per hour
// this should be a sane default when a completely new section is loaded
// keep in mind browsers request all files from a source-set
24,
60 * 60,
$user,
);
if (!$this->checkCanDownloadMedia($fileName)) {
$this->logger->warning('Tried to load media files for app discover section from untrusted source');
throw new OCSBadRequestException('Untrusted media source');
}
try {
$client = $this->clientService->newClient();
$fileResponse = $client->get($fileName);
$contentType = $fileResponse->getHeader('Content-Type');
$extension = $info['extension'] ?? '';
$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
} catch (\Throwable $e) {
$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
throw new OCSNotFoundException('Media file not found');
}
} else {
// File was found so we can get the content type from the file name
$contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
}
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
// cache for 7 days
$response->cacheFor(604800, false, true);
return $response;
}
private function checkCanDownloadMedia(string $filename): bool {
$urlInfo = parse_url($filename);
if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
return false;
}
// Always allowed hosts
if ($urlInfo['host'] === 'nextcloud.com') {
return true;
}
// Hosts that need further verification
// Github is only allowed if from our organization
$ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) {
return false;
}
if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) {
return true;
}
return false;
}
/**
* Remove orphaned folders from the image cache that do not match the current etag
* @param ISimpleFolder $folder The folder to clear
* @param string $etag The etag (directory name) to keep
*/
private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
// Cleanup old cache folders
$allFiles = $folder->getDirectoryListing();
foreach ($allFiles as $dir) {
try {
if ($dir->getName() !== $etag) {
$dir->delete();
}
} catch (NotPermittedException $e) {
// ignore folder for now
}
}
}
}

View file

@ -0,0 +1,110 @@
<?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\Installer;
use OCA\AppAPI\Service\ExAppsPageService;
use OCA\Appstore\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\IL10N;
use OCP\INavigationManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\Server;
use OCP\Util;
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class PageController extends Controller {
public function __construct(
IRequest $request,
private IL10N $l10n,
private IConfig $config,
private Installer $installer,
private AppManager $appManager,
private IURLGenerator $urlGenerator,
private IInitialState $initialState,
private BundleFetcher $bundleFetcher,
private INavigationManager $navigationManager,
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
*
* @return TemplateResponse
*/
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/settings/apps', root: '')]
#[FrontpageRoute(verb: 'GET', url: '/settings/apps/{category}', defaults: ['category' => ''], root: '')]
#[FrontpageRoute(verb: 'GET', url: '/settings/apps/{category}/{id}', defaults: ['category' => '', 'id' => ''], root: '')]
public function viewApps(): TemplateResponse {
$this->navigationManager->setActiveEntry('core_apps');
$this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
$this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
$this->initialState->provideInitialState('appstoreDeveloperDocs', $this->urlGenerator->linkToDocs('developer-manual'));
$this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
if ($this->appManager->isEnabledForAnyone('app_api')) {
try {
Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
} catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
}
}
$policy = new ContentSecurityPolicy();
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
$templateResponse = new TemplateResponse(Application::APP_ID, 'empty', ['pageTitle' => $this->l10n->t('App store')]);
$templateResponse->setContentSecurityPolicy($policy);
Util::addStyle(Application::APP_ID, 'main');
Util::addScript(Application::APP_ID, 'main');
return $templateResponse;
}
private function getAppsWithUpdates() {
$appClass = new \OC_App();
$apps = $appClass->listAllApps();
foreach ($apps as $key => $app) {
$newVersion = $this->installer->isUpdateAvailable($app['id']);
if ($newVersion === false) {
unset($apps[$key]);
}
}
return $apps;
}
private function getBundles() {
$result = [];
$bundles = $this->bundleFetcher->getBundles();
foreach ($bundles as $bundle) {
$result[] = [
'name' => $bundle->getName(),
'id' => $bundle->getIdentifier(),
'appIdentifiers' => $bundle->getAppIdentifiers()
];
}
return $result;
}
}

View file

@ -8,6 +8,7 @@ declare(strict_types=1);
*/
namespace OCA\Appstore\Search;
use OCA\Appstore\AppInfo\Application;
use OCP\IL10N;
use OCP\INavigationManager;
use OCP\IUser;
@ -25,7 +26,7 @@ class AppSearch implements IProvider {
#[\Override]
public function getId(): string {
return 'settings_apps';
return Application::APP_ID;
}
#[\Override]
@ -35,7 +36,7 @@ class AppSearch implements IProvider {
#[\Override]
public function getOrder(string $route, array $routeParameters): int {
return $route === 'settings.AppSettings.viewApps' ? -50 : 100;
return $route === 'appstore.Page.viewApps' ? -50 : 100;
}
#[\Override]

View file

@ -1,104 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "settings-administration",
"version": "0.0.1",
"description": "Nextcloud settings",
"license": {
"name": "agpl"
}
},
"components": {
"securitySchemes": {
"basic_auth": {
"type": "http",
"scheme": "basic"
},
"bearer_auth": {
"type": "http",
"scheme": "bearer"
}
},
"schemas": {}
},
"paths": {
"/index.php/settings/admin/log/download": {
"get": {
"operationId": "log_settings-download",
"summary": "download logfile",
"description": "This endpoint requires admin access",
"tags": [
"log_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"responses": {
"200": {
"description": "Logfile returned",
"headers": {
"Content-Disposition": {
"schema": {
"type": "string",
"enum": [
"attachment; filename=\"nextcloud.log\""
]
}
}
},
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"tags": []
}

View file

@ -1,709 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "settings-full",
"version": "0.0.1",
"description": "Nextcloud settings",
"license": {
"name": "agpl"
}
},
"components": {
"securitySchemes": {
"basic_auth": {
"type": "http",
"scheme": "basic"
},
"bearer_auth": {
"type": "http",
"scheme": "bearer"
}
},
"schemas": {
"DeclarativeForm": {
"type": "object",
"required": [
"id",
"priority",
"section_type",
"section_id",
"storage_type",
"title",
"app",
"fields"
],
"properties": {
"id": {
"type": "string"
},
"priority": {
"type": "integer",
"format": "int64"
},
"section_type": {
"type": "string",
"enum": [
"admin",
"personal"
]
},
"section_id": {
"type": "string"
},
"storage_type": {
"type": "string",
"enum": [
"internal",
"external"
]
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"doc_url": {
"type": "string"
},
"app": {
"type": "string"
},
"fields": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeclarativeFormField"
}
}
}
},
"DeclarativeFormField": {
"type": "object",
"required": [
"id",
"title",
"type",
"default",
"value"
],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"text",
"password",
"email",
"tel",
"url",
"number",
"checkbox",
"multi-checkbox",
"radio",
"select",
"multi-select"
]
},
"placeholder": {
"type": "string"
},
"label": {
"type": "string"
},
"default": {
"type": "object"
},
"options": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"required": [
"name",
"value"
],
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "object"
}
}
}
]
}
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer",
"format": "int64"
},
{
"type": "number",
"format": "double"
},
{
"type": "boolean"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"sensitive": {
"type": "boolean"
}
}
},
"OCSMeta": {
"type": "object",
"required": [
"status",
"statuscode"
],
"properties": {
"status": {
"type": "string"
},
"statuscode": {
"type": "integer"
},
"message": {
"type": "string"
},
"totalitems": {
"type": "string"
},
"itemsperpage": {
"type": "string"
}
}
}
}
},
"paths": {
"/index.php/settings/admin/log/download": {
"get": {
"operationId": "log_settings-download",
"summary": "download logfile",
"description": "This endpoint requires admin access",
"tags": [
"log_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"responses": {
"200": {
"description": "Logfile returned",
"headers": {
"Content-Disposition": {
"schema": {
"type": "string",
"enum": [
"attachment; filename=\"nextcloud.log\""
]
}
}
},
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/settings/api/declarative/value": {
"post": {
"operationId": "declarative_settings-set-value",
"summary": "Sets a declarative settings value",
"tags": [
"declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"app",
"formId",
"fieldId",
"value"
],
"properties": {
"app": {
"type": "string",
"description": "ID of the app"
},
"formId": {
"type": "string",
"description": "ID of the form"
},
"fieldId": {
"type": "string",
"description": "ID of the field"
},
"value": {
"type": "object",
"description": "Value to be saved"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Value set successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"500": {
"description": "Not logged in or not an admin user",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid arguments to save value",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/settings/api/declarative/value-sensitive": {
"post": {
"operationId": "declarative_settings-set-sensitive-value",
"summary": "Sets a declarative settings value. Password confirmation is required for sensitive values.",
"description": "This endpoint requires password confirmation",
"tags": [
"declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"app",
"formId",
"fieldId",
"value"
],
"properties": {
"app": {
"type": "string",
"description": "ID of the app"
},
"formId": {
"type": "string",
"description": "ID of the form"
},
"fieldId": {
"type": "string",
"description": "ID of the field"
},
"value": {
"type": "object",
"description": "Value to be saved"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Value set successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"500": {
"description": "Not logged in or not an admin user",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid arguments to save value",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/settings/api/declarative/forms": {
"get": {
"operationId": "declarative_settings-get-forms",
"summary": "Gets all declarative forms with the values prefilled.",
"tags": [
"declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Forms returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeclarativeForm"
}
}
}
}
}
}
}
}
},
"500": {
"description": "",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
}
},
"tags": []
}

File diff suppressed because it is too large Load diff

View file

@ -79,8 +79,8 @@
<span class="hidden-visually">{{ t('appstore', 'Actions') }}</span>
</th>
</tr>
<template v-for="bundle in bundles">
<tr :key="bundle.id">
<tbody v-for="bundle in bundles" :key="bundle.id">
<tr>
<th :id="`app-table-rowgroup-${bundle.id}`" colspan="5" scope="rowgroup">
<div class="apps-list__bundle-heading">
<span class="apps-list__bundle-header">
@ -99,7 +99,7 @@
:headers="`app-table-rowgroup-${bundle.id}`"
:app="app"
:category="category" />
</template>
</tbody>
</table>
<ul v-if="useAppStoreView" class="apps-list__store-container">
<AppItem

View file

@ -31,6 +31,7 @@
</template>
<script setup lang="ts">
import type { OCSResponse } from '@nextcloud/typings/ocs'
import type { IAppDiscoverElements } from '../../constants/AppDiscoverTypes.ts'
import { mdiEyeOffOutline } from '@mdi/js'
@ -70,7 +71,8 @@ function shuffleArray<T>(array: T[]): T[] {
*/
onBeforeMount(async () => {
try {
const { data } = await axios.get<Record<string, unknown>[]>(generateUrl('/settings/api/apps/discover'))
const response = await axios.get<OCSResponse<Record<string, unknown>[]>>(generateOcsUrl('/apps/appstore/api/v1/discover'))
const { data } = response.data.ocs
if (data.length === 0) {
logger.info('No app discover elements available (empty response)')
hasError.value = true

View file

@ -1,6 +1,6 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
?>

View file

@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Settings\Tests\AppInfo;
use OCA\Settings\AppInfo\Application;
use OCA\Settings\Controller\AdminSettingsController;
use OCA\Settings\Controller\AppSettingsController;
use OCA\Settings\Controller\AuthSettingsController;
use OCA\Settings\Controller\CheckSetupController;
use OCA\Settings\Controller\LogSettingsController;
use OCA\Settings\Controller\MailSettingsController;
use OCA\Settings\Controller\UsersController;
use OCA\Settings\Middleware\SubadminMiddleware;
use OCP\AppFramework\Controller;
use OCP\AppFramework\IAppContainer;
use OCP\AppFramework\Middleware;
use Test\TestCase;
/**
* Class ApplicationTest
*
* @package Tests\Settings
*/
#[\PHPUnit\Framework\Attributes\Group('DB')]
class ApplicationTest extends TestCase {
protected Application $app;
protected IAppContainer $container;
protected function setUp(): void {
parent::setUp();
$this->app = new Application();
$this->container = $this->app->getContainer();
}
public function testContainerAppName(): void {
$this->app = new Application();
$this->assertEquals('settings', $this->container->get('appName'));
}
public static function dataContainerQuery(): array {
return [
[AdminSettingsController::class, Controller::class],
[AppSettingsController::class, Controller::class],
[AuthSettingsController::class, Controller::class],
[CheckSetupController::class, Controller::class],
[LogSettingsController::class, Controller::class],
[MailSettingsController::class, Controller::class],
[UsersController::class, Controller::class],
[SubadminMiddleware::class, Middleware::class],
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataContainerQuery')]
public function testContainerQuery(string $service, string $expected): void {
$this->assertTrue($this->container->query($service) instanceof $expected);
}
}

View file

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Appstore\Tests\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\DependencyAnalyzer;
use OC\Installer;
use OCA\Appstore\Controller\ApiController;
use OCP\AppFramework\Http\DataResponse;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IRequest;
use OCP\L10N\IFactory;
use OCP\Support\Subscription\IRegistry;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
#[\PHPUnit\Framework\Attributes\Group('DB')]
class ApiControllerTest extends TestCase {
private IRequest&MockObject $request;
private IConfig&MockObject $config;
private IAppConfig&MockObject $appConfig;
private AppManager&MockObject $appManager;
private DependencyAnalyzer&MockObject $dependencyAnalyzer;
private CategoryFetcher&MockObject $categoryFetcher;
private AppFetcher&MockObject $appFetcher;
private IFactory&MockObject $l10nFactory;
private BundleFetcher&MockObject $bundleFetcher;
private Installer&MockObject $installer;
private IRegistry&MockObject $subscriptionRegistry;
private LoggerInterface&MockObject $logger;
private ApiController $apiController;
protected function setUp(): void {
parent::setUp();
$this->request = $this->createMock(IRequest::class);
$this->config = $this->createMock(IConfig::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->appManager = $this->createMock(AppManager::class);
$this->dependencyAnalyzer = $this->createMock(DependencyAnalyzer::class);
$this->categoryFetcher = $this->createMock(CategoryFetcher::class);
$this->appFetcher = $this->createMock(AppFetcher::class);
$this->l10nFactory = $this->createMock(IFactory::class);
$this->bundleFetcher = $this->createMock(BundleFetcher::class);
$this->installer = $this->createMock(Installer::class);
$this->subscriptionRegistry = $this->createMock(IRegistry::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->apiController = new ApiController(
$this->request,
$this->config,
$this->appConfig,
$this->appManager,
$this->dependencyAnalyzer,
$this->categoryFetcher,
$this->appFetcher,
$this->l10nFactory,
$this->bundleFetcher,
$this->installer,
$this->subscriptionRegistry,
$this->logger,
);
}
public function testListCategories(): void {
$json = file_get_contents(__DIR__ . '/../fixtures/categories.json');
$this->categoryFetcher
->expects($this->once())
->method('get')
->willReturn(json_decode($json, true)['data']);
$response = $this->apiController->listCategories();
$this->assertInstanceOf(DataResponse::class, $response);
$this->assertSame(200, $response->getStatus());
$jsonResponse = json_encode($response->getData());
$this->assertJsonStringEqualsJsonFile(__DIR__ . '/../fixtures/categories-api-response.json', $jsonResponse);
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Appstore\Tests\Controller;
use OC\App\AppManager;
use OC\App\AppStore\Bundles\BundleFetcher;
use OC\Installer;
use OCA\Appstore\Controller\PageController;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\IL10N;
use OCP\INavigationManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
#[\PHPUnit\Framework\Attributes\Group('DB')]
class PageControllerTest extends TestCase {
private IRequest&MockObject $request;
private IL10N&MockObject $l10n;
private IConfig&MockObject $config;
private INavigationManager&MockObject $navigationManager;
private AppManager&MockObject $appManager;
private BundleFetcher&MockObject $bundleFetcher;
private Installer&MockObject $installer;
private IURLGenerator&MockObject $urlGenerator;
private IInitialState&MockObject $initialState;
private PageController $pageController;
protected function setUp(): void {
parent::setUp();
$this->request = $this->createMock(IRequest::class);
$this->l10n = $this->createMock(IL10N::class);
$this->l10n->expects($this->any())
->method('t')
->willReturnArgument(0);
$this->config = $this->createMock(IConfig::class);
$this->navigationManager = $this->createMock(INavigationManager::class);
$this->appManager = $this->createMock(AppManager::class);
$this->bundleFetcher = $this->createMock(BundleFetcher::class);
$this->installer = $this->createMock(Installer::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->initialState = $this->createMock(IInitialState::class);
$this->pageController = new PageController(
$this->request,
$this->l10n,
$this->config,
$this->installer,
$this->appManager,
$this->urlGenerator,
$this->initialState,
$this->bundleFetcher,
$this->navigationManager,
);
}
public function testViewApps(): void {
$this->bundleFetcher->expects($this->once())->method('getBundles')->willReturn([]);
$this->installer->expects($this->any())
->method('isUpdateAvailable')
->willReturn(false);
$this->config
->expects($this->once())
->method('getSystemValueBool')
->with('appstoreenabled', true)
->willReturn(true);
$this->navigationManager
->expects($this->once())
->method('setActiveEntry')
->with('core_apps');
$this->initialState
->expects($this->exactly(4))
->method('provideInitialState');
$policy = new ContentSecurityPolicy();
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
$expected = new TemplateResponse('appstore',
'empty',
[
'pageTitle' => 'App store'
],
'user');
$expected->setContentSecurityPolicy($policy);
$this->assertEquals($expected, $this->pageController->viewApps());
}
public function testViewAppsAppstoreNotEnabled(): void {
$this->installer->expects($this->any())
->method('isUpdateAvailable')
->willReturn(false);
$this->bundleFetcher->expects($this->once())->method('getBundles')->willReturn([]);
$this->config
->expects($this->once())
->method('getSystemValueBool')
->with('appstoreenabled', true)
->willReturn(false);
$this->navigationManager
->expects($this->once())
->method('setActiveEntry')
->with('core_apps');
$this->initialState
->expects($this->exactly(4))
->method('provideInitialState');
$policy = new ContentSecurityPolicy();
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
$expected = new TemplateResponse('appstore',
'empty',
[
'pageTitle' => 'App store'
],
'user');
$expected->setContentSecurityPolicy($policy);
$this->assertEquals($expected, $this->pageController->viewApps());
}
}

View file

@ -0,0 +1,62 @@
[
{
"displayName": "AI",
"id": "ai"
},
{
"displayName": "Customization",
"id": "customization"
},
{
"displayName": "Dashboard",
"id": "dashboard"
},
{
"displayName": "Files",
"id": "files"
},
{
"displayName": "Games",
"id": "games"
},
{
"displayName": "Integration",
"id": "integration"
},
{
"displayName": "Monitoring",
"id": "monitoring"
},
{
"displayName": "Multimedia",
"id": "multimedia"
},
{
"displayName": "Office & text",
"id": "office"
},
{
"displayName": "Organization",
"id": "organization"
},
{
"displayName": "Search",
"id": "search"
},
{
"displayName": "Security",
"id": "security"
},
{
"displayName": "Social & communication",
"id": "social"
},
{
"displayName": "Tools",
"id": "tools"
},
{
"displayName": "Flow",
"id": "workflow"
}
]

View file

@ -0,0 +1,202 @@
{
"data": [
{
"id": "ai",
"translations": {
"en": {
"name": "AI",
"description": "Artificial Intelligence apps"
},
"de": {
"name": "AI",
"description": "Apps für künstliche Intelligenz"
}
}
},
{
"id": "customization",
"translations": {
"en": {
"name": "Customization",
"description": "Themes, layout and UX change apps"
},
"de": {
"name": "Anpassung",
"description": "Apps zur Änderung von Design, Layout und Benutzererfahrung"
}
}
},
{
"id": "dashboard",
"translations": {
"en": {
"name": "Dashboard",
"description": "Apps including Nextcloud Dashboard widgets"
},
"de": {
"name": "Dashboard",
"description": "Apps einschließlich Nextcloud Dashboard-Widgets"
}
}
},
{
"id": "files",
"translations": {
"en": {
"name": "Files",
"description": "File management and Files app extension apps"
},
"de": {
"name": "Dateien",
"description": "Dateimanagement sowie Erweiterungs-Apps für die Dateien-App"
}
}
},
{
"id": "games",
"translations": {
"en": {
"name": "Games",
"description": "Games run in your Nextcloud"
},
"de": {
"name": "Spiele",
"description": "Spiele für deine Nextcloud"
}
}
},
{
"id": "integration",
"translations": {
"en": {
"name": "Integration",
"description": "Apps that connect Nextcloud with other services and platforms"
},
"de": {
"name": "Einbindung",
"description": "Apps die Nextcloud mit anderen Diensten und Plattformen verbinden"
}
}
},
{
"id": "monitoring",
"translations": {
"en": {
"name": "Monitoring",
"description": "Data statistics, system diagnostics and activity apps"
},
"de": {
"name": "Überwachung",
"description": "Datenstatistiken-, Systemdiagnose- und Aktivitäten-Apps"
}
}
},
{
"id": "multimedia",
"translations": {
"en": {
"name": "Multimedia",
"description": "Audio, video and picture apps"
},
"de": {
"name": "Multimedia",
"description": "Audio-, Video- und Bilder-Apps"
}
}
},
{
"id": "office",
"translations": {
"en": {
"name": "Office & text",
"description": "Office and text processing apps"
},
"de": {
"name": "Büro & Text",
"description": "Büro- und Textverarbeitungs-Apps"
}
}
},
{
"id": "organization",
"translations": {
"en": {
"name": "Organization",
"description": "Time management, Todo list and calendar apps"
},
"de": {
"name": "Organisation",
"description": "Time management, Todo list and calendar apps"
}
}
},
{
"id": "search",
"translations": {
"en": {
"name": "Search",
"description": "Search related apps"
},
"de": {
"name": "Suche",
"description": "Verwandte Apps durchsuchen"
}
}
},
{
"id": "security",
"translations": {
"en": {
"name": "Security",
"description": "Apps that provide additional security mechanisms like authentication, authorization, encryption, etc."
},
"de": {
"name": "Sicherheit",
"description": "Apps die zusätzliche Sicherheitsmechanismen bereitstellen, wie z. B. Authentifizierung, Autorisierung, Verschlüsselung usw."
}
}
},
{
"id": "social",
"translations": {
"en": {
"name": "Social & communication",
"description": "Messaging, contact management and social media apps"
},
"de": {
"name": "Kommunikation",
"description": "Nachrichten-, Kontaktverwaltungs- und Social-Media-Apps"
}
}
},
{
"id": "tools",
"translations": {
"en": {
"name": "Tools",
"description": "Everything else"
},
"de": {
"name": "Werkzeuge",
"description": "Alles Andere"
}
}
},
{
"id": "workflow",
"translations": {
"en": {
"name": "Flow",
"description": "Apps for Nextcloud Flow"
},
"de": {
"name": "Flow",
"description": "Apps für Nextcloud Flow"
}
}
}
],
"timestamp": 1770475258,
"ncversion": "34.0.0.0",
"ETag": "\"6986f131-6575\""
}

View file

@ -22,7 +22,6 @@ return array(
'OCA\\Settings\\ConfigLexicon' => $baseDir . '/../lib/ConfigLexicon.php',
'OCA\\Settings\\Controller\\AISettingsController' => $baseDir . '/../lib/Controller/AISettingsController.php',
'OCA\\Settings\\Controller\\AdminSettingsController' => $baseDir . '/../lib/Controller/AdminSettingsController.php',
'OCA\\Settings\\Controller\\AppSettingsController' => $baseDir . '/../lib/Controller/AppSettingsController.php',
'OCA\\Settings\\Controller\\AuthSettingsController' => $baseDir . '/../lib/Controller/AuthSettingsController.php',
'OCA\\Settings\\Controller\\AuthorizedGroupController' => $baseDir . '/../lib/Controller/AuthorizedGroupController.php',
'OCA\\Settings\\Controller\\ChangePasswordController' => $baseDir . '/../lib/Controller/ChangePasswordController.php',
@ -48,7 +47,6 @@ return array(
'OCA\\Settings\\Mailer\\NewUserMailHelper' => $baseDir . '/../lib/Mailer/NewUserMailHelper.php',
'OCA\\Settings\\Middleware\\SubadminMiddleware' => $baseDir . '/../lib/Middleware/SubadminMiddleware.php',
'OCA\\Settings\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
'OCA\\Settings\\Search\\AppSearch' => $baseDir . '/../lib/Search/AppSearch.php',
'OCA\\Settings\\Search\\SectionSearch' => $baseDir . '/../lib/Search/SectionSearch.php',
'OCA\\Settings\\Search\\UserSearch' => $baseDir . '/../lib/Search/UserSearch.php',
'OCA\\Settings\\Sections\\Admin\\Additional' => $baseDir . '/../lib/Sections/Admin/Additional.php',

View file

@ -37,7 +37,6 @@ class ComposerStaticInitSettings
'OCA\\Settings\\ConfigLexicon' => __DIR__ . '/..' . '/../lib/ConfigLexicon.php',
'OCA\\Settings\\Controller\\AISettingsController' => __DIR__ . '/..' . '/../lib/Controller/AISettingsController.php',
'OCA\\Settings\\Controller\\AdminSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AdminSettingsController.php',
'OCA\\Settings\\Controller\\AppSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AppSettingsController.php',
'OCA\\Settings\\Controller\\AuthSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AuthSettingsController.php',
'OCA\\Settings\\Controller\\AuthorizedGroupController' => __DIR__ . '/..' . '/../lib/Controller/AuthorizedGroupController.php',
'OCA\\Settings\\Controller\\ChangePasswordController' => __DIR__ . '/..' . '/../lib/Controller/ChangePasswordController.php',
@ -63,7 +62,6 @@ class ComposerStaticInitSettings
'OCA\\Settings\\Mailer\\NewUserMailHelper' => __DIR__ . '/..' . '/../lib/Mailer/NewUserMailHelper.php',
'OCA\\Settings\\Middleware\\SubadminMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/SubadminMiddleware.php',
'OCA\\Settings\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
'OCA\\Settings\\Search\\AppSearch' => __DIR__ . '/..' . '/../lib/Search/AppSearch.php',
'OCA\\Settings\\Search\\SectionSearch' => __DIR__ . '/..' . '/../lib/Search/SectionSearch.php',
'OCA\\Settings\\Search\\UserSearch' => __DIR__ . '/..' . '/../lib/Search/UserSearch.php',
'OCA\\Settings\\Sections\\Admin\\Additional' => __DIR__ . '/..' . '/../lib/Sections/Admin/Additional.php',

View file

@ -2306,12 +2306,6 @@
<code><![CDATA[IAccountManager::PROPERTY_TWITTER]]></code>
</DeprecatedConstant>
</file>
<file src="apps/settings/lib/Controller/AppSettingsController.php">
<DeprecatedMethod>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getSupportedApps]]></code>
</DeprecatedMethod>
</file>
<file src="apps/settings/lib/Controller/MailSettingsController.php">
<DeprecatedMethod>
<code><![CDATA[getUserValue]]></code>

View file

@ -245,8 +245,8 @@ class NavigationManager implements INavigationManager {
'type' => 'settings',
'id' => 'core_apps',
'order' => 5,
'href' => $this->urlGenerator->linkToRoute('appstore.AppSettings.viewApps'),
'icon' => $this->urlGenerator->imagePath('appstore', 'apps.svg'),
'href' => $this->urlGenerator->linkToRoute('appstore.Page.viewApps'),
'icon' => $this->urlGenerator->imagePath('appstore', 'app.svg'),
'name' => $l->t('Apps'),
]);

View file

@ -449,13 +449,11 @@ class OC_App {
if ($appPath !== false) {
$appIcon = $appPath . '/img/' . $app . '.svg';
if (file_exists($appIcon)) {
$info['preview'] = $urlGenerator->imagePath($app, $app . '.svg');
$info['previewAsIcon'] = true;
$info['icon'] = $urlGenerator->imagePath($app, $app . '.svg');
} else {
$appIcon = $appPath . '/img/app.svg';
if (file_exists($appIcon)) {
$info['preview'] = $urlGenerator->imagePath($app, 'app.svg');
$info['previewAsIcon'] = true;
$info['icon'] = $urlGenerator->imagePath($app, 'app.svg');
}
}
}

View file

@ -17055,6 +17055,982 @@
}
}
},
"/ocs/v2.php/apps/appstore/api/v1/apps/categories": {
"get": {
"operationId": "appstore-api-list-categories",
"summary": "Get all available categories",
"description": "This endpoint requires admin access",
"tags": [
"appstore/api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "The categories were found successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"displayName"
],
"properties": {
"id": {
"type": "string"
},
"displayName": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/appstore/api/v1/apps": {
"get": {
"operationId": "appstore-api-list-apps",
"summary": "Get all available apps",
"description": "This endpoint requires admin access",
"tags": [
"appstore/api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "The apps were found successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"name",
"description"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/appstore/api/v1/apps/enable": {
"post": {
"operationId": "appstore-api-enable-app",
"summary": "Enable one apps",
"description": "App will be enabled for specific groups only if $groups is defined\nThis endpoint requires admin access\nThis endpoint requires password confirmation",
"tags": [
"appstore/api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"appId"
],
"properties": {
"appId": {
"type": "string",
"description": "- The app to enable"
},
"groups": {
"type": "array",
"default": [],
"description": "- The groups to enable the app for",
"items": {
"type": "string"
}
},
"force": {
"type": "boolean",
"default": false,
"description": "- Whether to force enable the app even if Nextcloud version requirements are not met"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "App successfully enabled",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"update_required"
],
"properties": {
"update_required": {
"type": "boolean"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/appstore/api/v1/apps/disable": {
"post": {
"operationId": "appstore-api-disable-app",
"summary": "Disable an app",
"description": "This endpoint requires admin access\nThis endpoint requires password confirmation",
"tags": [
"appstore/api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"appId"
],
"properties": {
"appId": {
"type": "string",
"description": "- The app to disable"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "App successfully disabled",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object"
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/appstore/api/v1/apps/uninstall": {
"post": {
"operationId": "appstore-api-uninstall-app",
"summary": "Uninstall an app. This will disable the app - if needed - and then remove the app from the system",
"description": "This endpoint requires admin access\nThis endpoint requires password confirmation",
"tags": [
"appstore/api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"appId"
],
"properties": {
"appId": {
"type": "string",
"description": "- The app to uninstall"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "App successfully uninstalled",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object"
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/appstore/api/v1/apps/update": {
"post": {
"operationId": "appstore-api-update-app",
"summary": "Update an app",
"description": "This endpoint requires admin access\nThis endpoint requires password confirmation",
"tags": [
"appstore/api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"appId"
],
"properties": {
"appId": {
"type": "string",
"description": "- The app to update"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "App successfully updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object"
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/appstore/api/v1/bundles/enable": {
"post": {
"operationId": "appstore-api-enable-bundle",
"summary": "Enable all apps of a bundle",
"description": "This endpoint requires admin access\nThis endpoint requires password confirmation",
"tags": [
"appstore/api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"bundleId"
],
"properties": {
"bundleId": {
"type": "string",
"description": "- The bundle to enable"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Bundle successfully enabled",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object"
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/index.php/ocm/shares": {
"post": {
"operationId": "cloud_federation_api-request_handler-add-share",

View file

@ -24,6 +24,7 @@
</plugins>
<projectFiles>
<directory name="apps/admin_audit"/>
<directory name="apps/appstore"/>
<directory name="apps/cloud_federation_api"/>
<directory name="apps/comments"/>
<directory name="apps/contactsinteraction"/>

View file

@ -279,7 +279,7 @@ class NavigationManagerTest extends TestCase {
'id' => 'core_apps',
'order' => 5,
'href' => '/apps/test/',
'icon' => '/apps/settings/img/apps.svg',
'icon' => '/apps/appstore/img/app.svg',
'name' => 'Apps',
'active' => false,
'type' => 'settings',