Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 207
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
UploadStash
0.00% covered (danger)
0.00%
0 / 206
0.00% covered (danger)
0.00%
0 / 12
2070
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getFile
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
156
 getMetadata
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getFileProps
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 stashFile
0.00% covered (danger)
0.00%
0 / 68
0.00% covered (danger)
0.00%
0 / 1
110
 clear
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 removeFile
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 removeFileNoAuth
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 listFiles
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 getExtensionForPath
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 fetchFileMetadata
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 initFile
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Temporary storage for uploaded files.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Upload;
10
11use MediaWiki\Context\RequestContext;
12use MediaWiki\FileRepo\File\File;
13use MediaWiki\FileRepo\LocalRepo;
14use MediaWiki\MainConfigNames;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Upload\Exception\UploadStashBadPathException;
17use MediaWiki\Upload\Exception\UploadStashFileException;
18use MediaWiki\Upload\Exception\UploadStashFileNotFoundException;
19use MediaWiki\Upload\Exception\UploadStashNoSuchKeyException;
20use MediaWiki\Upload\Exception\UploadStashNotLoggedInException;
21use MediaWiki\Upload\Exception\UploadStashWrongOwnerException;
22use MediaWiki\Upload\Exception\UploadStashZeroLengthFileException;
23use MediaWiki\User\UserIdentity;
24use MWFileProps;
25
26/**
27 * UploadStash is intended to accomplish a few things:
28 *   - Enable applications to temporarily stash files without publishing them to
29 *     the wiki.
30 *      - Several parts of MediaWiki do this in similar ways: UploadBase,
31 *        UploadWizard, and FirefoggChunkedExtension.
32 *        And there are several that reimplement stashing from scratch, in
33 *        idiosyncratic ways. The idea is to unify them all here.
34 *        Mostly all of them are the same except for storing some custom fields,
35 *        which we subsume into the data array.
36 *   - Enable applications to find said files later, as long as the db table or
37 *     temp files haven't been purged.
38 *   - Enable the uploading user (and *ONLY* the uploading user) to access said
39 *     files, and thumbnails of said files, via a URL. We accomplish this using
40 *     a database table, with ownership checking as you might expect. See
41 *     SpecialUploadStash, which implements a web interface to some files stored
42 *     this way.
43 *
44 * UploadStash right now is *mostly* intended to show you one user's slice of
45 * the entire stash. The user parameter is only optional because there are few
46 * cases where we clean out the stash from an automated script. In the future we
47 * might refactor this.
48 *
49 * UploadStash represents the entire stash of temporary files.
50 * UploadStashFile is a filestore for the actual physical disk files.
51 * UploadFromStash extends UploadBase, and represents a single stashed file as
52 * it is moved from the stash to the regular file repository
53 *
54 * @ingroup Upload
55 */
56class UploadStash {
57    // Format of the key for files -- has to be suitable as a filename itself (e.g. ab12cd34ef.jpg)
58    public const KEY_FORMAT_REGEX = '/^[\w\-\.]+\.\w*$/';
59    private const MAX_US_PROPS_SIZE = 65535;
60
61    /**
62     * repository that this uses to store temp files
63     * public because we sometimes need to get a LocalFile within the same repo.
64     *
65     * @var LocalRepo
66     */
67    public $repo;
68
69    /** @var array array of initialized repo objects */
70    protected $files = [];
71
72    /** @var array cache of the file metadata that's stored in the database */
73    protected $fileMetadata = [];
74
75    /** @var array fileprops cache */
76    protected $fileProps = [];
77
78    /** @var UserIdentity */
79    private $user;
80
81    /**
82     * Represents a temporary filestore, with metadata in the database.
83     * Designed to be compatible with the session stashing code in UploadBase
84     * (should replace it eventually).
85     *
86     * @param LocalRepo $repo
87     * @param UserIdentity|null $user
88     */
89    public function __construct( LocalRepo $repo, ?UserIdentity $user = null ) {
90        // this might change based on wiki's configuration.
91        $this->repo = $repo;
92
93        // if a user was passed, use it. otherwise, attempt to use the global request context.
94        // this keeps LocalRepo from breaking when it creates an UploadStash object
95        $this->user = $user ?? RequestContext::getMain()->getUser();
96    }
97
98    /**
99     * Get a file and its metadata from the stash.
100     * The noAuth param is a bit janky but is required for automated scripts
101     * which clean out the stash.
102     *
103     * @param string $key Key under which file information is stored
104     * @param bool $noAuth (optional) Don't check authentication. Used by maintenance scripts.
105     * @throws UploadStashNotLoggedInException
106     * @throws UploadStashWrongOwnerException
107     * @throws UploadStashBadPathException
108     * @throws UploadStashFileNotFoundException
109     * @return UploadStashFile
110     */
111    public function getFile( $key, $noAuth = false ) {
112        if ( !preg_match( self::KEY_FORMAT_REGEX, $key ) ) {
113            throw new UploadStashBadPathException(
114                wfMessage( 'uploadstash-bad-path-bad-format', $key )
115            );
116        }
117
118        if ( !$noAuth && !$this->user->isRegistered() ) {
119            throw new UploadStashNotLoggedInException(
120                wfMessage( 'uploadstash-not-logged-in' )
121            );
122        }
123
124        if ( !isset( $this->fileMetadata[$key] ) ) {
125            if ( !$this->fetchFileMetadata( $key ) ) {
126                // If nothing was received, it's likely due to replication lag.
127                // Check the primary DB to see if the record is there.
128                $this->fetchFileMetadata( $key, DB_PRIMARY );
129            }
130
131            if ( !isset( $this->fileMetadata[$key] ) ) {
132                throw new UploadStashFileNotFoundException(
133                    wfMessage( 'uploadstash-file-not-found', $key )
134                );
135            }
136
137            // create $this->files[$key]
138            $this->initFile( $key );
139
140            // fetch fileprops
141            if (
142                isset( $this->fileMetadata[$key]['us_props'] ) && strlen( $this->fileMetadata[$key]['us_props'] )
143            ) {
144                $this->fileProps[$key] = unserialize( $this->fileMetadata[$key]['us_props'] );
145            } else { // b/c for rows with no us_props
146                wfDebug( __METHOD__ . " fetched props for $key from file" );
147                $path = $this->fileMetadata[$key]['us_path'];
148                $this->fileProps[$key] = $this->repo->getFileProps( $path );
149            }
150        }
151
152        if ( !$this->files[$key]->exists() ) {
153            wfDebug( __METHOD__ . " tried to get file at $key, but it doesn't exist" );
154            // @todo Is this not an UploadStashFileNotFoundException case?
155            throw new UploadStashBadPathException(
156                wfMessage( 'uploadstash-bad-path' )
157            );
158        }
159
160        if ( !$noAuth && $this->fileMetadata[$key]['us_user'] != $this->user->getId() ) {
161            throw new UploadStashWrongOwnerException(
162                wfMessage( 'uploadstash-wrong-owner', $key )
163            );
164        }
165
166        return $this->files[$key];
167    }
168
169    /**
170     * Getter for file metadata.
171     *
172     * @param string $key Key under which file information is stored
173     * @return array
174     */
175    public function getMetadata( $key ) {
176        $this->getFile( $key );
177
178        return $this->fileMetadata[$key];
179    }
180
181    /**
182     * Getter for fileProps
183     *
184     * @param string $key Key under which file information is stored
185     * @return array
186     */
187    public function getFileProps( $key ) {
188        $this->getFile( $key );
189
190        return $this->fileProps[$key];
191    }
192
193    /**
194     * Stash a file in a temp directory and record that we did this in the
195     * database, along with other metadata.
196     *
197     * @param string $path Path to file you want stashed
198     * @param string|null $sourceType The type of upload that generated this file
199     *   (currently, I believe, 'file' or null)
200     * @param array|null $fileProps File props or null to regenerate
201     * @throws UploadStashFileException
202     * @throws UploadStashNotLoggedInException
203     * @throws UploadStashBadPathException
204     * @return UploadStashFile|null File, or null on failure
205     */
206    public function stashFile( $path, $sourceType = null, $fileProps = null ) {
207        if ( !is_file( $path ) ) {
208            wfDebug( __METHOD__ . " tried to stash file at '$path', but it doesn't exist" );
209            throw new UploadStashBadPathException(
210                wfMessage( 'uploadstash-bad-path' )
211            );
212        }
213
214        // File props is expensive to generate for large files, so reuse if possible.
215        if ( !$fileProps ) {
216            $mwProps = new MWFileProps( MediaWikiServices::getInstance()->getMimeAnalyzer() );
217            $fileProps = $mwProps->getPropsFromPath( $path, true );
218        }
219        wfDebug( __METHOD__ . " stashing file at '$path'" );
220
221        // we will be initializing from some tmpnam files that don't have extensions.
222        // most of MediaWiki assumes all uploaded files have good extensions. So, we fix this.
223        $extension = self::getExtensionForPath( $path );
224        if ( !preg_match( "/\\.\\Q$extension\\E$/", $path ) ) {
225            $pathWithGoodExtension = "$path.$extension";
226        } else {
227            $pathWithGoodExtension = $path;
228        }
229
230        // If no key was supplied, make one.  a mysql insertid would be totally
231        // reasonable here, except that for historical reasons, the key is this
232        // random thing instead.  At least it's not guessable.
233        // Some things that when combined will make a suitably unique key.
234        // see: http://www.jwz.org/doc/mid.html
235        [ $usec, $sec ] = explode( ' ', microtime() );
236        $usec = substr( $usec, 2 );
237        $key = \Wikimedia\base_convert( $sec . $usec, 10, 36 ) . '.' .
238            \Wikimedia\base_convert( (string)mt_rand(), 10, 36 ) . '.' .
239            $this->user->getId() . '.' .
240            $extension;
241
242        $this->fileProps[$key] = $fileProps;
243
244        if ( !preg_match( self::KEY_FORMAT_REGEX, $key ) ) {
245            throw new UploadStashBadPathException(
246                wfMessage( 'uploadstash-bad-path-bad-format', $key )
247            );
248        }
249
250        wfDebug( __METHOD__ . " key for '$path': $key" );
251
252        // if not already in a temporary area, put it there
253        $storeStatus = $this->repo->storeTemp( basename( $pathWithGoodExtension ), $path );
254
255        if ( !$storeStatus->isOK() ) {
256            // It is a convention in MediaWiki to only return one error per API
257            // exception, even if multiple errors are available.[citation needed]
258            // Pick the "first" thing that was wrong, preferring errors to warnings.
259            // This is a bit lame, as we may have more info in the
260            // $storeStatus and we're throwing it away, but to fix it means
261            // redesigning API errors significantly.
262            // $storeStatus->value just contains the virtual URL (if anything)
263            // which is probably useless to the caller.
264            foreach ( $storeStatus->getMessages( 'error' ) as $msg ) {
265                throw new UploadStashFileException( $msg );
266            }
267            foreach ( $storeStatus->getMessages( 'warning' ) as $msg ) {
268                throw new UploadStashFileException( $msg );
269            }
270            // XXX: This isn't a real message, hopefully this case is unreachable
271            throw new UploadStashFileException( [ 'unknown', 'no error recorded' ] );
272        }
273        $stashPath = $storeStatus->value;
274
275        // fetch the current user ID
276        if ( !$this->user->isRegistered() ) {
277            throw new UploadStashNotLoggedInException(
278                wfMessage( 'uploadstash-not-logged-in' )
279            );
280        }
281
282        // insert the file metadata into the db.
283        wfDebug( __METHOD__ . " inserting $stashPath under $key" );
284        $dbw = $this->repo->getPrimaryDB();
285
286        $serializedFileProps = serialize( $fileProps );
287        if ( strlen( $serializedFileProps ) > self::MAX_US_PROPS_SIZE ) {
288            // Database is going to truncate this and make the field invalid.
289            // Prioritize important metadata over file handler metadata.
290            // File handler should be prepared to regenerate invalid metadata if needed.
291            $fileProps['metadata'] = [];
292            $serializedFileProps = serialize( $fileProps );
293        }
294
295        $insertRow = [
296            'us_user' => $this->user->getId(),
297            'us_key' => $key,
298            'us_orig_path' => $path,
299            'us_path' => $stashPath, // virtual URL
300            'us_props' => $dbw->encodeBlob( $serializedFileProps ),
301            'us_size' => $fileProps['size'],
302            'us_sha1' => $fileProps['sha1'],
303            'us_mime' => $fileProps['mime'],
304            'us_media_type' => $fileProps['media_type'],
305            'us_image_width' => $fileProps['width'],
306            'us_image_height' => $fileProps['height'],
307            'us_image_bits' => $fileProps['bits'],
308            'us_source_type' => $sourceType,
309            'us_timestamp' => $dbw->timestamp(),
310            'us_status' => 'finished'
311        ];
312
313        $dbw->newInsertQueryBuilder()
314            ->insertInto( 'uploadstash' )
315            ->row( $insertRow )
316            ->caller( __METHOD__ )->execute();
317
318        // store the insertid in the class variable so immediate retrieval
319        // (possibly laggy) isn't necessary.
320        $insertRow['us_id'] = $dbw->insertId();
321
322        $this->fileMetadata[$key] = $insertRow;
323
324        # create the UploadStashFile object for this file.
325        $this->initFile( $key );
326
327        return $this->getFile( $key );
328    }
329
330    /**
331     * Remove all files from the stash.
332     * Does not clean up files in the repo, just the record of them.
333     *
334     * @throws UploadStashNotLoggedInException
335     * @return bool Success
336     */
337    public function clear() {
338        if ( !$this->user->isRegistered() ) {
339            throw new UploadStashNotLoggedInException(
340                wfMessage( 'uploadstash-not-logged-in' )
341            );
342        }
343
344        wfDebug( __METHOD__ . ' clearing all rows for user ' . $this->user->getId() );
345        $dbw = $this->repo->getPrimaryDB();
346        $dbw->newDeleteQueryBuilder()
347            ->deleteFrom( 'uploadstash' )
348            ->where( [ 'us_user' => $this->user->getId() ] )
349            ->caller( __METHOD__ )->execute();
350
351        # destroy objects.
352        $this->files = [];
353        $this->fileMetadata = [];
354
355        return true;
356    }
357
358    /**
359     * Remove a particular file from the stash.  Also removes it from the repo.
360     *
361     * @param string $key
362     * @throws UploadStashWrongOwnerException
363     * @throws UploadStashNoSuchKeyException|UploadStashNotLoggedInException
364     * @return bool Success
365     */
366    public function removeFile( $key ) {
367        if ( !$this->user->isRegistered() ) {
368            throw new UploadStashNotLoggedInException(
369                wfMessage( 'uploadstash-not-logged-in' )
370            );
371        }
372
373        $dbw = $this->repo->getPrimaryDB();
374
375        // this is a cheap query. it runs on the primary DB so that this function
376        // still works when there's lag. It won't be called all that often.
377        $row = $dbw->newSelectQueryBuilder()
378            ->select( 'us_user' )
379            ->from( 'uploadstash' )
380            ->where( [ 'us_key' => $key ] )
381            ->caller( __METHOD__ )->fetchRow();
382
383        if ( !$row ) {
384            throw new UploadStashNoSuchKeyException(
385                wfMessage( 'uploadstash-no-such-key', $key )
386            );
387        }
388
389        if ( $row->us_user != $this->user->getId() ) {
390            throw new UploadStashWrongOwnerException(
391                wfMessage( 'uploadstash-wrong-owner', $key )
392            );
393        }
394
395        return $this->removeFileNoAuth( $key );
396    }
397
398    /**
399     * Remove a file (see removeFile), but doesn't check ownership first.
400     *
401     * @param string $key
402     * @return bool Success
403     */
404    public function removeFileNoAuth( $key ) {
405        wfDebug( __METHOD__ . " clearing row $key" );
406
407        // Ensure we have the UploadStashFile loaded for this key
408        $this->getFile( $key, true );
409
410        $dbw = $this->repo->getPrimaryDB();
411
412        $dbw->newDeleteQueryBuilder()
413            ->deleteFrom( 'uploadstash' )
414            ->where( [ 'us_key' => $key ] )
415            ->caller( __METHOD__ )->execute();
416
417        /** @todo Look into UnregisteredLocalFile and find out why the rv here is
418         *  sometimes wrong (false when file was removed). For now, ignore.
419         */
420        $this->files[$key]->remove();
421
422        unset( $this->files[$key] );
423        unset( $this->fileMetadata[$key] );
424
425        return true;
426    }
427
428    /**
429     * List all files in the stash.
430     *
431     * @throws UploadStashNotLoggedInException
432     * @return array|false
433     */
434    public function listFiles() {
435        if ( !$this->user->isRegistered() ) {
436            throw new UploadStashNotLoggedInException(
437                wfMessage( 'uploadstash-not-logged-in' )
438            );
439        }
440
441        $res = $this->repo->getReplicaDB()->newSelectQueryBuilder()
442            ->select( 'us_key' )
443            ->from( 'uploadstash' )
444            ->where( [ 'us_user' => $this->user->getId() ] )
445            ->caller( __METHOD__ )->fetchResultSet();
446
447        if ( $res->numRows() == 0 ) {
448            // nothing to do.
449            return false;
450        }
451
452        // finish the read before starting writes.
453        $keys = [];
454        foreach ( $res as $row ) {
455            $keys[] = $row->us_key;
456        }
457
458        return $keys;
459    }
460
461    /**
462     * Find or guess extension -- ensuring that our extension matches our MIME type.
463     * Since these files are constructed from php tempnames they may not start off
464     * with an extension.
465     * XXX this is somewhat redundant with the checks that ApiUpload.php does with incoming
466     * uploads versus the desired filename. Maybe we can get that passed to us...
467     * @param string $path
468     * @return string
469     */
470    public static function getExtensionForPath( $path ) {
471        $prohibitedFileExtensions = MediaWikiServices::getInstance()
472            ->getMainConfig()->get( MainConfigNames::ProhibitedFileExtensions );
473        // Does this have an extension?
474        $n = strrpos( $path, '.' );
475
476        if ( $n !== false ) {
477            $extension = $n ? substr( $path, $n + 1 ) : '';
478        } else {
479            // If not, assume that it should be related to the MIME type of the original file.
480            $magic = MediaWikiServices::getInstance()->getMimeAnalyzer();
481            $mimeType = $magic->guessMimeType( $path );
482            $extension = $magic->getExtensionFromMimeTypeOrNull( $mimeType ) ?? '';
483        }
484
485        $extension = File::normalizeExtension( $extension );
486        if ( in_array( $extension, $prohibitedFileExtensions ) ) {
487            // The file should already be checked for being evil.
488            // However, if somehow we got here, we definitely
489            // don't want to give it an extension of .php and
490            // put it in a web accessible directory.
491            return '';
492        }
493
494        return $extension;
495    }
496
497    /**
498     * Helper function: do the actual database query to fetch file metadata.
499     *
500     * @param string $key
501     * @param int $readFromDB Constant (default: DB_REPLICA)
502     * @return bool
503     */
504    protected function fetchFileMetadata( $key, $readFromDB = DB_REPLICA ) {
505        // populate $fileMetadata[$key]
506        if ( $readFromDB === DB_PRIMARY ) {
507            // sometimes reading from the primary DB is necessary, if there's replication lag.
508            $dbr = $this->repo->getPrimaryDB();
509        } else {
510            $dbr = $this->repo->getReplicaDB();
511        }
512
513        $row = $dbr->newSelectQueryBuilder()
514            ->select( [
515                'us_user', 'us_key', 'us_orig_path', 'us_path', 'us_props',
516                'us_size', 'us_sha1', 'us_mime', 'us_media_type',
517                'us_image_width', 'us_image_height', 'us_image_bits',
518                'us_source_type', 'us_timestamp', 'us_status',
519            ] )
520            ->from( 'uploadstash' )
521            ->where( [ 'us_key' => $key ] )
522            ->caller( __METHOD__ )->fetchRow();
523
524        if ( !is_object( $row ) ) {
525            // key wasn't present in the database. this will happen sometimes.
526            return false;
527        }
528
529        $this->fileMetadata[$key] = (array)$row;
530        $this->fileMetadata[$key]['us_props'] = $dbr->decodeBlob( $row->us_props );
531
532        return true;
533    }
534
535    /**
536     * Helper function: Initialize the UploadStashFile for a given file.
537     *
538     * @param string $key Key under which to store the object
539     * @throws UploadStashZeroLengthFileException
540     * @return bool
541     */
542    protected function initFile( $key ) {
543        $file = new UploadStashFile(
544            $this->repo,
545            $this->fileMetadata[$key]['us_path'],
546            $key,
547            $this->fileMetadata[$key]['us_sha1'],
548            $this->fileMetadata[$key]['us_mime'] ?? false
549        );
550        if ( $file->getSize() === 0 ) {
551            throw new UploadStashZeroLengthFileException(
552                wfMessage( 'uploadstash-zero-length' )
553            );
554        }
555        $this->files[$key] = $file;
556
557        return true;
558    }
559}
560
561/** @deprecated class alias since 1.46 */
562class_alias( UploadStash::class, 'UploadStash' );