Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Apps discover section #44129

Merged
merged 6 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
Fixed Show fixed Hide fixed
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Show resolved Hide resolved
} catch (NotFoundException $e) {
$folder = $folder->newFolder($etag);
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
}

$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
Loading