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