-
-
Notifications
You must be signed in to change notification settings - Fork 5.1k
Expand file tree
/
Copy pathDiscoverController.php
More file actions
190 lines (170 loc) · 6.33 KB
/
Copy pathDiscoverController.php
File metadata and controls
190 lines (170 loc) · 6.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
<?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
}
}
}
}