MediaWiki
REL1_24
|
00001 <?php 00037 class FileRepo { 00038 const DELETE_SOURCE = 1; 00039 const OVERWRITE = 2; 00040 const OVERWRITE_SAME = 4; 00041 const SKIP_LOCKING = 8; 00042 00043 const NAME_AND_TIME_ONLY = 1; 00044 00047 public $fetchDescription; 00048 00050 public $descriptionCacheExpiry; 00051 00053 protected $backend; 00054 00056 protected $zones = array(); 00057 00059 protected $thumbScriptUrl; 00060 00063 protected $transformVia404; 00064 00068 protected $descBaseUrl; 00069 00073 protected $scriptDirUrl; 00074 00077 protected $scriptExtension; 00078 00080 protected $articleUrl; 00081 00087 protected $initialCapital; 00088 00094 protected $pathDisclosureProtection = 'simple'; 00095 00097 protected $url; 00098 00100 protected $thumbUrl; 00101 00103 protected $hashLevels; 00104 00106 protected $deletedHashLevels; 00107 00112 protected $abbrvThreshold; 00113 00115 protected $favicon; 00116 00121 protected $fileFactory = array( 'UnregisteredLocalFile', 'newFromTitle' ); 00122 protected $oldFileFactory = false; 00123 protected $fileFactoryKey = false; 00124 protected $oldFileFactoryKey = false; 00125 00130 public function __construct( array $info = null ) { 00131 // Verify required settings presence 00132 if ( 00133 $info === null 00134 || !array_key_exists( 'name', $info ) 00135 || !array_key_exists( 'backend', $info ) 00136 ) { 00137 throw new MWException( __CLASS__ . 00138 " requires an array of options having both 'name' and 'backend' keys.\n" ); 00139 } 00140 00141 // Required settings 00142 $this->name = $info['name']; 00143 if ( $info['backend'] instanceof FileBackend ) { 00144 $this->backend = $info['backend']; // useful for testing 00145 } else { 00146 $this->backend = FileBackendGroup::singleton()->get( $info['backend'] ); 00147 } 00148 00149 // Optional settings that can have no value 00150 $optionalSettings = array( 00151 'descBaseUrl', 'scriptDirUrl', 'articleUrl', 'fetchDescription', 00152 'thumbScriptUrl', 'pathDisclosureProtection', 'descriptionCacheExpiry', 00153 'scriptExtension', 'favicon' 00154 ); 00155 foreach ( $optionalSettings as $var ) { 00156 if ( isset( $info[$var] ) ) { 00157 $this->$var = $info[$var]; 00158 } 00159 } 00160 00161 // Optional settings that have a default 00162 $this->initialCapital = isset( $info['initialCapital'] ) 00163 ? $info['initialCapital'] 00164 : MWNamespace::isCapitalized( NS_FILE ); 00165 $this->url = isset( $info['url'] ) 00166 ? $info['url'] 00167 : false; // a subclass may set the URL (e.g. ForeignAPIRepo) 00168 if ( isset( $info['thumbUrl'] ) ) { 00169 $this->thumbUrl = $info['thumbUrl']; 00170 } else { 00171 $this->thumbUrl = $this->url ? "{$this->url}/thumb" : false; 00172 } 00173 $this->hashLevels = isset( $info['hashLevels'] ) 00174 ? $info['hashLevels'] 00175 : 2; 00176 $this->deletedHashLevels = isset( $info['deletedHashLevels'] ) 00177 ? $info['deletedHashLevels'] 00178 : $this->hashLevels; 00179 $this->transformVia404 = !empty( $info['transformVia404'] ); 00180 $this->abbrvThreshold = isset( $info['abbrvThreshold'] ) 00181 ? $info['abbrvThreshold'] 00182 : 255; 00183 $this->isPrivate = !empty( $info['isPrivate'] ); 00184 // Give defaults for the basic zones... 00185 $this->zones = isset( $info['zones'] ) ? $info['zones'] : array(); 00186 foreach ( array( 'public', 'thumb', 'transcoded', 'temp', 'deleted' ) as $zone ) { 00187 if ( !isset( $this->zones[$zone]['container'] ) ) { 00188 $this->zones[$zone]['container'] = "{$this->name}-{$zone}"; 00189 } 00190 if ( !isset( $this->zones[$zone]['directory'] ) ) { 00191 $this->zones[$zone]['directory'] = ''; 00192 } 00193 if ( !isset( $this->zones[$zone]['urlsByExt'] ) ) { 00194 $this->zones[$zone]['urlsByExt'] = array(); 00195 } 00196 } 00197 } 00198 00204 public function getBackend() { 00205 return $this->backend; 00206 } 00207 00214 public function getReadOnlyReason() { 00215 return $this->backend->getReadOnlyReason(); 00216 } 00217 00225 protected function initZones( $doZones = array() ) { 00226 $status = $this->newGood(); 00227 foreach ( (array)$doZones as $zone ) { 00228 $root = $this->getZonePath( $zone ); 00229 if ( $root === null ) { 00230 throw new MWException( "No '$zone' zone defined in the {$this->name} repo." ); 00231 } 00232 } 00233 00234 return $status; 00235 } 00236 00243 public static function isVirtualUrl( $url ) { 00244 return substr( $url, 0, 9 ) == 'mwrepo://'; 00245 } 00246 00255 public function getVirtualUrl( $suffix = false ) { 00256 $path = 'mwrepo://' . $this->name; 00257 if ( $suffix !== false ) { 00258 $path .= '/' . rawurlencode( $suffix ); 00259 } 00260 00261 return $path; 00262 } 00263 00271 public function getZoneUrl( $zone, $ext = null ) { 00272 if ( in_array( $zone, array( 'public', 'temp', 'thumb', 'transcoded' ) ) ) { 00273 // standard public zones 00274 if ( $ext !== null && isset( $this->zones[$zone]['urlsByExt'][$ext] ) ) { 00275 // custom URL for extension/zone 00276 return $this->zones[$zone]['urlsByExt'][$ext]; 00277 } elseif ( isset( $this->zones[$zone]['url'] ) ) { 00278 // custom URL for zone 00279 return $this->zones[$zone]['url']; 00280 } 00281 } 00282 switch ( $zone ) { 00283 case 'public': 00284 return $this->url; 00285 case 'temp': 00286 return "{$this->url}/temp"; 00287 case 'deleted': 00288 return false; // no public URL 00289 case 'thumb': 00290 return $this->thumbUrl; 00291 case 'transcoded': 00292 return "{$this->url}/transcoded"; 00293 default: 00294 return false; 00295 } 00296 } 00297 00301 public function backendSupportsUnicodePaths() { 00302 return ( $this->getBackend()->getFeatures() & FileBackend::ATTR_UNICODE_PATHS ); 00303 } 00304 00313 public function resolveVirtualUrl( $url ) { 00314 if ( substr( $url, 0, 9 ) != 'mwrepo://' ) { 00315 throw new MWException( __METHOD__ . ': unknown protocol' ); 00316 } 00317 $bits = explode( '/', substr( $url, 9 ), 3 ); 00318 if ( count( $bits ) != 3 ) { 00319 throw new MWException( __METHOD__ . ": invalid mwrepo URL: $url" ); 00320 } 00321 list( $repo, $zone, $rel ) = $bits; 00322 if ( $repo !== $this->name ) { 00323 throw new MWException( __METHOD__ . ": fetching from a foreign repo is not supported" ); 00324 } 00325 $base = $this->getZonePath( $zone ); 00326 if ( !$base ) { 00327 throw new MWException( __METHOD__ . ": invalid zone: $zone" ); 00328 } 00329 00330 return $base . '/' . rawurldecode( $rel ); 00331 } 00332 00339 protected function getZoneLocation( $zone ) { 00340 if ( !isset( $this->zones[$zone] ) ) { 00341 return array( null, null ); // bogus 00342 } 00343 00344 return array( $this->zones[$zone]['container'], $this->zones[$zone]['directory'] ); 00345 } 00346 00353 public function getZonePath( $zone ) { 00354 list( $container, $base ) = $this->getZoneLocation( $zone ); 00355 if ( $container === null || $base === null ) { 00356 return null; 00357 } 00358 $backendName = $this->backend->getName(); 00359 if ( $base != '' ) { // may not be set 00360 $base = "/{$base}"; 00361 } 00362 00363 return "mwstore://$backendName/{$container}{$base}"; 00364 } 00365 00377 public function newFile( $title, $time = false ) { 00378 $title = File::normalizeTitle( $title ); 00379 if ( !$title ) { 00380 return null; 00381 } 00382 if ( $time ) { 00383 if ( $this->oldFileFactory ) { 00384 return call_user_func( $this->oldFileFactory, $title, $this, $time ); 00385 } else { 00386 return false; 00387 } 00388 } else { 00389 return call_user_func( $this->fileFactory, $title, $this ); 00390 } 00391 } 00392 00409 public function findFile( $title, $options = array() ) { 00410 $title = File::normalizeTitle( $title ); 00411 if ( !$title ) { 00412 return false; 00413 } 00414 $time = isset( $options['time'] ) ? $options['time'] : false; 00415 # First try the current version of the file to see if it precedes the timestamp 00416 $img = $this->newFile( $title ); 00417 if ( !$img ) { 00418 return false; 00419 } 00420 if ( $img->exists() && ( !$time || $img->getTimestamp() == $time ) ) { 00421 return $img; 00422 } 00423 # Now try an old version of the file 00424 if ( $time !== false ) { 00425 $img = $this->newFile( $title, $time ); 00426 if ( $img && $img->exists() ) { 00427 if ( !$img->isDeleted( File::DELETED_FILE ) ) { 00428 return $img; // always OK 00429 } elseif ( !empty( $options['private'] ) && 00430 $img->userCan( File::DELETED_FILE, 00431 $options['private'] instanceof User ? $options['private'] : null 00432 ) 00433 ) { 00434 return $img; 00435 } 00436 } 00437 } 00438 00439 # Now try redirects 00440 if ( !empty( $options['ignoreRedirect'] ) ) { 00441 return false; 00442 } 00443 $redir = $this->checkRedirect( $title ); 00444 if ( $redir && $title->getNamespace() == NS_FILE ) { 00445 $img = $this->newFile( $redir ); 00446 if ( !$img ) { 00447 return false; 00448 } 00449 if ( $img->exists() ) { 00450 $img->redirectedFrom( $title->getDBkey() ); 00451 00452 return $img; 00453 } 00454 } 00455 00456 return false; 00457 } 00458 00476 public function findFiles( array $items, $flags = 0 ) { 00477 $result = array(); 00478 foreach ( $items as $item ) { 00479 if ( is_array( $item ) ) { 00480 $title = $item['title']; 00481 $options = $item; 00482 unset( $options['title'] ); 00483 } else { 00484 $title = $item; 00485 $options = array(); 00486 } 00487 $file = $this->findFile( $title, $options ); 00488 if ( $file ) { 00489 $searchName = File::normalizeTitle( $title )->getDBkey(); // must be valid 00490 if ( $flags & self::NAME_AND_TIME_ONLY ) { 00491 $result[$searchName] = array( 00492 'title' => $file->getTitle()->getDBkey(), 00493 'timestamp' => $file->getTimestamp() 00494 ); 00495 } else { 00496 $result[$searchName] = $file; 00497 } 00498 } 00499 } 00500 00501 return $result; 00502 } 00503 00513 public function findFileFromKey( $sha1, $options = array() ) { 00514 $time = isset( $options['time'] ) ? $options['time'] : false; 00515 # First try to find a matching current version of a file... 00516 if ( $this->fileFactoryKey ) { 00517 $img = call_user_func( $this->fileFactoryKey, $sha1, $this, $time ); 00518 } else { 00519 return false; // find-by-sha1 not supported 00520 } 00521 if ( $img && $img->exists() ) { 00522 return $img; 00523 } 00524 # Now try to find a matching old version of a file... 00525 if ( $time !== false && $this->oldFileFactoryKey ) { // find-by-sha1 supported? 00526 $img = call_user_func( $this->oldFileFactoryKey, $sha1, $this, $time ); 00527 if ( $img && $img->exists() ) { 00528 if ( !$img->isDeleted( File::DELETED_FILE ) ) { 00529 return $img; // always OK 00530 } elseif ( !empty( $options['private'] ) && 00531 $img->userCan( File::DELETED_FILE, 00532 $options['private'] instanceof User ? $options['private'] : null 00533 ) 00534 ) { 00535 return $img; 00536 } 00537 } 00538 } 00539 00540 return false; 00541 } 00542 00551 public function findBySha1( $hash ) { 00552 return array(); 00553 } 00554 00562 public function findBySha1s( array $hashes ) { 00563 $result = array(); 00564 foreach ( $hashes as $hash ) { 00565 $files = $this->findBySha1( $hash ); 00566 if ( count( $files ) ) { 00567 $result[$hash] = $files; 00568 } 00569 } 00570 00571 return $result; 00572 } 00573 00582 public function findFilesByPrefix( $prefix, $limit ) { 00583 return array(); 00584 } 00585 00592 public function getRootUrl() { 00593 return $this->getZoneUrl( 'public' ); 00594 } 00595 00601 public function getThumbScriptUrl() { 00602 return $this->thumbScriptUrl; 00603 } 00604 00610 public function canTransformVia404() { 00611 return $this->transformVia404; 00612 } 00613 00620 public function getNameFromTitle( Title $title ) { 00621 global $wgContLang; 00622 if ( $this->initialCapital != MWNamespace::isCapitalized( NS_FILE ) ) { 00623 $name = $title->getUserCaseDBKey(); 00624 if ( $this->initialCapital ) { 00625 $name = $wgContLang->ucfirst( $name ); 00626 } 00627 } else { 00628 $name = $title->getDBkey(); 00629 } 00630 00631 return $name; 00632 } 00633 00639 public function getRootDirectory() { 00640 return $this->getZonePath( 'public' ); 00641 } 00642 00650 public function getHashPath( $name ) { 00651 return self::getHashPathForLevel( $name, $this->hashLevels ); 00652 } 00653 00661 public function getTempHashPath( $suffix ) { 00662 $parts = explode( '!', $suffix, 2 ); // format is <timestamp>!<name> or just <name> 00663 $name = isset( $parts[1] ) ? $parts[1] : $suffix; // hash path is not based on timestamp 00664 return self::getHashPathForLevel( $name, $this->hashLevels ); 00665 } 00666 00672 protected static function getHashPathForLevel( $name, $levels ) { 00673 if ( $levels == 0 ) { 00674 return ''; 00675 } else { 00676 $hash = md5( $name ); 00677 $path = ''; 00678 for ( $i = 1; $i <= $levels; $i++ ) { 00679 $path .= substr( $hash, 0, $i ) . '/'; 00680 } 00681 00682 return $path; 00683 } 00684 } 00685 00691 public function getHashLevels() { 00692 return $this->hashLevels; 00693 } 00694 00700 public function getName() { 00701 return $this->name; 00702 } 00703 00711 public function makeUrl( $query = '', $entry = 'index' ) { 00712 if ( isset( $this->scriptDirUrl ) ) { 00713 $ext = isset( $this->scriptExtension ) ? $this->scriptExtension : '.php'; 00714 00715 return wfAppendQuery( "{$this->scriptDirUrl}/{$entry}{$ext}", $query ); 00716 } 00717 00718 return false; 00719 } 00720 00733 public function getDescriptionUrl( $name ) { 00734 $encName = wfUrlencode( $name ); 00735 if ( !is_null( $this->descBaseUrl ) ) { 00736 # "http://example.com/wiki/File:" 00737 return $this->descBaseUrl . $encName; 00738 } 00739 if ( !is_null( $this->articleUrl ) ) { 00740 # "http://example.com/wiki/$1" 00741 # 00742 # We use "Image:" as the canonical namespace for 00743 # compatibility across all MediaWiki versions. 00744 return str_replace( '$1', 00745 "Image:$encName", $this->articleUrl ); 00746 } 00747 if ( !is_null( $this->scriptDirUrl ) ) { 00748 # "http://example.com/w" 00749 # 00750 # We use "Image:" as the canonical namespace for 00751 # compatibility across all MediaWiki versions, 00752 # and just sort of hope index.php is right. ;) 00753 return $this->makeUrl( "title=Image:$encName" ); 00754 } 00755 00756 return false; 00757 } 00758 00769 public function getDescriptionRenderUrl( $name, $lang = null ) { 00770 $query = 'action=render'; 00771 if ( !is_null( $lang ) ) { 00772 $query .= '&uselang=' . $lang; 00773 } 00774 if ( isset( $this->scriptDirUrl ) ) { 00775 return $this->makeUrl( 00776 'title=' . 00777 wfUrlencode( 'Image:' . $name ) . 00778 "&$query" ); 00779 } else { 00780 $descUrl = $this->getDescriptionUrl( $name ); 00781 if ( $descUrl ) { 00782 return wfAppendQuery( $descUrl, $query ); 00783 } else { 00784 return false; 00785 } 00786 } 00787 } 00788 00794 public function getDescriptionStylesheetUrl() { 00795 if ( isset( $this->scriptDirUrl ) ) { 00796 return $this->makeUrl( 'title=MediaWiki:Filepage.css&' . 00797 wfArrayToCgi( Skin::getDynamicStylesheetQuery() ) ); 00798 } 00799 00800 return false; 00801 } 00802 00817 public function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) { 00818 $this->assertWritableRepo(); // fail out if read-only 00819 00820 $status = $this->storeBatch( array( array( $srcPath, $dstZone, $dstRel ) ), $flags ); 00821 if ( $status->successCount == 0 ) { 00822 $status->ok = false; 00823 } 00824 00825 return $status; 00826 } 00827 00841 public function storeBatch( array $triplets, $flags = 0 ) { 00842 $this->assertWritableRepo(); // fail out if read-only 00843 00844 $status = $this->newGood(); 00845 $backend = $this->backend; // convenience 00846 00847 $operations = array(); 00848 $sourceFSFilesToDelete = array(); // cleanup for disk source files 00849 // Validate each triplet and get the store operation... 00850 foreach ( $triplets as $triplet ) { 00851 list( $srcPath, $dstZone, $dstRel ) = $triplet; 00852 wfDebug( __METHOD__ 00853 . "( \$src='$srcPath', \$dstZone='$dstZone', \$dstRel='$dstRel' )\n" 00854 ); 00855 00856 // Resolve destination path 00857 $root = $this->getZonePath( $dstZone ); 00858 if ( !$root ) { 00859 throw new MWException( "Invalid zone: $dstZone" ); 00860 } 00861 if ( !$this->validateFilename( $dstRel ) ) { 00862 throw new MWException( 'Validation error in $dstRel' ); 00863 } 00864 $dstPath = "$root/$dstRel"; 00865 $dstDir = dirname( $dstPath ); 00866 // Create destination directories for this triplet 00867 if ( !$this->initDirectory( $dstDir )->isOK() ) { 00868 return $this->newFatal( 'directorycreateerror', $dstDir ); 00869 } 00870 00871 // Resolve source to a storage path if virtual 00872 $srcPath = $this->resolveToStoragePath( $srcPath ); 00873 00874 // Get the appropriate file operation 00875 if ( FileBackend::isStoragePath( $srcPath ) ) { 00876 $opName = ( $flags & self::DELETE_SOURCE ) ? 'move' : 'copy'; 00877 } else { 00878 $opName = 'store'; 00879 if ( $flags & self::DELETE_SOURCE ) { 00880 $sourceFSFilesToDelete[] = $srcPath; 00881 } 00882 } 00883 $operations[] = array( 00884 'op' => $opName, 00885 'src' => $srcPath, 00886 'dst' => $dstPath, 00887 'overwrite' => $flags & self::OVERWRITE, 00888 'overwriteSame' => $flags & self::OVERWRITE_SAME, 00889 ); 00890 } 00891 00892 // Execute the store operation for each triplet 00893 $opts = array( 'force' => true ); 00894 if ( $flags & self::SKIP_LOCKING ) { 00895 $opts['nonLocking'] = true; 00896 } 00897 $status->merge( $backend->doOperations( $operations, $opts ) ); 00898 // Cleanup for disk source files... 00899 foreach ( $sourceFSFilesToDelete as $file ) { 00900 wfSuppressWarnings(); 00901 unlink( $file ); // FS cleanup 00902 wfRestoreWarnings(); 00903 } 00904 00905 return $status; 00906 } 00907 00918 public function cleanupBatch( array $files, $flags = 0 ) { 00919 $this->assertWritableRepo(); // fail out if read-only 00920 00921 $status = $this->newGood(); 00922 00923 $operations = array(); 00924 foreach ( $files as $path ) { 00925 if ( is_array( $path ) ) { 00926 // This is a pair, extract it 00927 list( $zone, $rel ) = $path; 00928 $path = $this->getZonePath( $zone ) . "/$rel"; 00929 } else { 00930 // Resolve source to a storage path if virtual 00931 $path = $this->resolveToStoragePath( $path ); 00932 } 00933 $operations[] = array( 'op' => 'delete', 'src' => $path ); 00934 } 00935 // Actually delete files from storage... 00936 $opts = array( 'force' => true ); 00937 if ( $flags & self::SKIP_LOCKING ) { 00938 $opts['nonLocking'] = true; 00939 } 00940 $status->merge( $this->backend->doOperations( $operations, $opts ) ); 00941 00942 return $status; 00943 } 00944 00958 final public function quickImport( $src, $dst, $options = null ) { 00959 return $this->quickImportBatch( array( array( $src, $dst, $options ) ) ); 00960 } 00961 00970 final public function quickPurge( $path ) { 00971 return $this->quickPurgeBatch( array( $path ) ); 00972 } 00973 00981 public function quickCleanDir( $dir ) { 00982 $status = $this->newGood(); 00983 $status->merge( $this->backend->clean( 00984 array( 'dir' => $this->resolveToStoragePath( $dir ) ) ) ); 00985 00986 return $status; 00987 } 00988 01001 public function quickImportBatch( array $triples ) { 01002 $status = $this->newGood(); 01003 $operations = array(); 01004 foreach ( $triples as $triple ) { 01005 list( $src, $dst ) = $triple; 01006 $src = $this->resolveToStoragePath( $src ); 01007 $dst = $this->resolveToStoragePath( $dst ); 01008 01009 if ( !isset( $triple[2] ) ) { 01010 $headers = array(); 01011 } elseif ( is_string( $triple[2] ) ) { 01012 // back-compat 01013 $headers = array( 'Content-Disposition' => $triple[2] ); 01014 } elseif ( is_array( $triple[2] ) && isset( $triple[2]['headers'] ) ) { 01015 $headers = $triple[2]['headers']; 01016 } 01017 // @fixme: $headers might not be defined 01018 $operations[] = array( 01019 'op' => FileBackend::isStoragePath( $src ) ? 'copy' : 'store', 01020 'src' => $src, 01021 'dst' => $dst, 01022 'headers' => $headers 01023 ); 01024 $status->merge( $this->initDirectory( dirname( $dst ) ) ); 01025 } 01026 $status->merge( $this->backend->doQuickOperations( $operations ) ); 01027 01028 return $status; 01029 } 01030 01039 public function quickPurgeBatch( array $paths ) { 01040 $status = $this->newGood(); 01041 $operations = array(); 01042 foreach ( $paths as $path ) { 01043 $operations[] = array( 01044 'op' => 'delete', 01045 'src' => $this->resolveToStoragePath( $path ), 01046 'ignoreMissingSource' => true 01047 ); 01048 } 01049 $status->merge( $this->backend->doQuickOperations( $operations ) ); 01050 01051 return $status; 01052 } 01053 01064 public function storeTemp( $originalName, $srcPath ) { 01065 $this->assertWritableRepo(); // fail out if read-only 01066 01067 $date = MWTimestamp::getInstance()->format( 'YmdHis' ); 01068 $hashPath = $this->getHashPath( $originalName ); 01069 $dstUrlRel = $hashPath . $date . '!' . rawurlencode( $originalName ); 01070 $virtualUrl = $this->getVirtualUrl( 'temp' ) . '/' . $dstUrlRel; 01071 01072 $result = $this->quickImport( $srcPath, $virtualUrl ); 01073 $result->value = $virtualUrl; 01074 01075 return $result; 01076 } 01077 01084 public function freeTemp( $virtualUrl ) { 01085 $this->assertWritableRepo(); // fail out if read-only 01086 01087 $temp = $this->getVirtualUrl( 'temp' ); 01088 if ( substr( $virtualUrl, 0, strlen( $temp ) ) != $temp ) { 01089 wfDebug( __METHOD__ . ": Invalid temp virtual URL\n" ); 01090 01091 return false; 01092 } 01093 01094 return $this->quickPurge( $virtualUrl )->isOK(); 01095 } 01096 01106 public function concatenate( array $srcPaths, $dstPath, $flags = 0 ) { 01107 $this->assertWritableRepo(); // fail out if read-only 01108 01109 $status = $this->newGood(); 01110 01111 $sources = array(); 01112 foreach ( $srcPaths as $srcPath ) { 01113 // Resolve source to a storage path if virtual 01114 $source = $this->resolveToStoragePath( $srcPath ); 01115 $sources[] = $source; // chunk to merge 01116 } 01117 01118 // Concatenate the chunks into one FS file 01119 $params = array( 'srcs' => $sources, 'dst' => $dstPath ); 01120 $status->merge( $this->backend->concatenate( $params ) ); 01121 if ( !$status->isOK() ) { 01122 return $status; 01123 } 01124 01125 // Delete the sources if required 01126 if ( $flags & self::DELETE_SOURCE ) { 01127 $status->merge( $this->quickPurgeBatch( $srcPaths ) ); 01128 } 01129 01130 // Make sure status is OK, despite any quickPurgeBatch() fatals 01131 $status->setResult( true ); 01132 01133 return $status; 01134 } 01135 01155 public function publish( 01156 $srcPath, $dstRel, $archiveRel, $flags = 0, array $options = array() 01157 ) { 01158 $this->assertWritableRepo(); // fail out if read-only 01159 01160 $status = $this->publishBatch( 01161 array( array( $srcPath, $dstRel, $archiveRel, $options ) ), $flags ); 01162 if ( $status->successCount == 0 ) { 01163 $status->ok = false; 01164 } 01165 if ( isset( $status->value[0] ) ) { 01166 $status->value = $status->value[0]; 01167 } else { 01168 $status->value = false; 01169 } 01170 01171 return $status; 01172 } 01173 01184 public function publishBatch( array $ntuples, $flags = 0 ) { 01185 $this->assertWritableRepo(); // fail out if read-only 01186 01187 $backend = $this->backend; // convenience 01188 // Try creating directories 01189 $status = $this->initZones( 'public' ); 01190 if ( !$status->isOK() ) { 01191 return $status; 01192 } 01193 01194 $status = $this->newGood( array() ); 01195 01196 $operations = array(); 01197 $sourceFSFilesToDelete = array(); // cleanup for disk source files 01198 // Validate each triplet and get the store operation... 01199 foreach ( $ntuples as $ntuple ) { 01200 list( $srcPath, $dstRel, $archiveRel ) = $ntuple; 01201 $options = isset( $ntuple[3] ) ? $ntuple[3] : array(); 01202 // Resolve source to a storage path if virtual 01203 $srcPath = $this->resolveToStoragePath( $srcPath ); 01204 if ( !$this->validateFilename( $dstRel ) ) { 01205 throw new MWException( 'Validation error in $dstRel' ); 01206 } 01207 if ( !$this->validateFilename( $archiveRel ) ) { 01208 throw new MWException( 'Validation error in $archiveRel' ); 01209 } 01210 01211 $publicRoot = $this->getZonePath( 'public' ); 01212 $dstPath = "$publicRoot/$dstRel"; 01213 $archivePath = "$publicRoot/$archiveRel"; 01214 01215 $dstDir = dirname( $dstPath ); 01216 $archiveDir = dirname( $archivePath ); 01217 // Abort immediately on directory creation errors since they're likely to be repetitive 01218 if ( !$this->initDirectory( $dstDir )->isOK() ) { 01219 return $this->newFatal( 'directorycreateerror', $dstDir ); 01220 } 01221 if ( !$this->initDirectory( $archiveDir )->isOK() ) { 01222 return $this->newFatal( 'directorycreateerror', $archiveDir ); 01223 } 01224 01225 // Set any desired headers to be use in GET/HEAD responses 01226 $headers = isset( $options['headers'] ) ? $options['headers'] : array(); 01227 01228 // Archive destination file if it exists. 01229 // This will check if the archive file also exists and fail if does. 01230 // This is a sanity check to avoid data loss. On Windows and Linux, 01231 // copy() will overwrite, so the existence check is vulnerable to 01232 // race conditions unless a functioning LockManager is used. 01233 // LocalFile also uses SELECT FOR UPDATE for synchronization. 01234 $operations[] = array( 01235 'op' => 'copy', 01236 'src' => $dstPath, 01237 'dst' => $archivePath, 01238 'ignoreMissingSource' => true 01239 ); 01240 01241 // Copy (or move) the source file to the destination 01242 if ( FileBackend::isStoragePath( $srcPath ) ) { 01243 if ( $flags & self::DELETE_SOURCE ) { 01244 $operations[] = array( 01245 'op' => 'move', 01246 'src' => $srcPath, 01247 'dst' => $dstPath, 01248 'overwrite' => true, // replace current 01249 'headers' => $headers 01250 ); 01251 } else { 01252 $operations[] = array( 01253 'op' => 'copy', 01254 'src' => $srcPath, 01255 'dst' => $dstPath, 01256 'overwrite' => true, // replace current 01257 'headers' => $headers 01258 ); 01259 } 01260 } else { // FS source path 01261 $operations[] = array( 01262 'op' => 'store', 01263 'src' => $srcPath, 01264 'dst' => $dstPath, 01265 'overwrite' => true, // replace current 01266 'headers' => $headers 01267 ); 01268 if ( $flags & self::DELETE_SOURCE ) { 01269 $sourceFSFilesToDelete[] = $srcPath; 01270 } 01271 } 01272 } 01273 01274 // Execute the operations for each triplet 01275 $status->merge( $backend->doOperations( $operations ) ); 01276 // Find out which files were archived... 01277 foreach ( $ntuples as $i => $ntuple ) { 01278 list( , , $archiveRel ) = $ntuple; 01279 $archivePath = $this->getZonePath( 'public' ) . "/$archiveRel"; 01280 if ( $this->fileExists( $archivePath ) ) { 01281 $status->value[$i] = 'archived'; 01282 } else { 01283 $status->value[$i] = 'new'; 01284 } 01285 } 01286 // Cleanup for disk source files... 01287 foreach ( $sourceFSFilesToDelete as $file ) { 01288 wfSuppressWarnings(); 01289 unlink( $file ); // FS cleanup 01290 wfRestoreWarnings(); 01291 } 01292 01293 return $status; 01294 } 01295 01303 protected function initDirectory( $dir ) { 01304 $path = $this->resolveToStoragePath( $dir ); 01305 list( , $container, ) = FileBackend::splitStoragePath( $path ); 01306 01307 $params = array( 'dir' => $path ); 01308 if ( $this->isPrivate || $container === $this->zones['deleted']['container'] ) { 01309 # Take all available measures to prevent web accessibility of new deleted 01310 # directories, in case the user has not configured offline storage 01311 $params = array( 'noAccess' => true, 'noListing' => true ) + $params; 01312 } 01313 01314 return $this->backend->prepare( $params ); 01315 } 01316 01323 public function cleanDir( $dir ) { 01324 $this->assertWritableRepo(); // fail out if read-only 01325 01326 $status = $this->newGood(); 01327 $status->merge( $this->backend->clean( 01328 array( 'dir' => $this->resolveToStoragePath( $dir ) ) ) ); 01329 01330 return $status; 01331 } 01332 01339 public function fileExists( $file ) { 01340 $result = $this->fileExistsBatch( array( $file ) ); 01341 01342 return $result[0]; 01343 } 01344 01351 public function fileExistsBatch( array $files ) { 01352 $paths = array_map( array( $this, 'resolveToStoragePath' ), $files ); 01353 $this->backend->preloadFileStat( array( 'srcs' => $paths ) ); 01354 01355 $result = array(); 01356 foreach ( $files as $key => $file ) { 01357 $path = $this->resolveToStoragePath( $file ); 01358 $result[$key] = $this->backend->fileExists( array( 'src' => $path ) ); 01359 } 01360 01361 return $result; 01362 } 01363 01374 public function delete( $srcRel, $archiveRel ) { 01375 $this->assertWritableRepo(); // fail out if read-only 01376 01377 return $this->deleteBatch( array( array( $srcRel, $archiveRel ) ) ); 01378 } 01379 01397 public function deleteBatch( array $sourceDestPairs ) { 01398 $this->assertWritableRepo(); // fail out if read-only 01399 01400 // Try creating directories 01401 $status = $this->initZones( array( 'public', 'deleted' ) ); 01402 if ( !$status->isOK() ) { 01403 return $status; 01404 } 01405 01406 $status = $this->newGood(); 01407 01408 $backend = $this->backend; // convenience 01409 $operations = array(); 01410 // Validate filenames and create archive directories 01411 foreach ( $sourceDestPairs as $pair ) { 01412 list( $srcRel, $archiveRel ) = $pair; 01413 if ( !$this->validateFilename( $srcRel ) ) { 01414 throw new MWException( __METHOD__ . ':Validation error in $srcRel' ); 01415 } elseif ( !$this->validateFilename( $archiveRel ) ) { 01416 throw new MWException( __METHOD__ . ':Validation error in $archiveRel' ); 01417 } 01418 01419 $publicRoot = $this->getZonePath( 'public' ); 01420 $srcPath = "{$publicRoot}/$srcRel"; 01421 01422 $deletedRoot = $this->getZonePath( 'deleted' ); 01423 $archivePath = "{$deletedRoot}/{$archiveRel}"; 01424 $archiveDir = dirname( $archivePath ); // does not touch FS 01425 01426 // Create destination directories 01427 if ( !$this->initDirectory( $archiveDir )->isOK() ) { 01428 return $this->newFatal( 'directorycreateerror', $archiveDir ); 01429 } 01430 01431 $operations[] = array( 01432 'op' => 'move', 01433 'src' => $srcPath, 01434 'dst' => $archivePath, 01435 // We may have 2+ identical files being deleted, 01436 // all of which will map to the same destination file 01437 'overwriteSame' => true // also see bug 31792 01438 ); 01439 } 01440 01441 // Move the files by execute the operations for each pair. 01442 // We're now committed to returning an OK result, which will 01443 // lead to the files being moved in the DB also. 01444 $opts = array( 'force' => true ); 01445 $status->merge( $backend->doOperations( $operations, $opts ) ); 01446 01447 return $status; 01448 } 01449 01456 public function cleanupDeletedBatch( array $storageKeys ) { 01457 $this->assertWritableRepo(); 01458 } 01459 01468 public function getDeletedHashPath( $key ) { 01469 if ( strlen( $key ) < 31 ) { 01470 throw new MWException( "Invalid storage key '$key'." ); 01471 } 01472 $path = ''; 01473 for ( $i = 0; $i < $this->deletedHashLevels; $i++ ) { 01474 $path .= $key[$i] . '/'; 01475 } 01476 01477 return $path; 01478 } 01479 01488 protected function resolveToStoragePath( $path ) { 01489 if ( $this->isVirtualUrl( $path ) ) { 01490 return $this->resolveVirtualUrl( $path ); 01491 } 01492 01493 return $path; 01494 } 01495 01503 public function getLocalCopy( $virtualUrl ) { 01504 $path = $this->resolveToStoragePath( $virtualUrl ); 01505 01506 return $this->backend->getLocalCopy( array( 'src' => $path ) ); 01507 } 01508 01517 public function getLocalReference( $virtualUrl ) { 01518 $path = $this->resolveToStoragePath( $virtualUrl ); 01519 01520 return $this->backend->getLocalReference( array( 'src' => $path ) ); 01521 } 01522 01530 public function getFileProps( $virtualUrl ) { 01531 $path = $this->resolveToStoragePath( $virtualUrl ); 01532 01533 return $this->backend->getFileProps( array( 'src' => $path ) ); 01534 } 01535 01542 public function getFileTimestamp( $virtualUrl ) { 01543 $path = $this->resolveToStoragePath( $virtualUrl ); 01544 01545 return $this->backend->getFileTimestamp( array( 'src' => $path ) ); 01546 } 01547 01554 public function getFileSize( $virtualUrl ) { 01555 $path = $this->resolveToStoragePath( $virtualUrl ); 01556 01557 return $this->backend->getFileSize( array( 'src' => $path ) ); 01558 } 01559 01566 public function getFileSha1( $virtualUrl ) { 01567 $path = $this->resolveToStoragePath( $virtualUrl ); 01568 01569 return $this->backend->getFileSha1Base36( array( 'src' => $path ) ); 01570 } 01571 01579 public function streamFile( $virtualUrl, $headers = array() ) { 01580 $path = $this->resolveToStoragePath( $virtualUrl ); 01581 $params = array( 'src' => $path, 'headers' => $headers ); 01582 01583 return $this->backend->streamFile( $params )->isOK(); 01584 } 01585 01594 public function enumFiles( $callback ) { 01595 $this->enumFilesInStorage( $callback ); 01596 } 01597 01605 protected function enumFilesInStorage( $callback ) { 01606 $publicRoot = $this->getZonePath( 'public' ); 01607 $numDirs = 1 << ( $this->hashLevels * 4 ); 01608 // Use a priori assumptions about directory structure 01609 // to reduce the tree height of the scanning process. 01610 for ( $flatIndex = 0; $flatIndex < $numDirs; $flatIndex++ ) { 01611 $hexString = sprintf( "%0{$this->hashLevels}x", $flatIndex ); 01612 $path = $publicRoot; 01613 for ( $hexPos = 0; $hexPos < $this->hashLevels; $hexPos++ ) { 01614 $path .= '/' . substr( $hexString, 0, $hexPos + 1 ); 01615 } 01616 $iterator = $this->backend->getFileList( array( 'dir' => $path ) ); 01617 foreach ( $iterator as $name ) { 01618 // Each item returned is a public file 01619 call_user_func( $callback, "{$path}/{$name}" ); 01620 } 01621 } 01622 } 01623 01630 public function validateFilename( $filename ) { 01631 if ( strval( $filename ) == '' ) { 01632 return false; 01633 } 01634 01635 return FileBackend::isPathTraversalFree( $filename ); 01636 } 01637 01643 function getErrorCleanupFunction() { 01644 switch ( $this->pathDisclosureProtection ) { 01645 case 'none': 01646 case 'simple': // b/c 01647 $callback = array( $this, 'passThrough' ); 01648 break; 01649 default: // 'paranoid' 01650 $callback = array( $this, 'paranoidClean' ); 01651 } 01652 return $callback; 01653 } 01654 01661 function paranoidClean( $param ) { 01662 return '[hidden]'; 01663 } 01664 01671 function passThrough( $param ) { 01672 return $param; 01673 } 01674 01681 public function newFatal( $message /*, parameters...*/ ) { 01682 $params = func_get_args(); 01683 array_unshift( $params, $this ); 01684 01685 return call_user_func_array( array( 'FileRepoStatus', 'newFatal' ), $params ); 01686 } 01687 01694 public function newGood( $value = null ) { 01695 return FileRepoStatus::newGood( $this, $value ); 01696 } 01697 01706 public function checkRedirect( Title $title ) { 01707 return false; 01708 } 01709 01717 public function invalidateImageRedirect( Title $title ) { 01718 } 01719 01725 public function getDisplayName() { 01726 global $wgSitename; 01727 01728 if ( $this->isLocal() ) { 01729 return $wgSitename; 01730 } 01731 01732 // 'shared-repo-name-wikimediacommons' is used when $wgUseInstantCommons = true 01733 return wfMessageFallback( 'shared-repo-name-' . $this->name, 'shared-repo' )->text(); 01734 } 01735 01743 public function nameForThumb( $name ) { 01744 if ( strlen( $name ) > $this->abbrvThreshold ) { 01745 $ext = FileBackend::extensionFromPath( $name ); 01746 $name = ( $ext == '' ) ? 'thumbnail' : "thumbnail.$ext"; 01747 } 01748 01749 return $name; 01750 } 01751 01757 public function isLocal() { 01758 return $this->getName() == 'local'; 01759 } 01760 01769 public function getSharedCacheKey( /*...*/ ) { 01770 return false; 01771 } 01772 01780 public function getLocalCacheKey( /*...*/ ) { 01781 $args = func_get_args(); 01782 array_unshift( $args, 'filerepo', $this->getName() ); 01783 01784 return call_user_func_array( 'wfMemcKey', $args ); 01785 } 01786 01795 public function getTempRepo() { 01796 return new TempFileRepo( array( 01797 'name' => "{$this->name}-temp", 01798 'backend' => $this->backend, 01799 'zones' => array( 01800 'public' => array( 01801 'container' => $this->zones['temp']['container'], 01802 'directory' => $this->zones['temp']['directory'] 01803 ), 01804 'thumb' => array( 01805 'container' => $this->zones['thumb']['container'], 01806 'directory' => $this->zones['thumb']['directory'] == '' 01807 ? 'temp' 01808 : $this->zones['thumb']['directory'] . '/temp' 01809 ), 01810 'transcoded' => array( 01811 'container' => $this->zones['transcoded']['container'], 01812 'directory' => $this->zones['transcoded']['directory'] == '' 01813 ? 'temp' 01814 : $this->zones['transcoded']['directory'] . '/temp' 01815 ) 01816 ), 01817 'url' => $this->getZoneUrl( 'temp' ), 01818 'thumbUrl' => $this->getZoneUrl( 'thumb' ) . '/temp', 01819 'transcodedUrl' => $this->getZoneUrl( 'transcoded' ) . '/temp', 01820 'hashLevels' => $this->hashLevels // performance 01821 ) ); 01822 } 01823 01830 public function getUploadStash( User $user = null ) { 01831 return new UploadStash( $this, $user ); 01832 } 01833 01841 protected function assertWritableRepo() { 01842 } 01843 01850 public function getInfo() { 01851 $ret = array( 01852 'name' => $this->getName(), 01853 'displayname' => $this->getDisplayName(), 01854 'rootUrl' => $this->getZoneUrl( 'public' ), 01855 'local' => $this->isLocal(), 01856 ); 01857 01858 $optionalSettings = array( 01859 'url', 'thumbUrl', 'initialCapital', 'descBaseUrl', 'scriptDirUrl', 'articleUrl', 01860 'fetchDescription', 'descriptionCacheExpiry', 'scriptExtension', 'favicon' 01861 ); 01862 foreach ( $optionalSettings as $k ) { 01863 if ( isset( $this->$k ) ) { 01864 $ret[$k] = $this->$k; 01865 } 01866 } 01867 01868 return $ret; 01869 } 01870 } 01871 01875 class TempFileRepo extends FileRepo { 01876 public function getTempRepo() { 01877 throw new MWException( "Cannot get a temp repo from a temp repo." ); 01878 } 01879 }