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