Skip to content

Commit 74f996b

Browse files
authored
Merge pull request #44129 from nextcloud/feat/discover-apps-section
2 parents d15e45c + a61cef9 commit 74f996b

105 files changed

Lines changed: 1337 additions & 145 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/settings/appinfo/routes.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
['name' => 'MailSettings#storeCredentials', 'url' => '/settings/admin/mailsettings/credentials', 'verb' => 'POST' , 'root' => ''],
4040
['name' => 'MailSettings#sendTestMail', 'url' => '/settings/admin/mailtest', 'verb' => 'POST' , 'root' => ''],
4141

42+
['name' => 'AppSettings#getAppDiscoverJSON', 'url' => '/settings/api/apps/discover', 'verb' => 'GET', 'root' => ''],
43+
['name' => 'AppSettings#getAppDiscoverMedia', 'url' => '/settings/api/apps/media', 'verb' => 'GET', 'root' => ''],
4244
['name' => 'AppSettings#listCategories', 'url' => '/settings/apps/categories', 'verb' => 'GET' , 'root' => ''],
4345
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps', 'verb' => 'GET' , 'root' => ''],
4446
['name' => 'AppSettings#listApps', 'url' => '/settings/apps/list', 'verb' => 'GET' , 'root' => ''],

apps/settings/lib/Controller/AppSettingsController.php

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* @author Roeland Jago Douma <roeland@famdouma.nl>
1515
* @author Thomas Müller <thomas.mueller@tmit.eu>
1616
* @author Kate Döen <kate.doeen@nextcloud.com>
17+
* @author Ferdinand Thiessen <opensource@fthiessen.de>
1718
*
1819
* @license AGPL-3.0
1920
*
@@ -33,21 +34,33 @@
3334
namespace OCA\Settings\Controller;
3435

