]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - includes/filerepo/FileRepo.php
MediaWiki 1.30.2
[autoinstalls/mediawiki.git] / includes / filerepo / FileRepo.php
1 <?php
2 /**
3  * @defgroup FileRepo File Repository
4  *
5  * @brief This module handles how MediaWiki interacts with filesystems.
6  *
7  * @details
8  */
9
10 /**
11  * Base code for file repositories.
12  *
13  * This program is free software; you can redistribute it and/or modify
14  * it under the terms of the GNU General Public License as published by
15  * the Free Software Foundation; either version 2 of the License, or
16  * (at your option) any later version.
17  *
18  * This program is distributed in the hope that it will be useful,
19  * but WITHOUT ANY WARRANTY; without even the implied warranty of
20  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21  * GNU General Public License for more details.
22  *
23  * You should have received a copy of the GNU General Public License along
24  * with this program; if not, write to the Free Software Foundation, Inc.,
25  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
26  * http://www.gnu.org/copyleft/gpl.html
27  *
28  * @file
29  * @ingroup FileRepo
30  */
31
32 /**
33  * Base class for file repositories
34  *
35  * @ingroup FileRepo
36  */
37 class FileRepo {
38         const DELETE_SOURCE = 1;
39         const OVERWRITE = 2;
40         const OVERWRITE_SAME = 4;
41         const SKIP_LOCKING = 8;
42
43         const NAME_AND_TIME_ONLY = 1;
44
45         /** @var bool Whether to fetch commons image description pages and display
46          *    them on the local wiki */
47         public $fetchDescription;
48
49         /** @var int */
50         public $descriptionCacheExpiry;
51
52         /** @var bool */
53         protected $hasSha1Storage = false;
54
55         /** @var bool */
56         protected $supportsSha1URLs = false;
57
58         /** @var FileBackend */
59         protected $backend;
60
61         /** @var array Map of zones to config */
62         protected $zones = [];
63
64         /** @var string URL of thumb.php */
65         protected $thumbScriptUrl;
66
67         /** @var bool Whether to skip media file transformation on parse and rely
68          *    on a 404 handler instead. */
69         protected $transformVia404;
70
71         /** @var string URL of image description pages, e.g.
72          *    https://en.wikipedia.org/wiki/File:
73          */
74         protected $descBaseUrl;
75
76         /** @var string URL of the MediaWiki installation, equivalent to
77          *    $wgScriptPath, e.g. https://en.wikipedia.org/w
78          */
79         protected $scriptDirUrl;
80
81         /** @var string Script extension of the MediaWiki installation, equivalent
82          *    to the old $wgScriptExtension, e.g. .php5 defaults to .php */
83         protected $scriptExtension;
84
85         /** @var string Equivalent to $wgArticlePath, e.g. https://en.wikipedia.org/wiki/$1 */
86         protected $articleUrl;
87
88         /** @var bool Equivalent to $wgCapitalLinks (or $wgCapitalLinkOverrides[NS_FILE],
89          *    determines whether filenames implicitly start with a capital letter.
90          *    The current implementation may give incorrect description page links
91          *    when the local $wgCapitalLinks and initialCapital are mismatched.
92          */
93         protected $initialCapital;
94
95         /** @var string May be 'paranoid' to remove all parameters from error
96          *    messages, 'none' to leave the paths in unchanged, or 'simple' to
97          *    replace paths with placeholders. Default for LocalRepo is
98          *    'simple'.
99          */
100         protected $pathDisclosureProtection = 'simple';
101
102         /** @var string|false Public zone URL. */
103         protected $url;
104
105         /** @var string The base thumbnail URL. Defaults to "<url>/thumb". */
106         protected $thumbUrl;
107
108         /** @var int The number of directory levels for hash-based division of files */
109         protected $hashLevels;
110
111         /** @var int The number of directory levels for hash-based division of deleted files */
112         protected $deletedHashLevels;
113
114         /** @var int File names over this size will use the short form of thumbnail
115          *    names. Short thumbnail names only have the width, parameters, and the
116          *    extension.
117          */
118         protected $abbrvThreshold;
119
120         /** @var string The URL of the repo's favicon, if any */
121         protected $favicon;
122
123         /** @var bool Whether all zones should be private (e.g. private wiki repo) */
124         protected $isPrivate;
125
126         /** @var array callable Override these in the base class */
127         protected $fileFactory = [ 'UnregisteredLocalFile', 'newFromTitle' ];
128         /** @var array callable|bool Override these in the base class */
129         protected $oldFileFactory = false;
130         /** @var array callable|bool Override these in the base class */
131         protected $fileFactoryKey = false;
132         /** @var array callable|bool Override these in the base class */
133         protected $oldFileFactoryKey = false;
134
135         /**
136          * @param array|null $info
137          * @throws MWException
138          */
139         public function __construct( array $info = null ) {
140                 // Verify required settings presence
141                 if (
142                         $info === null
143                         || !array_key_exists( 'name', $info )
144                         || !array_key_exists( 'backend', $info )
145                 ) {
146                         throw new MWException( __CLASS__ .
147                                 " requires an array of options having both 'name' and 'backend' keys.\n" );
148                 }
149
150                 // Required settings
151                 $this->name = $info['name'];
152                 if ( $info['backend'] instanceof FileBackend ) {
153                         $this->backend = $info['backend']; // useful for testing
154                 } else {
155                         $this->backend = FileBackendGroup::singleton()->get( $info['backend'] );
156                 }
157
158                 // Optional settings that can have no value
159                 $optionalSettings = [
160                         'descBaseUrl', 'scriptDirUrl', 'articleUrl', 'fetchDescription',
161                         'thumbScriptUrl', 'pathDisclosureProtection', 'descriptionCacheExpiry',
162                         'scriptExtension', 'favicon'
163                 ];
164                 foreach ( $optionalSettings as $var ) {
165                         if ( isset( $info[$var] ) ) {
166                                 $this->$var = $info[$var];
167                         }
168                 }
169
170                 // Optional settings that have a default
171                 $this->initialCapital = isset( $info['initialCapital'] )
172                         ? $info['initialCapital']
173                         : MWNamespace::isCapitalized( NS_FILE );
174                 $this->url = isset( $info['url'] )
175                         ? $info['url']
176                         : false; // a subclass may set the URL (e.g. ForeignAPIRepo)
177                 if ( isset( $info['thumbUrl'] ) ) {
178                         $this->thumbUrl = $info['thumbUrl'];
179                 } else {
180                         $this->thumbUrl = $this->url ? "{$this->url}/thumb" : false;
181                 }
182                 $this->hashLevels = isset( $info['hashLevels'] )
183                         ? $info['hashLevels']
184                         : 2;
185                 $this->deletedHashLevels = isset( $info['deletedHashLevels'] )
186                         ? $info['deletedHashLevels']
187                         : $this->hashLevels;
188                 $this->transformVia404 = !empty( $info['transformVia404'] );
189                 $this->abbrvThreshold = isset( $info['abbrvThreshold'] )
190                         ? $info['abbrvThreshold']
191                         : 255;
192                 $this->isPrivate = !empty( $info['isPrivate'] );
193                 // Give defaults for the basic zones...
194                 $this->zones = isset( $info['zones'] ) ? $info['zones'] : [];
195                 foreach ( [ 'public', 'thumb', 'transcoded', 'temp', 'deleted' ] as $zone ) {
196                         if ( !isset( $this->zones[$zone]['container'] ) ) {
197                                 $this->zones[$zone]['container'] = "{$this->name}-{$zone}";
198                         }
199                         if ( !isset( $this->zones[$zone]['directory'] ) ) {
200                                 $this->zones[$zone]['directory'] = '';
201                         }
202                         if ( !isset( $this->zones[$zone]['urlsByExt'] ) ) {
203                                 $this->zones[$zone]['urlsByExt'] = [];
204                         }
205                 }
206
207                 $this->supportsSha1URLs = !empty( $info['supportsSha1URLs'] );
208         }
209
210         /**
211          * Get the file backend instance. Use this function wisely.
212          *
213          * @return FileBackend
214          */
215         public function getBackend() {
216                 return $this->backend;
217         }
218
219         /**
220          * Get an explanatory message if this repo is read-only.
221          * This checks if an administrator disabled writes to the backend.
222          *
223          * @return string|bool Returns false if the repo is not read-only
224          */
225         public function getReadOnlyReason() {
226                 return $this->backend->getReadOnlyReason();
227         }
228
229         /**
230          * Check if a single zone or list of zones is defined for usage
231          *
232          * @param array $doZones Only do a particular zones
233          * @throws MWException
234          * @return Status
235          */
236         protected function initZones( $doZones = [] ) {
237                 $status = $this->newGood();
238                 foreach ( (array)$doZones as $zone ) {
239                         $root = $this->getZonePath( $zone );
240                         if ( $root === null ) {
241                                 throw new MWException( "No '$zone' zone defined in the {$this->name} repo." );
242                         }
243                 }
244
245                 return $status;
246         }
247
248         /**
249          * Determine if a string is an mwrepo:// URL
250          *
251          * @param string $url
252          * @return bool
253          */
254         public static function isVirtualUrl( $url ) {
255                 return substr( $url, 0, 9 ) == 'mwrepo://';
256         }
257
258         /**
259          * Get a URL referring to this repository, with the private mwrepo protocol.
260          * The suffix, if supplied, is considered to be unencoded, and will be
261          * URL-encoded before being returned.
262          *
263          * @param string|bool $suffix
264          * @return string
265          */
266         public function getVirtualUrl( $suffix = false ) {
267                 $path = 'mwrepo://' . $this->name;
268                 if ( $suffix !== false ) {
269                         $path .= '/' . rawurlencode( $suffix );
270                 }
271
272                 return $path;
273         }
274
275         /**
276          * Get the URL corresponding to one of the four basic zones
277          *
278          * @param string $zone One of: public, deleted, temp, thumb
279          * @param string|null $ext Optional file extension
280          * @return string|bool
281          */
282         public function getZoneUrl( $zone, $ext = null ) {
283                 if ( in_array( $zone, [ 'public', 'thumb', 'transcoded' ] ) ) {
284                         // standard public zones
285                         if ( $ext !== null && isset( $this->zones[$zone]['urlsByExt'][$ext] ) ) {
286                                 // custom URL for extension/zone
287                                 return $this->zones[$zone]['urlsByExt'][$ext];
288                         } elseif ( isset( $this->zones[$zone]['url'] ) ) {
289                                 // custom URL for zone
290                                 return $this->zones[$zone]['url'];
291                         }
292                 }
293                 switch ( $zone ) {
294                         case 'public':
295                                 return $this->url;
296                         case 'temp':
297                         case 'deleted':
298                                 return false; // no public URL
299                         case 'thumb':
300                                 return $this->thumbUrl;
301                         case 'transcoded':
302                                 return "{$this->url}/transcoded";
303                         default:
304                                 return false;
305                 }
306         }
307
308         /**
309          * @return bool Whether non-ASCII path characters are allowed
310          */
311         public function backendSupportsUnicodePaths() {
312                 return (bool)( $this->getBackend()->getFeatures() & FileBackend::ATTR_UNICODE_PATHS );
313         }
314
315         /**
316          * Get the backend storage path corresponding to a virtual URL.
317          * Use this function wisely.
318          *
319          * @param string $url
320          * @throws MWException
321          * @return string
322          */
323         public function resolveVirtualUrl( $url ) {
324                 if ( substr( $url, 0, 9 ) != 'mwrepo://' ) {
325                         throw new MWException( __METHOD__ . ': unknown protocol' );
326                 }
327                 $bits = explode( '/', substr( $url, 9 ), 3 );
328                 if ( count( $bits ) != 3 ) {
329                         throw new MWException( __METHOD__ . ": invalid mwrepo URL: $url" );
330                 }
331                 list( $repo, $zone, $rel ) = $bits;
332                 if ( $repo !== $this->name ) {
333                         throw new MWException( __METHOD__ . ": fetching from a foreign repo is not supported" );
334                 }
335                 $base = $this->getZonePath( $zone );
336                 if ( !$base ) {
337                         throw new MWException( __METHOD__ . ": invalid zone: $zone" );
338                 }
339
340                 return $base . '/' . rawurldecode( $rel );
341         }
342
343         /**
344          * The the storage container and base path of a zone
345          *
346          * @param string $zone
347          * @return array (container, base path) or (null, null)
348          */
349         protected function getZoneLocation( $zone ) {
350                 if ( !isset( $this->zones[$zone] ) ) {
351                         return [ null, null ]; // bogus
352                 }
353
354                 return [ $this->zones[$zone]['container'], $this->zones[$zone]['directory'] ];
355         }
356
357         /**
358          * Get the storage path corresponding to one of the zones
359          *
360          * @param string $zone
361          * @return string|null Returns null if the zone is not defined
362          */
363         public function getZonePath( $zone ) {
364                 list( $container, $base ) = $this->getZoneLocation( $zone );
365                 if ( $container === null || $base === null ) {
366                         return null;
367                 }
368                 $backendName = $this->backend->getName();
369                 if ( $base != '' ) { // may not be set
370                         $base = "/{$base}";
371                 }
372
373                 return "mwstore://$backendName/{$container}{$base}";
374         }
375
376         /**
377          * Create a new File object from the local repository
378          *
379          * @param Title|string $title Title object or string
380          * @param bool|string $time Time at which the image was uploaded. If this
381          *   is specified, the returned object will be an instance of the
382          *   repository's old file class instead of a current file. Repositories
383          *   not supporting version control should return false if this parameter
384          *   is set.
385          * @return File|null A File, or null if passed an invalid Title
386          */
387         public function newFile( $title, $time = false ) {
388                 $title = File::normalizeTitle( $title );
389                 if ( !$title ) {
390                         return null;
391                 }
392                 if ( $time ) {
393                         if ( $this->oldFileFactory ) {
394                                 return call_user_func( $this->oldFileFactory, $title, $this, $time );
395                         } else {
396                                 return null;
397                         }
398                 } else {
399                         return call_user_func( $this->fileFactory, $title, $this );
400                 }
401         }
402
403         /**
404          * Find an instance of the named file created at the specified time
405          * Returns false if the file does not exist. Repositories not supporting
406          * version control should return false if the time is specified.
407          *
408          * @param Title|string $title Title object or string
409          * @param array $options Associative array of options:
410          *   time:           requested time for a specific file version, or false for the
411          *                   current version. An image object will be returned which was
412          *                   created at the specified time (which may be archived or current).
413          *   ignoreRedirect: If true, do not follow file redirects
414          *   private:        If true, return restricted (deleted) files if the current
415          *                   user is allowed to view them. Otherwise, such files will not
416          *                   be found. If a User object, use that user instead of the current.
417          *   latest:         If true, load from the latest available data into File objects
418          * @return File|bool False on failure
419          */
420         public function findFile( $title, $options = [] ) {
421                 $title = File::normalizeTitle( $title );
422                 if ( !$title ) {
423                         return false;
424                 }
425                 if ( isset( $options['bypassCache'] ) ) {
426                         $options['latest'] = $options['bypassCache']; // b/c
427                 }
428                 $time = isset( $options['time'] ) ? $options['time'] : false;
429                 $flags = !empty( $options['latest'] ) ? File::READ_LATEST : 0;
430                 # First try the current version of the file to see if it precedes the timestamp
431                 $img = $this->newFile( $title );
432                 if ( !$img ) {
433                         return false;
434                 }
435                 $img->load( $flags );
436                 if ( $img->exists() && ( !$time || $img->getTimestamp() == $time ) ) {
437                         return $img;
438                 }
439                 # Now try an old version of the file
440                 if ( $time !== false ) {
441                         $img = $this->newFile( $title, $time );
442                         if ( $img ) {
443                                 $img->load( $flags );
444                                 if ( $img->exists() ) {
445                                         if ( !$img->isDeleted( File::DELETED_FILE ) ) {
446                                                 return $img; // always OK
447                                         } elseif ( !empty( $options['private'] ) &&
448                                                 $img->userCan( File::DELETED_FILE,
449                                                         $options['private'] instanceof User ? $options['private'] : null
450                                                 )
451                                         ) {
452                                                 return $img;
453                                         }
454                                 }
455                         }
456                 }
457
458                 # Now try redirects
459                 if ( !empty( $options['ignoreRedirect'] ) ) {
460                         return false;
461                 }
462                 $redir = $this->checkRedirect( $title );
463                 if ( $redir && $title->getNamespace() == NS_FILE ) {
464                         $img = $this->newFile( $redir );
465                         if ( !$img ) {
466                                 return false;
467                         }
468                         $img->load( $flags );
469                         if ( $img->exists() ) {
470                                 $img->redirectedFrom( $title->getDBkey() );
471
472                                 return $img;
473                         }
474                 }
475
476                 return false;
477         }
478
479         /**
480          * Find many files at once.
481          *
482          * @param array $items An array of titles, or an array of findFile() options with
483          *    the "title" option giving the title. Example:
484          *
485          *     $findItem = [ 'title' => $title, 'private' => true ];
486          *     $findBatch = [ $findItem ];
487          *     $repo->findFiles( $findBatch );
488          *
489          *    No title should appear in $items twice, as the result use titles as keys
490          * @param int $flags Supports:
491          *     - FileRepo::NAME_AND_TIME_ONLY : return a (search title => (title,timestamp)) map.
492          *       The search title uses the input titles; the other is the final post-redirect title.
493          *       All titles are returned as string DB keys and the inner array is associative.
494          * @return array Map of (file name => File objects) for matches
495          */
496         public function findFiles( array $items, $flags = 0 ) {
497                 $result = [];
498                 foreach ( $items as $item ) {
499                         if ( is_array( $item ) ) {
500                                 $title = $item['title'];
501                                 $options = $item;
502                                 unset( $options['title'] );
503                         } else {
504                                 $title = $item;
505                                 $options = [];
506                         }
507                         $file = $this->findFile( $title, $options );
508                         if ( $file ) {
509                                 $searchName = File::normalizeTitle( $title )->getDBkey(); // must be valid
510                                 if ( $flags & self::NAME_AND_TIME_ONLY ) {
511                                         $result[$searchName] = [
512                                                 'title' => $file->getTitle()->getDBkey(),
513                                                 'timestamp' => $file->getTimestamp()
514                                         ];
515                                 } else {
516                                         $result[$searchName] = $file;
517                                 }
518                         }
519                 }
520
521                 return $result;
522         }
523
524         /**
525          * Find an instance of the file with this key, created at the specified time
526          * Returns false if the file does not exist. Repositories not supporting
527          * version control should return false if the time is specified.
528          *
529          * @param string $sha1 Base 36 SHA-1 hash
530          * @param array $options Option array, same as findFile().
531          * @return File|bool False on failure
532          */
533         public function findFileFromKey( $sha1, $options = [] ) {
534                 $time = isset( $options['time'] ) ? $options['time'] : false;
535                 # First try to find a matching current version of a file...
536                 if ( !$this->fileFactoryKey ) {
537                         return false; // find-by-sha1 not supported
538                 }
539                 $img = call_user_func( $this->fileFactoryKey, $sha1, $this, $time );
540                 if ( $img && $img->exists() ) {
541                         return $img;
542                 }
543                 # Now try to find a matching old version of a file...
544                 if ( $time !== false && $this->oldFileFactoryKey ) { // find-by-sha1 supported?
545                         $img = call_user_func( $this->oldFileFactoryKey, $sha1, $this, $time );
546                         if ( $img && $img->exists() ) {
547                                 if ( !$img->isDeleted( File::DELETED_FILE ) ) {
548                                         return $img; // always OK
549                                 } elseif ( !empty( $options['private'] ) &&
550                                         $img->userCan( File::DELETED_FILE,
551                                                 $options['private'] instanceof User ? $options['private'] : null
552                                         )
553                                 ) {
554                                         return $img;
555                                 }
556                         }
557                 }
558
559                 return false;
560         }
561
562         /**
563          * Get an array or iterator of file objects for files that have a given
564          * SHA-1 content hash.
565          *
566          * STUB
567          * @param string $hash SHA-1 hash
568          * @return File[]
569          */
570         public function findBySha1( $hash ) {
571                 return [];
572         }
573
574         /**
575          * Get an array of arrays or iterators of file objects for files that
576          * have the given SHA-1 content hashes.
577          *
578          * @param array $hashes An array of hashes
579          * @return array An Array of arrays or iterators of file objects and the hash as key
580          */
581         public function findBySha1s( array $hashes ) {
582                 $result = [];
583                 foreach ( $hashes as $hash ) {
584                         $files = $this->findBySha1( $hash );
585                         if ( count( $files ) ) {
586                                 $result[$hash] = $files;
587                         }
588                 }
589
590                 return $result;
591         }
592
593         /**
594          * Return an array of files where the name starts with $prefix.
595          *
596          * STUB
597          * @param string $prefix The prefix to search for
598          * @param int $limit The maximum amount of files to return
599          * @return array
600          */
601         public function findFilesByPrefix( $prefix, $limit ) {
602                 return [];
603         }
604
605         /**
606          * Get the URL of thumb.php
607          *
608          * @return string
609          */
610         public function getThumbScriptUrl() {
611                 return $this->thumbScriptUrl;
612         }
613
614         /**
615          * Returns true if the repository can transform files via a 404 handler
616          *
617          * @return bool
618          */
619         public function canTransformVia404() {
620                 return $this->transformVia404;
621         }
622
623         /**
624          * Get the name of a file from its title object
625          *
626          * @param Title $title
627          * @return string
628          */
629         public function getNameFromTitle( Title $title ) {
630                 global $wgContLang;
631                 if ( $this->initialCapital != MWNamespace::isCapitalized( NS_FILE ) ) {
632                         $name = $title->getUserCaseDBKey();
633                         if ( $this->initialCapital ) {
634                                 $name = $wgContLang->ucfirst( $name );
635                         }
636                 } else {
637                         $name = $title->getDBkey();
638                 }
639
640                 return $name;
641         }
642
643         /**
644          * Get the public zone root storage directory of the repository
645          *
646          * @return string
647          */
648         public function getRootDirectory() {
649                 return $this->getZonePath( 'public' );
650         }
651
652         /**
653          * Get a relative path including trailing slash, e.g. f/fa/
654          * If the repo is not hashed, returns an empty string
655          *
656          * @param string $name Name of file
657          * @return string
658          */
659         public function getHashPath( $name ) {
660                 return self::getHashPathForLevel( $name, $this->hashLevels );
661         }
662
663         /**
664          * Get a relative path including trailing slash, e.g. f/fa/
665          * If the repo is not hashed, returns an empty string
666          *
667          * @param string $suffix Basename of file from FileRepo::storeTemp()
668          * @return string
669          */
670         public function getTempHashPath( $suffix ) {
671                 $parts = explode( '!', $suffix, 2 ); // format is <timestamp>!<name> or just <name>
672                 $name = isset( $parts[1] ) ? $parts[1] : $suffix; // hash path is not based on timestamp
673                 return self::getHashPathForLevel( $name, $this->hashLevels );
674         }
675
676         /**
677          * @param string $name
678          * @param int $levels
679          * @return string
680          */
681         protected static function getHashPathForLevel( $name, $levels ) {
682                 if ( $levels == 0 ) {
683                         return '';
684                 } else {
685                         $hash = md5( $name );
686                         $path = '';
687                         for ( $i = 1; $i <= $levels; $i++ ) {
688                                 $path .= substr( $hash, 0, $i ) . '/';
689                         }
690
691                         return $path;
692                 }
693         }
694
695         /**
696          * Get the number of hash directory levels
697          *
698          * @return int
699          */
700         public function getHashLevels() {
701                 return $this->hashLevels;
702         }
703
704         /**
705          * Get the name of this repository, as specified by $info['name]' to the constructor
706          *
707          * @return string
708          */
709         public function getName() {
710                 return $this->name;
711         }
712
713         /**
714          * Make an url to this repo
715          *
716          * @param string $query Query string to append
717          * @param string $entry Entry point; defaults to index
718          * @return string|bool False on failure
719          */
720         public function makeUrl( $query = '', $entry = 'index' ) {
721                 if ( isset( $this->scriptDirUrl ) ) {
722                         $ext = isset( $this->scriptExtension ) ? $this->scriptExtension : '.php';
723
724                         return wfAppendQuery( "{$this->scriptDirUrl}/{$entry}{$ext}", $query );
725                 }
726
727                 return false;
728         }
729
730         /**
731          * Get the URL of an image description page. May return false if it is
732          * unknown or not applicable. In general this should only be called by the
733          * File class, since it may return invalid results for certain kinds of
734          * repositories. Use File::getDescriptionUrl() in user code.
735          *
736          * In particular, it uses the article paths as specified to the repository
737          * constructor, whereas local repositories use the local Title functions.
738          *
739          * @param string $name
740          * @return string|false
741          */
742         public function getDescriptionUrl( $name ) {
743                 $encName = wfUrlencode( $name );
744                 if ( !is_null( $this->descBaseUrl ) ) {
745                         # "http://example.com/wiki/File:"
746                         return $this->descBaseUrl . $encName;
747                 }
748                 if ( !is_null( $this->articleUrl ) ) {
749                         # "http://example.com/wiki/$1"
750                         # We use "Image:" as the canonical namespace for
751                         # compatibility across all MediaWiki versions.
752                         return str_replace( '$1',
753                                 "Image:$encName", $this->articleUrl );
754                 }
755                 if ( !is_null( $this->scriptDirUrl ) ) {
756                         # "http://example.com/w"
757                         # We use "Image:" as the canonical namespace for
758                         # compatibility across all MediaWiki versions,
759                         # and just sort of hope index.php is right. ;)
760                         return $this->makeUrl( "title=Image:$encName" );
761                 }
762
763                 return false;
764         }
765
766         /**
767          * Get the URL of the content-only fragment of the description page. For
768          * MediaWiki this means action=render. This should only be called by the
769          * repository's file class, since it may return invalid results. User code
770          * should use File::getDescriptionText().
771          *
772          * @param string $name Name of image to fetch
773          * @param string $lang Language to fetch it in, if any.
774          * @return string|false
775          */
776         public function getDescriptionRenderUrl( $name, $lang = null ) {
777                 $query = 'action=render';
778                 if ( !is_null( $lang ) ) {
779                         $query .= '&uselang=' . urlencode( $lang );
780                 }
781                 if ( isset( $this->scriptDirUrl ) ) {
782                         return $this->makeUrl(
783                                 'title=' .
784                                 wfUrlencode( 'Image:' . $name ) .
785                                 "&$query" );
786                 } else {
787                         $descUrl = $this->getDescriptionUrl( $name );
788                         if ( $descUrl ) {
789                                 return wfAppendQuery( $descUrl, $query );
790                         } else {
791                                 return false;
792                         }
793                 }
794         }
795
796         /**
797          * Get the URL of the stylesheet to apply to description pages
798          *
799          * @return string|bool False on failure
800          */
801         public function getDescriptionStylesheetUrl() {
802                 if ( isset( $this->scriptDirUrl ) ) {
803                         return $this->makeUrl( 'title=MediaWiki:Filepage.css&' .
804                                 wfArrayToCgi( Skin::getDynamicStylesheetQuery() ) );
805                 }
806
807                 return false;
808         }
809
810         /**
811          * Store a file to a given destination.
812          *
813          * @param string $srcPath Source file system path, storage path, or virtual URL
814          * @param string $dstZone Destination zone
815          * @param string $dstRel Destination relative path
816          * @param int $flags Bitwise combination of the following flags:
817          *   self::OVERWRITE         Overwrite an existing destination file instead of failing
818          *   self::OVERWRITE_SAME    Overwrite the file if the destination exists and has the
819          *                           same contents as the source
820          *   self::SKIP_LOCKING      Skip any file locking when doing the store
821          * @return Status
822          */
823         public function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) {
824                 $this->assertWritableRepo(); // fail out if read-only
825
826                 $status = $this->storeBatch( [ [ $srcPath, $dstZone, $dstRel ] ], $flags );
827                 if ( $status->successCount == 0 ) {
828                         $status->setOK( false );
829                 }
830
831                 return $status;
832         }
833
834         /**
835          * Store a batch of files
836          *
837          * @param array $triplets (src, dest zone, dest rel) triplets as per store()
838          * @param int $flags Bitwise combination of the following flags:
839          *   self::OVERWRITE         Overwrite an existing destination file instead of failing
840          *   self::OVERWRITE_SAME    Overwrite the file if the destination exists and has the
841          *                           same contents as the source
842          *   self::SKIP_LOCKING      Skip any file locking when doing the store
843          * @throws MWException
844          * @return Status
845          */
846         public function storeBatch( array $triplets, $flags = 0 ) {
847                 $this->assertWritableRepo(); // fail out if read-only
848
849                 if ( $flags & self::DELETE_SOURCE ) {
850                         throw new InvalidArgumentException( "DELETE_SOURCE not supported in " . __METHOD__ );
851                 }
852
853                 $status = $this->newGood();
854                 $backend = $this->backend; // convenience
855
856                 $operations = [];
857                 // Validate each triplet and get the store operation...
858                 foreach ( $triplets as $triplet ) {
859                         list( $srcPath, $dstZone, $dstRel ) = $triplet;
860                         wfDebug( __METHOD__
861                                 . "( \$src='$srcPath', \$dstZone='$dstZone', \$dstRel='$dstRel' )\n"
862                         );
863
864                         // Resolve destination path
865                         $root = $this->getZonePath( $dstZone );
866                         if ( !$root ) {
867                                 throw new MWException( "Invalid zone: $dstZone" );
868                         }
869                         if ( !$this->validateFilename( $dstRel ) ) {
870                                 throw new MWException( 'Validation error in $dstRel' );
871                         }
872                         $dstPath = "$root/$dstRel";
873                         $dstDir = dirname( $dstPath );
874                         // Create destination directories for this triplet
875                         if ( !$this->initDirectory( $dstDir )->isOK() ) {
876                                 return $this->newFatal( 'directorycreateerror', $dstDir );
877                         }
878
879                         // Resolve source to a storage path if virtual
880                         $srcPath = $this->resolveToStoragePath( $srcPath );
881
882                         // Get the appropriate file operation
883                         if ( FileBackend::isStoragePath( $srcPath ) ) {
884                                 $opName = 'copy';
885                         } else {
886                                 $opName = 'store';
887                         }
888                         $operations[] = [
889                                 'op' => $opName,
890                                 'src' => $srcPath,
891                                 'dst' => $dstPath,
892                                 'overwrite' => $flags & self::OVERWRITE,
893                                 'overwriteSame' => $flags & self::OVERWRITE_SAME,
894                         ];
895                 }
896
897                 // Execute the store operation for each triplet
898                 $opts = [ 'force' => true ];
899                 if ( $flags & self::SKIP_LOCKING ) {
900                         $opts['nonLocking'] = true;
901                 }
902                 $status->merge( $backend->doOperations( $operations, $opts ) );
903
904                 return $status;
905         }
906
907         /**
908          * Deletes a batch of files.
909          * Each file can be a (zone, rel) pair, virtual url, storage path.
910          * It will try to delete each file, but ignores any errors that may occur.
911          *
912          * @param array $files List of files to delete
913          * @param int $flags Bitwise combination of the following flags:
914          *   self::SKIP_LOCKING      Skip any file locking when doing the deletions
915          * @return Status
916          */
917         public function cleanupBatch( array $files, $flags = 0 ) {
918                 $this->assertWritableRepo(); // fail out if read-only
919
920                 $status = $this->newGood();
921
922                 $operations = [];
923                 foreach ( $files as $path ) {
924                         if ( is_array( $path ) ) {
925                                 // This is a pair, extract it
926                                 list( $zone, $rel ) = $path;
927                                 $path = $this->getZonePath( $zone ) . "/$rel";
928                         } else {
929                                 // Resolve source to a storage path if virtual
930                                 $path = $this->resolveToStoragePath( $path );
931                         }
932                         $operations[] = [ 'op' => 'delete', 'src' => $path ];
933                 }
934                 // Actually delete files from storage...
935                 $opts = [ 'force' => true ];
936                 if ( $flags & self::SKIP_LOCKING ) {
937                         $opts['nonLocking'] = true;
938                 }
939                 $status->merge( $this->backend->doOperations( $operations, $opts ) );
940
941                 return $status;
942         }
943
944         /**
945          * Import a file from the local file system into the repo.
946          * This does no locking nor journaling and overrides existing files.
947          * This function can be used to write to otherwise read-only foreign repos.
948          * This is intended for copying generated thumbnails into the repo.
949          *
950          * @param string|FSFile $src Source file system path, storage path, or virtual URL
951          * @param string $dst Virtual URL or storage path
952          * @param array|string|null $options An array consisting of a key named headers
953          *   listing extra headers. If a string, taken as content-disposition header.
954          *   (Support for array of options new in 1.23)
955          * @return Status
956          */
957         final public function quickImport( $src, $dst, $options = null ) {
958                 return $this->quickImportBatch( [ [ $src, $dst, $options ] ] );
959         }
960
961         /**
962          * Purge a file from the repo. This does no locking nor journaling.
963          * This function can be used to write to otherwise read-only foreign repos.
964          * This is intended for purging thumbnails.
965          *
966          * @param string $path Virtual URL or storage path
967          * @return Status
968          */
969         final public function quickPurge( $path ) {
970                 return $this->quickPurgeBatch( [ $path ] );
971         }
972
973         /**
974          * Deletes a directory if empty.
975          * This function can be used to write to otherwise read-only foreign repos.
976          *
977          * @param string $dir Virtual URL (or storage path) of directory to clean
978          * @return Status
979          */
980         public function quickCleanDir( $dir ) {
981                 $status = $this->newGood();
982                 $status->merge( $this->backend->clean(
983                         [ 'dir' => $this->resolveToStoragePath( $dir ) ] ) );
984
985                 return $status;
986         }
987
988         /**
989          * Import a batch of files from the local file system into the repo.
990          * This does no locking nor journaling and overrides existing files.
991          * This function can be used to write to otherwise read-only foreign repos.
992          * This is intended for copying generated thumbnails into the repo.
993          *
994          * All path parameters may be a file system path, storage path, or virtual URL.
995          * When "headers" are given they are used as HTTP headers if supported.
996          *
997          * @param array $triples List of (source path or FSFile, destination path, disposition)
998          * @return Status
999          */
1000         public function quickImportBatch( array $triples ) {
1001                 $status = $this->newGood();
1002                 $operations = [];
1003                 foreach ( $triples as $triple ) {
1004                         list( $src, $dst ) = $triple;
1005                         if ( $src instanceof FSFile ) {
1006                                 $op = 'store';
1007                         } else {
1008                                 $src = $this->resolveToStoragePath( $src );
1009                                 $op = FileBackend::isStoragePath( $src ) ? 'copy' : 'store';
1010                         }
1011                         $dst = $this->resolveToStoragePath( $dst );
1012
1013                         if ( !isset( $triple[2] ) ) {
1014                                 $headers = [];
1015                         } elseif ( is_string( $triple[2] ) ) {
1016                                 // back-compat
1017                                 $headers = [ 'Content-Disposition' => $triple[2] ];
1018                         } elseif ( is_array( $triple[2] ) && isset( $triple[2]['headers'] ) ) {
1019                                 $headers = $triple[2]['headers'];
1020                         } else {
1021                                 $headers = [];
1022                         }
1023
1024                         $operations[] = [
1025                                 'op' => $op,
1026                                 'src' => $src,
1027                                 'dst' => $dst,
1028                                 'headers' => $headers
1029                         ];
1030                         $status->merge( $this->initDirectory( dirname( $dst ) ) );
1031                 }
1032                 $status->merge( $this->backend->doQuickOperations( $operations ) );
1033
1034                 return $status;
1035         }
1036
1037         /**
1038          * Purge a batch of files from the repo.
1039          * This function can be used to write to otherwise read-only foreign repos.
1040          * This does no locking nor journaling and is intended for purging thumbnails.
1041          *
1042          * @param array $paths List of virtual URLs or storage paths
1043          * @return Status
1044          */
1045         public function quickPurgeBatch( array $paths ) {
1046                 $status = $this->newGood();
1047                 $operations = [];
1048                 foreach ( $paths as $path ) {
1049                         $operations[] = [
1050                                 'op' => 'delete',
1051                                 'src' => $this->resolveToStoragePath( $path ),
1052                                 'ignoreMissingSource' => true
1053                         ];
1054                 }
1055                 $status->merge( $this->backend->doQuickOperations( $operations ) );
1056
1057                 return $status;
1058         }
1059
1060         /**
1061          * Pick a random name in the temp zone and store a file to it.
1062          * Returns a Status object with the file Virtual URL in the value,
1063          * file can later be disposed using FileRepo::freeTemp().
1064          *
1065          * @param string $originalName The base name of the file as specified
1066          *   by the user. The file extension will be maintained.
1067          * @param string $srcPath The current location of the file.
1068          * @return Status Object with the URL in the value.
1069          */
1070         public function storeTemp( $originalName, $srcPath ) {
1071                 $this->assertWritableRepo(); // fail out if read-only
1072
1073                 $date = MWTimestamp::getInstance()->format( 'YmdHis' );
1074                 $hashPath = $this->getHashPath( $originalName );
1075                 $dstUrlRel = $hashPath . $date . '!' . rawurlencode( $originalName );
1076                 $virtualUrl = $this->getVirtualUrl( 'temp' ) . '/' . $dstUrlRel;
1077
1078                 $result = $this->quickImport( $srcPath, $virtualUrl );
1079                 $result->value = $virtualUrl;
1080
1081                 return $result;
1082         }
1083
1084         /**
1085          * Remove a temporary file or mark it for garbage collection
1086          *
1087          * @param string $virtualUrl The virtual URL returned by FileRepo::storeTemp()
1088          * @return bool True on success, false on failure
1089          */
1090         public function freeTemp( $virtualUrl ) {
1091                 $this->assertWritableRepo(); // fail out if read-only
1092
1093                 $temp = $this->getVirtualUrl( 'temp' );
1094                 if ( substr( $virtualUrl, 0, strlen( $temp ) ) != $temp ) {
1095                         wfDebug( __METHOD__ . ": Invalid temp virtual URL\n" );
1096
1097                         return false;
1098                 }
1099
1100                 return $this->quickPurge( $virtualUrl )->isOK();
1101         }
1102
1103         /**
1104          * Concatenate a list of temporary files into a target file location.
1105          *
1106          * @param array $srcPaths Ordered list of source virtual URLs/storage paths
1107          * @param string $dstPath Target file system path
1108          * @param int $flags Bitwise combination of the following flags:
1109          *   self::DELETE_SOURCE     Delete the source files on success
1110          * @return Status
1111          */
1112         public function concatenate( array $srcPaths, $dstPath, $flags = 0 ) {
1113                 $this->assertWritableRepo(); // fail out if read-only
1114
1115                 $status = $this->newGood();
1116
1117                 $sources = [];
1118                 foreach ( $srcPaths as $srcPath ) {
1119                         // Resolve source to a storage path if virtual
1120                         $source = $this->resolveToStoragePath( $srcPath );
1121                         $sources[] = $source; // chunk to merge
1122                 }
1123
1124                 // Concatenate the chunks into one FS file
1125                 $params = [ 'srcs' => $sources, 'dst' => $dstPath ];
1126                 $status->merge( $this->backend->concatenate( $params ) );
1127                 if ( !$status->isOK() ) {
1128                         return $status;
1129                 }
1130
1131                 // Delete the sources if required
1132                 if ( $flags & self::DELETE_SOURCE ) {
1133                         $status->merge( $this->quickPurgeBatch( $srcPaths ) );
1134                 }
1135
1136                 // Make sure status is OK, despite any quickPurgeBatch() fatals
1137                 $status->setResult( true );
1138
1139                 return $status;
1140         }
1141
1142         /**
1143          * Copy or move a file either from a storage path, virtual URL,
1144          * or file system path, into this repository at the specified destination location.
1145          *
1146          * Returns a Status object. On success, the value contains "new" or
1147          * "archived", to indicate whether the file was new with that name.
1148          *
1149          * Options to $options include:
1150          *   - headers : name/value map of HTTP headers to use in response to GET/HEAD requests
1151          *
1152          * @param string|FSFile $src The source file system path, storage path, or URL
1153          * @param string $dstRel The destination relative path
1154          * @param string $archiveRel The relative path where the existing file is to
1155          *   be archived, if there is one. Relative to the public zone root.
1156          * @param int $flags Bitfield, may be FileRepo::DELETE_SOURCE to indicate
1157          *   that the source file should be deleted if possible
1158          * @param array $options Optional additional parameters
1159          * @return Status
1160          */
1161         public function publish(
1162                 $src, $dstRel, $archiveRel, $flags = 0, array $options = []
1163         ) {
1164                 $this->assertWritableRepo(); // fail out if read-only
1165
1166                 $status = $this->publishBatch(
1167                         [ [ $src, $dstRel, $archiveRel, $options ] ], $flags );
1168                 if ( $status->successCount == 0 ) {
1169                         $status->setOK( false );
1170                 }
1171                 if ( isset( $status->value[0] ) ) {
1172                         $status->value = $status->value[0];
1173                 } else {
1174                         $status->value = false;
1175                 }
1176
1177                 return $status;
1178         }
1179
1180         /**
1181          * Publish a batch of files
1182          *
1183          * @param array $ntuples (source, dest, archive) triplets or
1184          *   (source, dest, archive, options) 4-tuples as per publish().
1185          * @param int $flags Bitfield, may be FileRepo::DELETE_SOURCE to indicate
1186          *   that the source files should be deleted if possible
1187          * @throws MWException
1188          * @return Status
1189          */
1190         public function publishBatch( array $ntuples, $flags = 0 ) {
1191                 $this->assertWritableRepo(); // fail out if read-only
1192
1193                 $backend = $this->backend; // convenience
1194                 // Try creating directories
1195                 $status = $this->initZones( 'public' );
1196                 if ( !$status->isOK() ) {
1197                         return $status;
1198                 }
1199
1200                 $status = $this->newGood( [] );
1201
1202                 $operations = [];
1203                 $sourceFSFilesToDelete = []; // cleanup for disk source files
1204                 // Validate each triplet and get the store operation...
1205                 foreach ( $ntuples as $ntuple ) {
1206                         list( $src, $dstRel, $archiveRel ) = $ntuple;
1207                         $srcPath = ( $src instanceof FSFile ) ? $src->getPath() : $src;
1208
1209                         $options = isset( $ntuple[3] ) ? $ntuple[3] : [];
1210                         // Resolve source to a storage path if virtual
1211                         $srcPath = $this->resolveToStoragePath( $srcPath );
1212                         if ( !$this->validateFilename( $dstRel ) ) {
1213                                 throw new MWException( 'Validation error in $dstRel' );
1214                         }
1215                         if ( !$this->validateFilename( $archiveRel ) ) {
1216                                 throw new MWException( 'Validation error in $archiveRel' );
1217                         }
1218
1219                         $publicRoot = $this->getZonePath( 'public' );
1220                         $dstPath = "$publicRoot/$dstRel";
1221                         $archivePath = "$publicRoot/$archiveRel";
1222
1223                         $dstDir = dirname( $dstPath );
1224                         $archiveDir = dirname( $archivePath );
1225                         // Abort immediately on directory creation errors since they're likely to be repetitive
1226                         if ( !$this->initDirectory( $dstDir )->isOK() ) {
1227                                 return $this->newFatal( 'directorycreateerror', $dstDir );
1228                         }
1229                         if ( !$this->initDirectory( $archiveDir )->isOK() ) {
1230                                 return $this->newFatal( 'directorycreateerror', $archiveDir );
1231                         }
1232
1233                         // Set any desired headers to be use in GET/HEAD responses
1234                         $headers = isset( $options['headers'] ) ? $options['headers'] : [];
1235
1236                         // Archive destination file if it exists.
1237                         // This will check if the archive file also exists and fail if does.
1238                         // This is a sanity check to avoid data loss. On Windows and Linux,
1239                         // copy() will overwrite, so the existence check is vulnerable to
1240                         // race conditions unless a functioning LockManager is used.
1241                         // LocalFile also uses SELECT FOR UPDATE for synchronization.
1242                         $operations[] = [
1243                                 'op' => 'copy',
1244                                 'src' => $dstPath,
1245                                 'dst' => $archivePath,
1246                                 'ignoreMissingSource' => true
1247                         ];
1248
1249                         // Copy (or move) the source file to the destination
1250                         if ( FileBackend::isStoragePath( $srcPath ) ) {
1251                                 if ( $flags & self::DELETE_SOURCE ) {
1252                                         $operations[] = [
1253                                                 'op' => 'move',
1254                                                 'src' => $srcPath,
1255                                                 'dst' => $dstPath,
1256                                                 'overwrite' => true, // replace current
1257                                                 'headers' => $headers
1258                                         ];
1259                                 } else {
1260                                         $operations[] = [
1261                                                 'op' => 'copy',
1262                                                 'src' => $srcPath,
1263                                                 'dst' => $dstPath,
1264                                                 'overwrite' => true, // replace current
1265                                                 'headers' => $headers
1266                                         ];
1267                                 }
1268                         } else { // FS source path
1269                                 $operations[] = [
1270                                         'op' => 'store',
1271                                         'src' => $src, // prefer FSFile objects
1272                                         'dst' => $dstPath,
1273                                         'overwrite' => true, // replace current
1274                                         'headers' => $headers
1275                                 ];
1276                                 if ( $flags & self::DELETE_SOURCE ) {
1277                                         $sourceFSFilesToDelete[] = $srcPath;
1278                                 }
1279                         }
1280                 }
1281
1282                 // Execute the operations for each triplet
1283                 $status->merge( $backend->doOperations( $operations ) );
1284                 // Find out which files were archived...
1285                 foreach ( $ntuples as $i => $ntuple ) {
1286                         list( , , $archiveRel ) = $ntuple;
1287                         $archivePath = $this->getZonePath( 'public' ) . "/$archiveRel";
1288                         if ( $this->fileExists( $archivePath ) ) {
1289                                 $status->value[$i] = 'archived';
1290                         } else {
1291                                 $status->value[$i] = 'new';
1292                         }
1293                 }
1294                 // Cleanup for disk source files...
1295                 foreach ( $sourceFSFilesToDelete as $file ) {
1296                         MediaWiki\suppressWarnings();
1297                         unlink( $file ); // FS cleanup
1298                         MediaWiki\restoreWarnings();
1299                 }
1300
1301                 return $status;
1302         }
1303
1304         /**
1305          * Creates a directory with the appropriate zone permissions.
1306          * Callers are responsible for doing read-only and "writable repo" checks.
1307          *
1308          * @param string $dir Virtual URL (or storage path) of directory to clean
1309          * @return Status
1310          */
1311         protected function initDirectory( $dir ) {
1312                 $path = $this->resolveToStoragePath( $dir );
1313                 list( , $container, ) = FileBackend::splitStoragePath( $path );
1314
1315                 $params = [ 'dir' => $path ];
1316                 if ( $this->isPrivate
1317                         || $container === $this->zones['deleted']['container']
1318                         || $container === $this->zones['temp']['container']
1319                 ) {
1320                         # Take all available measures to prevent web accessibility of new deleted
1321                         # directories, in case the user has not configured offline storage
1322                         $params = [ 'noAccess' => true, 'noListing' => true ] + $params;
1323                 }
1324
1325                 $status = $this->newGood();
1326                 $status->merge( $this->backend->prepare( $params ) );
1327
1328                 return $status;
1329         }
1330
1331         /**
1332          * Deletes a directory if empty.
1333          *
1334          * @param string $dir Virtual URL (or storage path) of directory to clean
1335          * @return Status
1336          */
1337         public function cleanDir( $dir ) {
1338                 $this->assertWritableRepo(); // fail out if read-only
1339
1340                 $status = $this->newGood();
1341                 $status->merge( $this->backend->clean(
1342                         [ 'dir' => $this->resolveToStoragePath( $dir ) ] ) );
1343
1344                 return $status;
1345         }
1346
1347         /**
1348          * Checks existence of a a file
1349          *
1350          * @param string $file Virtual URL (or storage path) of file to check
1351          * @return bool
1352          */
1353         public function fileExists( $file ) {
1354                 $result = $this->fileExistsBatch( [ $file ] );
1355
1356                 return $result[0];
1357         }
1358
1359         /**
1360          * Checks existence of an array of files.
1361          *
1362          * @param array $files Virtual URLs (or storage paths) of files to check
1363          * @return array Map of files and existence flags, or false
1364          */
1365         public function fileExistsBatch( array $files ) {
1366                 $paths = array_map( [ $this, 'resolveToStoragePath' ], $files );
1367                 $this->backend->preloadFileStat( [ 'srcs' => $paths ] );
1368
1369                 $result = [];
1370                 foreach ( $files as $key => $file ) {
1371                         $path = $this->resolveToStoragePath( $file );
1372                         $result[$key] = $this->backend->fileExists( [ 'src' => $path ] );
1373                 }
1374
1375                 return $result;
1376         }
1377
1378         /**
1379          * Move a file to the deletion archive.
1380          * If no valid deletion archive exists, this may either delete the file
1381          * or throw an exception, depending on the preference of the repository
1382          *
1383          * @param mixed $srcRel Relative path for the file to be deleted
1384          * @param mixed $archiveRel Relative path for the archive location.
1385          *   Relative to a private archive directory.
1386          * @return Status
1387          */
1388         public function delete( $srcRel, $archiveRel ) {
1389                 $this->assertWritableRepo(); // fail out if read-only
1390
1391                 return $this->deleteBatch( [ [ $srcRel, $archiveRel ] ] );
1392         }
1393
1394         /**
1395          * Move a group of files to the deletion archive.
1396          *
1397          * If no valid deletion archive is configured, this may either delete the
1398          * file or throw an exception, depending on the preference of the repository.
1399          *
1400          * The overwrite policy is determined by the repository -- currently LocalRepo
1401          * assumes a naming scheme in the deleted zone based on content hash, as
1402          * opposed to the public zone which is assumed to be unique.
1403          *
1404          * @param array $sourceDestPairs Array of source/destination pairs. Each element
1405          *   is a two-element array containing the source file path relative to the
1406          *   public root in the first element, and the archive file path relative
1407          *   to the deleted zone root in the second element.
1408          * @throws MWException
1409          * @return Status
1410          */
1411         public function deleteBatch( array $sourceDestPairs ) {
1412                 $this->assertWritableRepo(); // fail out if read-only
1413
1414                 // Try creating directories
1415                 $status = $this->initZones( [ 'public', 'deleted' ] );
1416                 if ( !$status->isOK() ) {
1417                         return $status;
1418                 }
1419
1420                 $status = $this->newGood();
1421
1422                 $backend = $this->backend; // convenience
1423                 $operations = [];
1424                 // Validate filenames and create archive directories
1425                 foreach ( $sourceDestPairs as $pair ) {
1426                         list( $srcRel, $archiveRel ) = $pair;
1427                         if ( !$this->validateFilename( $srcRel ) ) {
1428                                 throw new MWException( __METHOD__ . ':Validation error in $srcRel' );
1429                         } elseif ( !$this->validateFilename( $archiveRel ) ) {
1430                                 throw new MWException( __METHOD__ . ':Validation error in $archiveRel' );
1431                         }
1432
1433                         $publicRoot = $this->getZonePath( 'public' );
1434                         $srcPath = "{$publicRoot}/$srcRel";
1435
1436                         $deletedRoot = $this->getZonePath( 'deleted' );
1437                         $archivePath = "{$deletedRoot}/{$archiveRel}";
1438                         $archiveDir = dirname( $archivePath ); // does not touch FS
1439
1440                         // Create destination directories
1441                         if ( !$this->initDirectory( $archiveDir )->isOK() ) {
1442                                 return $this->newFatal( 'directorycreateerror', $archiveDir );
1443                         }
1444
1445                         $operations[] = [
1446                                 'op' => 'move',
1447                                 'src' => $srcPath,
1448                                 'dst' => $archivePath,
1449                                 // We may have 2+ identical files being deleted,
1450                                 // all of which will map to the same destination file
1451                                 'overwriteSame' => true // also see T33792
1452                         ];
1453                 }
1454
1455                 // Move the files by execute the operations for each pair.
1456                 // We're now committed to returning an OK result, which will
1457                 // lead to the files being moved in the DB also.
1458                 $opts = [ 'force' => true ];
1459                 $status->merge( $backend->doOperations( $operations, $opts ) );
1460
1461                 return $status;
1462         }
1463
1464         /**
1465          * Delete files in the deleted directory if they are not referenced in the filearchive table
1466          *
1467          * STUB
1468          * @param array $storageKeys
1469          */
1470         public function cleanupDeletedBatch( array $storageKeys ) {
1471                 $this->assertWritableRepo();
1472         }
1473
1474         /**
1475          * Get a relative path for a deletion archive key,
1476          * e.g. s/z/a/ for sza251lrxrc1jad41h5mgilp8nysje52.jpg
1477          *
1478          * @param string $key
1479          * @throws MWException
1480          * @return string
1481          */
1482         public function getDeletedHashPath( $key ) {
1483                 if ( strlen( $key ) < 31 ) {
1484                         throw new MWException( "Invalid storage key '$key'." );
1485                 }
1486                 $path = '';
1487                 for ( $i = 0; $i < $this->deletedHashLevels; $i++ ) {
1488                         $path .= $key[$i] . '/';
1489                 }
1490
1491                 return $path;
1492         }
1493
1494         /**
1495          * If a path is a virtual URL, resolve it to a storage path.
1496          * Otherwise, just return the path as it is.
1497          *
1498          * @param string $path
1499          * @return string
1500          * @throws MWException
1501          */
1502         protected function resolveToStoragePath( $path ) {
1503                 if ( $this->isVirtualUrl( $path ) ) {
1504                         return $this->resolveVirtualUrl( $path );
1505                 }
1506
1507                 return $path;
1508         }
1509
1510         /**
1511          * Get a local FS copy of a file with a given virtual URL/storage path.
1512          * Temporary files may be purged when the file object falls out of scope.
1513          *
1514          * @param string $virtualUrl
1515          * @return TempFSFile|null Returns null on failure
1516          */
1517         public function getLocalCopy( $virtualUrl ) {
1518                 $path = $this->resolveToStoragePath( $virtualUrl );
1519
1520                 return $this->backend->getLocalCopy( [ 'src' => $path ] );
1521         }
1522
1523         /**
1524          * Get a local FS file with a given virtual URL/storage path.
1525          * The file is either an original or a copy. It should not be changed.
1526          * Temporary files may be purged when the file object falls out of scope.
1527          *
1528          * @param string $virtualUrl
1529          * @return FSFile|null Returns null on failure.
1530          */
1531         public function getLocalReference( $virtualUrl ) {
1532                 $path = $this->resolveToStoragePath( $virtualUrl );
1533
1534                 return $this->backend->getLocalReference( [ 'src' => $path ] );
1535         }
1536
1537         /**
1538          * Get properties of a file with a given virtual URL/storage path.
1539          * Properties should ultimately be obtained via FSFile::getProps().
1540          *
1541          * @param string $virtualUrl
1542          * @return array
1543          */
1544         public function getFileProps( $virtualUrl ) {
1545                 $fsFile = $this->getLocalReference( $virtualUrl );
1546                 $mwProps = new MWFileProps( MimeMagic::singleton() );
1547                 if ( $fsFile ) {
1548                         $props = $mwProps->getPropsFromPath( $fsFile->getPath(), true );
1549                 } else {
1550                         $props = $mwProps->newPlaceholderProps();
1551                 }
1552
1553                 return $props;
1554         }
1555
1556         /**
1557          * Get the timestamp of a file with a given virtual URL/storage path
1558          *
1559          * @param string $virtualUrl
1560          * @return string|bool False on failure
1561          */
1562         public function getFileTimestamp( $virtualUrl ) {
1563                 $path = $this->resolveToStoragePath( $virtualUrl );
1564
1565                 return $this->backend->getFileTimestamp( [ 'src' => $path ] );
1566         }
1567
1568         /**
1569          * Get the size of a file with a given virtual URL/storage path
1570          *
1571          * @param string $virtualUrl
1572          * @return int|bool False on failure
1573          */
1574         public function getFileSize( $virtualUrl ) {
1575                 $path = $this->resolveToStoragePath( $virtualUrl );
1576
1577                 return $this->backend->getFileSize( [ 'src' => $path ] );
1578         }
1579
1580         /**
1581          * Get the sha1 (base 36) of a file with a given virtual URL/storage path
1582          *
1583          * @param string $virtualUrl
1584          * @return string|bool
1585          */
1586         public function getFileSha1( $virtualUrl ) {
1587                 $path = $this->resolveToStoragePath( $virtualUrl );
1588
1589                 return $this->backend->getFileSha1Base36( [ 'src' => $path ] );
1590         }
1591
1592         /**
1593          * Attempt to stream a file with the given virtual URL/storage path
1594          *
1595          * @param string $virtualUrl
1596          * @param array $headers Additional HTTP headers to send on success
1597          * @param array $optHeaders HTTP request headers (if-modified-since, range, ...)
1598          * @return Status
1599          * @since 1.27
1600          */
1601         public function streamFileWithStatus( $virtualUrl, $headers = [], $optHeaders = [] ) {
1602                 $path = $this->resolveToStoragePath( $virtualUrl );
1603                 $params = [ 'src' => $path, 'headers' => $headers, 'options' => $optHeaders ];
1604
1605                 // T172851: HHVM does not flush the output properly, causing OOM
1606                 ob_start( null, 1048576 );
1607                 ob_implicit_flush( true );
1608
1609                 $status = $this->newGood();
1610                 $status->merge( $this->backend->streamFile( $params ) );
1611
1612                 // T186565: Close the buffer, unless it has already been closed
1613                 // in HTTPFileStreamer::resetOutputBuffers().
1614                 if ( ob_get_status() ) {
1615                         ob_end_flush();
1616                 }
1617
1618                 return $status;
1619         }
1620
1621         /**
1622          * Attempt to stream a file with the given virtual URL/storage path
1623          *
1624          * @deprecated since 1.26, use streamFileWithStatus
1625          * @param string $virtualUrl
1626          * @param array $headers Additional HTTP headers to send on success
1627          * @return bool Success
1628          */
1629         public function streamFile( $virtualUrl, $headers = [] ) {
1630                 return $this->streamFileWithStatus( $virtualUrl, $headers )->isOK();
1631         }
1632
1633         /**
1634          * Call a callback function for every public regular file in the repository.
1635          * This only acts on the current version of files, not any old versions.
1636          * May use either the database or the filesystem.
1637          *
1638          * @param callable $callback
1639          * @return void
1640          */
1641         public function enumFiles( $callback ) {
1642                 $this->enumFilesInStorage( $callback );
1643         }
1644
1645         /**
1646          * Call a callback function for every public file in the repository.
1647          * May use either the database or the filesystem.
1648          *
1649          * @param callable $callback
1650          * @return void
1651          */
1652         protected function enumFilesInStorage( $callback ) {
1653                 $publicRoot = $this->getZonePath( 'public' );
1654                 $numDirs = 1 << ( $this->hashLevels * 4 );
1655                 // Use a priori assumptions about directory structure
1656                 // to reduce the tree height of the scanning process.
1657                 for ( $flatIndex = 0; $flatIndex < $numDirs; $flatIndex++ ) {
1658                         $hexString = sprintf( "%0{$this->hashLevels}x", $flatIndex );
1659                         $path = $publicRoot;
1660                         for ( $hexPos = 0; $hexPos < $this->hashLevels; $hexPos++ ) {
1661                                 $path .= '/' . substr( $hexString, 0, $hexPos + 1 );
1662                         }
1663                         $iterator = $this->backend->getFileList( [ 'dir' => $path ] );
1664                         foreach ( $iterator as $name ) {
1665                                 // Each item returned is a public file
1666                                 call_user_func( $callback, "{$path}/{$name}" );
1667                         }
1668                 }
1669         }
1670
1671         /**
1672          * Determine if a relative path is valid, i.e. not blank or involving directory traveral
1673          *
1674          * @param string $filename
1675          * @return bool
1676          */
1677         public function validateFilename( $filename ) {
1678                 if ( strval( $filename ) == '' ) {
1679                         return false;
1680                 }
1681
1682                 return FileBackend::isPathTraversalFree( $filename );
1683         }
1684
1685         /**
1686          * Get a callback function to use for cleaning error message parameters
1687          *
1688          * @return array
1689          */
1690         function getErrorCleanupFunction() {
1691                 switch ( $this->pathDisclosureProtection ) {
1692                         case 'none':
1693                         case 'simple': // b/c
1694                                 $callback = [ $this, 'passThrough' ];
1695                                 break;
1696                         default: // 'paranoid'
1697                                 $callback = [ $this, 'paranoidClean' ];
1698                 }
1699                 return $callback;
1700         }
1701
1702         /**
1703          * Path disclosure protection function
1704          *
1705          * @param string $param
1706          * @return string
1707          */
1708         function paranoidClean( $param ) {
1709                 return '[hidden]';
1710         }
1711
1712         /**
1713          * Path disclosure protection function
1714          *
1715          * @param string $param
1716          * @return string
1717          */
1718         function passThrough( $param ) {
1719                 return $param;
1720         }
1721
1722         /**
1723          * Create a new fatal error
1724          *
1725          * @param string $message
1726          * @return Status
1727          */
1728         public function newFatal( $message /*, parameters...*/ ) {
1729                 $status = call_user_func_array( [ 'Status', 'newFatal' ], func_get_args() );
1730                 $status->cleanCallback = $this->getErrorCleanupFunction();
1731
1732                 return $status;
1733         }
1734
1735         /**
1736          * Create a new good result
1737          *
1738          * @param null|string $value
1739          * @return Status
1740          */
1741         public function newGood( $value = null ) {
1742                 $status = Status::newGood( $value );
1743                 $status->cleanCallback = $this->getErrorCleanupFunction();
1744
1745                 return $status;
1746         }
1747
1748         /**
1749          * Checks if there is a redirect named as $title. If there is, return the
1750          * title object. If not, return false.
1751          * STUB
1752          *
1753          * @param Title $title Title of image
1754          * @return bool
1755          */
1756         public function checkRedirect( Title $title ) {
1757                 return false;
1758         }
1759
1760         /**
1761          * Invalidates image redirect cache related to that image
1762          * Doesn't do anything for repositories that don't support image redirects.
1763          *
1764          * STUB
1765          * @param Title $title Title of image
1766          */
1767         public function invalidateImageRedirect( Title $title ) {
1768         }
1769
1770         /**
1771          * Get the human-readable name of the repo
1772          *
1773          * @return string
1774          */
1775         public function getDisplayName() {
1776                 global $wgSitename;
1777
1778                 if ( $this->isLocal() ) {
1779                         return $wgSitename;
1780                 }
1781
1782                 // 'shared-repo-name-wikimediacommons' is used when $wgUseInstantCommons = true
1783                 return wfMessageFallback( 'shared-repo-name-' . $this->name, 'shared-repo' )->text();
1784         }
1785
1786         /**
1787          * Get the portion of the file that contains the origin file name.
1788          * If that name is too long, then the name "thumbnail.<ext>" will be given.
1789          *
1790          * @param string $name
1791          * @return string
1792          */
1793         public function nameForThumb( $name ) {
1794                 if ( strlen( $name ) > $this->abbrvThreshold ) {
1795                         $ext = FileBackend::extensionFromPath( $name );
1796                         $name = ( $ext == '' ) ? 'thumbnail' : "thumbnail.$ext";
1797                 }
1798
1799                 return $name;
1800         }
1801
1802         /**
1803          * Returns true if this the local file repository.
1804          *
1805          * @return bool
1806          */
1807         public function isLocal() {
1808                 return $this->getName() == 'local';
1809         }
1810
1811         /**
1812          * Get a key on the primary cache for this repository.
1813          * Returns false if the repository's cache is not accessible at this site.
1814          * The parameters are the parts of the key, as for wfMemcKey().
1815          *
1816          * STUB
1817          * @return bool
1818          */
1819         public function getSharedCacheKey( /*...*/ ) {
1820                 return false;
1821         }
1822
1823         /**
1824          * Get a key for this repo in the local cache domain. These cache keys are
1825          * not shared with remote instances of the repo.
1826          * The parameters are the parts of the key, as for wfMemcKey().
1827          *
1828          * @return string
1829          */
1830         public function getLocalCacheKey( /*...*/ ) {
1831                 $args = func_get_args();
1832                 array_unshift( $args, 'filerepo', $this->getName() );
1833
1834                 return call_user_func_array( 'wfMemcKey', $args );
1835         }
1836
1837         /**
1838          * Get a temporary private FileRepo associated with this repo.
1839          *
1840          * Files will be created in the temp zone of this repo.
1841          * It will have the same backend as this repo.
1842          *
1843          * @return TempFileRepo
1844          */
1845         public function getTempRepo() {
1846                 return new TempFileRepo( [
1847                         'name' => "{$this->name}-temp",
1848                         'backend' => $this->backend,
1849                         'zones' => [
1850                                 'public' => [
1851                                         // Same place storeTemp() uses in the base repo, though
1852                                         // the path hashing is mismatched, which is annoying.
1853                                         'container' => $this->zones['temp']['container'],
1854                                         'directory' => $this->zones['temp']['directory']
1855                                 ],
1856                                 'thumb' => [
1857                                         'container' => $this->zones['temp']['container'],
1858                                         'directory' => $this->zones['temp']['directory'] == ''
1859                                                 ? 'thumb'
1860                                                 : $this->zones['temp']['directory'] . '/thumb'
1861                                 ],
1862                                 'transcoded' => [
1863                                         'container' => $this->zones['temp']['container'],
1864                                         'directory' => $this->zones['temp']['directory'] == ''
1865                                                 ? 'transcoded'
1866                                                 : $this->zones['temp']['directory'] . '/transcoded'
1867                                 ]
1868                         ],
1869                         'hashLevels' => $this->hashLevels, // performance
1870                         'isPrivate' => true // all in temp zone
1871                 ] );
1872         }
1873
1874         /**
1875          * Get an UploadStash associated with this repo.
1876          *
1877          * @param User $user
1878          * @return UploadStash
1879          */
1880         public function getUploadStash( User $user = null ) {
1881                 return new UploadStash( $this, $user );
1882         }
1883
1884         /**
1885          * Throw an exception if this repo is read-only by design.
1886          * This does not and should not check getReadOnlyReason().
1887          *
1888          * @return void
1889          * @throws MWException
1890          */
1891         protected function assertWritableRepo() {
1892         }
1893
1894         /**
1895          * Return information about the repository.
1896          *
1897          * @return array
1898          * @since 1.22
1899          */
1900         public function getInfo() {
1901                 $ret = [
1902                         'name' => $this->getName(),
1903                         'displayname' => $this->getDisplayName(),
1904                         'rootUrl' => $this->getZoneUrl( 'public' ),
1905                         'local' => $this->isLocal(),
1906                 ];
1907
1908                 $optionalSettings = [
1909                         'url', 'thumbUrl', 'initialCapital', 'descBaseUrl', 'scriptDirUrl', 'articleUrl',
1910                         'fetchDescription', 'descriptionCacheExpiry', 'scriptExtension', 'favicon'
1911                 ];
1912                 foreach ( $optionalSettings as $k ) {
1913                         if ( isset( $this->$k ) ) {
1914                                 $ret[$k] = $this->$k;
1915                         }
1916                 }
1917
1918                 return $ret;
1919         }
1920
1921         /**
1922          * Returns whether or not storage is SHA-1 based
1923          * @return bool
1924          */
1925         public function hasSha1Storage() {
1926                 return $this->hasSha1Storage;
1927         }
1928
1929         /**
1930          * Returns whether or not repo supports having originals SHA-1s in the thumb URLs
1931          * @return bool
1932          */
1933         public function supportsSha1URLs() {
1934                 return $this->supportsSha1URLs;
1935         }
1936 }