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