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