Completed
Push — master ( 30fb9e...cb7cab )
by
unknown
29:53 queued 19s
created
apps/settings/lib/Controller/AppSettingsController.php 1 patch
Indentation   +632 added lines, -632 removed lines patch added patch discarded remove patch
@@ -54,636 +54,636 @@
 block discarded – undo
54 54
 #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
55 55
 class AppSettingsController extends Controller {
56 56
 
57
-	/** @var array */
58
-	private $allApps = [];
59
-
60
-	private IAppData $appData;
61
-
62
-	public function __construct(
63
-		string $appName,
64
-		IRequest $request,
65
-		IAppDataFactory $appDataFactory,
66
-		private IL10N $l10n,
67
-		private IConfig $config,
68
-		private INavigationManager $navigationManager,
69
-		private AppManager $appManager,
70
-		private CategoryFetcher $categoryFetcher,
71
-		private AppFetcher $appFetcher,
72
-		private IFactory $l10nFactory,
73
-		private BundleFetcher $bundleFetcher,
74
-		private Installer $installer,
75
-		private IURLGenerator $urlGenerator,
76
-		private LoggerInterface $logger,
77
-		private IInitialState $initialState,
78
-		private AppDiscoverFetcher $discoverFetcher,
79
-		private IClientService $clientService,
80
-	) {
81
-		parent::__construct($appName, $request);
82
-		$this->appData = $appDataFactory->get('appstore');
83
-	}
84
-
85
-	/**
86
-	 * @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
87
-	 *
88
-	 * @return TemplateResponse
89
-	 */
90
-	#[NoCSRFRequired]
91
-	public function viewApps(): TemplateResponse {
92
-		$this->navigationManager->setActiveEntry('core_apps');
93
-
94
-		$this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
95
-		$this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
96
-		$this->initialState->provideInitialState('appstoreDeveloperDocs', $this->urlGenerator->linkToDocs('developer-manual'));
97
-		$this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
98
-
99
-		if ($this->appManager->isEnabledForAnyone('app_api')) {
100
-			try {
101
-				Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
102
-			} catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
103
-			}
104
-		}
105
-
106
-		$policy = new ContentSecurityPolicy();
107
-		$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
108
-
109
-		$templateResponse = new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]);
110
-		$templateResponse->setContentSecurityPolicy($policy);
111
-
112
-		Util::addStyle('settings', 'settings');
113
-		Util::addScript('settings', 'vue-settings-apps-users-management');
114
-
115
-		return $templateResponse;
116
-	}
117
-
118
-	/**
119
-	 * Get all active entries for the app discover section
120
-	 */
121
-	#[NoCSRFRequired]
122
-	public function getAppDiscoverJSON(): JSONResponse {
123
-		$data = $this->discoverFetcher->get(true);
124
-		return new JSONResponse(array_values($data));
125
-	}
126
-
127
-	/**
128
-	 * Get a image for the app discover section - this is proxied for privacy and CSP reasons
129
-	 *
130
-	 * @param string $image
131
-	 * @throws \Exception
132
-	 */
133
-	#[NoCSRFRequired]
134
-	public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): Response {
135
-		$getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
136
-		$etag = trim($getEtag, '"');
137
-
138
-		$folder = null;
139
-		try {
140
-			$folder = $this->appData->getFolder('app-discover-cache');
141
-			$this->cleanUpImageCache($folder, $etag);
142
-		} catch (\Throwable $e) {
143
-			$folder = $this->appData->newFolder('app-discover-cache');
144
-		}
145
-
146
-		// Get the current cache folder
147
-		try {
148
-			$folder = $folder->getFolder($etag);
149
-		} catch (NotFoundException $e) {
150
-			$folder = $folder->newFolder($etag);
151
-		}
152
-
153
-		$info = pathinfo($fileName);
154
-		$hashName = md5($fileName);
155
-		$allFiles = $folder->getDirectoryListing();
156
-		// Try to find the file
157
-		$file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
158
-			return str_starts_with($file->getName(), $hashName);
159
-		});
160
-		// Get the first entry
161
-		$file = reset($file);
162
-		// If not found request from Web
163
-		if ($file === false) {
164
-			$user = $session->getUser();
165
-			// this route is not public thus we can assume a user is logged-in
166
-			assert($user !== null);
167
-			// Register a user request to throttle fetching external data
168
-			// this will prevent using the server for DoS of other systems.
169
-			$limiter->registerUserRequest(
170
-				'settings-discover-media',
171
-				// allow up to 24 media requests per hour
172
-				// this should be a sane default when a completely new section is loaded
173
-				// keep in mind browsers request all files from a source-set
174
-				24,
175
-				60 * 60,
176
-				$user,
177
-			);
178
-
179
-			if (!$this->checkCanDownloadMedia($fileName)) {
180
-				$this->logger->warning('Tried to load media files for app discover section from untrusted source');
181
-				return new NotFoundResponse(Http::STATUS_BAD_REQUEST);
182
-			}
183
-
184
-			try {
185
-				$client = $this->clientService->newClient();
186
-				$fileResponse = $client->get($fileName);
187
-				$contentType = $fileResponse->getHeader('Content-Type');
188
-				$extension = $info['extension'] ?? '';
189
-				$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
190
-			} catch (\Throwable $e) {
191
-				$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
192
-				return new NotFoundResponse();
193
-			}
194
-		} else {
195
-			// File was found so we can get the content type from the file name
196
-			$contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
197
-		}
198
-
199
-		$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
200
-		// cache for 7 days
201
-		$response->cacheFor(604800, false, true);
202
-		return $response;
203
-	}
204
-
205
-	private function checkCanDownloadMedia(string $filename): bool {
206
-		$urlInfo = parse_url($filename);
207
-		if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
208
-			return false;
209
-		}
210
-
211
-		// Always allowed hosts
212
-		if ($urlInfo['host'] === 'nextcloud.com') {
213
-			return true;
214
-		}
215
-
216
-		// Hosts that need further verification
217
-		// Github is only allowed if from our organization
218
-		$ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
219
-		if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) {
220
-			return false;
221
-		}
222
-
223
-		if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) {
224
-			return true;
225
-		}
226
-
227
-		return false;
228
-	}
229
-
230
-	/**
231
-	 * Remove orphaned folders from the image cache that do not match the current etag
232
-	 * @param ISimpleFolder $folder The folder to clear
233
-	 * @param string $etag The etag (directory name) to keep
234
-	 */
235
-	private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
236
-		// Cleanup old cache folders
237
-		$allFiles = $folder->getDirectoryListing();
238
-		foreach ($allFiles as $dir) {
239
-			try {
240
-				if ($dir->getName() !== $etag) {
241
-					$dir->delete();
242
-				}
243
-			} catch (NotPermittedException $e) {
244
-				// ignore folder for now
245
-			}
246
-		}
247
-	}
248
-
249
-	private function getAppsWithUpdates() {
250
-		$appClass = new \OC_App();
251
-		$apps = $appClass->listAllApps();
252
-		foreach ($apps as $key => $app) {
253
-			$newVersion = $this->installer->isUpdateAvailable($app['id']);
254
-			if ($newVersion === false) {
255
-				unset($apps[$key]);
256
-			}
257
-		}
258
-		return $apps;
259
-	}
260
-
261
-	private function getBundles() {
262
-		$result = [];
263
-		$bundles = $this->bundleFetcher->getBundles();
264
-		foreach ($bundles as $bundle) {
265
-			$result[] = [
266
-				'name' => $bundle->getName(),
267
-				'id' => $bundle->getIdentifier(),
268
-				'appIdentifiers' => $bundle->getAppIdentifiers()
269
-			];
270
-		}
271
-		return $result;
272
-	}
273
-
274
-	/**
275
-	 * Get all available categories
276
-	 *
277
-	 * @return JSONResponse
278
-	 */
279
-	public function listCategories(): JSONResponse {
280
-		return new JSONResponse($this->getAllCategories());
281
-	}
282
-
283
-	private function getAllCategories() {
284
-		$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
285
-
286
-		$categories = $this->categoryFetcher->get();
287
-		return array_map(fn ($category) => [
288
-			'id' => $category['id'],
289
-			'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
290
-		], $categories);
291
-	}
292
-
293
-	/**
294
-	 * Convert URL to proxied URL so CSP is no problem
295
-	 */
296
-	private function createProxyPreviewUrl(string $url): string {
297
-		if ($url === '') {
298
-			return '';
299
-		}
300
-		return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url);
301
-	}
302
-
303
-	private function fetchApps() {
304
-		$appClass = new \OC_App();
305
-		$apps = $appClass->listAllApps();
306
-		foreach ($apps as $app) {
307
-			$app['installed'] = true;
308
-
309
-			if (isset($app['screenshot'][0])) {
310
-				$appScreenshot = $app['screenshot'][0] ?? null;
311
-				if (is_array($appScreenshot)) {
312
-					// Screenshot with thumbnail
313
-					$appScreenshot = $appScreenshot['@value'];
314
-				}
315
-
316
-				$app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot);
317
-			}
318
-			$this->allApps[$app['id']] = $app;
319
-		}
320
-
321
-		$apps = $this->getAppsForCategory('');
322
-		$supportedApps = $appClass->getSupportedApps();
323
-		foreach ($apps as $app) {
324
-			$app['appstore'] = true;
325
-			if (!array_key_exists($app['id'], $this->allApps)) {
326
-				$this->allApps[$app['id']] = $app;
327
-			} else {
328
-				$this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]);
329
-			}
330
-
331
-			if (in_array($app['id'], $supportedApps)) {
332
-				$this->allApps[$app['id']]['level'] = \OC_App::supportedApp;
333
-			}
334
-		}
335
-
336
-		// add bundle information
337
-		$bundles = $this->bundleFetcher->getBundles();
338
-		foreach ($bundles as $bundle) {
339
-			foreach ($bundle->getAppIdentifiers() as $identifier) {
340
-				foreach ($this->allApps as &$app) {
341
-					if ($app['id'] === $identifier) {
342
-						$app['bundleIds'][] = $bundle->getIdentifier();
343
-						continue;
344
-					}
345
-				}
346
-			}
347
-		}
348
-	}
349
-
350
-	private function getAllApps() {
351
-		return $this->allApps;
352
-	}
353
-
354
-	/**
355
-	 * Get all available apps in a category
356
-	 *
357
-	 * @return JSONResponse
358
-	 * @throws \Exception
359
-	 */
360
-	public function listApps(): JSONResponse {
361
-		$this->fetchApps();
362
-		$apps = $this->getAllApps();
363
-
364
-		$dependencyAnalyzer = new DependencyAnalyzer(new Platform($this->config), $this->l10n);
365
-
366
-		$ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
367
-		if (!is_array($ignoreMaxApps)) {
368
-			$this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...');
369
-			$ignoreMaxApps = [];
370
-		}
371
-
372
-		// Extend existing app details
373
-		$apps = array_map(function (array $appData) use ($dependencyAnalyzer, $ignoreMaxApps) {
374
-			if (isset($appData['appstoreData'])) {
375
-				$appstoreData = $appData['appstoreData'];
376
-				$appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
377
-				$appData['category'] = $appstoreData['categories'];
378
-				$appData['releases'] = $appstoreData['releases'];
379
-			}
380
-
381
-			$newVersion = $this->installer->isUpdateAvailable($appData['id']);
382
-			if ($newVersion) {
383
-				$appData['update'] = $newVersion;
384
-			}
385
-
386
-			// fix groups to be an array
387
-			$groups = [];
388
-			if (is_string($appData['groups'])) {
389
-				$groups = json_decode($appData['groups']);
390
-				// ensure 'groups' is an array
391
-				if (!is_array($groups)) {
392
-					$groups = [$groups];
393
-				}
394
-			}
395
-			$appData['groups'] = $groups;
396
-			$appData['canUnInstall'] = !$appData['active'] && $appData['removable'];
397
-
398
-			// fix licence vs license
399
-			if (isset($appData['license']) && !isset($appData['licence'])) {
400
-				$appData['licence'] = $appData['license'];
401
-			}
402
-
403
-			$ignoreMax = in_array($appData['id'], $ignoreMaxApps);
404
-
405
-			// analyse dependencies
406
-			$missing = $dependencyAnalyzer->analyze($appData, $ignoreMax);
407
-			$appData['canInstall'] = empty($missing);
408
-			$appData['missingDependencies'] = $missing;
409
-
410
-			$appData['missingMinOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
411
-			$appData['missingMaxOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
412
-			$appData['isCompatible'] = $dependencyAnalyzer->isMarkedCompatible($appData);
413
-
414
-			return $appData;
415
-		}, $apps);
416
-
417
-		usort($apps, [$this, 'sortApps']);
418
-
419
-		return new JSONResponse(['apps' => $apps, 'status' => 'success']);
420
-	}
421
-
422
-	/**
423
-	 * Get all apps for a category from the app store
424
-	 *
425
-	 * @param string $requestedCategory
426
-	 * @return array
427
-	 * @throws \Exception
428
-	 */
429
-	private function getAppsForCategory($requestedCategory = ''): array {
430
-		$versionParser = new VersionParser();
431
-		$formattedApps = [];
432
-		$apps = $this->appFetcher->get();
433
-		foreach ($apps as $app) {
434
-			// Skip all apps not in the requested category
435
-			if ($requestedCategory !== '') {
436
-				$isInCategory = false;
437
-				foreach ($app['categories'] as $category) {
438
-					if ($category === $requestedCategory) {
439
-						$isInCategory = true;
440
-					}
441
-				}
442
-				if (!$isInCategory) {
443
-					continue;
444
-				}
445
-			}
446
-
447
-			if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) {
448
-				continue;
449
-			}
450
-			$nextCloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']);
451
-			$nextCloudVersionDependencies = [];
452
-			if ($nextCloudVersion->getMinimumVersion() !== '') {
453
-				$nextCloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextCloudVersion->getMinimumVersion();
454
-			}
455
-			if ($nextCloudVersion->getMaximumVersion() !== '') {
456
-				$nextCloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextCloudVersion->getMaximumVersion();
457
-			}
458
-			$phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']);
459
-
460
-			try {
461
-				$this->appManager->getAppPath($app['id']);
462
-				$existsLocally = true;
463
-			} catch (AppPathNotFoundException) {
464
-				$existsLocally = false;
465
-			}
466
-
467
-			$phpDependencies = [];
468
-			if ($phpVersion->getMinimumVersion() !== '') {
469
-				$phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion();
470
-			}
471
-			if ($phpVersion->getMaximumVersion() !== '') {
472
-				$phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion();
473
-			}
474
-			if (isset($app['releases'][0]['minIntSize'])) {
475
-				$phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize'];
476
-			}
477
-			$authors = '';
478
-			foreach ($app['authors'] as $key => $author) {
479
-				$authors .= $author['name'];
480
-				if ($key !== count($app['authors']) - 1) {
481
-					$authors .= ', ';
482
-				}
483
-			}
484
-
485
-			$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
486
-			$enabledValue = $this->config->getAppValue($app['id'], 'enabled', 'no');
487
-			$groups = null;
488
-			if ($enabledValue !== 'no' && $enabledValue !== 'yes') {
489
-				$groups = $enabledValue;
490
-			}
491
-
492
-			$currentVersion = '';
493
-			if ($this->appManager->isEnabledForAnyone($app['id'])) {
494
-				$currentVersion = $this->appManager->getAppVersion($app['id']);
495
-			} else {
496
-				$currentVersion = $app['releases'][0]['version'];
497
-			}
498
-
499
-			$formattedApps[] = [
500
-				'id' => $app['id'],
501
-				'app_api' => false,
502
-				'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'],
503
-				'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'],
504
-				'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'],
505
-				'license' => $app['releases'][0]['licenses'],
506
-				'author' => $authors,
507
-				'shipped' => $this->appManager->isShipped($app['id']),
508
-				'version' => $currentVersion,
509
-				'default_enable' => '',
510
-				'types' => [],
511
-				'documentation' => [
512
-					'admin' => $app['adminDocs'],
513
-					'user' => $app['userDocs'],
514
-					'developer' => $app['developerDocs']
515
-				],
516
-				'website' => $app['website'],
517
-				'bugs' => $app['issueTracker'],
518
-				'detailpage' => $app['website'],
519
-				'dependencies' => array_merge(
520
-					$nextCloudVersionDependencies,
521
-					$phpDependencies
522
-				),
523
-				'level' => ($app['isFeatured'] === true) ? 200 : 100,
524
-				'missingMaxOwnCloudVersion' => false,
525
-				'missingMinOwnCloudVersion' => false,
526
-				'canInstall' => true,
527
-				'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
528
-				'score' => $app['ratingOverall'],
529
-				'ratingNumOverall' => $app['ratingNumOverall'],
530
-				'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
531
-				'removable' => $existsLocally,
532
-				'active' => $this->appManager->isEnabledForUser($app['id']),
533
-				'needsDownload' => !$existsLocally,
534
-				'groups' => $groups,
535
-				'fromAppStore' => true,
536
-				'appstoreData' => $app,
537
-			];
538
-		}
539
-
540
-		return $formattedApps;
541
-	}
542
-
543
-	/**
544
-	 * @param string $appId
545
-	 * @param array $groups
546
-	 * @return JSONResponse
547
-	 */
548
-	#[PasswordConfirmationRequired]
549
-	public function enableApp(string $appId, array $groups = []): JSONResponse {
550
-		return $this->enableApps([$appId], $groups);
551
-	}
552
-
553
-	/**
554
-	 * Enable one or more apps
555
-	 *
556
-	 * apps will be enabled for specific groups only if $groups is defined
557
-	 *
558
-	 * @param array $appIds
559
-	 * @param array $groups
560
-	 * @return JSONResponse
561
-	 */
562
-	#[PasswordConfirmationRequired]
563
-	public function enableApps(array $appIds, array $groups = []): JSONResponse {
564
-		try {
565
-			$updateRequired = false;
566
-
567
-			foreach ($appIds as $appId) {
568
-				$appId = $this->appManager->cleanAppId($appId);
569
-
570
-				// Check if app is already downloaded
571
-				/** @var Installer $installer */
572
-				$installer = Server::get(Installer::class);
573
-				$isDownloaded = $installer->isDownloaded($appId);
574
-
575
-				if (!$isDownloaded) {
576
-					$installer->downloadApp($appId);
577
-				}
578
-
579
-				$installer->installApp($appId);
580
-
581
-				if (count($groups) > 0) {
582
-					$this->appManager->enableAppForGroups($appId, $this->getGroupList($groups));
583
-				} else {
584
-					$this->appManager->enableApp($appId);
585
-				}
586
-				if (\OC_App::shouldUpgrade($appId)) {
587
-					$updateRequired = true;
588
-				}
589
-			}
590
-			return new JSONResponse(['data' => ['update_required' => $updateRequired]]);
591
-		} catch (\Throwable $e) {
592
-			$this->logger->error('could not enable apps', ['exception' => $e]);
593
-			return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
594
-		}
595
-	}
596
-
597
-	private function getGroupList(array $groups) {
598
-		$groupManager = Server::get(IGroupManager::class);
599
-		$groupsList = [];
600
-		foreach ($groups as $group) {
601
-			$groupItem = $groupManager->get($group);
602
-			if ($groupItem instanceof IGroup) {
603
-				$groupsList[] = $groupManager->get($group);
604
-			}
605
-		}
606
-		return $groupsList;
607
-	}
608
-
609
-	/**
610
-	 * @param string $appId
611
-	 * @return JSONResponse
612
-	 */
613
-	#[PasswordConfirmationRequired]
614
-	public function disableApp(string $appId): JSONResponse {
615
-		return $this->disableApps([$appId]);
616
-	}
617
-
618
-	/**
619
-	 * @param array $appIds
620
-	 * @return JSONResponse
621
-	 */
622
-	#[PasswordConfirmationRequired]
623
-	public function disableApps(array $appIds): JSONResponse {
624
-		try {
625
-			foreach ($appIds as $appId) {
626
-				$appId = $this->appManager->cleanAppId($appId);
627
-				$this->appManager->disableApp($appId);
628
-			}
629
-			return new JSONResponse([]);
630
-		} catch (\Exception $e) {
631
-			$this->logger->error('could not disable app', ['exception' => $e]);
632
-			return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
633
-		}
634
-	}
635
-
636
-	/**
637
-	 * @param string $appId
638
-	 * @return JSONResponse
639
-	 */
640
-	#[PasswordConfirmationRequired]
641
-	public function uninstallApp(string $appId): JSONResponse {
642
-		$appId = $this->appManager->cleanAppId($appId);
643
-		$result = $this->installer->removeApp($appId);
644
-		if ($result !== false) {
645
-			// If this app was force enabled, remove the force-enabled-state
646
-			$this->appManager->removeOverwriteNextcloudRequirement($appId);
647
-			$this->appManager->clearAppsCache();
648
-			return new JSONResponse(['data' => ['appid' => $appId]]);
649
-		}
650
-		return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not remove app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
651
-	}
652
-
653
-	/**
654
-	 * @param string $appId
655
-	 * @return JSONResponse
656
-	 */
657
-	public function updateApp(string $appId): JSONResponse {
658
-		$appId = $this->appManager->cleanAppId($appId);
659
-
660
-		$this->config->setSystemValue('maintenance', true);
661
-		try {
662
-			$result = $this->installer->updateAppstoreApp($appId);
663
-			$this->config->setSystemValue('maintenance', false);
664
-		} catch (\Exception $ex) {
665
-			$this->config->setSystemValue('maintenance', false);
666
-			return new JSONResponse(['data' => ['message' => $ex->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
667
-		}
668
-
669
-		if ($result !== false) {
670
-			return new JSONResponse(['data' => ['appid' => $appId]]);
671
-		}
672
-		return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not update app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
673
-	}
674
-
675
-	private function sortApps($a, $b) {
676
-		$a = (string)$a['name'];
677
-		$b = (string)$b['name'];
678
-		if ($a === $b) {
679
-			return 0;
680
-		}
681
-		return ($a < $b) ? -1 : 1;
682
-	}
683
-
684
-	public function force(string $appId): JSONResponse {
685
-		$appId = $this->appManager->cleanAppId($appId);
686
-		$this->appManager->overwriteNextcloudRequirement($appId);
687
-		return new JSONResponse();
688
-	}
57
+    /** @var array */
58
+    private $allApps = [];
59
+
60
+    private IAppData $appData;
61
+
62
+    public function __construct(
63
+        string $appName,
64
+        IRequest $request,
65
+        IAppDataFactory $appDataFactory,
66
+        private IL10N $l10n,
67
+        private IConfig $config,
68
+        private INavigationManager $navigationManager,
69
+        private AppManager $appManager,
70
+        private CategoryFetcher $categoryFetcher,
71
+        private AppFetcher $appFetcher,
72
+        private IFactory $l10nFactory,
73
+        private BundleFetcher $bundleFetcher,
74
+        private Installer $installer,
75
+        private IURLGenerator $urlGenerator,
76
+        private LoggerInterface $logger,
77
+        private IInitialState $initialState,
78
+        private AppDiscoverFetcher $discoverFetcher,
79
+        private IClientService $clientService,
80
+    ) {
81
+        parent::__construct($appName, $request);
82
+        $this->appData = $appDataFactory->get('appstore');
83
+    }
84
+
85
+    /**
86
+     * @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
87
+     *
88
+     * @return TemplateResponse
89
+     */
90
+    #[NoCSRFRequired]
91
+    public function viewApps(): TemplateResponse {
92
+        $this->navigationManager->setActiveEntry('core_apps');
93
+
94
+        $this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
95
+        $this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
96
+        $this->initialState->provideInitialState('appstoreDeveloperDocs', $this->urlGenerator->linkToDocs('developer-manual'));
97
+        $this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
98
+
99
+        if ($this->appManager->isEnabledForAnyone('app_api')) {
100
+            try {
101
+                Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
102
+            } catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
103
+            }
104
+        }
105
+
106
+        $policy = new ContentSecurityPolicy();
107
+        $policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
108
+
109
+        $templateResponse = new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]);
110
+        $templateResponse->setContentSecurityPolicy($policy);
111
+
112
+        Util::addStyle('settings', 'settings');
113
+        Util::addScript('settings', 'vue-settings-apps-users-management');
114
+
115
+        return $templateResponse;
116
+    }
117
+
118
+    /**
119
+     * Get all active entries for the app discover section
120
+     */
121
+    #[NoCSRFRequired]
122
+    public function getAppDiscoverJSON(): JSONResponse {
123
+        $data = $this->discoverFetcher->get(true);
124
+        return new JSONResponse(array_values($data));
125
+    }
126
+
127
+    /**
128
+     * Get a image for the app discover section - this is proxied for privacy and CSP reasons
129
+     *
130
+     * @param string $image
131
+     * @throws \Exception
132
+     */
133
+    #[NoCSRFRequired]
134
+    public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): Response {
135
+        $getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
136
+        $etag = trim($getEtag, '"');
137
+
138
+        $folder = null;
139
+        try {
140
+            $folder = $this->appData->getFolder('app-discover-cache');
141
+            $this->cleanUpImageCache($folder, $etag);
142
+        } catch (\Throwable $e) {
143
+            $folder = $this->appData->newFolder('app-discover-cache');
144
+        }
145
+
146
+        // Get the current cache folder
147
+        try {
148
+            $folder = $folder->getFolder($etag);
149
+        } catch (NotFoundException $e) {
150
+            $folder = $folder->newFolder($etag);
151
+        }
152
+
153
+        $info = pathinfo($fileName);
154
+        $hashName = md5($fileName);
155
+        $allFiles = $folder->getDirectoryListing();
156
+        // Try to find the file
157
+        $file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
158
+            return str_starts_with($file->getName(), $hashName);
159
+        });
160
+        // Get the first entry
161
+        $file = reset($file);
162
+        // If not found request from Web
163
+        if ($file === false) {
164
+            $user = $session->getUser();
165
+            // this route is not public thus we can assume a user is logged-in
166
+            assert($user !== null);
167
+            // Register a user request to throttle fetching external data
168
+            // this will prevent using the server for DoS of other systems.
169
+            $limiter->registerUserRequest(
170
+                'settings-discover-media',
171
+                // allow up to 24 media requests per hour
172
+                // this should be a sane default when a completely new section is loaded
173
+                // keep in mind browsers request all files from a source-set
174
+                24,
175
+                60 * 60,
176
+                $user,
177
+            );
178
+
179
+            if (!$this->checkCanDownloadMedia($fileName)) {
180
+                $this->logger->warning('Tried to load media files for app discover section from untrusted source');
181
+                return new NotFoundResponse(Http::STATUS_BAD_REQUEST);
182
+            }
183
+
184
+            try {
185
+                $client = $this->clientService->newClient();
186
+                $fileResponse = $client->get($fileName);
187
+                $contentType = $fileResponse->getHeader('Content-Type');
188
+                $extension = $info['extension'] ?? '';
189
+                $file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
190
+            } catch (\Throwable $e) {
191
+                $this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
192
+                return new NotFoundResponse();
193
+            }
194
+        } else {
195
+            // File was found so we can get the content type from the file name
196
+            $contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
197
+        }
198
+
199
+        $response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
200
+        // cache for 7 days
201
+        $response->cacheFor(604800, false, true);
202
+        return $response;
203
+    }
204
+
205
+    private function checkCanDownloadMedia(string $filename): bool {
206
+        $urlInfo = parse_url($filename);
207
+        if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
208
+            return false;
209
+        }
210
+
211
+        // Always allowed hosts
212
+        if ($urlInfo['host'] === 'nextcloud.com') {
213
+            return true;
214
+        }
215
+
216
+        // Hosts that need further verification
217
+        // Github is only allowed if from our organization
218
+        $ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
219
+        if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) {
220
+            return false;
221
+        }
222
+
223
+        if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) {
224
+            return true;
225
+        }
226
+
227
+        return false;
228
+    }
229
+
230
+    /**
231
+     * Remove orphaned folders from the image cache that do not match the current etag
232
+     * @param ISimpleFolder $folder The folder to clear
233
+     * @param string $etag The etag (directory name) to keep
234
+     */
235
+    private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
236
+        // Cleanup old cache folders
237
+        $allFiles = $folder->getDirectoryListing();
238
+        foreach ($allFiles as $dir) {
239
+            try {
240
+                if ($dir->getName() !== $etag) {
241
+                    $dir->delete();
242
+                }
243
+            } catch (NotPermittedException $e) {
244
+                // ignore folder for now
245
+            }
246
+        }
247
+    }
248
+
249
+    private function getAppsWithUpdates() {
250
+        $appClass = new \OC_App();
251
+        $apps = $appClass->listAllApps();
252
+        foreach ($apps as $key => $app) {
253
+            $newVersion = $this->installer->isUpdateAvailable($app['id']);
254
+            if ($newVersion === false) {
255
+                unset($apps[$key]);
256
+            }
257
+        }
258
+        return $apps;
259
+    }
260
+
261
+    private function getBundles() {
262
+        $result = [];
263
+        $bundles = $this->bundleFetcher->getBundles();
264
+        foreach ($bundles as $bundle) {
265
+            $result[] = [
266
+                'name' => $bundle->getName(),
267
+                'id' => $bundle->getIdentifier(),
268
+                'appIdentifiers' => $bundle->getAppIdentifiers()
269
+            ];
270
+        }
271
+        return $result;
272
+    }
273
+
274
+    /**
275
+     * Get all available categories
276
+     *
277
+     * @return JSONResponse
278
+     */
279
+    public function listCategories(): JSONResponse {
280
+        return new JSONResponse($this->getAllCategories());
281
+    }
282
+
283
+    private function getAllCategories() {
284
+        $currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
285
+
286
+        $categories = $this->categoryFetcher->get();
287
+        return array_map(fn ($category) => [
288
+            'id' => $category['id'],
289
+            'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
290
+        ], $categories);
291
+    }
292
+
293
+    /**
294
+     * Convert URL to proxied URL so CSP is no problem
295
+     */
296
+    private function createProxyPreviewUrl(string $url): string {
297
+        if ($url === '') {
298
+            return '';
299
+        }
300
+        return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url);
301
+    }
302
+
303
+    private function fetchApps() {
304
+        $appClass = new \OC_App();
305
+        $apps = $appClass->listAllApps();
306
+        foreach ($apps as $app) {
307
+            $app['installed'] = true;
308
+
309
+            if (isset($app['screenshot'][0])) {
310
+                $appScreenshot = $app['screenshot'][0] ?? null;
311
+                if (is_array($appScreenshot)) {
312
+                    // Screenshot with thumbnail
313
+                    $appScreenshot = $appScreenshot['@value'];
314
+                }
315
+
316
+                $app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot);
317
+            }
318
+            $this->allApps[$app['id']] = $app;
319
+        }
320
+
321
+        $apps = $this->getAppsForCategory('');
322
+        $supportedApps = $appClass->getSupportedApps();
323
+        foreach ($apps as $app) {
324
+            $app['appstore'] = true;
325
+            if (!array_key_exists($app['id'], $this->allApps)) {
326
+                $this->allApps[$app['id']] = $app;
327
+            } else {
328
+                $this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]);
329
+            }
330
+
331
+            if (in_array($app['id'], $supportedApps)) {
332
+                $this->allApps[$app['id']]['level'] = \OC_App::supportedApp;
333
+            }
334
+        }
335
+
336
+        // add bundle information
337
+        $bundles = $this->bundleFetcher->getBundles();
338
+        foreach ($bundles as $bundle) {
339
+            foreach ($bundle->getAppIdentifiers() as $identifier) {
340
+                foreach ($this->allApps as &$app) {
341
+                    if ($app['id'] === $identifier) {
342
+                        $app['bundleIds'][] = $bundle->getIdentifier();
343
+                        continue;
344
+                    }
345
+                }
346
+            }
347
+        }
348
+    }
349
+
350
+    private function getAllApps() {
351
+        return $this->allApps;
352
+    }
353
+
354
+    /**
355
+     * Get all available apps in a category
356
+     *
357
+     * @return JSONResponse
358
+     * @throws \Exception
359
+     */
360
+    public function listApps(): JSONResponse {
361
+        $this->fetchApps();
362
+        $apps = $this->getAllApps();
363
+
364
+        $dependencyAnalyzer = new DependencyAnalyzer(new Platform($this->config), $this->l10n);
365
+
366
+        $ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
367
+        if (!is_array($ignoreMaxApps)) {
368
+            $this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...');
369
+            $ignoreMaxApps = [];
370
+        }
371
+
372
+        // Extend existing app details
373
+        $apps = array_map(function (array $appData) use ($dependencyAnalyzer, $ignoreMaxApps) {
374
+            if (isset($appData['appstoreData'])) {
375
+                $appstoreData = $appData['appstoreData'];
376
+                $appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
377
+                $appData['category'] = $appstoreData['categories'];
378
+                $appData['releases'] = $appstoreData['releases'];
379
+            }
380
+
381
+            $newVersion = $this->installer->isUpdateAvailable($appData['id']);
382
+            if ($newVersion) {
383
+                $appData['update'] = $newVersion;
384
+            }
385
+
386
+            // fix groups to be an array
387
+            $groups = [];
388
+            if (is_string($appData['groups'])) {
389
+                $groups = json_decode($appData['groups']);
390
+                // ensure 'groups' is an array
391
+                if (!is_array($groups)) {
392
+                    $groups = [$groups];
393
+                }
394
+            }
395
+            $appData['groups'] = $groups;
396
+            $appData['canUnInstall'] = !$appData['active'] && $appData['removable'];
397
+
398
+            // fix licence vs license
399
+            if (isset($appData['license']) && !isset($appData['licence'])) {
400
+                $appData['licence'] = $appData['license'];
401
+            }
402
+
403
+            $ignoreMax = in_array($appData['id'], $ignoreMaxApps);
404
+
405
+            // analyse dependencies
406
+            $missing = $dependencyAnalyzer->analyze($appData, $ignoreMax);
407
+            $appData['canInstall'] = empty($missing);
408
+            $appData['missingDependencies'] = $missing;
409
+
410
+            $appData['missingMinOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
411
+            $appData['missingMaxOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
412
+            $appData['isCompatible'] = $dependencyAnalyzer->isMarkedCompatible($appData);
413
+
414
+            return $appData;
415
+        }, $apps);
416
+
417
+        usort($apps, [$this, 'sortApps']);
418
+
419
+        return new JSONResponse(['apps' => $apps, 'status' => 'success']);
420
+    }
421
+
422
+    /**
423
+     * Get all apps for a category from the app store
424
+     *
425
+     * @param string $requestedCategory
426
+     * @return array
427
+     * @throws \Exception
428
+     */
429
+    private function getAppsForCategory($requestedCategory = ''): array {
430
+        $versionParser = new VersionParser();
431
+        $formattedApps = [];
432
+        $apps = $this->appFetcher->get();
433
+        foreach ($apps as $app) {
434
+            // Skip all apps not in the requested category
435
+            if ($requestedCategory !== '') {
436
+                $isInCategory = false;
437
+                foreach ($app['categories'] as $category) {
438
+                    if ($category === $requestedCategory) {
439
+                        $isInCategory = true;
440
+                    }
441
+                }
442
+                if (!$isInCategory) {
443
+                    continue;
444
+                }
445
+            }
446
+
447
+            if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) {
448
+                continue;
449
+            }
450
+            $nextCloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']);
451
+            $nextCloudVersionDependencies = [];
452
+            if ($nextCloudVersion->getMinimumVersion() !== '') {
453
+                $nextCloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextCloudVersion->getMinimumVersion();
454
+            }
455
+            if ($nextCloudVersion->getMaximumVersion() !== '') {
456
+                $nextCloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextCloudVersion->getMaximumVersion();
457
+            }
458
+            $phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']);
459
+
460
+            try {
461
+                $this->appManager->getAppPath($app['id']);
462
+                $existsLocally = true;
463
+            } catch (AppPathNotFoundException) {
464
+                $existsLocally = false;
465
+            }
466
+
467
+            $phpDependencies = [];
468
+            if ($phpVersion->getMinimumVersion() !== '') {
469
+                $phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion();
470
+            }
471
+            if ($phpVersion->getMaximumVersion() !== '') {
472
+                $phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion();
473
+            }
474
+            if (isset($app['releases'][0]['minIntSize'])) {
475
+                $phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize'];
476
+            }
477
+            $authors = '';
478
+            foreach ($app['authors'] as $key => $author) {
479
+                $authors .= $author['name'];
480
+                if ($key !== count($app['authors']) - 1) {
481
+                    $authors .= ', ';
482
+                }
483
+            }
484
+
485
+            $currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
486
+            $enabledValue = $this->config->getAppValue($app['id'], 'enabled', 'no');
487
+            $groups = null;
488
+            if ($enabledValue !== 'no' && $enabledValue !== 'yes') {
489
+                $groups = $enabledValue;
490
+            }
491
+
492
+            $currentVersion = '';
493
+            if ($this->appManager->isEnabledForAnyone($app['id'])) {
494
+                $currentVersion = $this->appManager->getAppVersion($app['id']);
495
+            } else {
496
+                $currentVersion = $app['releases'][0]['version'];
497
+            }
498
+
499
+            $formattedApps[] = [
500
+                'id' => $app['id'],
501
+                'app_api' => false,
502
+                'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'],
503
+                'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'],
504
+                'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'],
505
+                'license' => $app['releases'][0]['licenses'],
506
+                'author' => $authors,
507
+                'shipped' => $this->appManager->isShipped($app['id']),
508
+                'version' => $currentVersion,
509
+                'default_enable' => '',
510
+                'types' => [],
511
+                'documentation' => [
512
+                    'admin' => $app['adminDocs'],
513
+                    'user' => $app['userDocs'],
514
+                    'developer' => $app['developerDocs']
515
+                ],
516
+                'website' => $app['website'],
517
+                'bugs' => $app['issueTracker'],
518
+                'detailpage' => $app['website'],
519
+                'dependencies' => array_merge(
520
+                    $nextCloudVersionDependencies,
521
+                    $phpDependencies
522
+                ),
523
+                'level' => ($app['isFeatured'] === true) ? 200 : 100,
524
+                'missingMaxOwnCloudVersion' => false,
525
+                'missingMinOwnCloudVersion' => false,
526
+                'canInstall' => true,
527
+                'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
528
+                'score' => $app['ratingOverall'],
529
+                'ratingNumOverall' => $app['ratingNumOverall'],
530
+                'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
531
+                'removable' => $existsLocally,
532
+                'active' => $this->appManager->isEnabledForUser($app['id']),
533
+                'needsDownload' => !$existsLocally,
534
+                'groups' => $groups,
535
+                'fromAppStore' => true,
536
+                'appstoreData' => $app,
537
+            ];
538
+        }
539
+
540
+        return $formattedApps;
541
+    }
542
+
543
+    /**
544
+     * @param string $appId
545
+     * @param array $groups
546
+     * @return JSONResponse
547
+     */
548
+    #[PasswordConfirmationRequired]
549
+    public function enableApp(string $appId, array $groups = []): JSONResponse {
550
+        return $this->enableApps([$appId], $groups);
551
+    }
552
+
553
+    /**
554
+     * Enable one or more apps
555
+     *
556
+     * apps will be enabled for specific groups only if $groups is defined
557
+     *
558
+     * @param array $appIds
559
+     * @param array $groups
560
+     * @return JSONResponse
561
+     */
562
+    #[PasswordConfirmationRequired]
563
+    public function enableApps(array $appIds, array $groups = []): JSONResponse {
564
+        try {
565
+            $updateRequired = false;
566
+
567
+            foreach ($appIds as $appId) {
568
+                $appId = $this->appManager->cleanAppId($appId);
569
+
570
+                // Check if app is already downloaded
571
+                /** @var Installer $installer */
572
+                $installer = Server::get(Installer::class);
573
+                $isDownloaded = $installer->isDownloaded($appId);
574
+
575
+                if (!$isDownloaded) {
576
+                    $installer->downloadApp($appId);
577
+                }
578
+
579
+                $installer->installApp($appId);
580
+
581
+                if (count($groups) > 0) {
582
+                    $this->appManager->enableAppForGroups($appId, $this->getGroupList($groups));
583
+                } else {
584
+                    $this->appManager->enableApp($appId);
585
+                }
586
+                if (\OC_App::shouldUpgrade($appId)) {
587
+                    $updateRequired = true;
588
+                }
589
+            }
590
+            return new JSONResponse(['data' => ['update_required' => $updateRequired]]);
591
+        } catch (\Throwable $e) {
592
+            $this->logger->error('could not enable apps', ['exception' => $e]);
593
+            return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
594
+        }
595
+    }
596
+
597
+    private function getGroupList(array $groups) {
598
+        $groupManager = Server::get(IGroupManager::class);
599
+        $groupsList = [];
600
+        foreach ($groups as $group) {
601
+            $groupItem = $groupManager->get($group);
602
+            if ($groupItem instanceof IGroup) {
603
+                $groupsList[] = $groupManager->get($group);
604
+            }
605
+        }
606
+        return $groupsList;
607
+    }
608
+
609
+    /**
610
+     * @param string $appId
611
+     * @return JSONResponse
612
+     */
613
+    #[PasswordConfirmationRequired]
614
+    public function disableApp(string $appId): JSONResponse {
615
+        return $this->disableApps([$appId]);
616
+    }
617
+
618
+    /**
619
+     * @param array $appIds
620
+     * @return JSONResponse
621
+     */
622
+    #[PasswordConfirmationRequired]
623
+    public function disableApps(array $appIds): JSONResponse {
624
+        try {
625
+            foreach ($appIds as $appId) {
626
+                $appId = $this->appManager->cleanAppId($appId);
627
+                $this->appManager->disableApp($appId);
628
+            }
629
+            return new JSONResponse([]);
630
+        } catch (\Exception $e) {
631
+            $this->logger->error('could not disable app', ['exception' => $e]);
632
+            return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
633
+        }
634
+    }
635
+
636
+    /**
637
+     * @param string $appId
638
+     * @return JSONResponse
639
+     */
640
+    #[PasswordConfirmationRequired]
641
+    public function uninstallApp(string $appId): JSONResponse {
642
+        $appId = $this->appManager->cleanAppId($appId);
643
+        $result = $this->installer->removeApp($appId);
644
+        if ($result !== false) {
645
+            // If this app was force enabled, remove the force-enabled-state
646
+            $this->appManager->removeOverwriteNextcloudRequirement($appId);
647
+            $this->appManager->clearAppsCache();
648
+            return new JSONResponse(['data' => ['appid' => $appId]]);
649
+        }
650
+        return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not remove app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
651
+    }
652
+
653
+    /**
654
+     * @param string $appId
655
+     * @return JSONResponse
656
+     */
657
+    public function updateApp(string $appId): JSONResponse {
658
+        $appId = $this->appManager->cleanAppId($appId);
659
+
660
+        $this->config->setSystemValue('maintenance', true);
661
+        try {
662
+            $result = $this->installer->updateAppstoreApp($appId);
663
+            $this->config->setSystemValue('maintenance', false);
664
+        } catch (\Exception $ex) {
665
+            $this->config->setSystemValue('maintenance', false);
666
+            return new JSONResponse(['data' => ['message' => $ex->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
667
+        }
668
+
669
+        if ($result !== false) {
670
+            return new JSONResponse(['data' => ['appid' => $appId]]);
671
+        }
672
+        return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not update app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
673
+    }
674
+
675
+    private function sortApps($a, $b) {
676
+        $a = (string)$a['name'];
677
+        $b = (string)$b['name'];
678
+        if ($a === $b) {
679
+            return 0;
680
+        }
681
+        return ($a < $b) ? -1 : 1;
682
+    }
683
+
684
+    public function force(string $appId): JSONResponse {
685
+        $appId = $this->appManager->cleanAppId($appId);
686
+        $this->appManager->overwriteNextcloudRequirement($appId);
687
+        return new JSONResponse();
688
+    }
689 689
 }
Please login to merge, or discard this patch.