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