Completed
Push — master ( 4e64c0...433e3d )
by
unknown
55:55 queued 27:25
created
apps/settings/lib/Controller/AppSettingsController.php 1 patch
Indentation   +625 added lines, -625 removed lines patch added patch discarded remove patch
@@ -53,629 +53,629 @@
 block discarded – undo
53 53
 #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
54 54
 class AppSettingsController extends Controller {
55 55
 
56
-	/** @var array */
57
-	private $allApps = [];
58
-
59
-	private IAppData $appData;
60
-
61
-	public function __construct(
62
-		string $appName,
63
-		IRequest $request,
64
-		IAppDataFactory $appDataFactory,
65
-		private IL10N $l10n,
66
-		private IConfig $config,
67
-		private INavigationManager $navigationManager,
68
-		private AppManager $appManager,
69
-		private CategoryFetcher $categoryFetcher,
70
-		private AppFetcher $appFetcher,
71
-		private IFactory $l10nFactory,
72
-		private BundleFetcher $bundleFetcher,
73
-		private Installer $installer,
74
-		private IURLGenerator $urlGenerator,
75
-		private LoggerInterface $logger,
76
-		private IInitialState $initialState,
77
-		private AppDiscoverFetcher $discoverFetcher,
78
-		private IClientService $clientService,
79
-	) {
80
-		parent::__construct($appName, $request);
81
-		$this->appData = $appDataFactory->get('appstore');
82
-	}
83
-
84
-	/**
85
-	 * @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
86
-	 *
87
-	 * @return TemplateResponse
88
-	 */
89
-	#[NoCSRFRequired]
90
-	public function viewApps(): TemplateResponse {
91
-		$this->navigationManager->setActiveEntry('core_apps');
92
-
93
-		$this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
94
-		$this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
95
-		$this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
96
-
97
-		if ($this->appManager->isEnabledForAnyone('app_api')) {
98
-			try {
99
-				Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
100
-			} catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
101
-			}
102
-		}
103
-
104
-		$policy = new ContentSecurityPolicy();
105
-		$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
106
-
107
-		$templateResponse = new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]);
108
-		$templateResponse->setContentSecurityPolicy($policy);
109
-
110
-		Util::addStyle('settings', 'settings');
111
-		Util::addScript('settings', 'vue-settings-apps-users-management');
112
-
113
-		return $templateResponse;
114
-	}
115
-
116
-	/**
117
-	 * Get all active entries for the app discover section
118
-	 */
119
-	#[NoCSRFRequired]
120
-	public function getAppDiscoverJSON(): JSONResponse {
121
-		$data = $this->discoverFetcher->get(true);
122
-		return new JSONResponse(array_values($data));
123
-	}
124
-
125
-	/**
126
-	 * Get a image for the app discover section - this is proxied for privacy and CSP reasons
127
-	 *
128
-	 * @param string $image
129
-	 * @throws \Exception
130
-	 */
131
-	#[NoCSRFRequired]
132
-	public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): Response {
133
-		$getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
134
-		$etag = trim($getEtag, '"');
135
-
136
-		$folder = null;
137
-		try {
138
-			$folder = $this->appData->getFolder('app-discover-cache');
139
-			$this->cleanUpImageCache($folder, $etag);
140
-		} catch (\Throwable $e) {
141
-			$folder = $this->appData->newFolder('app-discover-cache');
142
-		}
143
-
144
-		// Get the current cache folder
145
-		try {
146
-			$folder = $folder->getFolder($etag);
147
-		} catch (NotFoundException $e) {
148
-			$folder = $folder->newFolder($etag);
149
-		}
150
-
151
-		$info = pathinfo($fileName);
152
-		$hashName = md5($fileName);
153
-		$allFiles = $folder->getDirectoryListing();
154
-		// Try to find the file
155
-		$file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
156
-			return str_starts_with($file->getName(), $hashName);
157
-		});
158
-		// Get the first entry
159
-		$file = reset($file);
160
-		// If not found request from Web
161
-		if ($file === false) {
162
-			$user = $session->getUser();
163
-			// this route is not public thus we can assume a user is logged-in
164
-			assert($user !== null);
165
-			// Register a user request to throttle fetching external data
166
-			// this will prevent using the server for DoS of other systems.
167
-			$limiter->registerUserRequest(
168
-				'settings-discover-media',
169
-				// allow up to 24 media requests per hour
170
-				// this should be a sane default when a completely new section is loaded
171
-				// keep in mind browsers request all files from a source-set
172
-				24,
173
-				60 * 60,
174
-				$user,
175
-			);
176
-
177
-			if (!$this->checkCanDownloadMedia($fileName)) {
178
-				$this->logger->warning('Tried to load media files for app discover section from untrusted source');
179
-				return new NotFoundResponse(Http::STATUS_BAD_REQUEST);
180
-			}
181
-
182
-			try {
183
-				$client = $this->clientService->newClient();
184
-				$fileResponse = $client->get($fileName);
185
-				$contentType = $fileResponse->getHeader('Content-Type');
186
-				$extension = $info['extension'] ?? '';
187
-				$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
188
-			} catch (\Throwable $e) {
189
-				$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
190
-				return new NotFoundResponse();
191
-			}
192
-		} else {
193
-			// File was found so we can get the content type from the file name
194
-			$contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
195
-		}
196
-
197
-		$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
198
-		// cache for 7 days
199
-		$response->cacheFor(604800, false, true);
200
-		return $response;
201
-	}
202
-
203
-	private function checkCanDownloadMedia(string $filename): bool {
204
-		$urlInfo = parse_url($filename);
205
-		if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
206
-			return false;
207
-		}
208
-
209
-		// Always allowed hosts
210
-		if ($urlInfo['host'] === 'nextcloud.com') {
211
-			return true;
212
-		}
213
-
214
-		// Hosts that need further verification
215
-		// Github is only allowed if from our organization
216
-		$ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
217
-		if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) {
218
-			return false;
219
-		}
220
-
221
-		if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) {
222
-			return true;
223
-		}
224
-
225
-		return false;
226
-	}
227
-
228
-	/**
229
-	 * Remove orphaned folders from the image cache that do not match the current etag
230
-	 * @param ISimpleFolder $folder The folder to clear
231
-	 * @param string $etag The etag (directory name) to keep
232
-	 */
233
-	private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
234
-		// Cleanup old cache folders
235
-		$allFiles = $folder->getDirectoryListing();
236
-		foreach ($allFiles as $dir) {
237
-			try {
238
-				if ($dir->getName() !== $etag) {
239
-					$dir->delete();
240
-				}
241
-			} catch (NotPermittedException $e) {
242
-				// ignore folder for now
243
-			}
244
-		}
245
-	}
246
-
247
-	private function getAppsWithUpdates() {
248
-		$appClass = new \OC_App();
249
-		$apps = $appClass->listAllApps();
250
-		foreach ($apps as $key => $app) {
251
-			$newVersion = $this->installer->isUpdateAvailable($app['id']);
252
-			if ($newVersion === false) {
253
-				unset($apps[$key]);
254
-			}
255
-		}
256
-		return $apps;
257
-	}
258
-
259
-	private function getBundles() {
260
-		$result = [];
261
-		$bundles = $this->bundleFetcher->getBundles();
262
-		foreach ($bundles as $bundle) {
263
-			$result[] = [
264
-				'name' => $bundle->getName(),
265
-				'id' => $bundle->getIdentifier(),
266
-				'appIdentifiers' => $bundle->getAppIdentifiers()
267
-			];
268
-		}
269
-		return $result;
270
-	}
271
-
272
-	/**
273
-	 * Get all available categories
274
-	 *
275
-	 * @return JSONResponse
276
-	 */
277
-	public function listCategories(): JSONResponse {
278
-		return new JSONResponse($this->getAllCategories());
279
-	}
280
-
281
-	private function getAllCategories() {
282
-		$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
283
-
284
-		$categories = $this->categoryFetcher->get();
285
-		return array_map(fn ($category) => [
286
-			'id' => $category['id'],
287
-			'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
288
-		], $categories);
289
-	}
290
-
291
-	/**
292
-	 * Convert URL to proxied URL so CSP is no problem
293
-	 */
294
-	private function createProxyPreviewUrl(string $url): string {
295
-		if ($url === '') {
296
-			return '';
297
-		}
298
-		return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url);
299
-	}
300
-
301
-	private function fetchApps() {
302
-		$appClass = new \OC_App();
303
-		$apps = $appClass->listAllApps();
304
-		foreach ($apps as $app) {
305
-			$app['installed'] = true;
306
-
307
-			if (isset($app['screenshot'][0])) {
308
-				$appScreenshot = $app['screenshot'][0] ?? null;
309
-				if (is_array($appScreenshot)) {
310
-					// Screenshot with thumbnail
311
-					$appScreenshot = $appScreenshot['@value'];
312
-				}
313
-
314
-				$app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot);
315
-			}
316
-			$this->allApps[$app['id']] = $app;
317
-		}
318
-
319
-		$apps = $this->getAppsForCategory('');
320
-		$supportedApps = $appClass->getSupportedApps();
321
-		foreach ($apps as $app) {
322
-			$app['appstore'] = true;
323
-			if (!array_key_exists($app['id'], $this->allApps)) {
324
-				$this->allApps[$app['id']] = $app;
325
-			} else {
326
-				$this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]);
327
-			}
328
-
329
-			if (in_array($app['id'], $supportedApps)) {
330
-				$this->allApps[$app['id']]['level'] = \OC_App::supportedApp;
331
-			}
332
-		}
333
-
334
-		// add bundle information
335
-		$bundles = $this->bundleFetcher->getBundles();
336
-		foreach ($bundles as $bundle) {
337
-			foreach ($bundle->getAppIdentifiers() as $identifier) {
338
-				foreach ($this->allApps as &$app) {
339
-					if ($app['id'] === $identifier) {
340
-						$app['bundleIds'][] = $bundle->getIdentifier();
341
-						continue;
342
-					}
343
-				}
344
-			}
345
-		}
346
-	}
347
-
348
-	private function getAllApps() {
349
-		return $this->allApps;
350
-	}
351
-
352
-	/**
353
-	 * Get all available apps in a category
354
-	 *
355
-	 * @return JSONResponse
356
-	 * @throws \Exception
357
-	 */
358
-	public function listApps(): JSONResponse {
359
-		$this->fetchApps();
360
-		$apps = $this->getAllApps();
361
-
362
-		$dependencyAnalyzer = Server::get(DependencyAnalyzer::class);
363
-
364
-		$ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
365
-		if (!is_array($ignoreMaxApps)) {
366
-			$this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...');
367
-			$ignoreMaxApps = [];
368
-		}
369
-
370
-		// Extend existing app details
371
-		$apps = array_map(function (array $appData) use ($dependencyAnalyzer, $ignoreMaxApps) {
372
-			if (isset($appData['appstoreData'])) {
373
-				$appstoreData = $appData['appstoreData'];
374
-				$appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
375
-				$appData['category'] = $appstoreData['categories'];
376
-				$appData['releases'] = $appstoreData['releases'];
377
-			}
378
-
379
-			$newVersion = $this->installer->isUpdateAvailable($appData['id']);
380
-			if ($newVersion) {
381
-				$appData['update'] = $newVersion;
382
-			}
383
-
384
-			// fix groups to be an array
385
-			$groups = [];
386
-			if (is_string($appData['groups'])) {
387
-				$groups = json_decode($appData['groups']);
388
-				// ensure 'groups' is an array
389
-				if (!is_array($groups)) {
390
-					$groups = [$groups];
391
-				}
392
-			}
393
-			$appData['groups'] = $groups;
394
-			$appData['canUnInstall'] = !$appData['active'] && $appData['removable'];
395
-
396
-			// fix licence vs license
397
-			if (isset($appData['license']) && !isset($appData['licence'])) {
398
-				$appData['licence'] = $appData['license'];
399
-			}
400
-
401
-			$ignoreMax = in_array($appData['id'], $ignoreMaxApps);
402
-
403
-			// analyse dependencies
404
-			$missing = $dependencyAnalyzer->analyze($appData, $ignoreMax);
405
-			$appData['canInstall'] = empty($missing);
406
-			$appData['missingDependencies'] = $missing;
407
-
408
-			$appData['missingMinOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
409
-			$appData['missingMaxOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
410
-			$appData['isCompatible'] = $dependencyAnalyzer->isMarkedCompatible($appData);
411
-
412
-			return $appData;
413
-		}, $apps);
414
-
415
-		usort($apps, [$this, 'sortApps']);
416
-
417
-		return new JSONResponse(['apps' => $apps, 'status' => 'success']);
418
-	}
419
-
420
-	/**
421
-	 * Get all apps for a category from the app store
422
-	 *
423
-	 * @param string $requestedCategory
424
-	 * @return array
425
-	 * @throws \Exception
426
-	 */
427
-	private function getAppsForCategory($requestedCategory = ''): array {
428
-		$versionParser = new VersionParser();
429
-		$formattedApps = [];
430
-		$apps = $this->appFetcher->get();
431
-		foreach ($apps as $app) {
432
-			// Skip all apps not in the requested category
433
-			if ($requestedCategory !== '') {
434
-				$isInCategory = false;
435
-				foreach ($app['categories'] as $category) {
436
-					if ($category === $requestedCategory) {
437
-						$isInCategory = true;
438
-					}
439
-				}
440
-				if (!$isInCategory) {
441
-					continue;
442
-				}
443
-			}
444
-
445
-			if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) {
446
-				continue;
447
-			}
448
-			$nextCloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']);
449
-			$nextCloudVersionDependencies = [];
450
-			if ($nextCloudVersion->getMinimumVersion() !== '') {
451
-				$nextCloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextCloudVersion->getMinimumVersion();
452
-			}
453
-			if ($nextCloudVersion->getMaximumVersion() !== '') {
454
-				$nextCloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextCloudVersion->getMaximumVersion();
455
-			}
456
-			$phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']);
457
-
458
-			try {
459
-				$this->appManager->getAppPath($app['id']);
460
-				$existsLocally = true;
461
-			} catch (AppPathNotFoundException) {
462
-				$existsLocally = false;
463
-			}
464
-
465
-			$phpDependencies = [];
466
-			if ($phpVersion->getMinimumVersion() !== '') {
467
-				$phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion();
468
-			}
469
-			if ($phpVersion->getMaximumVersion() !== '') {
470
-				$phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion();
471
-			}
472
-			if (isset($app['releases'][0]['minIntSize'])) {
473
-				$phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize'];
474
-			}
475
-			$authors = '';
476
-			foreach ($app['authors'] as $key => $author) {
477
-				$authors .= $author['name'];
478
-				if ($key !== count($app['authors']) - 1) {
479
-					$authors .= ', ';
480
-				}
481
-			}
482
-
483
-			$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
484
-			$enabledValue = $this->config->getAppValue($app['id'], 'enabled', 'no');
485
-			$groups = null;
486
-			if ($enabledValue !== 'no' && $enabledValue !== 'yes') {
487
-				$groups = $enabledValue;
488
-			}
489
-
490
-			$currentVersion = '';
491
-			if ($this->appManager->isEnabledForAnyone($app['id'])) {
492
-				$currentVersion = $this->appManager->getAppVersion($app['id']);
493
-			} else {
494
-				$currentVersion = $app['releases'][0]['version'];
495
-			}
496
-
497
-			$formattedApps[] = [
498
-				'id' => $app['id'],
499
-				'app_api' => false,
500
-				'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'],
501
-				'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'],
502
-				'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'],
503
-				'license' => $app['releases'][0]['licenses'],
504
-				'author' => $authors,
505
-				'shipped' => $this->appManager->isShipped($app['id']),
506
-				'version' => $currentVersion,
507
-				'default_enable' => '',
508
-				'types' => [],
509
-				'documentation' => [
510
-					'admin' => $app['adminDocs'],
511
-					'user' => $app['userDocs'],
512
-					'developer' => $app['developerDocs']
513
-				],
514
-				'website' => $app['website'],
515
-				'bugs' => $app['issueTracker'],
516
-				'detailpage' => $app['website'],
517
-				'dependencies' => array_merge(
518
-					$nextCloudVersionDependencies,
519
-					$phpDependencies
520
-				),
521
-				'level' => ($app['isFeatured'] === true) ? 200 : 100,
522
-				'missingMaxOwnCloudVersion' => false,
523
-				'missingMinOwnCloudVersion' => false,
524
-				'canInstall' => true,
525
-				'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
526
-				'score' => $app['ratingOverall'],
527
-				'ratingNumOverall' => $app['ratingNumOverall'],
528
-				'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
529
-				'removable' => $existsLocally,
530
-				'active' => $this->appManager->isEnabledForUser($app['id']),
531
-				'needsDownload' => !$existsLocally,
532
-				'groups' => $groups,
533
-				'fromAppStore' => true,
534
-				'appstoreData' => $app,
535
-			];
536
-		}
537
-
538
-		return $formattedApps;
539
-	}
540
-
541
-	/**
542
-	 * @param string $appId
543
-	 * @param array $groups
544
-	 * @return JSONResponse
545
-	 */
546
-	#[PasswordConfirmationRequired]
547
-	public function enableApp(string $appId, array $groups = []): JSONResponse {
548
-		return $this->enableApps([$appId], $groups);
549
-	}
550
-
551
-	/**
552
-	 * Enable one or more apps
553
-	 *
554
-	 * apps will be enabled for specific groups only if $groups is defined
555
-	 *
556
-	 * @param array $appIds
557
-	 * @param array $groups
558
-	 * @return JSONResponse
559
-	 */
560
-	#[PasswordConfirmationRequired]
561
-	public function enableApps(array $appIds, array $groups = []): JSONResponse {
562
-		try {
563
-			$updateRequired = false;
564
-
565
-			foreach ($appIds as $appId) {
566
-				$appId = $this->appManager->cleanAppId($appId);
567
-
568
-				// Check if app is already downloaded
569
-				if (!$this->installer->isDownloaded($appId)) {
570
-					$this->installer->downloadApp($appId);
571
-				}
572
-
573
-				$this->installer->installApp($appId);
574
-
575
-				if (count($groups) > 0) {
576
-					$this->appManager->enableAppForGroups($appId, $this->getGroupList($groups));
577
-				} else {
578
-					$this->appManager->enableApp($appId);
579
-				}
580
-				$updateRequired = $updateRequired || $this->appManager->isUpgradeRequired($appId);
581
-			}
582
-			return new JSONResponse(['data' => ['update_required' => $updateRequired]]);
583
-		} catch (\Throwable $e) {
584
-			$this->logger->error('could not enable apps', ['exception' => $e]);
585
-			return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
586
-		}
587
-	}
588
-
589
-	private function getGroupList(array $groups) {
590
-		$groupManager = Server::get(IGroupManager::class);
591
-		$groupsList = [];
592
-		foreach ($groups as $group) {
593
-			$groupItem = $groupManager->get($group);
594
-			if ($groupItem instanceof IGroup) {
595
-				$groupsList[] = $groupManager->get($group);
596
-			}
597
-		}
598
-		return $groupsList;
599
-	}
600
-
601
-	/**
602
-	 * @param string $appId
603
-	 * @return JSONResponse
604
-	 */
605
-	#[PasswordConfirmationRequired]
606
-	public function disableApp(string $appId): JSONResponse {
607
-		return $this->disableApps([$appId]);
608
-	}
609
-
610
-	/**
611
-	 * @param array $appIds
612
-	 * @return JSONResponse
613
-	 */
614
-	#[PasswordConfirmationRequired]
615
-	public function disableApps(array $appIds): JSONResponse {
616
-		try {
617
-			foreach ($appIds as $appId) {
618
-				$appId = $this->appManager->cleanAppId($appId);
619
-				$this->appManager->disableApp($appId);
620
-			}
621
-			return new JSONResponse([]);
622
-		} catch (\Exception $e) {
623
-			$this->logger->error('could not disable app', ['exception' => $e]);
624
-			return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
625
-		}
626
-	}
627
-
628
-	/**
629
-	 * @param string $appId
630
-	 * @return JSONResponse
631
-	 */
632
-	#[PasswordConfirmationRequired]
633
-	public function uninstallApp(string $appId): JSONResponse {
634
-		$appId = $this->appManager->cleanAppId($appId);
635
-		$result = $this->installer->removeApp($appId);
636
-		if ($result !== false) {
637
-			// If this app was force enabled, remove the force-enabled-state
638
-			$this->appManager->removeOverwriteNextcloudRequirement($appId);
639
-			$this->appManager->clearAppsCache();
640
-			return new JSONResponse(['data' => ['appid' => $appId]]);
641
-		}
642
-		return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not remove app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
643
-	}
644
-
645
-	/**
646
-	 * @param string $appId
647
-	 * @return JSONResponse
648
-	 */
649
-	public function updateApp(string $appId): JSONResponse {
650
-		$appId = $this->appManager->cleanAppId($appId);
651
-
652
-		$this->config->setSystemValue('maintenance', true);
653
-		try {
654
-			$result = $this->installer->updateAppstoreApp($appId);
655
-			$this->config->setSystemValue('maintenance', false);
656
-		} catch (\Exception $ex) {
657
-			$this->config->setSystemValue('maintenance', false);
658
-			return new JSONResponse(['data' => ['message' => $ex->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
659
-		}
660
-
661
-		if ($result !== false) {
662
-			return new JSONResponse(['data' => ['appid' => $appId]]);
663
-		}
664
-		return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not update app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
665
-	}
666
-
667
-	private function sortApps($a, $b) {
668
-		$a = (string)$a['name'];
669
-		$b = (string)$b['name'];
670
-		if ($a === $b) {
671
-			return 0;
672
-		}
673
-		return ($a < $b) ? -1 : 1;
674
-	}
675
-
676
-	public function force(string $appId): JSONResponse {
677
-		$appId = $this->appManager->cleanAppId($appId);
678
-		$this->appManager->overwriteNextcloudRequirement($appId);
679
-		return new JSONResponse();
680
-	}
56
+    /** @var array */
57
+    private $allApps = [];
58
+
59
+    private IAppData $appData;
60
+
61
+    public function __construct(
62
+        string $appName,
63
+        IRequest $request,
64
+        IAppDataFactory $appDataFactory,
65
+        private IL10N $l10n,
66
+        private IConfig $config,
67
+        private INavigationManager $navigationManager,
68
+        private AppManager $appManager,
69
+        private CategoryFetcher $categoryFetcher,
70
+        private AppFetcher $appFetcher,
71
+        private IFactory $l10nFactory,
72
+        private BundleFetcher $bundleFetcher,
73
+        private Installer $installer,
74
+        private IURLGenerator $urlGenerator,
75
+        private LoggerInterface $logger,
76
+        private IInitialState $initialState,
77
+        private AppDiscoverFetcher $discoverFetcher,
78
+        private IClientService $clientService,
79
+    ) {
80
+        parent::__construct($appName, $request);
81
+        $this->appData = $appDataFactory->get('appstore');
82
+    }
83
+
84
+    /**
85
+     * @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
86
+     *
87
+     * @return TemplateResponse
88
+     */
89
+    #[NoCSRFRequired]
90
+    public function viewApps(): TemplateResponse {
91
+        $this->navigationManager->setActiveEntry('core_apps');
92
+
93
+        $this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
94
+        $this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
95
+        $this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
96
+
97
+        if ($this->appManager->isEnabledForAnyone('app_api')) {
98
+            try {
99
+                Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
100
+            } catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
101
+            }
102
+        }
103
+
104
+        $policy = new ContentSecurityPolicy();
105
+        $policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
106
+
107
+        $templateResponse = new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]);
108
+        $templateResponse->setContentSecurityPolicy($policy);
109
+
110
+        Util::addStyle('settings', 'settings');
111
+        Util::addScript('settings', 'vue-settings-apps-users-management');
112
+
113
+        return $templateResponse;
114
+    }
115
+
116
+    /**
117
+     * Get all active entries for the app discover section
118
+     */
119
+    #[NoCSRFRequired]
120
+    public function getAppDiscoverJSON(): JSONResponse {
121
+        $data = $this->discoverFetcher->get(true);
122
+        return new JSONResponse(array_values($data));
123
+    }
124
+
125
+    /**
126
+     * Get a image for the app discover section - this is proxied for privacy and CSP reasons
127
+     *
128
+     * @param string $image
129
+     * @throws \Exception
130
+     */
131
+    #[NoCSRFRequired]
132
+    public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): Response {
133
+        $getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
134
+        $etag = trim($getEtag, '"');
135
+
136
+        $folder = null;
137
+        try {
138
+            $folder = $this->appData->getFolder('app-discover-cache');
139
+            $this->cleanUpImageCache($folder, $etag);
140
+        } catch (\Throwable $e) {
141
+            $folder = $this->appData->newFolder('app-discover-cache');
142
+        }
143
+
144
+        // Get the current cache folder
145
+        try {
146
+            $folder = $folder->getFolder($etag);
147
+        } catch (NotFoundException $e) {
148
+            $folder = $folder->newFolder($etag);
149
+        }
150
+
151
+        $info = pathinfo($fileName);
152
+        $hashName = md5($fileName);
153
+        $allFiles = $folder->getDirectoryListing();
154
+        // Try to find the file
155
+        $file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
156
+            return str_starts_with($file->getName(), $hashName);
157
+        });
158
+        // Get the first entry
159
+        $file = reset($file);
160
+        // If not found request from Web
161
+        if ($file === false) {
162
+            $user = $session->getUser();
163
+            // this route is not public thus we can assume a user is logged-in
164
+            assert($user !== null);
165
+            // Register a user request to throttle fetching external data
166
+            // this will prevent using the server for DoS of other systems.
167
+            $limiter->registerUserRequest(
168
+                'settings-discover-media',
169
+                // allow up to 24 media requests per hour
170
+                // this should be a sane default when a completely new section is loaded
171
+                // keep in mind browsers request all files from a source-set
172
+                24,
173
+                60 * 60,
174
+                $user,
175
+            );
176
+
177
+            if (!$this->checkCanDownloadMedia($fileName)) {
178
+                $this->logger->warning('Tried to load media files for app discover section from untrusted source');
179
+                return new NotFoundResponse(Http::STATUS_BAD_REQUEST);
180
+            }
181
+
182
+            try {
183
+                $client = $this->clientService->newClient();
184
+                $fileResponse = $client->get($fileName);
185
+                $contentType = $fileResponse->getHeader('Content-Type');
186
+                $extension = $info['extension'] ?? '';
187
+                $file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
188
+            } catch (\Throwable $e) {
189
+                $this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
190
+                return new NotFoundResponse();
191
+            }
192
+        } else {
193
+            // File was found so we can get the content type from the file name
194
+            $contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
195
+        }
196
+
197
+        $response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
198
+        // cache for 7 days
199
+        $response->cacheFor(604800, false, true);
200
+        return $response;
201
+    }
202
+
203
+    private function checkCanDownloadMedia(string $filename): bool {
204
+        $urlInfo = parse_url($filename);
205
+        if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
206
+            return false;
207
+        }
208
+
209
+        // Always allowed hosts
210
+        if ($urlInfo['host'] === 'nextcloud.com') {
211
+            return true;
212
+        }
213
+
214
+        // Hosts that need further verification
215
+        // Github is only allowed if from our organization
216
+        $ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
217
+        if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) {
218
+            return false;
219
+        }
220
+
221
+        if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) {
222
+            return true;
223
+        }
224
+
225
+        return false;
226
+    }
227
+
228
+    /**
229
+     * Remove orphaned folders from the image cache that do not match the current etag
230
+     * @param ISimpleFolder $folder The folder to clear
231
+     * @param string $etag The etag (directory name) to keep
232
+     */
233
+    private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
234
+        // Cleanup old cache folders
235
+        $allFiles = $folder->getDirectoryListing();
236
+        foreach ($allFiles as $dir) {
237
+            try {
238
+                if ($dir->getName() !== $etag) {
239
+                    $dir->delete();
240
+                }
241
+            } catch (NotPermittedException $e) {
242
+                // ignore folder for now
243
+            }
244
+        }
245
+    }
246
+
247
+    private function getAppsWithUpdates() {
248
+        $appClass = new \OC_App();
249
+        $apps = $appClass->listAllApps();
250
+        foreach ($apps as $key => $app) {
251
+            $newVersion = $this->installer->isUpdateAvailable($app['id']);
252
+            if ($newVersion === false) {
253
+                unset($apps[$key]);
254
+            }
255
+        }
256
+        return $apps;
257
+    }
258
+
259
+    private function getBundles() {
260
+        $result = [];
261
+        $bundles = $this->bundleFetcher->getBundles();
262
+        foreach ($bundles as $bundle) {
263
+            $result[] = [
264
+                'name' => $bundle->getName(),
265
+                'id' => $bundle->getIdentifier(),
266
+                'appIdentifiers' => $bundle->getAppIdentifiers()
267
+            ];
268
+        }
269
+        return $result;
270
+    }
271
+
272
+    /**
273
+     * Get all available categories
274
+     *
275
+     * @return JSONResponse
276
+     */
277
+    public function listCategories(): JSONResponse {
278
+        return new JSONResponse($this->getAllCategories());
279
+    }
280
+
281
+    private function getAllCategories() {
282
+        $currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
283
+
284
+        $categories = $this->categoryFetcher->get();
285
+        return array_map(fn ($category) => [
286
+            'id' => $category['id'],
287
+            'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
288
+        ], $categories);
289
+    }
290
+
291
+    /**
292
+     * Convert URL to proxied URL so CSP is no problem
293
+     */
294
+    private function createProxyPreviewUrl(string $url): string {
295
+        if ($url === '') {
296
+            return '';
297
+        }
298
+        return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url);
299
+    }
300
+
301
+    private function fetchApps() {
302
+        $appClass = new \OC_App();
303
+        $apps = $appClass->listAllApps();
304
+        foreach ($apps as $app) {
305
+            $app['installed'] = true;
306
+
307
+            if (isset($app['screenshot'][0])) {
308
+                $appScreenshot = $app['screenshot'][0] ?? null;
309
+                if (is_array($appScreenshot)) {
310
+                    // Screenshot with thumbnail
311
+                    $appScreenshot = $appScreenshot['@value'];
312
+                }
313
+
314
+                $app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot);
315
+            }
316
+            $this->allApps[$app['id']] = $app;
317
+        }
318
+
319
+        $apps = $this->getAppsForCategory('');
320
+        $supportedApps = $appClass->getSupportedApps();
321
+        foreach ($apps as $app) {
322
+            $app['appstore'] = true;
323
+            if (!array_key_exists($app['id'], $this->allApps)) {
324
+                $this->allApps[$app['id']] = $app;
325
+            } else {
326
+                $this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]);
327
+            }
328
+
329
+            if (in_array($app['id'], $supportedApps)) {
330
+                $this->allApps[$app['id']]['level'] = \OC_App::supportedApp;
331
+            }
332
+        }
333
+
334
+        // add bundle information
335
+        $bundles = $this->bundleFetcher->getBundles();
336
+        foreach ($bundles as $bundle) {
337
+            foreach ($bundle->getAppIdentifiers() as $identifier) {
338
+                foreach ($this->allApps as &$app) {
339
+                    if ($app['id'] === $identifier) {
340
+                        $app['bundleIds'][] = $bundle->getIdentifier();
341
+                        continue;
342
+                    }
343
+                }
344
+            }
345
+        }
346
+    }
347
+
348
+    private function getAllApps() {
349
+        return $this->allApps;
350
+    }
351
+
352
+    /**
353
+     * Get all available apps in a category
354
+     *
355
+     * @return JSONResponse
356
+     * @throws \Exception
357
+     */
358
+    public function listApps(): JSONResponse {
359
+        $this->fetchApps();
360
+        $apps = $this->getAllApps();
361
+
362
+        $dependencyAnalyzer = Server::get(DependencyAnalyzer::class);
363
+
364
+        $ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
365
+        if (!is_array($ignoreMaxApps)) {
366
+            $this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...');
367
+            $ignoreMaxApps = [];
368
+        }
369
+
370
+        // Extend existing app details
371
+        $apps = array_map(function (array $appData) use ($dependencyAnalyzer, $ignoreMaxApps) {
372
+            if (isset($appData['appstoreData'])) {
373
+                $appstoreData = $appData['appstoreData'];
374
+                $appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
375
+                $appData['category'] = $appstoreData['categories'];
376
+                $appData['releases'] = $appstoreData['releases'];
377
+            }
378
+
379
+            $newVersion = $this->installer->isUpdateAvailable($appData['id']);
380
+            if ($newVersion) {
381
+                $appData['update'] = $newVersion;
382
+            }
383
+
384
+            // fix groups to be an array
385
+            $groups = [];
386
+            if (is_string($appData['groups'])) {
387
+                $groups = json_decode($appData['groups']);
388
+                // ensure 'groups' is an array
389
+                if (!is_array($groups)) {
390
+                    $groups = [$groups];
391
+                }
392
+            }
393
+            $appData['groups'] = $groups;
394
+            $appData['canUnInstall'] = !$appData['active'] && $appData['removable'];
395
+
396
+            // fix licence vs license
397
+            if (isset($appData['license']) && !isset($appData['licence'])) {
398
+                $appData['licence'] = $appData['license'];
399
+            }
400
+
401
+            $ignoreMax = in_array($appData['id'], $ignoreMaxApps);
402
+
403
+            // analyse dependencies
404
+            $missing = $dependencyAnalyzer->analyze($appData, $ignoreMax);
405
+            $appData['canInstall'] = empty($missing);
406
+            $appData['missingDependencies'] = $missing;
407
+
408
+            $appData['missingMinOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
409
+            $appData['missingMaxOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
410
+            $appData['isCompatible'] = $dependencyAnalyzer->isMarkedCompatible($appData);
411
+
412
+            return $appData;
413
+        }, $apps);
414
+
415
+        usort($apps, [$this, 'sortApps']);
416
+
417
+        return new JSONResponse(['apps' => $apps, 'status' => 'success']);
418
+    }
419
+
420
+    /**
421
+     * Get all apps for a category from the app store
422
+     *
423
+     * @param string $requestedCategory
424
+     * @return array
425
+     * @throws \Exception
426
+     */
427
+    private function getAppsForCategory($requestedCategory = ''): array {
428
+        $versionParser = new VersionParser();
429
+        $formattedApps = [];
430
+        $apps = $this->appFetcher->get();
431
+        foreach ($apps as $app) {
432
+            // Skip all apps not in the requested category
433
+            if ($requestedCategory !== '') {
434
+                $isInCategory = false;
435
+                foreach ($app['categories'] as $category) {
436
+                    if ($category === $requestedCategory) {
437
+                        $isInCategory = true;
438
+                    }
439
+                }
440
+                if (!$isInCategory) {
441
+                    continue;
442
+                }
443
+            }
444
+
445
+            if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) {
446
+                continue;
447
+            }
448
+            $nextCloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']);
449
+            $nextCloudVersionDependencies = [];
450
+            if ($nextCloudVersion->getMinimumVersion() !== '') {
451
+                $nextCloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextCloudVersion->getMinimumVersion();
452
+            }
453
+            if ($nextCloudVersion->getMaximumVersion() !== '') {
454
+                $nextCloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextCloudVersion->getMaximumVersion();
455
+            }
456
+            $phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']);
457
+
458
+            try {
459
+                $this->appManager->getAppPath($app['id']);
460
+                $existsLocally = true;
461
+            } catch (AppPathNotFoundException) {
462
+                $existsLocally = false;
463
+            }
464
+
465
+            $phpDependencies = [];
466
+            if ($phpVersion->getMinimumVersion() !== '') {
467
+                $phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion();
468
+            }
469
+            if ($phpVersion->getMaximumVersion() !== '') {
470
+                $phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion();
471
+            }
472
+            if (isset($app['releases'][0]['minIntSize'])) {
473
+                $phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize'];
474
+            }
475
+            $authors = '';
476
+            foreach ($app['authors'] as $key => $author) {
477
+                $authors .= $author['name'];
478
+                if ($key !== count($app['authors']) - 1) {
479
+                    $authors .= ', ';
480
+                }
481
+            }
482
+
483
+            $currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
484
+            $enabledValue = $this->config->getAppValue($app['id'], 'enabled', 'no');
485
+            $groups = null;
486
+            if ($enabledValue !== 'no' && $enabledValue !== 'yes') {
487
+                $groups = $enabledValue;
488
+            }
489
+
490
+            $currentVersion = '';
491
+            if ($this->appManager->isEnabledForAnyone($app['id'])) {
492
+                $currentVersion = $this->appManager->getAppVersion($app['id']);
493
+            } else {
494
+                $currentVersion = $app['releases'][0]['version'];
495
+            }
496
+
497
+            $formattedApps[] = [
498
+                'id' => $app['id'],
499
+                'app_api' => false,
500
+                'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'],
501
+                'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'],
502
+                'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'],
503
+                'license' => $app['releases'][0]['licenses'],
504
+                'author' => $authors,
505
+                'shipped' => $this->appManager->isShipped($app['id']),
506
+                'version' => $currentVersion,
507
+                'default_enable' => '',
508
+                'types' => [],
509
+                'documentation' => [
510
+                    'admin' => $app['adminDocs'],
511
+                    'user' => $app['userDocs'],
512
+                    'developer' => $app['developerDocs']
513
+                ],
514
+                'website' => $app['website'],
515
+                'bugs' => $app['issueTracker'],
516
+                'detailpage' => $app['website'],
517
+                'dependencies' => array_merge(
518
+                    $nextCloudVersionDependencies,
519
+                    $phpDependencies
520
+                ),
521
+                'level' => ($app['isFeatured'] === true) ? 200 : 100,
522
+                'missingMaxOwnCloudVersion' => false,
523
+                'missingMinOwnCloudVersion' => false,
524
+                'canInstall' => true,
525
+                'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
526
+                'score' => $app['ratingOverall'],
527
+                'ratingNumOverall' => $app['ratingNumOverall'],
528
+                'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
529
+                'removable' => $existsLocally,
530
+                'active' => $this->appManager->isEnabledForUser($app['id']),
531
+                'needsDownload' => !$existsLocally,
532
+                'groups' => $groups,
533
+                'fromAppStore' => true,
534
+                'appstoreData' => $app,
535
+            ];
536
+        }
537
+
538
+        return $formattedApps;
539
+    }
540
+
541
+    /**
542
+     * @param string $appId
543
+     * @param array $groups
544
+     * @return JSONResponse
545
+     */
546
+    #[PasswordConfirmationRequired]
547
+    public function enableApp(string $appId, array $groups = []): JSONResponse {
548
+        return $this->enableApps([$appId], $groups);
549
+    }
550
+
551
+    /**
552
+     * Enable one or more apps
553
+     *
554
+     * apps will be enabled for specific groups only if $groups is defined
555
+     *
556
+     * @param array $appIds
557
+     * @param array $groups
558
+     * @return JSONResponse
559
+     */
560
+    #[PasswordConfirmationRequired]
561
+    public function enableApps(array $appIds, array $groups = []): JSONResponse {
562
+        try {
563
+            $updateRequired = false;
564
+
565
+            foreach ($appIds as $appId) {
566
+                $appId = $this->appManager->cleanAppId($appId);
567
+
568
+                // Check if app is already downloaded
569
+                if (!$this->installer->isDownloaded($appId)) {
570
+                    $this->installer->downloadApp($appId);
571
+                }
572
+
573
+                $this->installer->installApp($appId);
574
+
575
+                if (count($groups) > 0) {
576
+                    $this->appManager->enableAppForGroups($appId, $this->getGroupList($groups));
577
+                } else {
578
+                    $this->appManager->enableApp($appId);
579
+                }
580
+                $updateRequired = $updateRequired || $this->appManager->isUpgradeRequired($appId);
581
+            }
582
+            return new JSONResponse(['data' => ['update_required' => $updateRequired]]);
583
+        } catch (\Throwable $e) {
584
+            $this->logger->error('could not enable apps', ['exception' => $e]);
585
+            return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
586
+        }
587
+    }
588
+
589
+    private function getGroupList(array $groups) {
590
+        $groupManager = Server::get(IGroupManager::class);
591
+        $groupsList = [];
592
+        foreach ($groups as $group) {
593
+            $groupItem = $groupManager->get($group);
594
+            if ($groupItem instanceof IGroup) {
595
+                $groupsList[] = $groupManager->get($group);
596
+            }
597
+        }
598
+        return $groupsList;
599
+    }
600
+
601
+    /**
602
+     * @param string $appId
603
+     * @return JSONResponse
604
+     */
605
+    #[PasswordConfirmationRequired]
606
+    public function disableApp(string $appId): JSONResponse {
607
+        return $this->disableApps([$appId]);
608
+    }
609
+
610
+    /**
611
+     * @param array $appIds
612
+     * @return JSONResponse
613
+     */
614
+    #[PasswordConfirmationRequired]
615
+    public function disableApps(array $appIds): JSONResponse {
616
+        try {
617
+            foreach ($appIds as $appId) {
618
+                $appId = $this->appManager->cleanAppId($appId);
619
+                $this->appManager->disableApp($appId);
620
+            }
621
+            return new JSONResponse([]);
622
+        } catch (\Exception $e) {
623
+            $this->logger->error('could not disable app', ['exception' => $e]);
624
+            return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
625
+        }
626
+    }
627
+
628
+    /**
629
+     * @param string $appId
630
+     * @return JSONResponse
631
+     */
632
+    #[PasswordConfirmationRequired]
633
+    public function uninstallApp(string $appId): JSONResponse {
634
+        $appId = $this->appManager->cleanAppId($appId);
635
+        $result = $this->installer->removeApp($appId);
636
+        if ($result !== false) {
637
+            // If this app was force enabled, remove the force-enabled-state
638
+            $this->appManager->removeOverwriteNextcloudRequirement($appId);
639
+            $this->appManager->clearAppsCache();
640
+            return new JSONResponse(['data' => ['appid' => $appId]]);
641
+        }
642
+        return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not remove app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
643
+    }
644
+
645
+    /**
646
+     * @param string $appId
647
+     * @return JSONResponse
648
+     */
649
+    public function updateApp(string $appId): JSONResponse {
650
+        $appId = $this->appManager->cleanAppId($appId);
651
+
652
+        $this->config->setSystemValue('maintenance', true);
653
+        try {
654
+            $result = $this->installer->updateAppstoreApp($appId);
655
+            $this->config->setSystemValue('maintenance', false);
656
+        } catch (\Exception $ex) {
657
+            $this->config->setSystemValue('maintenance', false);
658
+            return new JSONResponse(['data' => ['message' => $ex->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
659
+        }
660
+
661
+        if ($result !== false) {
662
+            return new JSONResponse(['data' => ['appid' => $appId]]);
663
+        }
664
+        return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not update app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
665
+    }
666
+
667
+    private function sortApps($a, $b) {
668
+        $a = (string)$a['name'];
669
+        $b = (string)$b['name'];
670
+        if ($a === $b) {
671
+            return 0;
672
+        }
673
+        return ($a < $b) ? -1 : 1;
674
+    }
675
+
676
+    public function force(string $appId): JSONResponse {
677
+        $appId = $this->appManager->cleanAppId($appId);
678
+        $this->appManager->overwriteNextcloudRequirement($appId);
679
+        return new JSONResponse();
680
+    }
681 681
 }
Please login to merge, or discard this patch.
apps/settings/tests/Controller/AppSettingsControllerTest.php 1 patch
Indentation   +182 added lines, -182 removed lines patch added patch discarded remove patch
@@ -37,186 +37,186 @@
 block discarded – undo
37 37
  */
38 38
 #[\PHPUnit\Framework\Attributes\Group(name: 'DB')]
39 39
 class AppSettingsControllerTest extends TestCase {
40
-	private IRequest&MockObject $request;
41
-	private IL10N&MockObject $l10n;
42
-	private IConfig&MockObject $config;
43
-	private INavigationManager&MockObject $navigationManager;
44
-	private AppManager&MockObject $appManager;
45
-	private CategoryFetcher&MockObject $categoryFetcher;
46
-	private AppFetcher&MockObject $appFetcher;
47
-	private IFactory&MockObject $l10nFactory;
48
-	private BundleFetcher&MockObject $bundleFetcher;
49
-	private Installer&MockObject $installer;
50
-	private IURLGenerator&MockObject $urlGenerator;
51
-	private LoggerInterface&MockObject $logger;
52
-	private IInitialState&MockObject $initialState;
53
-	private IAppDataFactory&MockObject $appDataFactory;
54
-	private AppDiscoverFetcher&MockObject $discoverFetcher;
55
-	private IClientService&MockObject $clientService;
56
-	private AppSettingsController $appSettingsController;
57
-
58
-	protected function setUp(): void {
59
-		parent::setUp();
60
-
61
-		$this->request = $this->createMock(IRequest::class);
62
-		$this->appDataFactory = $this->createMock(IAppDataFactory::class);
63
-		$this->l10n = $this->createMock(IL10N::class);
64
-		$this->l10n->expects($this->any())
65
-			->method('t')
66
-			->willReturnArgument(0);
67
-		$this->config = $this->createMock(IConfig::class);
68
-		$this->navigationManager = $this->createMock(INavigationManager::class);
69
-		$this->appManager = $this->createMock(AppManager::class);
70
-		$this->categoryFetcher = $this->createMock(CategoryFetcher::class);
71
-		$this->appFetcher = $this->createMock(AppFetcher::class);
72
-		$this->l10nFactory = $this->createMock(IFactory::class);
73
-		$this->bundleFetcher = $this->createMock(BundleFetcher::class);
74
-		$this->installer = $this->createMock(Installer::class);
75
-		$this->urlGenerator = $this->createMock(IURLGenerator::class);
76
-		$this->logger = $this->createMock(LoggerInterface::class);
77
-		$this->initialState = $this->createMock(IInitialState::class);
78
-		$this->discoverFetcher = $this->createMock(AppDiscoverFetcher::class);
79
-		$this->clientService = $this->createMock(IClientService::class);
80
-
81
-		$this->appSettingsController = new AppSettingsController(
82
-			'settings',
83
-			$this->request,
84
-			$this->appDataFactory,
85
-			$this->l10n,
86
-			$this->config,
87
-			$this->navigationManager,
88
-			$this->appManager,
89
-			$this->categoryFetcher,
90
-			$this->appFetcher,
91
-			$this->l10nFactory,
92
-			$this->bundleFetcher,
93
-			$this->installer,
94
-			$this->urlGenerator,
95
-			$this->logger,
96
-			$this->initialState,
97
-			$this->discoverFetcher,
98
-			$this->clientService,
99
-		);
100
-	}
101
-
102
-	public function testListCategories(): void {
103
-		$this->installer->expects($this->any())
104
-			->method('isUpdateAvailable')
105
-			->willReturn(false);
106
-		$expected = new JSONResponse([
107
-			[
108
-				'id' => 'auth',
109
-				'displayName' => 'Authentication & authorization',
110
-			],
111
-			[
112
-				'id' => 'customization',
113
-				'displayName' => 'Customization',
114
-			],
115
-			[
116
-				'id' => 'files',
117
-				'displayName' => 'Files',
118
-			],
119
-			[
120
-				'id' => 'integration',
121
-				'displayName' => 'Integration',
122
-			],
123
-			[
124
-				'id' => 'monitoring',
125
-				'displayName' => 'Monitoring',
126
-			],
127
-			[
128
-				'id' => 'multimedia',
129
-				'displayName' => 'Multimedia',
130
-			],
131
-			[
132
-				'id' => 'office',
133
-				'displayName' => 'Office & text',
134
-			],
135
-			[
136
-				'id' => 'organization',
137
-				'displayName' => 'Organization',
138
-			],
139
-			[
140
-				'id' => 'social',
141
-				'displayName' => 'Social & communication',
142
-			],
143
-			[
144
-				'id' => 'tools',
145
-				'displayName' => 'Tools',
146
-			],
147
-		]);
148
-
149
-		$this->categoryFetcher
150
-			->expects($this->once())
151
-			->method('get')
152
-			->willReturn(json_decode('[{"id":"auth","translations":{"cs":{"name":"Autentizace & autorizace","description":"Aplikace poskytující služby dodatečného ověření nebo přihlášení"},"hu":{"name":"Azonosítás és hitelesítés","description":"Apps that provide additional authentication or authorization services"},"de":{"name":"Authentifizierung & Authorisierung","description":"Apps die zusätzliche Autentifizierungs- oder Autorisierungsdienste bereitstellen"},"nl":{"name":"Authenticatie & authorisatie","description":"Apps die aanvullende authenticatie- en autorisatiediensten bieden"},"nb":{"name":"Pålogging og tilgangsstyring","description":"Apper for å tilby ekstra pålogging eller tilgangsstyring"},"it":{"name":"Autenticazione e autorizzazione","description":"Apps that provide additional authentication or authorization services"},"fr":{"name":"Authentification et autorisations","description":"Applications qui fournissent des services d\'authentification ou d\'autorisations additionnels."},"ru":{"name":"Аутентификация и авторизация","description":"Apps that provide additional authentication or authorization services"},"en":{"name":"Authentication & authorization","description":"Apps that provide additional authentication or authorization services"}}},{"id":"customization","translations":{"cs":{"name":"Přizpůsobení","description":"Motivy a aplikace měnící rozvržení a uživatelské rozhraní"},"it":{"name":"Personalizzazione","description":"Applicazioni di temi, modifiche della disposizione e UX"},"de":{"name":"Anpassung","description":"Apps zur Änderung von Themen, Layout und Benutzererfahrung"},"hu":{"name":"Személyre szabás","description":"Témák, elrendezések felhasználói felület módosító alkalmazások"},"nl":{"name":"Maatwerk","description":"Thema\'s, layout en UX aanpassingsapps"},"nb":{"name":"Tilpasning","description":"Apper for å endre Tema, utseende og brukeropplevelse"},"fr":{"name":"Personalisation","description":"Thèmes, apparence et applications modifiant l\'expérience utilisateur"},"ru":{"name":"Настройка","description":"Themes, layout and UX change apps"},"en":{"name":"Customization","description":"Themes, layout and UX change apps"}}},{"id":"files","translations":{"cs":{"name":"Soubory","description":"Aplikace rozšiřující správu souborů nebo aplikaci Soubory"},"it":{"name":"File","description":"Applicazioni di gestione dei file ed estensione dell\'applicazione FIle"},"de":{"name":"Dateien","description":"Dateimanagement sowie Erweiterungs-Apps für die Dateien-App"},"hu":{"name":"Fájlok","description":"Fájl kezelő és kiegészítő alkalmazások"},"nl":{"name":"Bestanden","description":"Bestandebeheer en uitbreidingen van bestand apps"},"nb":{"name":"Filer","description":"Apper for filhåndtering og filer"},"fr":{"name":"Fichiers","description":"Applications de gestion de fichiers et extensions à l\'application Fichiers"},"ru":{"name":"Файлы","description":"Расширение: файлы и управление файлами"},"en":{"name":"Files","description":"File management and Files app extension apps"}}},{"id":"integration","translations":{"it":{"name":"Integrazione","description":"Applicazioni che collegano Nextcloud con altri servizi e piattaforme"},"hu":{"name":"Integráció","description":"Apps that connect Nextcloud with other services and platforms"},"nl":{"name":"Integratie","description":"Apps die Nextcloud verbinden met andere services en platformen"},"nb":{"name":"Integrasjon","description":"Apper som kobler Nextcloud med andre tjenester og plattformer"},"de":{"name":"Integration","description":"Apps die Nextcloud mit anderen Diensten und Plattformen verbinden"},"cs":{"name":"Propojení","description":"Aplikace propojující NextCloud s dalšími službami a platformami"},"fr":{"name":"Intégration","description":"Applications qui connectent Nextcloud avec d\'autres services et plateformes"},"ru":{"name":"Интеграция","description":"Приложения, соединяющие Nextcloud с другими службами и платформами"},"en":{"name":"Integration","description":"Apps that connect Nextcloud with other services and platforms"}}},{"id":"monitoring","translations":{"nb":{"name":"Overvåking","description":"Apper for statistikk, systemdiagnose og aktivitet"},"it":{"name":"Monitoraggio","description":"Applicazioni di statistiche, diagnostica di sistema e attività"},"de":{"name":"Überwachung","description":"Datenstatistiken-, Systemdiagnose- und Aktivitäten-Apps"},"hu":{"name":"Megfigyelés","description":"Data statistics, system diagnostics and activity apps"},"nl":{"name":"Monitoren","description":"Gegevensstatistiek, systeem diagnose en activiteit apps"},"cs":{"name":"Kontrola","description":"Datové statistiky, diagnózy systému a aktivity aplikací"},"fr":{"name":"Surveillance","description":"Applications de statistiques sur les données, de diagnostics systèmes et d\'activité."},"ru":{"name":"Мониторинг","description":"Статистика данных, диагностика системы и активность приложений"},"en":{"name":"Monitoring","description":"Data statistics, system diagnostics and activity apps"}}},{"id":"multimedia","translations":{"nb":{"name":"Multimedia","description":"Apper for lyd, film og bilde"},"it":{"name":"Multimedia","description":"Applicazioni per audio, video e immagini"},"de":{"name":"Multimedia","description":"Audio-, Video- und Bilder-Apps"},"hu":{"name":"Multimédia","description":"Hang, videó és kép alkalmazások"},"nl":{"name":"Multimedia","description":"Audio, video en afbeelding apps"},"en":{"name":"Multimedia","description":"Audio, video and picture apps"},"cs":{"name":"Multimédia","description":"Aplikace audia, videa a obrázků"},"fr":{"name":"Multimédia","description":"Applications audio, vidéo et image"},"ru":{"name":"Мультимедиа","description":"Приложение аудио, видео и изображения"}}},{"id":"office","translations":{"nb":{"name":"Kontorstøtte og tekst","description":"Apper for Kontorstøtte og tekstbehandling"},"it":{"name":"Ufficio e testo","description":"Applicazione per ufficio ed elaborazione di testi"},"de":{"name":"Büro & Text","description":"Büro- und Textverarbeitungs-Apps"},"hu":{"name":"Iroda és szöveg","description":"Irodai és szöveg feldolgozó alkalmazások"},"nl":{"name":"Office & tekst","description":"Office en tekstverwerkingsapps"},"cs":{"name":"Kancelář a text","description":"Aplikace pro kancelář a zpracování textu"},"fr":{"name":"Bureautique & texte","description":"Applications de bureautique et de traitement de texte"},"en":{"name":"Office & text","description":"Office and text processing apps"}}},{"id":"organization","translations":{"nb":{"name":"Organisering","description":"Apper for tidsstyring, oppgaveliste og kalender"},"it":{"name":"Organizzazione","description":"Applicazioni di gestione del tempo, elenco delle cose da fare e calendario"},"hu":{"name":"Szervezet","description":"Időbeosztás, teendő lista és naptár alkalmazások"},"nl":{"name":"Organisatie","description":"Tijdmanagement, takenlijsten en agenda apps"},"cs":{"name":"Organizace","description":"Aplikace pro správu času, plánování a kalendáře"},"de":{"name":"Organisation","description":"Time management, Todo list and calendar apps"},"fr":{"name":"Organisation","description":"Applications de gestion du temps, de listes de tâches et d\'agendas"},"ru":{"name":"Организация","description":"Приложения по управлению временем, список задач и календарь"},"en":{"name":"Organization","description":"Time management, Todo list and calendar apps"}}},{"id":"social","translations":{"nb":{"name":"Sosialt og kommunikasjon","description":"Apper for meldinger, kontakthåndtering og sosiale medier"},"it":{"name":"Sociale e comunicazione","description":"Applicazioni di messaggistica, gestione dei contatti e reti sociali"},"de":{"name":"Kommunikation","description":"Nachrichten-, Kontaktverwaltungs- und Social-Media-Apps"},"hu":{"name":"Közösségi és kommunikáció","description":"Üzenetküldő, kapcsolat kezelő és közösségi média alkalmazások"},"nl":{"name":"Sociaal & communicatie","description":"Messaging, contactbeheer en social media apps"},"cs":{"name":"Sociální sítě a komunikace","description":"Aplikace pro zasílání zpráv, správu kontaktů a sociální sítě"},"fr":{"name":"Social & communication","description":"Applications de messagerie, de gestion de contacts et de réseaux sociaux"},"ru":{"name":"Социальное и связь","description":"Общение, управление контактами и социальное медиа-приложение"},"en":{"name":"Social & communication","description":"Messaging, contact management and social media apps"}}},{"id":"tools","translations":{"nb":{"name":"Verktøy","description":"Alt annet"},"it":{"name":"Strumenti","description":"Tutto il resto"},"hu":{"name":"Eszközök","description":"Minden más"},"nl":{"name":"Tools","description":"De rest"},"de":{"name":"Werkzeuge","description":"Alles Andere"},"en":{"name":"Tools","description":"Everything else"},"cs":{"name":"Nástroje","description":"Vše ostatní"},"fr":{"name":"Outils","description":"Tout le reste"},"ru":{"name":"Приложения","description":"Что-то еще"}}}]', true));
153
-
154
-		$this->assertEquals($expected, $this->appSettingsController->listCategories());
155
-	}
156
-
157
-	public function testViewApps(): void {
158
-		$this->bundleFetcher->expects($this->once())->method('getBundles')->willReturn([]);
159
-		$this->installer->expects($this->any())
160
-			->method('isUpdateAvailable')
161
-			->willReturn(false);
162
-		$this->config
163
-			->expects($this->once())
164
-			->method('getSystemValueBool')
165
-			->with('appstoreenabled', true)
166
-			->willReturn(true);
167
-		$this->navigationManager
168
-			->expects($this->once())
169
-			->method('setActiveEntry')
170
-			->with('core_apps');
171
-
172
-		$this->initialState
173
-			->expects($this->exactly(3))
174
-			->method('provideInitialState');
175
-
176
-		$policy = new ContentSecurityPolicy();
177
-		$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
178
-
179
-		$expected = new TemplateResponse('settings',
180
-			'settings/empty',
181
-			[
182
-				'pageTitle' => 'Settings'
183
-			],
184
-			'user');
185
-		$expected->setContentSecurityPolicy($policy);
186
-
187
-		$this->assertEquals($expected, $this->appSettingsController->viewApps());
188
-	}
189
-
190
-	public function testViewAppsAppstoreNotEnabled(): void {
191
-		$this->installer->expects($this->any())
192
-			->method('isUpdateAvailable')
193
-			->willReturn(false);
194
-		$this->bundleFetcher->expects($this->once())->method('getBundles')->willReturn([]);
195
-		$this->config
196
-			->expects($this->once())
197
-			->method('getSystemValueBool')
198
-			->with('appstoreenabled', true)
199
-			->willReturn(false);
200
-		$this->navigationManager
201
-			->expects($this->once())
202
-			->method('setActiveEntry')
203
-			->with('core_apps');
204
-
205
-		$this->initialState
206
-			->expects($this->exactly(3))
207
-			->method('provideInitialState');
208
-
209
-		$policy = new ContentSecurityPolicy();
210
-		$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
211
-
212
-		$expected = new TemplateResponse('settings',
213
-			'settings/empty',
214
-			[
215
-				'pageTitle' => 'Settings'
216
-			],
217
-			'user');
218
-		$expected->setContentSecurityPolicy($policy);
219
-
220
-		$this->assertEquals($expected, $this->appSettingsController->viewApps());
221
-	}
40
+    private IRequest&MockObject $request;
41
+    private IL10N&MockObject $l10n;
42
+    private IConfig&MockObject $config;
43
+    private INavigationManager&MockObject $navigationManager;
44
+    private AppManager&MockObject $appManager;
45
+    private CategoryFetcher&MockObject $categoryFetcher;
46
+    private AppFetcher&MockObject $appFetcher;
47
+    private IFactory&MockObject $l10nFactory;
48
+    private BundleFetcher&MockObject $bundleFetcher;
49
+    private Installer&MockObject $installer;
50
+    private IURLGenerator&MockObject $urlGenerator;
51
+    private LoggerInterface&MockObject $logger;
52
+    private IInitialState&MockObject $initialState;
53
+    private IAppDataFactory&MockObject $appDataFactory;
54
+    private AppDiscoverFetcher&MockObject $discoverFetcher;
55
+    private IClientService&MockObject $clientService;
56
+    private AppSettingsController $appSettingsController;
57
+
58
+    protected function setUp(): void {
59
+        parent::setUp();
60
+
61
+        $this->request = $this->createMock(IRequest::class);
62
+        $this->appDataFactory = $this->createMock(IAppDataFactory::class);
63
+        $this->l10n = $this->createMock(IL10N::class);
64
+        $this->l10n->expects($this->any())
65
+            ->method('t')
66
+            ->willReturnArgument(0);
67
+        $this->config = $this->createMock(IConfig::class);
68
+        $this->navigationManager = $this->createMock(INavigationManager::class);
69
+        $this->appManager = $this->createMock(AppManager::class);
70
+        $this->categoryFetcher = $this->createMock(CategoryFetcher::class);
71
+        $this->appFetcher = $this->createMock(AppFetcher::class);
72
+        $this->l10nFactory = $this->createMock(IFactory::class);
73
+        $this->bundleFetcher = $this->createMock(BundleFetcher::class);
74
+        $this->installer = $this->createMock(Installer::class);
75
+        $this->urlGenerator = $this->createMock(IURLGenerator::class);
76
+        $this->logger = $this->createMock(LoggerInterface::class);
77
+        $this->initialState = $this->createMock(IInitialState::class);
78
+        $this->discoverFetcher = $this->createMock(AppDiscoverFetcher::class);
79
+        $this->clientService = $this->createMock(IClientService::class);
80
+
81
+        $this->appSettingsController = new AppSettingsController(
82
+            'settings',
83
+            $this->request,
84
+            $this->appDataFactory,
85
+            $this->l10n,
86
+            $this->config,
87
+            $this->navigationManager,
88
+            $this->appManager,
89
+            $this->categoryFetcher,
90
+            $this->appFetcher,
91
+            $this->l10nFactory,
92
+            $this->bundleFetcher,
93
+            $this->installer,
94
+            $this->urlGenerator,
95
+            $this->logger,
96
+            $this->initialState,
97
+            $this->discoverFetcher,
98
+            $this->clientService,
99
+        );
100
+    }
101
+
102
+    public function testListCategories(): void {
103
+        $this->installer->expects($this->any())
104
+            ->method('isUpdateAvailable')
105
+            ->willReturn(false);
106
+        $expected = new JSONResponse([
107
+            [
108
+                'id' => 'auth',
109
+                'displayName' => 'Authentication & authorization',
110
+            ],
111
+            [
112
+                'id' => 'customization',
113
+                'displayName' => 'Customization',
114
+            ],
115
+            [
116
+                'id' => 'files',
117
+                'displayName' => 'Files',
118
+            ],
119
+            [
120
+                'id' => 'integration',
121
+                'displayName' => 'Integration',
122
+            ],
123
+            [
124
+                'id' => 'monitoring',
125
+                'displayName' => 'Monitoring',
126
+            ],
127
+            [
128
+                'id' => 'multimedia',
129
+                'displayName' => 'Multimedia',
130
+            ],
131
+            [
132
+                'id' => 'office',
133
+                'displayName' => 'Office & text',
134
+            ],
135
+            [
136
+                'id' => 'organization',
137
+                'displayName' => 'Organization',
138
+            ],
139
+            [
140
+                'id' => 'social',
141
+                'displayName' => 'Social & communication',
142
+            ],
143
+            [
144
+                'id' => 'tools',
145
+                'displayName' => 'Tools',
146
+            ],
147
+        ]);
148
+
149
+        $this->categoryFetcher
150
+            ->expects($this->once())
151
+            ->method('get')
152
+            ->willReturn(json_decode('[{"id":"auth","translations":{"cs":{"name":"Autentizace & autorizace","description":"Aplikace poskytující služby dodatečného ověření nebo přihlášení"},"hu":{"name":"Azonosítás és hitelesítés","description":"Apps that provide additional authentication or authorization services"},"de":{"name":"Authentifizierung & Authorisierung","description":"Apps die zusätzliche Autentifizierungs- oder Autorisierungsdienste bereitstellen"},"nl":{"name":"Authenticatie & authorisatie","description":"Apps die aanvullende authenticatie- en autorisatiediensten bieden"},"nb":{"name":"Pålogging og tilgangsstyring","description":"Apper for å tilby ekstra pålogging eller tilgangsstyring"},"it":{"name":"Autenticazione e autorizzazione","description":"Apps that provide additional authentication or authorization services"},"fr":{"name":"Authentification et autorisations","description":"Applications qui fournissent des services d\'authentification ou d\'autorisations additionnels."},"ru":{"name":"Аутентификация и авторизация","description":"Apps that provide additional authentication or authorization services"},"en":{"name":"Authentication & authorization","description":"Apps that provide additional authentication or authorization services"}}},{"id":"customization","translations":{"cs":{"name":"Přizpůsobení","description":"Motivy a aplikace měnící rozvržení a uživatelské rozhraní"},"it":{"name":"Personalizzazione","description":"Applicazioni di temi, modifiche della disposizione e UX"},"de":{"name":"Anpassung","description":"Apps zur Änderung von Themen, Layout und Benutzererfahrung"},"hu":{"name":"Személyre szabás","description":"Témák, elrendezések felhasználói felület módosító alkalmazások"},"nl":{"name":"Maatwerk","description":"Thema\'s, layout en UX aanpassingsapps"},"nb":{"name":"Tilpasning","description":"Apper for å endre Tema, utseende og brukeropplevelse"},"fr":{"name":"Personalisation","description":"Thèmes, apparence et applications modifiant l\'expérience utilisateur"},"ru":{"name":"Настройка","description":"Themes, layout and UX change apps"},"en":{"name":"Customization","description":"Themes, layout and UX change apps"}}},{"id":"files","translations":{"cs":{"name":"Soubory","description":"Aplikace rozšiřující správu souborů nebo aplikaci Soubory"},"it":{"name":"File","description":"Applicazioni di gestione dei file ed estensione dell\'applicazione FIle"},"de":{"name":"Dateien","description":"Dateimanagement sowie Erweiterungs-Apps für die Dateien-App"},"hu":{"name":"Fájlok","description":"Fájl kezelő és kiegészítő alkalmazások"},"nl":{"name":"Bestanden","description":"Bestandebeheer en uitbreidingen van bestand apps"},"nb":{"name":"Filer","description":"Apper for filhåndtering og filer"},"fr":{"name":"Fichiers","description":"Applications de gestion de fichiers et extensions à l\'application Fichiers"},"ru":{"name":"Файлы","description":"Расширение: файлы и управление файлами"},"en":{"name":"Files","description":"File management and Files app extension apps"}}},{"id":"integration","translations":{"it":{"name":"Integrazione","description":"Applicazioni che collegano Nextcloud con altri servizi e piattaforme"},"hu":{"name":"Integráció","description":"Apps that connect Nextcloud with other services and platforms"},"nl":{"name":"Integratie","description":"Apps die Nextcloud verbinden met andere services en platformen"},"nb":{"name":"Integrasjon","description":"Apper som kobler Nextcloud med andre tjenester og plattformer"},"de":{"name":"Integration","description":"Apps die Nextcloud mit anderen Diensten und Plattformen verbinden"},"cs":{"name":"Propojení","description":"Aplikace propojující NextCloud s dalšími službami a platformami"},"fr":{"name":"Intégration","description":"Applications qui connectent Nextcloud avec d\'autres services et plateformes"},"ru":{"name":"Интеграция","description":"Приложения, соединяющие Nextcloud с другими службами и платформами"},"en":{"name":"Integration","description":"Apps that connect Nextcloud with other services and platforms"}}},{"id":"monitoring","translations":{"nb":{"name":"Overvåking","description":"Apper for statistikk, systemdiagnose og aktivitet"},"it":{"name":"Monitoraggio","description":"Applicazioni di statistiche, diagnostica di sistema e attività"},"de":{"name":"Überwachung","description":"Datenstatistiken-, Systemdiagnose- und Aktivitäten-Apps"},"hu":{"name":"Megfigyelés","description":"Data statistics, system diagnostics and activity apps"},"nl":{"name":"Monitoren","description":"Gegevensstatistiek, systeem diagnose en activiteit apps"},"cs":{"name":"Kontrola","description":"Datové statistiky, diagnózy systému a aktivity aplikací"},"fr":{"name":"Surveillance","description":"Applications de statistiques sur les données, de diagnostics systèmes et d\'activité."},"ru":{"name":"Мониторинг","description":"Статистика данных, диагностика системы и активность приложений"},"en":{"name":"Monitoring","description":"Data statistics, system diagnostics and activity apps"}}},{"id":"multimedia","translations":{"nb":{"name":"Multimedia","description":"Apper for lyd, film og bilde"},"it":{"name":"Multimedia","description":"Applicazioni per audio, video e immagini"},"de":{"name":"Multimedia","description":"Audio-, Video- und Bilder-Apps"},"hu":{"name":"Multimédia","description":"Hang, videó és kép alkalmazások"},"nl":{"name":"Multimedia","description":"Audio, video en afbeelding apps"},"en":{"name":"Multimedia","description":"Audio, video and picture apps"},"cs":{"name":"Multimédia","description":"Aplikace audia, videa a obrázků"},"fr":{"name":"Multimédia","description":"Applications audio, vidéo et image"},"ru":{"name":"Мультимедиа","description":"Приложение аудио, видео и изображения"}}},{"id":"office","translations":{"nb":{"name":"Kontorstøtte og tekst","description":"Apper for Kontorstøtte og tekstbehandling"},"it":{"name":"Ufficio e testo","description":"Applicazione per ufficio ed elaborazione di testi"},"de":{"name":"Büro & Text","description":"Büro- und Textverarbeitungs-Apps"},"hu":{"name":"Iroda és szöveg","description":"Irodai és szöveg feldolgozó alkalmazások"},"nl":{"name":"Office & tekst","description":"Office en tekstverwerkingsapps"},"cs":{"name":"Kancelář a text","description":"Aplikace pro kancelář a zpracování textu"},"fr":{"name":"Bureautique & texte","description":"Applications de bureautique et de traitement de texte"},"en":{"name":"Office & text","description":"Office and text processing apps"}}},{"id":"organization","translations":{"nb":{"name":"Organisering","description":"Apper for tidsstyring, oppgaveliste og kalender"},"it":{"name":"Organizzazione","description":"Applicazioni di gestione del tempo, elenco delle cose da fare e calendario"},"hu":{"name":"Szervezet","description":"Időbeosztás, teendő lista és naptár alkalmazások"},"nl":{"name":"Organisatie","description":"Tijdmanagement, takenlijsten en agenda apps"},"cs":{"name":"Organizace","description":"Aplikace pro správu času, plánování a kalendáře"},"de":{"name":"Organisation","description":"Time management, Todo list and calendar apps"},"fr":{"name":"Organisation","description":"Applications de gestion du temps, de listes de tâches et d\'agendas"},"ru":{"name":"Организация","description":"Приложения по управлению временем, список задач и календарь"},"en":{"name":"Organization","description":"Time management, Todo list and calendar apps"}}},{"id":"social","translations":{"nb":{"name":"Sosialt og kommunikasjon","description":"Apper for meldinger, kontakthåndtering og sosiale medier"},"it":{"name":"Sociale e comunicazione","description":"Applicazioni di messaggistica, gestione dei contatti e reti sociali"},"de":{"name":"Kommunikation","description":"Nachrichten-, Kontaktverwaltungs- und Social-Media-Apps"},"hu":{"name":"Közösségi és kommunikáció","description":"Üzenetküldő, kapcsolat kezelő és közösségi média alkalmazások"},"nl":{"name":"Sociaal & communicatie","description":"Messaging, contactbeheer en social media apps"},"cs":{"name":"Sociální sítě a komunikace","description":"Aplikace pro zasílání zpráv, správu kontaktů a sociální sítě"},"fr":{"name":"Social & communication","description":"Applications de messagerie, de gestion de contacts et de réseaux sociaux"},"ru":{"name":"Социальное и связь","description":"Общение, управление контактами и социальное медиа-приложение"},"en":{"name":"Social & communication","description":"Messaging, contact management and social media apps"}}},{"id":"tools","translations":{"nb":{"name":"Verktøy","description":"Alt annet"},"it":{"name":"Strumenti","description":"Tutto il resto"},"hu":{"name":"Eszközök","description":"Minden más"},"nl":{"name":"Tools","description":"De rest"},"de":{"name":"Werkzeuge","description":"Alles Andere"},"en":{"name":"Tools","description":"Everything else"},"cs":{"name":"Nástroje","description":"Vše ostatní"},"fr":{"name":"Outils","description":"Tout le reste"},"ru":{"name":"Приложения","description":"Что-то еще"}}}]', true));
153
+
154
+        $this->assertEquals($expected, $this->appSettingsController->listCategories());
155
+    }
156
+
157
+    public function testViewApps(): void {
158
+        $this->bundleFetcher->expects($this->once())->method('getBundles')->willReturn([]);
159
+        $this->installer->expects($this->any())
160
+            ->method('isUpdateAvailable')
161
+            ->willReturn(false);
162
+        $this->config
163
+            ->expects($this->once())
164
+            ->method('getSystemValueBool')
165
+            ->with('appstoreenabled', true)
166
+            ->willReturn(true);
167
+        $this->navigationManager
168
+            ->expects($this->once())
169
+            ->method('setActiveEntry')
170
+            ->with('core_apps');
171
+
172
+        $this->initialState
173
+            ->expects($this->exactly(3))
174
+            ->method('provideInitialState');
175
+
176
+        $policy = new ContentSecurityPolicy();
177
+        $policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
178
+
179
+        $expected = new TemplateResponse('settings',
180
+            'settings/empty',
181
+            [
182
+                'pageTitle' => 'Settings'
183
+            ],
184
+            'user');
185
+        $expected->setContentSecurityPolicy($policy);
186
+
187
+        $this->assertEquals($expected, $this->appSettingsController->viewApps());
188
+    }
189
+
190
+    public function testViewAppsAppstoreNotEnabled(): void {
191
+        $this->installer->expects($this->any())
192
+            ->method('isUpdateAvailable')
193
+            ->willReturn(false);
194
+        $this->bundleFetcher->expects($this->once())->method('getBundles')->willReturn([]);
195
+        $this->config
196
+            ->expects($this->once())
197
+            ->method('getSystemValueBool')
198
+            ->with('appstoreenabled', true)
199
+            ->willReturn(false);
200
+        $this->navigationManager
201
+            ->expects($this->once())
202
+            ->method('setActiveEntry')
203
+            ->with('core_apps');
204
+
205
+        $this->initialState
206
+            ->expects($this->exactly(3))
207
+            ->method('provideInitialState');
208
+
209
+        $policy = new ContentSecurityPolicy();
210
+        $policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
211
+
212
+        $expected = new TemplateResponse('settings',
213
+            'settings/empty',
214
+            [
215
+                'pageTitle' => 'Settings'
216
+            ],
217
+            'user');
218
+        $expected->setContentSecurityPolicy($policy);
219
+
220
+        $this->assertEquals($expected, $this->appSettingsController->viewApps());
221
+    }
222 222
 }
Please login to merge, or discard this patch.