-
-
Notifications
You must be signed in to change notification settings - Fork 4.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #44129 from nextcloud/feat/discover-apps-section
- Loading branch information
Showing
105 changed files
with
1,337 additions
and
145 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,7 @@ | |
* @author Roeland Jago Douma <[email protected]> | ||
* @author Thomas Müller <[email protected]> | ||
* @author Kate Döen <[email protected]> | ||
* @author Ferdinand Thiessen <[email protected]> | ||
* | ||
* @license AGPL-3.0 | ||
* | ||
|
@@ -33,21 +34,33 @@ | |
namespace OCA\Settings\Controller; | ||
|
||
use OC\App\AppStore\Bundles\BundleFetcher; | ||
use OC\App\AppStore\Fetcher\AppDiscoverFetcher; | ||
use OC\App\AppStore\Fetcher\AppFetcher; | ||
use OC\App\AppStore\Fetcher\CategoryFetcher; | ||
use OC\App\AppStore\Version\VersionParser; | ||
use OC\App\DependencyAnalyzer; | ||
use OC\App\Platform; | ||
use OC\Installer; | ||
use OC_App; | ||
use OCP\App\AppPathNotFoundException; | ||
use OCP\App\IAppManager; | ||
use OCP\AppFramework\Controller; | ||
use OCP\AppFramework\Http; | ||
use OCP\AppFramework\Http\Attribute\OpenAPI; | ||
use OCP\AppFramework\Http\ContentSecurityPolicy; | ||
use OCP\AppFramework\Http\FileDisplayResponse; | ||
use OCP\AppFramework\Http\JSONResponse; | ||
use OCP\AppFramework\Http\NotFoundResponse; | ||
use OCP\AppFramework\Http\Response; | ||
use OCP\AppFramework\Http\TemplateResponse; | ||
use OCP\AppFramework\Services\IInitialState; | ||
use OCP\Files\AppData\IAppDataFactory; | ||
use OCP\Files\IAppData; | ||
use OCP\Files\NotFoundException; | ||
use OCP\Files\NotPermittedException; | ||
use OCP\Files\SimpleFS\ISimpleFile; | ||
use OCP\Files\SimpleFS\ISimpleFolder; | ||
use OCP\Http\Client\IClientService; | ||
use OCP\IConfig; | ||
use OCP\IL10N; | ||
use OCP\INavigationManager; | ||
|
@@ -62,9 +75,12 @@ class AppSettingsController extends Controller { | |
/** @var array */ | ||
private $allApps = []; | ||
|
||
private IAppData $appData; | ||
|
||
public function __construct( | ||
string $appName, | ||
IRequest $request, | ||
IAppDataFactory $appDataFactory, | ||
private IL10N $l10n, | ||
private IConfig $config, | ||
private INavigationManager $navigationManager, | ||
|
@@ -77,8 +93,11 @@ public function __construct( | |
private IURLGenerator $urlGenerator, | ||
private LoggerInterface $logger, | ||
private IInitialState $initialState, | ||
private AppDiscoverFetcher $discoverFetcher, | ||
private IClientService $clientService, | ||
) { | ||
parent::__construct($appName, $request); | ||
$this->appData = $appDataFactory->get('appstore'); | ||
} | ||
|
||
/** | ||
|
@@ -106,6 +125,93 @@ public function viewApps(): TemplateResponse { | |
return $templateResponse; | ||
} | ||
|
||
/** | ||
* Get all active entries for the app discover section | ||
* | ||
* @NoCSRFRequired | ||
*/ | ||
public function getAppDiscoverJSON(): JSONResponse { | ||
$data = $this->discoverFetcher->get(); | ||
return new JSONResponse($data); | ||
} | ||
|
||
/** | ||
* @PublicPage | ||
* @NoCSRFRequired | ||
* | ||
* Get a image for the app discover section - this is proxied for privacy and CSP reasons | ||
* | ||
* @param string $image | ||
* @throws \Exception | ||
*/ | ||
public function getAppDiscoverMedia(string $fileName): Response { | ||
$etag = $this->discoverFetcher->getETag() ?? date('Y-m'); | ||
$folder = null; | ||
try { | ||
$folder = $this->appData->getFolder('app-discover-cache'); | ||
$this->cleanUpImageCache($folder, $etag); | ||
} catch (\Throwable $e) { | ||
$folder = $this->appData->newFolder('app-discover-cache'); | ||
} | ||
|
||
// Get the current cache folder | ||
try { | ||
$folder = $folder->getFolder($etag); | ||
} catch (NotFoundException $e) { | ||
$folder = $folder->newFolder($etag); | ||
} | ||
|
||
$info = pathinfo($fileName); | ||
$hashName = md5($fileName); | ||
$allFiles = $folder->getDirectoryListing(); | ||
// Try to find the file | ||
$file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) { | ||
return str_starts_with($file->getName(), $hashName); | ||
}); | ||
// Get the first entry | ||
$file = reset($file); | ||
// If not found request from Web | ||
if ($file === false) { | ||
try { | ||
$client = $this->clientService->newClient(); | ||
$fileResponse = $client->get($fileName); | ||
$contentType = $fileResponse->getHeader('Content-Type'); | ||
$extension = $info['extension'] ?? ''; | ||
$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody()); | ||
} catch (\Throwable $e) { | ||
$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]); | ||
return new NotFoundResponse(); | ||
} | ||
} else { | ||
// File was found so we can get the content type from the file name | ||
$contentType = base64_decode(explode('.', $file->getName())[1] ?? ''); | ||
} | ||
|
||
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]); | ||
// cache for 7 days | ||
$response->cacheFor(604800, false, true); | ||
return $response; | ||
} | ||
|
||
/** | ||
* Remove orphaned folders from the image cache that do not match the current etag | ||
* @param ISimpleFolder $folder The folder to clear | ||
* @param string $etag The etag (directory name) to keep | ||
*/ | ||
private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void { | ||
// Cleanup old cache folders | ||
$allFiles = $folder->getDirectoryListing(); | ||
foreach ($allFiles as $dir) { | ||
try { | ||
if ($dir->getName() !== $etag) { | ||
$dir->delete(); | ||
} | ||
} catch (NotPermittedException $e) { | ||
// ignore folder for now | ||
} | ||
} | ||
} | ||
|
||
private function getAppsWithUpdates() { | ||
$appClass = new \OC_App(); | ||
$apps = $appClass->listAllApps(); | ||
|
@@ -190,6 +296,7 @@ private function fetchApps() { | |
private function getAllApps() { | ||
return $this->allApps; | ||
} | ||
|
||
/** | ||
* Get all available apps in a category | ||
* | ||
|
@@ -291,7 +398,14 @@ private function getAppsForCategory($requestedCategory = ''): array { | |
$nextCloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextCloudVersion->getMaximumVersion(); | ||
} | ||
$phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']); | ||
$existsLocally = \OC_App::getAppPath($app['id']) !== false; | ||
|
||
try { | ||
$this->appManager->getAppPath($app['id']); | ||
$existsLocally = true; | ||
} catch (AppPathNotFoundException $e) { | ||
$existsLocally = false; | ||
} | ||
|
||
$phpDependencies = []; | ||
if ($phpVersion->getMinimumVersion() !== '') { | ||
$phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion(); | ||
|
@@ -310,7 +424,7 @@ private function getAppsForCategory($requestedCategory = ''): array { | |
} | ||
} | ||
|
||
$currentLanguage = substr(\OC::$server->getL10NFactory()->findLanguage(), 0, 2); | ||
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2); | ||
$enabledValue = $this->config->getAppValue($app['id'], 'enabled', 'no'); | ||
$groups = null; | ||
if ($enabledValue !== 'no' && $enabledValue !== 'yes') { | ||
|
@@ -397,7 +511,7 @@ public function enableApps(array $appIds, array $groups = []): JSONResponse { | |
|
||
// Check if app is already downloaded | ||
/** @var Installer $installer */ | ||
$installer = \OC::$server->query(Installer::class); | ||
$installer = \OC::$server->get(Installer::class); | ||
$isDownloaded = $installer->isDownloaded($appId); | ||
|
||
if (!$isDownloaded) { | ||
|
@@ -416,7 +530,7 @@ public function enableApps(array $appIds, array $groups = []): JSONResponse { | |
} | ||
} | ||
return new JSONResponse(['data' => ['update_required' => $updateRequired]]); | ||
} catch (\Exception $e) { | ||
} catch (\Throwable $e) { | ||
$this->logger->error('could not enable apps', ['exception' => $e]); | ||
return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR); | ||
} | ||
|
101 changes: 101 additions & 0 deletions
101
apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
<template> | ||
<div class="app-discover"> | ||
<NcEmptyContent v-if="hasError" | ||
:name="t('settings', 'Nothing to show')" | ||
:description="t('settings', 'Could not load section content from app store.')"> | ||
<template #icon> | ||
<NcIconSvgWrapper :path="mdiEyeOff" :size="64" /> | ||
</template> | ||
</NcEmptyContent> | ||
<NcEmptyContent v-else-if="elements.length === 0" | ||
:name="t('settings', 'Loading')" | ||
:description="t('settings', 'Fetching the latest news…')"> | ||
<template #icon> | ||
<NcLoadingIcon :size="64" /> | ||
</template> | ||
</NcEmptyContent> | ||
<template v-else> | ||
<component :is="getComponent(entry.type)" | ||
v-for="entry, index in elements" | ||
:key="entry.id ?? index" | ||
v-bind="entry" /> | ||
</template> | ||
</div> | ||
</template> | ||
|
||
<script setup lang="ts"> | ||
import type { IAppDiscoverElements } from '../../constants/AppDiscoverTypes.ts' | ||
import { mdiEyeOff } from '@mdi/js' | ||
import { showError } from '@nextcloud/dialogs' | ||
import { translate as t } from '@nextcloud/l10n' | ||
import { generateUrl } from '@nextcloud/router' | ||
import { defineAsyncComponent, defineComponent, onBeforeMount, ref } from 'vue' | ||
import axios from '@nextcloud/axios' | ||
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' | ||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' | ||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' | ||
import logger from '../../logger' | ||
import { apiTypeParser } from '../../utils/appDiscoverTypeParser.ts' | ||
const PostType = defineAsyncComponent(() => import('./PostType.vue')) | ||
const CarouselType = defineAsyncComponent(() => import('./CarouselType.vue')) | ||
const hasError = ref(false) | ||
const elements = ref<IAppDiscoverElements[]>([]) | ||
/** | ||
* Shuffle using the Fisher-Yates algorithm | ||
* @param array The array to shuffle (in place) | ||
*/ | ||
const shuffleArray = (array) => { | ||
for (let i = array.length - 1; i > 0; i--) { | ||
const j = Math.floor(Math.random() * (i + 1)); | ||
[array[i], array[j]] = [array[j], array[i]] | ||
} | ||
return array | ||
} | ||
/** | ||
* Load the app discover section information | ||
*/ | ||
onBeforeMount(async () => { | ||
try { | ||
const { data } = await axios.get<Record<string, unknown>[]>(generateUrl('/settings/api/apps/discover')) | ||
const parsedData = data.map(apiTypeParser) | ||
elements.value = shuffleArray(parsedData) | ||
} catch (error) { | ||
hasError.value = true | ||
logger.error(error as Error) | ||
showError(t('settings', 'Could not load app discover section')) | ||
} | ||
}) | ||
const getComponent = (type) => { | ||
if (type === 'post') { | ||
return PostType | ||
} else if (type === 'carousel') { | ||
return CarouselType | ||
} | ||
return defineComponent({ | ||
mounted: () => logger.error('Unknown component requested ', type), | ||
render: (h) => h('div', t('settings', 'Could not render element')), | ||
}) | ||
} | ||
</script> | ||
|
||
<style scoped lang="scss"> | ||
.app-discover { | ||
max-width: 1008px; /* 900px + 2x 54px padding for the carousel controls */ | ||
margin-inline: auto; | ||
padding-inline: 54px; | ||
/* Padding required to make last element not bound to the bottom */ | ||
padding-block-end: var(--default-clickable-area, 44px); | ||
display: flex; | ||
flex-direction: column; | ||
gap: var(--default-clickable-area, 44px); | ||
} | ||
</style> |
Oops, something went wrong.