Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 205
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 / 205
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 / 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.[citation needed]
260            // Pick the "first" thing that was wrong, preferring errors to warnings.
261            // 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            foreach ( $storeStatus->getMessages( 'error' ) as $msg ) {
267                throw new UploadStashFileException( $msg );
268            }
269            foreach ( $storeStatus->getMessages( 'warning' ) as $msg ) {
270                throw new UploadStashFileException( $msg );
271            }
272            // XXX: This isn't a real message, hopefully this case is unreachable
273            throw new UploadStashFileException( [ 'unknown', 'no error recorded' ] );
274        }
275        $stashPath = $storeStatus->value;
276
277        // fetch the current user ID
278        if ( !$this->user->isRegistered() ) {
279            throw new UploadStashNotLoggedInException(
280                wfMessage( 'uploadstash-not-logged-in' )
281            );
282        }
283
284        // insert the file metadata into the db.
285        wfDebug( __METHOD__ . " inserting $stashPath under $key" );
286        $dbw = $this->repo->getPrimaryDB();
287
288        $serializedFileProps = serialize( $fileProps );
289        if ( strlen( $serializedFileProps ) > self::MAX_US_PROPS_SIZE ) {
290            // Database is going to truncate this and make the field invalid.
291            // Prioritize important metadata over file handler metadata.
292            // File handler should be prepared to regenerate invalid metadata if needed.
293            $fileProps['metadata'] = [];
294            $serializedFileProps = serialize( $fileProps );
295        }
296
297        $insertRow = [
298            'us_user' => $this->user->getId(),
299            'us_key' => $key,
300            'us_orig_path' => $path,
301            'us_path' => $stashPath, // virtual URL
302            'us_props' => $dbw->encodeBlob( $serializedFileProps ),
303            'us_size' => $fileProps['size'],
304            'us_sha1' => $fileProps['sha1'],
305            'us_mime' => $fileProps['mime'],
306            'us_media_type' => $fileProps['media_type'],
307            'us_image_width' => $fileProps['width'],
308            'us_image_height' => $fileProps['height'],
309            'us_image_bits' => $fileProps['bits'],
310            'us_source_type' => $sourceType,
311            'us_timestamp' => $dbw->timestamp(),
312            'us_status' => 'finished'
313        ];
314
315        $dbw->newInsertQueryBuilder()
316            ->insertInto( 'uploadstash' )
317            ->row( $insertRow )
318            ->caller( __METHOD__ )->execute();
319
320        // store the insertid in the class variable so immediate retrieval
321        // (possibly laggy) isn't necessary.
322        $insertRow['us_id'] = $dbw->insertId();
323
324        $this->fileMetadata[$key] = $insertRow;
325
326        # create the UploadStashFile object for this file.
327        $this->initFile( $key );
328
329        return $this->getFile( $key );
330    }
331
332    /**
333     * Remove all files from the stash.
334     * Does not clean up files in the repo, just the record of them.
335     *
336     * @throws UploadStashNotLoggedInException
337     * @return bool Success
338     */
339    public function clear() {
340        if ( !$this->user->isRegistered() ) {
341            throw new UploadStashNotLoggedInException(
342                wfMessage( 'uploadstash-not-logged-in' )
343            );
344        }
345
346        wfDebug( __METHOD__ . ' clearing all rows for user ' . $this->user->getId() );
347        $dbw = $this->repo->getPrimaryDB();
348        $dbw->newDeleteQueryBuilder()
349            ->deleteFrom( 'uploadstash' )
350            ->where( [ 'us_user' => $this->user->getId() ] )
351            ->caller( __METHOD__ )->execute();
352
353        # destroy objects.
354        $this->files = [];
355        $this->fileMetadata = [];
356
357        return true;
358    }
359
360    /**
361     * Remove a particular file from the stash.  Also removes it from the repo.
362     *
363     * @param string $key
364     * @throws UploadStashNoSuchKeyException|UploadStashNotLoggedInException
365     * @throws UploadStashWrongOwnerException
366     * @return bool Success
367     */
368    public function removeFile( $key ) {
369        if ( !$this->user->isRegistered() ) {
370            throw new UploadStashNotLoggedInException(
371                wfMessage( 'uploadstash-not-logged-in' )
372            );
373        }
374
375        $dbw = $this->repo->getPrimaryDB();
376
377        // this is a cheap query. it runs on the primary DB so that this function
378        // still works when there's lag. It won't be called all that often.
379        $row = $dbw->newSelectQueryBuilder()
380            ->select( 'us_user' )
381            ->from( 'uploadstash' )
382            ->where( [ 'us_key' => $key ] )
383            ->caller( __METHOD__ )->fetchRow();
384
385        if ( !$row ) {
386            throw new UploadStashNoSuchKeyException(
387                wfMessage( 'uploadstash-no-such-key', $key )
388            );
389        }
390
391        if ( $row->us_user != $this->user->getId() ) {
392            throw new UploadStashWrongOwnerException(
393                wfMessage( 'uploadstash-wrong-owner', $key )
394            );
395        }
396
397        return $this->removeFileNoAuth( $key );
398    }
399
400    /**
401     * Remove a file (see removeFile), but doesn't check ownership first.
402     *
403     * @param string $key
404     * @return bool Success
405     */
406    public function removeFileNoAuth( $key ) {
407        wfDebug( __METHOD__ . " clearing row $key" );
408
409        // Ensure we have the UploadStashFile loaded for this key
410        $this->getFile( $key, true );
411
412        $dbw = $this->repo->getPrimaryDB();
413
414        $dbw->newDeleteQueryBuilder()
415            ->deleteFrom( 'uploadstash' )
416            ->where( [ 'us_key' => $key ] )
417            ->caller( __METHOD__ )->execute();
418
419        /** @todo Look into UnregisteredLocalFile and find out why the rv here is
420         *  sometimes wrong (false when file was removed). For now, ignore.
421         */
422        $this->files[$key]->remove();
423
424        unset( $this->files[$key] );
425        unset( $this->fileMetadata[$key] );
426
427        return true;
428    }
429
430    /**
431     * List all files in the stash.
432     *
433     * @throws UploadStashNotLoggedInException
434     * @return array|false
435     */
436    public function listFiles() {
437        if ( !$this->user->isRegistered() ) {
438            throw new UploadStashNotLoggedInException(
439                wfMessage( 'uploadstash-not-logged-in' )
440            );
441        }
442
443        $res = $this->repo->getReplicaDB()->newSelectQueryBuilder()
444            ->select( 'us_key' )
445            ->from( 'uploadstash' )
446            ->where( [ 'us_user' => $this->user->getId() ] )
447            ->caller( __METHOD__ )->fetchResultSet();
448
449        if ( $res->numRows() == 0 ) {
450            // nothing to do.
451            return false;
452        }
453
454        // finish the read before starting writes.
455        $keys = [];
456        foreach ( $res as $row ) {
457            $keys[] = $row->us_key;
458        }
459
460        return $keys;
461    }
462
463    /**
464     * Find or guess extension -- ensuring that our extension matches our MIME type.
465     * Since these files are constructed from php tempnames they may not start off
466     * with an extension.
467     * XXX this is somewhat redundant with the checks that ApiUpload.php does with incoming
468     * uploads versus the desired filename. Maybe we can get that passed to us...
469     * @param string $path
470     * @return string
471     */
472    public static function getExtensionForPath( $path ) {
473        $prohibitedFileExtensions = MediaWikiServices::getInstance()
474            ->getMainConfig()->get( MainConfigNames::ProhibitedFileExtensions );
475        // Does this have an extension?
476        $n = strrpos( $path, '.' );
477
478        if ( $n !== false ) {
479            $extension = $n ? substr( $path, $n + 1 ) : '';
480        } else {
481            // If not, assume that it should be related to the MIME type of the original file.
482            $magic = MediaWikiServices::getInstance()->getMimeAnalyzer();
483            $mimeType = $magic->guessMimeType( $path );
484            $extension = $magic->getExtensionFromMimeTypeOrNull( $mimeType ) ?? '';
485        }
486
487        $extension = File::normalizeExtension( $extension );
488        if ( in_array( $extension, $prohibitedFileExtensions ) ) {
489            // The file should already be checked for being evil.
490            // However, if somehow we got here, we definitely
491            // don't want to give it an extension of .php and
492            // put it in a web accessible directory.
493            return '';
494        }
495
496        return $extension;
497    }
498
499    /**
500     * Helper function: do the actual database query to fetch file metadata.
501     *
502     * @param string $key
503     * @param int $readFromDB Constant (default: DB_REPLICA)
504     * @return bool
505     */
506    protected function fetchFileMetadata( $key, $readFromDB = DB_REPLICA ) {
507        // populate $fileMetadata[$key]
508        if ( $readFromDB === DB_PRIMARY ) {
509            // sometimes reading from the primary DB is necessary, if there's replication lag.
510            $dbr = $this->repo->getPrimaryDB();
511        } else {
512            $dbr = $this->repo->getReplicaDB();
513        }
514
515        $row = $dbr->newSelectQueryBuilder()
516            ->select( [
517                'us_user', 'us_key', 'us_orig_path', 'us_path', 'us_props',
518                'us_size', 'us_sha1', 'us_mime', 'us_media_type',
519                'us_image_width', 'us_image_height', 'us_image_bits',
520                'us_source_type', 'us_timestamp', 'us_status',
521            ] )
522            ->from( 'uploadstash' )
523            ->where( [ 'us_key' => $key ] )
524            ->caller( __METHOD__ )->fetchRow();
525
526        if ( !is_object( $row ) ) {
527            // key wasn't present in the database. this will happen sometimes.
528            return false;
529        }
530
531        $this->fileMetadata[$key] = (array)$row;
532        $this->fileMetadata[$key]['us_props'] = $dbr->decodeBlob( $row->us_props );
533
534        return true;
535    }
536
537    /**
538     * Helper function: Initialize the UploadStashFile for a given file.
539     *
540     * @param string $key Key under which to store the object
541     * @throws UploadStashZeroLengthFileException
542     * @return bool
543     */
544    protected function initFile( $key ) {
545        $file = new UploadStashFile(
546            $this->repo,
547            $this->fileMetadata[$key]['us_path'],
548            $key,
549            $this->fileMetadata[$key]['us_sha1']
550        );
551        if ( $file->getSize() === 0 ) {
552            throw new UploadStashZeroLengthFileException(
553                wfMessage( 'uploadstash-zero-length' )
554            );
555        }
556        $this->files[$key] = $file;
557
558        return true;
559    }
560}