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