Skip to content

Commit

Permalink
Merge pull request #44129 from nextcloud/feat/discover-apps-section
Browse files Browse the repository at this point in the history
  • Loading branch information
Altahrim authored Mar 14, 2024
2 parents d15e45c + a61cef9 commit 74f996b
Show file tree
Hide file tree
Showing 105 changed files with 1,337 additions and 145 deletions.
2 changes: 2 additions & 0 deletions apps/settings/appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
['name' => 'MailSettings#storeCredentials', 'url' => '/settings/admin/mailsettings/credentials', 'verb' => 'POST' , 'root' => ''],
['name' => 'MailSettings#sendTestMail', 'url' => '/settings/admin/mailtest', 'verb' => 'POST' , 'root' => ''],

['name' => 'AppSettings#getAppDiscoverJSON', 'url' => '/settings/api/apps/discover', 'verb' => 'GET', 'root' => ''],
['name' => 'AppSettings#getAppDiscoverMedia', 'url' => '/settings/api/apps/media', 'verb' => 'GET', 'root' => ''],
['name' => 'AppSettings#listCategories', 'url' => '/settings/apps/categories', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps', 'verb' => 'GET' , 'root' => ''],
['name' => 'AppSettings#listApps', 'url' => '/settings/apps/list', 'verb' => 'GET' , 'root' => ''],
Expand Down
122 changes: 118 additions & 4 deletions apps/settings/lib/Controller/AppSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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');
}

/**
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -190,6 +296,7 @@ private function fetchApps() {
private function getAllApps() {
return $this->allApps;
}

/**
* Get all available apps in a category
*
Expand Down Expand Up @@ -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();
Expand All @@ -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') {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down
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>
Loading

0 comments on commit 74f996b

Please sign in to comment.