MediaWiki  master
UploadStash.php
Go to the documentation of this file.
1 <?php
26 
57 class UploadStash {
58  // Format of the key for files -- has to be suitable as a filename itself (e.g. ab12cd34ef.jpg)
59  public const KEY_FORMAT_REGEX = '/^[\w\-\.]+\.\w*$/';
60  private const MAX_US_PROPS_SIZE = 65535;
61 
68  public $repo;
69 
71  protected $files = [];
72 
74  protected $fileMetadata = [];
75 
77  protected $fileProps = [];
78 
80  private $user;
81 
90  public function __construct( FileRepo $repo, UserIdentity $user = null ) {
91  // this might change based on wiki's configuration.
92  $this->repo = $repo;
93 
94  // if a user was passed, use it. otherwise, attempt to use the global request context.
95  // this keeps FileRepo from breaking when it creates an UploadStash object
96  $this->user = $user ?? RequestContext::getMain()->getUser();
97  }
98 
112  public function getFile( $key, $noAuth = false ) {
113  if ( !preg_match( self::KEY_FORMAT_REGEX, $key ) ) {
114  throw new UploadStashBadPathException(
115  wfMessage( 'uploadstash-bad-path-bad-format', $key )
116  );
117  }
118 
119  if ( !$noAuth && !$this->user->isRegistered() ) {
121  wfMessage( 'uploadstash-not-logged-in' )
122  );
123  }
124 
125  if ( !isset( $this->fileMetadata[$key] ) ) {
126  if ( !$this->fetchFileMetadata( $key ) ) {
127  // If nothing was received, it's likely due to replication lag.
128  // Check the primary DB to see if the record is there.
129  $this->fetchFileMetadata( $key, DB_PRIMARY );
130  }
131 
132  if ( !isset( $this->fileMetadata[$key] ) ) {
134  wfMessage( 'uploadstash-file-not-found', $key )
135  );
136  }
137 
138  // create $this->files[$key]
139  $this->initFile( $key );
140 
141  // fetch fileprops
142  if (
143  isset( $this->fileMetadata[$key]['us_props'] ) && strlen( $this->fileMetadata[$key]['us_props'] )
144  ) {
145  $this->fileProps[$key] = unserialize( $this->fileMetadata[$key]['us_props'] );
146  } else { // b/c for rows with no us_props
147  wfDebug( __METHOD__ . " fetched props for $key from file" );
148  $path = $this->fileMetadata[$key]['us_path'];
149  $this->fileProps[$key] = $this->repo->getFileProps( $path );
150  }
151  }
152 
153  if ( !$this->files[$key]->exists() ) {
154  wfDebug( __METHOD__ . " tried to get file at $key, but it doesn't exist" );
155  // @todo Is this not an UploadStashFileNotFoundException case?
156  throw new UploadStashBadPathException(
157  wfMessage( 'uploadstash-bad-path' )
158  );
159  }
160 
161  if ( !$noAuth && $this->fileMetadata[$key]['us_user'] != $this->user->getId() ) {
163  wfMessage( 'uploadstash-wrong-owner', $key )
164  );
165  }
166 
167  return $this->files[$key];
168  }
169 
176  public function getMetadata( $key ) {
177  $this->getFile( $key );
178 
179  return $this->fileMetadata[$key];
180  }
181 
188  public function getFileProps( $key ) {
189  $this->getFile( $key );
190 
191  return $this->fileProps[$key];
192  }
193 
206  public function stashFile( $path, $sourceType = 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  $mwProps = new MWFileProps( MediaWikiServices::getInstance()->getMimeAnalyzer() );
215  $fileProps = $mwProps->getPropsFromPath( $path, true );
216  wfDebug( __METHOD__ . " stashing file at '$path'" );
217 
218  // we will be initializing from some tmpnam files that don't have extensions.
219  // most of MediaWiki assumes all uploaded files have good extensions. So, we fix this.
220  $extension = self::getExtensionForPath( $path );
221  if ( !preg_match( "/\\.\\Q$extension\\E$/", $path ) ) {
222  $pathWithGoodExtension = "$path.$extension";
223  } else {
224  $pathWithGoodExtension = $path;
225  }
226 
227  // If no key was supplied, make one. a mysql insertid would be totally
228  // reasonable here, except that for historical reasons, the key is this
229  // random thing instead. At least it's not guessable.
230  // Some things that when combined will make a suitably unique key.
231  // see: http://www.jwz.org/doc/mid.html
232  [ $usec, $sec ] = explode( ' ', microtime() );
233  $usec = substr( $usec, 2 );
234  $key = Wikimedia\base_convert( $sec . $usec, 10, 36 ) . '.' .
235  Wikimedia\base_convert( (string)mt_rand(), 10, 36 ) . '.' .
236  $this->user->getId() . '.' .
237  $extension;
238 
239  $this->fileProps[$key] = $fileProps;
240 
241  if ( !preg_match( self::KEY_FORMAT_REGEX, $key ) ) {
242  throw new UploadStashBadPathException(
243  wfMessage( 'uploadstash-bad-path-bad-format', $key )
244  );
245  }
246 
247  wfDebug( __METHOD__ . " key for '$path': $key" );
248 
249  // if not already in a temporary area, put it there
250  $storeStatus = $this->repo->storeTemp( basename( $pathWithGoodExtension ), $path );
251 
252  if ( !$storeStatus->isOK() ) {
253  // It is a convention in MediaWiki to only return one error per API
254  // exception, even if multiple errors are available. We use reset()
255  // to pick the "first" thing that was wrong, preferring errors to
256  // warnings. This is a bit lame, as we may have more info in the
257  // $storeStatus and we're throwing it away, but to fix it means
258  // redesigning API errors significantly.
259  // $storeStatus->value just contains the virtual URL (if anything)
260  // which is probably useless to the caller.
261  $error = $storeStatus->getErrorsArray();
262  $error = reset( $error );
263  if ( !count( $error ) ) {
264  $error = $storeStatus->getWarningsArray();
265  $error = reset( $error );
266  if ( !count( $error ) ) {
267  $error = [ 'unknown', 'no error recorded' ];
268  }
269  }
270  // At this point, $error should contain the single "most important"
271  // error, plus any parameters.
272  $errorMsg = array_shift( $error );
273  throw new UploadStashFileException( wfMessage( $errorMsg, $error ) );
274  }
275  $stashPath = $storeStatus->value;
276 
277  // fetch the current user ID
278  if ( !$this->user->isRegistered() ) {
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 
339  public function clear() {
340  if ( !$this->user->isRegistered() ) {
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 
368  public function removeFile( $key ) {
369  if ( !$this->user->isRegistered() ) {
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 ) {
387  wfMessage( 'uploadstash-no-such-key', $key )
388  );
389  }
390 
391  if ( $row->us_user != $this->user->getId() ) {
393  wfMessage( 'uploadstash-wrong-owner', $key )
394  );
395  }
396 
397  return $this->removeFileNoAuth( $key );
398  }
399 
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 
422  $this->files[$key]->remove();
423 
424  unset( $this->files[$key] );
425  unset( $this->fileMetadata[$key] );
426 
427  return true;
428  }
429 
436  public function listFiles() {
437  if ( !$this->user->isRegistered() ) {
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 ( !is_object( $res ) || $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 
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 
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 
544  protected function initFile( $key ) {
545  $file = new UploadStashFile( $this->repo, $this->fileMetadata[$key]['us_path'], $key );
546  if ( $file->getSize() === 0 ) {
548  wfMessage( 'uploadstash-zero-length' )
549  );
550  }
551  $this->files[$key] = $file;
552 
553  return true;
554  }
555 }
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Base class for file repositories.
Definition: FileRepo.php:50
static normalizeExtension( $extension)
Normalize a file extension to the common form, making it lowercase and checking some synonyms,...
Definition: File.php:258
MimeMagic helper wrapper.
Definition: MWFileProps.php:28
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
static getMain()
Get the RequestContext object associated with the main request.
UploadStash is intended to accomplish a few things:
Definition: UploadStash.php:57
static getExtensionForPath( $path)
Find or guess extension – ensuring that our extension matches our MIME type.
removeFile( $key)
Remove a particular file from the stash.
const KEY_FORMAT_REGEX
Definition: UploadStash.php:59
fetchFileMetadata( $key, $readFromDB=DB_REPLICA)
Helper function: do the actual database query to fetch file metadata.
getFileProps( $key)
Getter for fileProps.
stashFile( $path, $sourceType=null)
Stash a file in a temp directory and record that we did this in the database, along with other metada...
clear()
Remove all files from the stash.
array $fileMetadata
cache of the file metadata that's stored in the database
Definition: UploadStash.php:74
array $fileProps
fileprops cache
Definition: UploadStash.php:77
listFiles()
List all files in the stash.
__construct(FileRepo $repo, UserIdentity $user=null)
Represents a temporary filestore, with metadata in the database.
Definition: UploadStash.php:90
getMetadata( $key)
Getter for file metadata.
removeFileNoAuth( $key)
Remove a file (see removeFile), but doesn't check ownership first.
initFile( $key)
Helper function: Initialize the UploadStashFile for a given file.
getFile( $key, $noAuth=false)
Get a file and its metadata from the stash.
LocalRepo $repo
repository that this uses to store temp files public because we sometimes need to get a LocalFile wit...
Definition: UploadStash.php:68
array $files
array of initialized repo objects
Definition: UploadStash.php:71
Interface for objects representing user identity.
const DB_REPLICA
Definition: defines.php:26
const DB_PRIMARY
Definition: defines.php:28
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42