nextcloud/apps/appstore/lib/Controller/DiscoverController.php
Ferdinand Thiessen 3f8710500c chore: apply strict rector rules on appstore
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-05-05 10:41:04 +02:00

190 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Appstore\Controller;
use OC\App\AppStore\Fetcher\AppDiscoverFetcher;
use OC\App\AppStore\Fetcher\ResponseDefinitions;
use OCA\Appstore\AppInfo\Application;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\OCS\OCSBadRequestException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
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\IRequest;
use OCP\IUserSession;
use OCP\Security\RateLimiting\ILimiter;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type AppStoreFetcherDiscoverElement from ResponseDefinitions
*/
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
final class DiscoverController extends OCSController {
private readonly IAppData $appData;
public function __construct(
IRequest $request,
IAppDataFactory $appDataFactory,
private readonly IClientService $clientService,
private readonly AppDiscoverFetcher $discoverFetcher,
private readonly LoggerInterface $logger,
) {
parent::__construct(Application::APP_ID, $request);
$this->appData = $appDataFactory->get(Application::APP_ID);
}
/**
* Get all active entries for the app discover section
*
* @return DataResponse<Http::STATUS_OK, list<AppStoreFetcherDiscoverElement>, array{}>
*
* 200: List of active entries for the app discover section
*/
#[NoCSRFRequired]
#[ApiRoute(verb: 'GET', url:'/api/v1/discover')]
public function getAppDiscoverJSON(): DataResponse {
$data = $this->discoverFetcher->get(true);
return new DataResponse($data);
}
/**
* Get a image for the app discover section - this is proxied for privacy and CSP reasons
*
* @param string $fileName - The image file name
* @return FileDisplayResponse<Http::STATUS_OK, array{'Content-Type': string}>
* @throws OCSBadRequestException - if the media source is not trusted
* @throws OCSNotFoundException - if the media file could not be found
*
* 200: The media file was found and is returned
* 400: The media source is not trusted
* 404: The media file could not be found
*/
#[NoCSRFRequired]
#[ApiRoute(verb: 'GET', url: '/api/v1/discover/media')]
public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): FileDisplayResponse {
$getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
$etag = trim($getEtag, '"');
try {
$folder = $this->appData->getFolder('app-discover-cache');
$this->cleanUpImageCache($folder, $etag);
} catch (\Throwable) {
$folder = $this->appData->newFolder('app-discover-cache');
}
// Get the current cache folder
try {
$folder = $folder->getFolder($etag);
} catch (NotFoundException) {
$folder = $folder->newFolder($etag);
}
$info = pathinfo($fileName);
$hashName = md5($fileName);
$allFiles = $folder->getDirectoryListing();
// Try to find the file
$file = array_filter($allFiles, fn (ISimpleFile $file): bool => str_starts_with($file->getName(), $hashName));
// Get the first entry
$file = reset($file);
// If not found request from Web
if ($file === false) {
$user = $session->getUser();
// this route is not public thus we can assume a user is logged-in
assert($user !== null);
// Register a user request to throttle fetching external data
// this will prevent using the server for DoS of other systems.
$limiter->registerUserRequest(
'settings-discover-media',
// allow up to 24 media requests per hour
// this should be a sane default when a completely new section is loaded
// keep in mind browsers request all files from a source-set
24,
60 * 60,
$user,
);
if (!$this->checkCanDownloadMedia($fileName)) {
$this->logger->warning('Tried to load media files for app discover section from untrusted source');
throw new OCSBadRequestException('Untrusted media source');
}
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]);
throw new OCSNotFoundException('Media file not found');
}
} 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;
}
private function checkCanDownloadMedia(string $filename): bool {
$urlInfo = parse_url($filename);
if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
return false;
}
// Always allowed hosts
if ($urlInfo['host'] === 'nextcloud.com') {
return true;
}
// Hosts that need further verification
// Github is only allowed if from our organization
$ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
if (!in_array($urlInfo['host'], $ALLOWED_HOSTS, true)) {
return false;
}
return str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/');
}
/**
* 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) {
// ignore folder for now
}
}
}
}