3536
use OC\App\AppStore\Bundles\BundleFetcher;
37+
use OC\App\AppStore\Fetcher\AppDiscoverFetcher;
3638
use OC\App\AppStore\Fetcher\AppFetcher;
3739
use OC\App\AppStore\Fetcher\CategoryFetcher;
3840
use OC\App\AppStore\Version\VersionParser;
3941
use OC\App\DependencyAnalyzer;
4042
use OC\App\Platform;
4143
use OC\Installer;
4244
use OC_App;
45+
use OCP\App\AppPathNotFoundException;
4346
use OCP\App\IAppManager;
4447
use OCP\AppFramework\Controller;
4548
use OCP\AppFramework\Http;
4649
use OCP\AppFramework\Http\Attribute\OpenAPI;
4750
use OCP\AppFramework\Http\ContentSecurityPolicy;
51+
use OCP\AppFramework\Http\FileDisplayResponse;
4852
use OCP\AppFramework\Http\JSONResponse;
53+
use OCP\AppFramework\Http\NotFoundResponse;
54+
use OCP\AppFramework\Http\Response;
4955
use OCP\AppFramework\Http\TemplateResponse;
5056
use OCP\AppFramework\Services\IInitialState;
57+
use OCP\Files\AppData\IAppDataFactory;
58+
use OCP\Files\IAppData;
59+
use OCP\Files\NotFoundException;
60+
use OCP\Files\NotPermittedException;
61+
use OCP\Files\SimpleFS\ISimpleFile;
62+
use OCP\Files\SimpleFS\ISimpleFolder;
63+
use OCP\Http\Client\IClientService;
5164
use OCP\IConfig;
5265
use OCP\IL10N;
5366
use OCP\INavigationManager;
@@ -62,9 +75,12 @@ class AppSettingsController extends Controller {
6275
/** @var array */
6376
private $allApps = [];
6477

78+
private IAppData $appData;
79+
6580
public function __construct(
6681
string $appName,
6782
IRequest $request,
83+
IAppDataFactory $appDataFactory,
6884
private IL10N $l10n,
6985
private IConfig $config,
7086
private INavigationManager $navigationManager,
@@ -77,8 +93,11 @@ public function __construct(
7793
private IURLGenerator $urlGenerator,
7894
private LoggerInterface $logger,
7995
private IInitialState $initialState,
96+
private AppDiscoverFetcher $discoverFetcher,
97+
private IClientService $clientService,
8098
) {
8199
parent::__construct($appName, $request);
100+
$this->appData = $appDataFactory->get('appstore');
82101
}
83102

84103
/**
@@ -106,6 +125,93 @@ public function viewApps(): TemplateResponse {
106125
return $templateResponse;
107126
}
108127

128+
/**
129+
* Get all active entries for the app discover section
130+
*
131+
* @NoCSRFRequired
132+
*/
133+
public function getAppDiscoverJSON(): JSONResponse {
134+
$data = $this->discoverFetcher->get();
135+
return new JSONResponse($data);
136+
}
137+
138+
/**
139+
* @PublicPage
140+
* @NoCSRFRequired
141+
*
142+
* Get a image for the app discover section - this is proxied for privacy and CSP reasons
143+
*
144+
* @param string $image
145+
* @throws \Exception
146+
*/
147+
public function getAppDiscoverMedia(string $fileName): Response {
148+
$etag = $this->discoverFetcher->getETag() ?? date('Y-m');
149+
$folder = null;
150+
try {
151+
$folder = $this->appData->getFolder('app-discover-cache');
152+
$this->cleanUpImageCache($folder, $etag);
153+
} catch (\Throwable $e) {
154+
$folder = $this->appData->newFolder('app-discover-cache');
155+
}
156+
157+
// Get the current cache folder
158+
try {
159+
$folder = $folder->getFolder($etag);
160+
} catch (NotFoundException $e) {
161+
$folder = $folder->newFolder($etag);
162+
}
163+
164+
$info = pathinfo($fileName);
165+
$hashName = md5($fileName);
166+
$allFiles = $folder->getDirectoryListing();
167+
// Try to find the file
168+
$file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
169+
return str_starts_with($file->getName(), $hashName);
170+
});
171+
// Get the first entry
172+
$file = reset($file);
173+
// If not found request from Web
174+
if ($file === false) {
175+
try {
176+
$client = $this->clientService->newClient();
177+
$fileResponse = $client->get($fileName);
178+
$contentType = $fileResponse->getHeader('Content-Type');
179+
$extension = $info['extension'] ?? '';
180+
$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
181+
} catch (\Throwable $e) {
182+
$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
183+
return new NotFoundResponse();
184+
}
185+
} else {
186+
// File was found so we can get the content type from the file name
187+
$contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
188+
}
189+
190+
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
191+
// cache for 7 days
192+
$response->cacheFor(604800, false, true);
193+
return $response;
194+
}
195+
196+
/**
197+
* Remove orphaned folders from the image cache that do not match the current etag
198+
* @param ISimpleFolder $folder The folder to clear
199+
* @param string $etag The etag (directory name) to keep
200+
*/
201+
private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
202+
// Cleanup old cache folders
203+
$allFiles = $folder->getDirectoryListing();
204+
foreach ($allFiles as $dir) {
205+
try {
206+
if ($dir->getName() !== $etag) {
207+
$dir->delete();
208+
}
209+
} catch (NotPermittedException $e) {
210+
// ignore folder for now
211+
}
212+
}
213+
}
214+
109215
private function getAppsWithUpdates() {
110216
$appClass = new \OC_App();
111217
$apps = $appClass->listAllApps();
@@ -190,6 +296,7 @@ private function fetchApps() {
190296
private function getAllApps() {
191297
return $this->allApps;
192298
}
299+
193300
/**
194301
* Get all available apps in a category
195302
*
@@ -291,7 +398,14 @@ private function getAppsForCategory($requestedCategory = ''): array {
291398
$nextCloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextCloudVersion->getMaximumVersion();
292399
}
293400
$phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']);
294-
$existsLocally = \OC_App::getAppPath($app['id']) !== false;
401+
402+
try {
403+
$this->appManager->getAppPath($app['id']);
404+
$existsLocally = true;
405+
} catch (AppPathNotFoundException $e) {
406+
$existsLocally = false;
407+
}
408+
295409
$phpDependencies = [];
296410
if ($phpVersion->getMinimumVersion() !== '') {
297411
$phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion();
@@ -310,7 +424,7 @@ private function getAppsForCategory($requestedCategory = ''): array {
310424
}
311425
}
312426

313-
$currentLanguage = substr(\OC::$server->getL10NFactory()->findLanguage(), 0, 2);
427+
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
314428
$enabledValue = $this->config->getAppValue($app['id'], 'enabled', 'no');
315429
$groups = null;
316430
if ($enabledValue !== 'no' && $enabledValue !== 'yes') {
@@ -397,7 +511,7 @@ public function enableApps(array $appIds, array $groups = []): JSONResponse {
397511

398512
// Check if app is already downloaded
399513
/** @var Installer $installer */
400-
$installer = \OC::$server->query(Installer::class);
514+
$installer = \OC::$server->get(Installer::class);
401515
$isDownloaded = $installer->isDownloaded($appId);
402516

403517
if (!$isDownloaded) {
@@ -416,7 +530,7 @@ public function enableApps(array $appIds, array $groups = []): JSONResponse {
416530
}
417531
}
418532
return new JSONResponse(['data' => ['update_required' => $updateRequired]]);
419-
} catch (\Exception $e) {
533+
} catch (\Throwable $e) {
420534
$this->logger->error('could not enable apps', ['exception' => $e]);
421535
return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
422536
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<template>
2+
<div class="app-discover">
3+
<NcEmptyContent v-if="hasError"
4+
:name="t('settings', 'Nothing to show')"
5+
:description="t('settings', 'Could not load section content from app store.')">
6+
<template #icon>
7+
<NcIconSvgWrapper :path="mdiEyeOff" :size="64" />
8+
</template>
9+
</NcEmptyContent>
10+
<NcEmptyContent v-else-if="elements.length === 0"
11+
:name="t('settings', 'Loading')"
12+
:description="t('settings', 'Fetching the latest news…')">
13+
<template #icon>
14+
<NcLoadingIcon :size="64" />
15+
</template>
16+
</NcEmptyContent>
17+
<template v-else>
18+
<component :is="getComponent(entry.type)"
19+
v-for="entry, index in elements"
20+
:key="entry.id ?? index"
21+
v-bind="entry" />
22+
</template>
23+
</div>
24+
</template>
25+
26+
<script setup lang="ts">
27+
import type { IAppDiscoverElements } from '../../constants/AppDiscoverTypes.ts'
28+
29+
import { mdiEyeOff } from '@mdi/js'
30+
import { showError } from '@nextcloud/dialogs'
31+
import { translate as t } from '@nextcloud/l10n'
32+
import { generateUrl } from '@nextcloud/router'
33+
import { defineAsyncComponent, defineComponent, onBeforeMount, ref } from 'vue'
34+
35+
import axios from '@nextcloud/axios'
36+
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
37+
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
38+
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
39+
40+
import logger from '../../logger'
41+
import { apiTypeParser } from '../../utils/appDiscoverTypeParser.ts'
42+
43+
const PostType = defineAsyncComponent(() => import('./PostType.vue'))
44+
const CarouselType = defineAsyncComponent(() => import('./CarouselType.vue'))
45+
46+
const hasError = ref(false)
47+
const elements = ref<IAppDiscoverElements[]>([])
48+
49+
/**
50+
* Shuffle using the Fisher-Yates algorithm
51+
* @param array The array to shuffle (in place)
52+
*/
53+
const shuffleArray = (array) => {
54+
for (let i = array.length - 1; i > 0; i--) {
55+
const j = Math.floor(Math.random() * (i + 1));
56+
[array[i], array[j]] = [array[j], array[i]]
57+
}
58+
return array
59+
}
60+
61+
/**
62+
* Load the app discover section information
63+
*/
64+
onBeforeMount(async () => {
65+
try {
66+
const { data } = await axios.get<Record<string, unknown>[]>(generateUrl('/settings/api/apps/discover'))
67+
const parsedData = data.map(apiTypeParser)
68+
elements.value = shuffleArray(parsedData)
69+
} catch (error) {
70+
hasError.value = true
71+
logger.error(error as Error)
72+
showError(t('settings', 'Could not load app discover section'))
73+
}
74+
})
75+
76+
const getComponent = (type) => {
77+
if (type === 'post') {
78+
return PostType
79+
} else if (type === 'carousel') {
80+
return CarouselType
81+
}
82+
return defineComponent({
83+
mounted: () => logger.error('Unknown component requested ', type),
84+
render: (h) => h('div', t('settings', 'Could not render element')),
85+
})
86+
}
87+
</script>
88+
89+
<style scoped lang="scss">
90+
.app-discover {
91+
max-width: 1008px; /* 900px + 2x 54px padding for the carousel controls */
92+
margin-inline: auto;
93+
padding-inline: 54px;
94+
/* Padding required to make last element not bound to the bottom */
95+
padding-block-end: var(--default-clickable-area, 44px);
96+
97+
display: flex;
98+
flex-direction: column;
99+
gap: var(--default-clickable-area, 44px);
100+
}
101+
</style>

0 commit comments

Comments
 (0)