Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 168 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
SpecialUploadStash | |
0.00% |
0 / 167 |
|
0.00% |
0 / 12 |
1482 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
showUpload | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
parseKey | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
72 | |||
outputThumbFromStash | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
outputLocallyScaledThumb | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
outputRemoteScaledThumb | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
42 | |||
outputLocalFile | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
outputContents | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
outputFileHeaders | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
showUploads | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\Specials; |
22 | |
23 | use Exception; |
24 | use MediaWiki\Exception\HttpError; |
25 | use MediaWiki\FileRepo\File\File; |
26 | use MediaWiki\FileRepo\File\UnregisteredLocalFile; |
27 | use MediaWiki\FileRepo\LocalRepo; |
28 | use MediaWiki\FileRepo\RepoGroup; |
29 | use MediaWiki\Html\Html; |
30 | use MediaWiki\HTMLForm\HTMLForm; |
31 | use MediaWiki\Http\HttpRequestFactory; |
32 | use MediaWiki\MainConfigNames; |
33 | use MediaWiki\Pager\UploadStashPager; |
34 | use MediaWiki\Parser\ParserOptions; |
35 | use MediaWiki\SpecialPage\UnlistedSpecialPage; |
36 | use MediaWiki\Status\Status; |
37 | use MediaWiki\Utils\UrlUtils; |
38 | use SpecialUploadStashTooLargeException; |
39 | use UploadStash; |
40 | use UploadStashBadPathException; |
41 | use UploadStashFileNotFoundException; |
42 | use Wikimedia\Rdbms\IConnectionProvider; |
43 | |
44 | /** |
45 | * Web access for files temporarily stored by UploadStash. |
46 | * |
47 | * For example -- files that were uploaded with the UploadWizard extension are stored temporarily |
48 | * before committing them to the db. But we want to see their thumbnails and get other information |
49 | * about them. |
50 | * |
51 | * Since this is based on the user's session, in effect this creates a private temporary file area. |
52 | * However, the URLs for the files cannot be shared. |
53 | * |
54 | * @ingroup SpecialPage |
55 | * @ingroup Upload |
56 | */ |
57 | class SpecialUploadStash extends UnlistedSpecialPage { |
58 | /** @var UploadStash|null */ |
59 | private $stash; |
60 | |
61 | private LocalRepo $localRepo; |
62 | private HttpRequestFactory $httpRequestFactory; |
63 | private UrlUtils $urlUtils; |
64 | private IConnectionProvider $dbProvider; |
65 | |
66 | /** |
67 | * Since we are directly writing the file to STDOUT, |
68 | * we should not be reading in really big files and serving them out. |
69 | * |
70 | * We also don't want people using this as a file drop, even if they |
71 | * share credentials. |
72 | * |
73 | * This service is really for thumbnails and other such previews while |
74 | * uploading. |
75 | */ |
76 | private const MAX_SERVE_BYTES = 1_048_576; // 1 MiB |
77 | |
78 | public function __construct( |
79 | RepoGroup $repoGroup, |
80 | HttpRequestFactory $httpRequestFactory, |
81 | UrlUtils $urlUtils, |
82 | IConnectionProvider $dbProvider |
83 | ) { |
84 | parent::__construct( 'UploadStash', 'upload' ); |
85 | $this->localRepo = $repoGroup->getLocalRepo(); |
86 | $this->httpRequestFactory = $httpRequestFactory; |
87 | $this->urlUtils = $urlUtils; |
88 | $this->dbProvider = $dbProvider; |
89 | } |
90 | |
91 | public function doesWrites() { |
92 | return true; |
93 | } |
94 | |
95 | /** |
96 | * Execute page -- can output a file directly or show a listing of them. |
97 | * |
98 | * @param string|null $subPage Subpage, e.g. in |
99 | * https://example.com/wiki/Special:UploadStash/foo.jpg, the "foo.jpg" part |
100 | */ |
101 | public function execute( $subPage ) { |
102 | $this->useTransactionalTimeLimit(); |
103 | |
104 | // This is not set in constructor, because the context with the user is not safe to be set |
105 | $this->stash = $this->localRepo->getUploadStash( $this->getUser() ); |
106 | $this->checkPermissions(); |
107 | |
108 | if ( $subPage === null || $subPage === '' ) { |
109 | $this->showUploads(); |
110 | } else { |
111 | $this->showUpload( $subPage ); |
112 | } |
113 | } |
114 | |
115 | /** |
116 | * If file available in stash, cats it out to the client as a simple HTTP response. |
117 | * n.b. Most checking done in UploadStashLocalFile, so this is straightforward. |
118 | * |
119 | * @param string $key The key of a particular requested file |
120 | * @throws HttpError |
121 | */ |
122 | public function showUpload( $key ) { |
123 | // prevent callers from doing standard HTML output -- we'll take it from here |
124 | $this->getOutput()->disable(); |
125 | |
126 | try { |
127 | $params = $this->parseKey( $key ); |
128 | if ( $params['type'] === 'thumb' ) { |
129 | $this->outputThumbFromStash( $params['file'], $params['params'] ); |
130 | } else { |
131 | $this->outputLocalFile( $params['file'] ); |
132 | } |
133 | return; |
134 | } catch ( UploadStashFileNotFoundException $e ) { |
135 | $code = 404; |
136 | $message = $e->getMessage(); |
137 | } catch ( Exception $e ) { |
138 | $code = 500; |
139 | $message = $e->getMessage(); |
140 | } |
141 | |
142 | throw new HttpError( $code, $message ); |
143 | } |
144 | |
145 | /** |
146 | * Parse the key passed to the SpecialPage. Returns an array containing |
147 | * the associated file object, the type ('file' or 'thumb') and if |
148 | * application the transform parameters |
149 | * |
150 | * @param string $key |
151 | * @throws UploadStashBadPathException |
152 | * @return array |
153 | */ |
154 | private function parseKey( $key ) { |
155 | $type = strtok( $key, '/' ); |
156 | |
157 | if ( $type !== 'file' && $type !== 'thumb' ) { |
158 | throw new UploadStashBadPathException( |
159 | $this->msg( 'uploadstash-bad-path-unknown-type', $type ) |
160 | ); |
161 | } |
162 | $fileName = strtok( '/' ); |
163 | $thumbPart = strtok( '/' ); |
164 | $file = $this->stash->getFile( $fileName ); |
165 | if ( $type === 'thumb' ) { |
166 | $srcNamePos = strrpos( $thumbPart, $fileName ); |
167 | if ( $srcNamePos === false || $srcNamePos < 1 ) { |
168 | throw new UploadStashBadPathException( |
169 | $this->msg( 'uploadstash-bad-path-unrecognized-thumb-name' ) |
170 | ); |
171 | } |
172 | $paramString = substr( $thumbPart, 0, $srcNamePos - 1 ); |
173 | |
174 | $handler = $file->getHandler(); |
175 | if ( $handler ) { |
176 | $params = $handler->parseParamString( $paramString ); |
177 | if ( $params === false ) { |
178 | // The params are invalid, but still try to show a thumb |
179 | $params = []; |
180 | } |
181 | |
182 | return [ 'file' => $file, 'type' => $type, 'params' => $params ]; |
183 | } else { |
184 | throw new UploadStashBadPathException( |
185 | $this->msg( 'uploadstash-bad-path-no-handler', $file->getMimeType(), $file->getPath() ) |
186 | ); |
187 | } |
188 | } |
189 | |
190 | return [ 'file' => $file, 'type' => $type ]; |
191 | } |
192 | |
193 | /** |
194 | * Get a thumbnail for file, either generated locally or remotely, and stream it out |
195 | * |
196 | * @param File $file |
197 | * @param array $params |
198 | */ |
199 | private function outputThumbFromStash( $file, $params ) { |
200 | // this config option, if it exists, points to a "scaler", as you might find in |
201 | // the Wikimedia Foundation cluster. See outputRemoteScaledThumb(). This |
202 | // is part of our horrible NFS-based system, we create a file on a mount |
203 | // point here, but fetch the scaled file from somewhere else that |
204 | // happens to share it over NFS. |
205 | if ( $file->getRepo()->getThumbProxyUrl() |
206 | || $this->getConfig()->get( MainConfigNames::UploadStashScalerBaseUrl ) |
207 | ) { |
208 | $this->outputRemoteScaledThumb( $file, $params ); |
209 | } else { |
210 | $this->outputLocallyScaledThumb( $file, $params ); |
211 | } |
212 | } |
213 | |
214 | /** |
215 | * Scale a file (probably with a locally installed imagemagick, or similar) |
216 | * and output it to STDOUT. |
217 | * @param File $file |
218 | * @param array $params Scaling parameters ( e.g. [ width => '50' ] ); |
219 | */ |
220 | private function outputLocallyScaledThumb( $file, $params ) { |
221 | // n.b. this is stupid, we insist on re-transforming the file every time we are invoked. We rely |
222 | // on HTTP caching to ensure this doesn't happen. |
223 | |
224 | $thumbnailImage = $file->transform( $params, File::RENDER_NOW ); |
225 | if ( !$thumbnailImage ) { |
226 | throw new UploadStashFileNotFoundException( |
227 | $this->msg( 'uploadstash-file-not-found-no-thumb' ) |
228 | ); |
229 | } |
230 | |
231 | // we should have just generated it locally |
232 | if ( !$thumbnailImage->getStoragePath() ) { |
233 | throw new UploadStashFileNotFoundException( |
234 | $this->msg( 'uploadstash-file-not-found-no-local-path' ) |
235 | ); |
236 | } |
237 | |
238 | // now we should construct a File, so we can get MIME and other such info in a standard way |
239 | // n.b. MIME type may be different from original (ogx original -> jpeg thumb) |
240 | $thumbFile = new UnregisteredLocalFile( false, |
241 | $this->stash->repo, $thumbnailImage->getStoragePath(), false ); |
242 | |
243 | $this->outputLocalFile( $thumbFile ); |
244 | } |
245 | |
246 | /** |
247 | * Scale a file with a remote "scaler", as exists on the Wikimedia Foundation |
248 | * cluster, and output it to STDOUT. |
249 | * Note: Unlike the usual thumbnail process, the web client never sees the |
250 | * cluster URL; we do the whole HTTP transaction to the scaler ourselves |
251 | * and cat the results out. |
252 | * Note: We rely on NFS to have propagated the file contents to the scaler. |
253 | * However, we do not rely on the thumbnail being created in NFS and then |
254 | * propagated back to our filesystem. Instead we take the results of the |
255 | * HTTP request instead. |
256 | * Note: No caching is being done here, although we are instructing the |
257 | * client to cache it forever. |
258 | * |
259 | * @param File $file |
260 | * @param array $params Scaling parameters ( e.g. [ width => '50' ] ); |
261 | */ |
262 | private function outputRemoteScaledThumb( $file, $params ) { |
263 | // We need to use generateThumbName() instead of thumbName(), because |
264 | // the suffix needs to match the file name for the remote thumbnailer |
265 | // to work |
266 | $scalerThumbName = $file->generateThumbName( $file->getName(), $params ); |
267 | |
268 | // If a thumb proxy is set up for the repo, we favor that, as that will |
269 | // keep the request internal |
270 | $thumbProxyUrl = $file->getRepo()->getThumbProxyUrl(); |
271 | if ( $thumbProxyUrl !== null ) { |
272 | $scalerThumbUrl = $thumbProxyUrl . 'temp/' . $file->getUrlRel() . |
273 | '/' . rawurlencode( $scalerThumbName ); |
274 | $secret = $file->getRepo()->getThumbProxySecret(); |
275 | } else { |
276 | // This option probably looks something like |
277 | // '//upload.wikimedia.org/wikipedia/test/thumb/temp'. Do not use |
278 | // trailing slash. |
279 | $scalerBaseUrl = $this->getConfig()->get( MainConfigNames::UploadStashScalerBaseUrl ); |
280 | |
281 | if ( preg_match( '/^\/\//', $scalerBaseUrl ) ) { |
282 | // this is apparently a protocol-relative URL, which makes no sense in this context, |
283 | // since this is used for communication that's internal to the application. |
284 | // default to http. |
285 | $scalerBaseUrl = $this->urlUtils->expand( $scalerBaseUrl, PROTO_CANONICAL ); |
286 | } |
287 | |
288 | $scalerThumbUrl = $scalerBaseUrl . '/' . $file->getUrlRel() . |
289 | '/' . rawurlencode( $scalerThumbName ); |
290 | $secret = null; |
291 | } |
292 | |
293 | // make an http request based on wgUploadStashScalerBaseUrl to lazy-create |
294 | // a thumbnail |
295 | $httpOptions = [ |
296 | 'method' => 'GET', |
297 | 'timeout' => 5 // T90599 attempt to time out cleanly |
298 | ]; |
299 | $req = $this->httpRequestFactory->create( $scalerThumbUrl, $httpOptions, __METHOD__ ); |
300 | |
301 | // Pass a secret key shared with the proxied service if any |
302 | if ( $secret !== null ) { |
303 | $req->setHeader( 'X-Swift-Secret', $secret ); |
304 | } |
305 | |
306 | $status = $req->execute(); |
307 | if ( !$status->isOK() ) { |
308 | throw new UploadStashFileNotFoundException( |
309 | $this->msg( |
310 | 'uploadstash-file-not-found-no-remote-thumb', |
311 | $status->getMessage(), |
312 | $scalerThumbUrl |
313 | ) |
314 | ); |
315 | } |
316 | $contentType = $req->getResponseHeader( "content-type" ); |
317 | if ( !$contentType ) { |
318 | throw new UploadStashFileNotFoundException( |
319 | $this->msg( 'uploadstash-file-not-found-missing-content-type' ) |
320 | ); |
321 | } |
322 | |
323 | $this->outputContents( $req->getContent(), $contentType ); |
324 | } |
325 | |
326 | /** |
327 | * Output HTTP response for file |
328 | * Side effect: writes HTTP response to STDOUT. |
329 | * |
330 | * @param File $file File object with a local path (e.g. UnregisteredLocalFile, |
331 | * LocalFile. Oddly these don't share an ancestor!) |
332 | * @throws SpecialUploadStashTooLargeException |
333 | */ |
334 | private function outputLocalFile( File $file ) { |
335 | if ( $file->getSize() > self::MAX_SERVE_BYTES ) { |
336 | throw new SpecialUploadStashTooLargeException( |
337 | $this->msg( 'uploadstash-file-too-large', self::MAX_SERVE_BYTES ) |
338 | ); |
339 | } |
340 | |
341 | $file->getRepo()->streamFileWithStatus( $file->getPath(), |
342 | [ 'Content-Transfer-Encoding: binary', |
343 | 'Expires: Sun, 17-Jan-2038 19:14:07 GMT' ] |
344 | ); |
345 | } |
346 | |
347 | /** |
348 | * Output HTTP response of raw content |
349 | * Side effect: writes HTTP response to STDOUT. |
350 | * @param string $content |
351 | * @param string $contentType MIME type |
352 | * @throws SpecialUploadStashTooLargeException |
353 | */ |
354 | private function outputContents( $content, $contentType ) { |
355 | $size = strlen( $content ); |
356 | if ( $size > self::MAX_SERVE_BYTES ) { |
357 | throw new SpecialUploadStashTooLargeException( |
358 | $this->msg( 'uploadstash-file-too-large', self::MAX_SERVE_BYTES ) |
359 | ); |
360 | } |
361 | // Cancel output buffering and gzipping if set |
362 | wfResetOutputBuffers(); |
363 | self::outputFileHeaders( $contentType, $size ); |
364 | print $content; |
365 | } |
366 | |
367 | /** |
368 | * Output headers for streaming |
369 | * @todo Unsure about encoding as binary; if we received from HTTP perhaps |
370 | * we should use that encoding, concatenated with semicolon to `$contentType` as it |
371 | * usually is. |
372 | * Side effect: preps PHP to write headers to STDOUT. |
373 | * @param string $contentType String suitable for content-type header |
374 | * @param int $size Length in bytes |
375 | */ |
376 | private static function outputFileHeaders( $contentType, $size ) { |
377 | header( "Content-Type: $contentType", true ); |
378 | header( 'Content-Transfer-Encoding: binary', true ); |
379 | header( 'Expires: Sun, 17-Jan-2038 19:14:07 GMT', true ); |
380 | // T55032 - It shouldn't be a problem here, but let's be safe and not cache |
381 | header( 'Cache-Control: private' ); |
382 | header( "Content-Length: $size", true ); |
383 | } |
384 | |
385 | /** |
386 | * Default action when we don't have a subpage -- just show links to the uploads we have, |
387 | * Also show a button to clear stashed files |
388 | */ |
389 | private function showUploads() { |
390 | // sets the title, etc. |
391 | $this->setHeaders(); |
392 | $this->outputHeader(); |
393 | |
394 | // create the form, which will also be used to execute a callback to process incoming form data |
395 | // this design is extremely dubious, but supposedly HTMLForm is our standard now? |
396 | |
397 | $form = HTMLForm::factory( 'ooui', [ |
398 | 'Clear' => [ |
399 | 'type' => 'hidden', |
400 | 'default' => true, |
401 | 'name' => 'clear', |
402 | ] |
403 | ], $this->getContext(), 'clearStashedUploads' ); |
404 | $form->setTitle( $this->getPageTitle() ); // Remove subpage |
405 | $form->setSubmitDestructive(); |
406 | $form->setSubmitCallback( function ( $formData, $form ) { |
407 | if ( isset( $formData['Clear'] ) ) { |
408 | wfDebug( 'stash has: ' . print_r( $this->stash->listFiles(), true ) ); |
409 | |
410 | if ( !$this->stash->clear() ) { |
411 | return Status::newFatal( 'uploadstash-errclear' ); |
412 | } |
413 | } |
414 | |
415 | return Status::newGood(); |
416 | } ); |
417 | $form->setSubmitTextMsg( 'uploadstash-clear' ); |
418 | |
419 | $form->prepareForm(); |
420 | $formResult = $form->tryAuthorizedSubmit(); |
421 | |
422 | // show the files + form, if there are any, or just say there are none |
423 | $linkRenderer = $this->getLinkRenderer(); |
424 | $refreshHtml = $linkRenderer->makeKnownLink( |
425 | $this->getPageTitle(), |
426 | $this->msg( 'uploadstash-refresh' )->text() |
427 | ); |
428 | $pager = new UploadStashPager( |
429 | $this->getContext(), |
430 | $linkRenderer, |
431 | $this->dbProvider, |
432 | $this->stash, |
433 | $this->localRepo |
434 | ); |
435 | if ( $pager->getNumRows() ) { |
436 | $pager->getForm(); |
437 | $this->getOutput()->addParserOutputContent( |
438 | $pager->getFullOutput(), |
439 | ParserOptions::newFromContext( $this->getContext() ) |
440 | ); |
441 | $form->displayForm( $formResult ); |
442 | $this->getOutput()->addHTML( Html::rawElement( 'p', [], $refreshHtml ) ); |
443 | } else { |
444 | $this->getOutput()->addHTML( Html::rawElement( 'p', [], |
445 | Html::element( 'span', [], $this->msg( 'uploadstash-nofiles' )->text() ) |
446 | . ' ' |
447 | . $refreshHtml |
448 | ) ); |
449 | } |
450 | } |
451 | } |
452 | |
453 | /** |
454 | * Retain the old class name for backwards compatibility. |
455 | * @deprecated since 1.41 |
456 | */ |
457 | class_alias( SpecialUploadStash::class, 'SpecialUploadStash' ); |