FileRepo::getInfo()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 20
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 13
nc 3
nop 0
dl 0
loc 20
rs 9.4285
c 0
b 0
f 0
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 bool 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'];
0 ignored issues
show
Bug introduced by
The property name does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
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 ( $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 ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $base of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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 View Code Duplication
			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 View Code Duplication
				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 View Code Duplication
			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 View Code Duplication
	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
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
775
	 */
776
	public function getDescriptionRenderUrl( $name, $lang = null ) {
777
		$query = 'action=render';
778
		if ( !is_null( $lang ) ) {
779
			$query .= '&uselang=' . $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 ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $descUrl of type string|false is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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 View Code Duplication
	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 View Code Duplication
	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 FileRepoStatus 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 FileRepoStatus 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 View Code Duplication
	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 bug 31792
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 View Code Duplication
	public function streamFileWithStatus( $virtualUrl, $headers = [], $optHeaders = [] ) {
1602
		$path = $this->resolveToStoragePath( $virtualUrl );
1603
		$params = [ 'src' => $path, 'headers' => $headers, 'options' => $optHeaders ];
1604
1605
		$status = $this->newGood();
1606
		$status->merge( $this->backend->streamFile( $params ) );
1607
1608
		return $status;
1609
	}
1610
1611
	/**
1612
	 * Attempt to stream a file with the given virtual URL/storage path
1613
	 *
1614
	 * @deprecated since 1.26, use streamFileWithStatus
1615
	 * @param string $virtualUrl
1616
	 * @param array $headers Additional HTTP headers to send on success
1617
	 * @return bool Success
1618
	 */
1619
	public function streamFile( $virtualUrl, $headers = [] ) {
1620
		return $this->streamFileWithStatus( $virtualUrl, $headers )->isOK();
1621
	}
1622
1623
	/**
1624
	 * Call a callback function for every public regular file in the repository.
1625
	 * This only acts on the current version of files, not any old versions.
1626
	 * May use either the database or the filesystem.
1627
	 *
1628
	 * @param callable $callback
1629
	 * @return void
1630
	 */
1631
	public function enumFiles( $callback ) {
1632
		$this->enumFilesInStorage( $callback );
1633
	}
1634
1635
	/**
1636
	 * Call a callback function for every public file in the repository.
1637
	 * May use either the database or the filesystem.
1638
	 *
1639
	 * @param callable $callback
1640
	 * @return void
1641
	 */
1642
	protected function enumFilesInStorage( $callback ) {
1643
		$publicRoot = $this->getZonePath( 'public' );
1644
		$numDirs = 1 << ( $this->hashLevels * 4 );
1645
		// Use a priori assumptions about directory structure
1646
		// to reduce the tree height of the scanning process.
1647
		for ( $flatIndex = 0; $flatIndex < $numDirs; $flatIndex++ ) {
1648
			$hexString = sprintf( "%0{$this->hashLevels}x", $flatIndex );
1649
			$path = $publicRoot;
1650
			for ( $hexPos = 0; $hexPos < $this->hashLevels; $hexPos++ ) {
1651
				$path .= '/' . substr( $hexString, 0, $hexPos + 1 );
1652
			}
1653
			$iterator = $this->backend->getFileList( [ 'dir' => $path ] );
1654
			foreach ( $iterator as $name ) {
0 ignored issues
show
Bug introduced by
The expression $iterator of type object<Traversable>|array|null is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
1655
				// Each item returned is a public file
1656
				call_user_func( $callback, "{$path}/{$name}" );
1657
			}
1658
		}
1659
	}
1660
1661
	/**
1662
	 * Determine if a relative path is valid, i.e. not blank or involving directory traveral
1663
	 *
1664
	 * @param string $filename
1665
	 * @return bool
1666
	 */
1667
	public function validateFilename( $filename ) {
1668
		if ( strval( $filename ) == '' ) {
1669
			return false;
1670
		}
1671
1672
		return FileBackend::isPathTraversalFree( $filename );
1673
	}
1674
1675
	/**
1676
	 * Get a callback function to use for cleaning error message parameters
1677
	 *
1678
	 * @return array
1679
	 */
1680
	function getErrorCleanupFunction() {
1681
		switch ( $this->pathDisclosureProtection ) {
1682
			case 'none':
1683
			case 'simple': // b/c
1684
				$callback = [ $this, 'passThrough' ];
1685
				break;
1686
			default: // 'paranoid'
1687
				$callback = [ $this, 'paranoidClean' ];
1688
		}
1689
		return $callback;
1690
	}
1691
1692
	/**
1693
	 * Path disclosure protection function
1694
	 *
1695
	 * @param string $param
1696
	 * @return string
1697
	 */
1698
	function paranoidClean( $param ) {
1699
		return '[hidden]';
1700
	}
1701
1702
	/**
1703
	 * Path disclosure protection function
1704
	 *
1705
	 * @param string $param
1706
	 * @return string
1707
	 */
1708
	function passThrough( $param ) {
1709
		return $param;
1710
	}
1711
1712
	/**
1713
	 * Create a new fatal error
1714
	 *
1715
	 * @param string $message
1716
	 * @return Status
1717
	 */
1718
	public function newFatal( $message /*, parameters...*/ ) {
1719
		$status = call_user_func_array( [ 'Status', 'newFatal' ], func_get_args() );
1720
		$status->cleanCallback = $this->getErrorCleanupFunction();
1721
1722
		return $status;
1723
	}
1724
1725
	/**
1726
	 * Create a new good result
1727
	 *
1728
	 * @param null|string $value
1729
	 * @return Status
1730
	 */
1731
	public function newGood( $value = null ) {
1732
		$status = Status::newGood( $value );
1733
		$status->cleanCallback = $this->getErrorCleanupFunction();
1734
1735
		return $status;
1736
	}
1737
1738
	/**
1739
	 * Checks if there is a redirect named as $title. If there is, return the
1740
	 * title object. If not, return false.
1741
	 * STUB
1742
	 *
1743
	 * @param Title $title Title of image
1744
	 * @return bool
1745
	 */
1746
	public function checkRedirect( Title $title ) {
1747
		return false;
1748
	}
1749
1750
	/**
1751
	 * Invalidates image redirect cache related to that image
1752
	 * Doesn't do anything for repositories that don't support image redirects.
1753
	 *
1754
	 * STUB
1755
	 * @param Title $title Title of image
1756
	 */
1757
	public function invalidateImageRedirect( Title $title ) {
1758
	}
1759
1760
	/**
1761
	 * Get the human-readable name of the repo
1762
	 *
1763
	 * @return string
1764
	 */
1765
	public function getDisplayName() {
1766
		global $wgSitename;
1767
1768
		if ( $this->isLocal() ) {
1769
			return $wgSitename;
1770
		}
1771
1772
		// 'shared-repo-name-wikimediacommons' is used when $wgUseInstantCommons = true
1773
		return wfMessageFallback( 'shared-repo-name-' . $this->name, 'shared-repo' )->text();
1774
	}
1775
1776
	/**
1777
	 * Get the portion of the file that contains the origin file name.
1778
	 * If that name is too long, then the name "thumbnail.<ext>" will be given.
1779
	 *
1780
	 * @param string $name
1781
	 * @return string
1782
	 */
1783
	public function nameForThumb( $name ) {
1784
		if ( strlen( $name ) > $this->abbrvThreshold ) {
1785
			$ext = FileBackend::extensionFromPath( $name );
1786
			$name = ( $ext == '' ) ? 'thumbnail' : "thumbnail.$ext";
1787
		}
1788
1789
		return $name;
1790
	}
1791
1792
	/**
1793
	 * Returns true if this the local file repository.
1794
	 *
1795
	 * @return bool
1796
	 */
1797
	public function isLocal() {
1798
		return $this->getName() == 'local';
1799
	}
1800
1801
	/**
1802
	 * Get a key on the primary cache for this repository.
1803
	 * Returns false if the repository's cache is not accessible at this site.
1804
	 * The parameters are the parts of the key, as for wfMemcKey().
1805
	 *
1806
	 * STUB
1807
	 * @return bool
1808
	 */
1809
	public function getSharedCacheKey( /*...*/ ) {
1810
		return false;
1811
	}
1812
1813
	/**
1814
	 * Get a key for this repo in the local cache domain. These cache keys are
1815
	 * not shared with remote instances of the repo.
1816
	 * The parameters are the parts of the key, as for wfMemcKey().
1817
	 *
1818
	 * @return string
1819
	 */
1820
	public function getLocalCacheKey( /*...*/ ) {
1821
		$args = func_get_args();
1822
		array_unshift( $args, 'filerepo', $this->getName() );
1823
1824
		return call_user_func_array( 'wfMemcKey', $args );
1825
	}
1826
1827
	/**
1828
	 * Get a temporary private FileRepo associated with this repo.
1829
	 *
1830
	 * Files will be created in the temp zone of this repo.
1831
	 * It will have the same backend as this repo.
1832
	 *
1833
	 * @return TempFileRepo
1834
	 */
1835
	public function getTempRepo() {
1836
		return new TempFileRepo( [
1837
			'name' => "{$this->name}-temp",
1838
			'backend' => $this->backend,
1839
			'zones' => [
1840
				'public' => [
1841
					// Same place storeTemp() uses in the base repo, though
1842
					// the path hashing is mismatched, which is annoying.
1843
					'container' => $this->zones['temp']['container'],
1844
					'directory' => $this->zones['temp']['directory']
1845
				],
1846
				'thumb' => [
1847
					'container' => $this->zones['temp']['container'],
1848
					'directory' => $this->zones['temp']['directory'] == ''
1849
						? 'thumb'
1850
						: $this->zones['temp']['directory'] . '/thumb'
1851
				],
1852
				'transcoded' => [
1853
					'container' => $this->zones['temp']['container'],
1854
					'directory' => $this->zones['temp']['directory'] == ''
1855
						? 'transcoded'
1856
						: $this->zones['temp']['directory'] . '/transcoded'
1857
				]
1858
			],
1859
			'hashLevels' => $this->hashLevels, // performance
1860
			'isPrivate' => true // all in temp zone
1861
		] );
1862
	}
1863
1864
	/**
1865
	 * Get an UploadStash associated with this repo.
1866
	 *
1867
	 * @param User $user
1868
	 * @return UploadStash
1869
	 */
1870
	public function getUploadStash( User $user = null ) {
1871
		return new UploadStash( $this, $user );
1872
	}
1873
1874
	/**
1875
	 * Throw an exception if this repo is read-only by design.
1876
	 * This does not and should not check getReadOnlyReason().
1877
	 *
1878
	 * @return void
1879
	 * @throws MWException
1880
	 */
1881
	protected function assertWritableRepo() {
1882
	}
1883
1884
	/**
1885
	 * Return information about the repository.
1886
	 *
1887
	 * @return array
1888
	 * @since 1.22
1889
	 */
1890
	public function getInfo() {
1891
		$ret = [
1892
			'name' => $this->getName(),
1893
			'displayname' => $this->getDisplayName(),
1894
			'rootUrl' => $this->getZoneUrl( 'public' ),
1895
			'local' => $this->isLocal(),
1896
		];
1897
1898
		$optionalSettings = [
1899
			'url', 'thumbUrl', 'initialCapital', 'descBaseUrl', 'scriptDirUrl', 'articleUrl',
1900
			'fetchDescription', 'descriptionCacheExpiry', 'scriptExtension', 'favicon'
1901
		];
1902
		foreach ( $optionalSettings as $k ) {
1903
			if ( isset( $this->$k ) ) {
1904
				$ret[$k] = $this->$k;
1905
			}
1906
		}
1907
1908
		return $ret;
1909
	}
1910
1911
	/**
1912
	 * Returns whether or not storage is SHA-1 based
1913
	 * @return bool
1914
	 */
1915
	public function hasSha1Storage() {
1916
		return $this->hasSha1Storage;
1917
	}
1918
1919
	/**
1920
	 * Returns whether or not repo supports having originals SHA-1s in the thumb URLs
1921
	 * @return bool
1922
	 */
1923
	public function supportsSha1URLs() {
1924
		return $this->supportsSha1URLs;
1925
	}
1926
}
1927
1928
/**
1929
 * FileRepo for temporary files created via FileRepo::getTempRepo()
1930
 */
1931
class TempFileRepo extends FileRepo {
1932
	public function getTempRepo() {
1933
		throw new MWException( "Cannot get a temp repo from a temp repo." );
1934
	}
1935
}
1936