MediaWiki
REL1_23
|
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 00311 public function getZoneHandlerUrl( $zone ) { 00312 if ( isset( $this->zones[$zone]['handlerUrl'] ) 00313 && in_array( $zone, array( 'public', 'temp', 'thumb', 'transcoded' ) ) 00314 ) { 00315 return $this->zones[$zone]['handlerUrl']; 00316 } 00317 00318 return false; 00319 } 00320 00329 public function resolveVirtualUrl( $url ) { 00330 if ( substr( $url, 0, 9 ) != 'mwrepo://' ) { 00331 throw new MWException( __METHOD__ . ': unknown protocol' ); 00332 } 00333 $bits = explode( '/', substr( $url, 9 ), 3 ); 00334 if ( count( $bits ) != 3 ) { 00335 throw new MWException( __METHOD__ . ": invalid mwrepo URL: $url" ); 00336 } 00337 list( $repo, $zone, $rel ) = $bits; 00338 if ( $repo !== $this->name ) { 00339 throw new MWException( __METHOD__ . ": fetching from a foreign repo is not supported" ); 00340 } 00341 $base = $this->getZonePath( $zone ); 00342 if ( !$base ) { 00343 throw new MWException( __METHOD__ . ": invalid zone: $zone" ); 00344 } 00345 00346 return $base . '/' . rawurldecode( $rel ); 00347 } 00348 00355 protected function getZoneLocation( $zone ) { 00356 if ( !isset( $this->zones[$zone] ) ) { 00357 return array( null, null ); // bogus 00358 } 00359 00360 return array( $this->zones[$zone]['container'], $this->zones[$zone]['directory'] ); 00361 } 00362 00369 public function getZonePath( $zone ) { 00370 list( $container, $base ) = $this->getZoneLocation( $zone ); 00371 if ( $container === null || $base === null ) { 00372 return null; 00373 } 00374 $backendName = $this->backend->getName(); 00375 if ( $base != '' ) { // may not be set 00376 $base = "/{$base}"; 00377 } 00378 00379 return "mwstore://$backendName/{$container}{$base}"; 00380 } 00381 00393 public function newFile( $title, $time = false ) { 00394 $title = File::normalizeTitle( $title ); 00395 if ( !$title ) { 00396 return null; 00397 } 00398 if ( $time ) { 00399 if ( $this->oldFileFactory ) { 00400 return call_user_func( $this->oldFileFactory, $title, $this, $time ); 00401 } else { 00402 return false; 00403 } 00404 } else { 00405 return call_user_func( $this->fileFactory, $title, $this ); 00406 } 00407 } 00408 00425 public function findFile( $title, $options = array() ) { 00426 $title = File::normalizeTitle( $title ); 00427 if ( !$title ) { 00428 return false; 00429 } 00430 $time = isset( $options['time'] ) ? $options['time'] : false; 00431 # First try the current version of the file to see if it precedes the timestamp 00432 $img = $this->newFile( $title ); 00433 if ( !$img ) { 00434 return false; 00435 } 00436 if ( $img->exists() && ( !$time || $img->getTimestamp() == $time ) ) { 00437 return $img; 00438 } 00439 # Now try an old version of the file 00440 if ( $time !== false ) { 00441 $img = $this->newFile( $title, $time ); 00442 if ( $img && $img->exists() ) { 00443 if ( !$img->isDeleted( File::DELETED_FILE ) ) { 00444 return $img; // always OK 00445 } elseif ( !empty( $options['private'] ) && 00446 $img->userCan( File::DELETED_FILE, 00447 $options['private'] instanceof User ? $options['private'] : null 00448 ) 00449 ) { 00450 return $img; 00451 } 00452 } 00453 } 00454 00455 # Now try redirects 00456 if ( !empty( $options['ignoreRedirect'] ) ) { 00457 return false; 00458 } 00459 $redir = $this->checkRedirect( $title ); 00460 if ( $redir && $title->getNamespace() == NS_FILE ) { 00461 $img = $this->newFile( $redir ); 00462 if ( !$img ) { 00463 return false; 00464 } 00465 if ( $img->exists() ) { 00466 $img->redirectedFrom( $title->getDBkey() ); 00467 00468 return $img; 00469 } 00470 } 00471 00472 return false; 00473 } 00474 00492 public function findFiles( array $items, $flags = 0 ) { 00493 $result = array(); 00494 foreach ( $items as $item ) { 00495 if ( is_array( $item ) ) { 00496 $title = $item['title']; 00497 $options = $item; 00498 unset( $options['title'] ); 00499 } else { 00500 $title = $item; 00501 $options = array(); 00502 } 00503 $file = $this->findFile( $title, $options ); 00504 if ( $file ) { 00505 $searchName = File::normalizeTitle( $title )->getDBkey(); // must be valid 00506 if ( $flags & self::NAME_AND_TIME_ONLY ) { 00507 $result[$searchName] = array( 00508 'title' => $file->getTitle()->getDBkey(), 00509 'timestamp' => $file->getTimestamp() 00510 ); 00511 } else { 00512 $result[$searchName] = $file; 00513 } 00514 } 00515 } 00516 00517 return $result; 00518 } 00519 00529 public function findFileFromKey( $sha1, $options = array() ) { 00530 $time = isset( $options['time'] ) ? $options['time'] : false; 00531 # First try to find a matching current version of a file... 00532 if ( $this->fileFactoryKey ) { 00533 $img = call_user_func( $this->fileFactoryKey, $sha1, $this, $time ); 00534 } else { 00535 return false; // find-by-sha1 not supported 00536 } 00537 if ( $img && $img->exists() ) { 00538 return $img; 00539 } 00540 # Now try to find a matching old version of a file... 00541 if ( $time !== false && $this->oldFileFactoryKey ) { // find-by-sha1 supported? 00542 $img = call_user_func( $this->oldFileFactoryKey, $sha1, $this, $time ); 00543 if ( $img && $img->exists() ) { 00544 if ( !$img->isDeleted( File::DELETED_FILE ) ) { 00545 return $img; // always OK 00546 } elseif ( !empty( $options['private'] ) && 00547 $img->userCan( File::DELETED_FILE, 00548 $options['private'] instanceof User ? $options['private'] : null 00549 ) 00550 ) { 00551 return $img; 00552 } 00553 } 00554 } 00555 00556 return false; 00557 } 00558 00567 public function findBySha1( $hash ) { 00568 return array(); 00569 } 00570 00578 public function findBySha1s( array $hashes ) { 00579 $result = array(); 00580 foreach ( $hashes as $hash ) { 00581 $files = $this->findBySha1( $hash ); 00582 if ( count( $files ) ) { 00583 $result[$hash] = $files; 00584 } 00585 } 00586 00587 return $result; 00588 } 00589 00598 public function findFilesByPrefix( $prefix, $limit ) { 00599 return array(); 00600 } 00601 00608 public function getRootUrl() { 00609 return $this->getZoneUrl( 'public' ); 00610 } 00611 00617 public function getThumbScriptUrl() { 00618 return $this->thumbScriptUrl; 00619 } 00620 00626 public function canTransformVia404() { 00627 return $this->transformVia404; 00628 } 00629 00636 public function getNameFromTitle( Title $title ) { 00637 global $wgContLang; 00638 if ( $this->initialCapital != MWNamespace::isCapitalized( NS_FILE ) ) { 00639 $name = $title->getUserCaseDBKey(); 00640 if ( $this->initialCapital ) { 00641 $name = $wgContLang->ucfirst( $name ); 00642 } 00643 } else { 00644 $name = $title->getDBkey(); 00645 } 00646 00647 return $name; 00648 } 00649 00655 public function getRootDirectory() { 00656 return $this->getZonePath( 'public' ); 00657 } 00658 00666 public function getHashPath( $name ) { 00667 return self::getHashPathForLevel( $name, $this->hashLevels ); 00668 } 00669 00677 public function getTempHashPath( $suffix ) { 00678 $parts = explode( '!', $suffix, 2 ); // format is <timestamp>!<name> or just <name> 00679 $name = isset( $parts[1] ) ? $parts[1] : $suffix; // hash path is not based on timestamp 00680 return self::getHashPathForLevel( $name, $this->hashLevels ); 00681 } 00682 00688 protected static function getHashPathForLevel( $name, $levels ) { 00689 if ( $levels == 0 ) { 00690 return ''; 00691 } else { 00692 $hash = md5( $name ); 00693 $path = ''; 00694 for ( $i = 1; $i <= $levels; $i++ ) { 00695 $path .= substr( $hash, 0, $i ) . '/'; 00696 } 00697 00698 return $path; 00699 } 00700 } 00701 00707 public function getHashLevels() { 00708 return $this->hashLevels; 00709 } 00710 00716 public function getName() { 00717 return $this->name; 00718 } 00719 00727 public function makeUrl( $query = '', $entry = 'index' ) { 00728 if ( isset( $this->scriptDirUrl ) ) { 00729 $ext = isset( $this->scriptExtension ) ? $this->scriptExtension : '.php'; 00730 00731 return wfAppendQuery( "{$this->scriptDirUrl}/{$entry}{$ext}", $query ); 00732 } 00733 00734 return false; 00735 } 00736 00749 public function getDescriptionUrl( $name ) { 00750 $encName = wfUrlencode( $name ); 00751 if ( !is_null( $this->descBaseUrl ) ) { 00752 # "http://example.com/wiki/File:" 00753 return $this->descBaseUrl . $encName; 00754 } 00755 if ( !is_null( $this->articleUrl ) ) { 00756 # "http://example.com/wiki/$1" 00757 # 00758 # We use "Image:" as the canonical namespace for 00759 # compatibility across all MediaWiki versions. 00760 return str_replace( '$1', 00761 "Image:$encName", $this->articleUrl ); 00762 } 00763 if ( !is_null( $this->scriptDirUrl ) ) { 00764 # "http://example.com/w" 00765 # 00766 # We use "Image:" as the canonical namespace for 00767 # compatibility across all MediaWiki versions, 00768 # and just sort of hope index.php is right. ;) 00769 return $this->makeUrl( "title=Image:$encName" ); 00770 } 00771 00772 return false; 00773 } 00774 00785 public function getDescriptionRenderUrl( $name, $lang = null ) { 00786 $query = 'action=render'; 00787 if ( !is_null( $lang ) ) { 00788 $query .= '&uselang=' . $lang; 00789 } 00790 if ( isset( $this->scriptDirUrl ) ) { 00791 return $this->makeUrl( 00792 'title=' . 00793 wfUrlencode( 'Image:' . $name ) . 00794 "&$query" ); 00795 } else { 00796 $descUrl = $this->getDescriptionUrl( $name ); 00797 if ( $descUrl ) { 00798 return wfAppendQuery( $descUrl, $query ); 00799 } else { 00800 return false; 00801 } 00802 } 00803 } 00804 00810 public function getDescriptionStylesheetUrl() { 00811 if ( isset( $this->scriptDirUrl ) ) { 00812 return $this->makeUrl( 'title=MediaWiki:Filepage.css&' . 00813 wfArrayToCgi( Skin::getDynamicStylesheetQuery() ) ); 00814 } 00815 00816 return false; 00817 } 00818 00833 public function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) { 00834 $this->assertWritableRepo(); // fail out if read-only 00835 00836 $status = $this->storeBatch( array( array( $srcPath, $dstZone, $dstRel ) ), $flags ); 00837 if ( $status->successCount == 0 ) { 00838 $status->ok = false; 00839 } 00840 00841 return $status; 00842 } 00843 00857 public function storeBatch( array $triplets, $flags = 0 ) { 00858 $this->assertWritableRepo(); // fail out if read-only 00859 00860 $status = $this->newGood(); 00861 $backend = $this->backend; // convenience 00862 00863 $operations = array(); 00864 $sourceFSFilesToDelete = array(); // cleanup for disk source files 00865 // Validate each triplet and get the store operation... 00866 foreach ( $triplets as $triplet ) { 00867 list( $srcPath, $dstZone, $dstRel ) = $triplet; 00868 wfDebug( __METHOD__ 00869 . "( \$src='$srcPath', \$dstZone='$dstZone', \$dstRel='$dstRel' )\n" 00870 ); 00871 00872 // Resolve destination path 00873 $root = $this->getZonePath( $dstZone ); 00874 if ( !$root ) { 00875 throw new MWException( "Invalid zone: $dstZone" ); 00876 } 00877 if ( !$this->validateFilename( $dstRel ) ) { 00878 throw new MWException( 'Validation error in $dstRel' ); 00879 } 00880 $dstPath = "$root/$dstRel"; 00881 $dstDir = dirname( $dstPath ); 00882 // Create destination directories for this triplet 00883 if ( !$this->initDirectory( $dstDir )->isOK() ) { 00884 return $this->newFatal( 'directorycreateerror', $dstDir ); 00885 } 00886 00887 // Resolve source to a storage path if virtual 00888 $srcPath = $this->resolveToStoragePath( $srcPath ); 00889 00890 // Get the appropriate file operation 00891 if ( FileBackend::isStoragePath( $srcPath ) ) { 00892 $opName = ( $flags & self::DELETE_SOURCE ) ? 'move' : 'copy'; 00893 } else { 00894 $opName = 'store'; 00895 if ( $flags & self::DELETE_SOURCE ) { 00896 $sourceFSFilesToDelete[] = $srcPath; 00897 } 00898 } 00899 $operations[] = array( 00900 'op' => $opName, 00901 'src' => $srcPath, 00902 'dst' => $dstPath, 00903 'overwrite' => $flags & self::OVERWRITE, 00904 'overwriteSame' => $flags & self::OVERWRITE_SAME, 00905 ); 00906 } 00907 00908 // Execute the store operation for each triplet 00909 $opts = array( 'force' => true ); 00910 if ( $flags & self::SKIP_LOCKING ) { 00911 $opts['nonLocking'] = true; 00912 } 00913 $status->merge( $backend->doOperations( $operations, $opts ) ); 00914 // Cleanup for disk source files... 00915 foreach ( $sourceFSFilesToDelete as $file ) { 00916 wfSuppressWarnings(); 00917 unlink( $file ); // FS cleanup 00918 wfRestoreWarnings(); 00919 } 00920 00921 return $status; 00922 } 00923 00934 public function cleanupBatch( array $files, $flags = 0 ) { 00935 $this->assertWritableRepo(); // fail out if read-only 00936 00937 $status = $this->newGood(); 00938 00939 $operations = array(); 00940 foreach ( $files as $path ) { 00941 if ( is_array( $path ) ) { 00942 // This is a pair, extract it 00943 list( $zone, $rel ) = $path; 00944 $path = $this->getZonePath( $zone ) . "/$rel"; 00945 } else { 00946 // Resolve source to a storage path if virtual 00947 $path = $this->resolveToStoragePath( $path ); 00948 } 00949 $operations[] = array( 'op' => 'delete', 'src' => $path ); 00950 } 00951 // Actually delete files from storage... 00952 $opts = array( 'force' => true ); 00953 if ( $flags & self::SKIP_LOCKING ) { 00954 $opts['nonLocking'] = true; 00955 } 00956 $status->merge( $this->backend->doOperations( $operations, $opts ) ); 00957 00958 return $status; 00959 } 00960 00974 final public function quickImport( $src, $dst, $options = null ) { 00975 return $this->quickImportBatch( array( array( $src, $dst, $options ) ) ); 00976 } 00977 00986 final public function quickPurge( $path ) { 00987 return $this->quickPurgeBatch( array( $path ) ); 00988 } 00989 00997 public function quickCleanDir( $dir ) { 00998 $status = $this->newGood(); 00999 $status->merge( $this->backend->clean( 01000 array( 'dir' => $this->resolveToStoragePath( $dir ) ) ) ); 01001 01002 return $status; 01003 } 01004 01017 public function quickImportBatch( array $triples ) { 01018 $status = $this->newGood(); 01019 $operations = array(); 01020 foreach ( $triples as $triple ) { 01021 list( $src, $dst ) = $triple; 01022 $src = $this->resolveToStoragePath( $src ); 01023 $dst = $this->resolveToStoragePath( $dst ); 01024 01025 if ( !isset( $triple[2] ) ) { 01026 $headers = array(); 01027 } elseif ( is_string( $triple[2] ) ) { 01028 // back-compat 01029 $headers = array( 'Content-Disposition' => $triple[2] ); 01030 } elseif ( is_array( $triple[2] ) && isset( $triple[2]['headers'] ) ) { 01031 $headers = $triple[2]['headers']; 01032 } 01033 $operations[] = array( 01034 'op' => FileBackend::isStoragePath( $src ) ? 'copy' : 'store', 01035 'src' => $src, 01036 'dst' => $dst, 01037 'headers' => $headers 01038 ); 01039 $status->merge( $this->initDirectory( dirname( $dst ) ) ); 01040 } 01041 $status->merge( $this->backend->doQuickOperations( $operations ) ); 01042 01043 return $status; 01044 } 01045 01054 public function quickPurgeBatch( array $paths ) { 01055 $status = $this->newGood(); 01056 $operations = array(); 01057 foreach ( $paths as $path ) { 01058 $operations[] = array( 01059 'op' => 'delete', 01060 'src' => $this->resolveToStoragePath( $path ), 01061 'ignoreMissingSource' => true 01062 ); 01063 } 01064 $status->merge( $this->backend->doQuickOperations( $operations ) ); 01065 01066 return $status; 01067 } 01068 01079 public function storeTemp( $originalName, $srcPath ) { 01080 $this->assertWritableRepo(); // fail out if read-only 01081 01082 $date = MWTimestamp::getInstance()->format( 'YmdHis' ); 01083 $hashPath = $this->getHashPath( $originalName ); 01084 $dstUrlRel = $hashPath . $date . '!' . rawurlencode( $originalName ); 01085 $virtualUrl = $this->getVirtualUrl( 'temp' ) . '/' . $dstUrlRel; 01086 01087 $result = $this->quickImport( $srcPath, $virtualUrl ); 01088 $result->value = $virtualUrl; 01089 01090 return $result; 01091 } 01092 01099 public function freeTemp( $virtualUrl ) { 01100 $this->assertWritableRepo(); // fail out if read-only 01101 01102 $temp = $this->getVirtualUrl( 'temp' ); 01103 if ( substr( $virtualUrl, 0, strlen( $temp ) ) != $temp ) { 01104 wfDebug( __METHOD__ . ": Invalid temp virtual URL\n" ); 01105 01106 return false; 01107 } 01108 01109 return $this->quickPurge( $virtualUrl )->isOK(); 01110 } 01111 01121 public function concatenate( array $srcPaths, $dstPath, $flags = 0 ) { 01122 $this->assertWritableRepo(); // fail out if read-only 01123 01124 $status = $this->newGood(); 01125 01126 $sources = array(); 01127 foreach ( $srcPaths as $srcPath ) { 01128 // Resolve source to a storage path if virtual 01129 $source = $this->resolveToStoragePath( $srcPath ); 01130 $sources[] = $source; // chunk to merge 01131 } 01132 01133 // Concatenate the chunks into one FS file 01134 $params = array( 'srcs' => $sources, 'dst' => $dstPath ); 01135 $status->merge( $this->backend->concatenate( $params ) ); 01136 if ( !$status->isOK() ) { 01137 return $status; 01138 } 01139 01140 // Delete the sources if required 01141 if ( $flags & self::DELETE_SOURCE ) { 01142 $status->merge( $this->quickPurgeBatch( $srcPaths ) ); 01143 } 01144 01145 // Make sure status is OK, despite any quickPurgeBatch() fatals 01146 $status->setResult( true ); 01147 01148 return $status; 01149 } 01150 01170 public function publish( 01171 $srcPath, $dstRel, $archiveRel, $flags = 0, array $options = array() 01172 ) { 01173 $this->assertWritableRepo(); // fail out if read-only 01174 01175 $status = $this->publishBatch( 01176 array( array( $srcPath, $dstRel, $archiveRel, $options ) ), $flags ); 01177 if ( $status->successCount == 0 ) { 01178 $status->ok = false; 01179 } 01180 if ( isset( $status->value[0] ) ) { 01181 $status->value = $status->value[0]; 01182 } else { 01183 $status->value = false; 01184 } 01185 01186 return $status; 01187 } 01188 01199 public function publishBatch( array $ntuples, $flags = 0 ) { 01200 $this->assertWritableRepo(); // fail out if read-only 01201 01202 $backend = $this->backend; // convenience 01203 // Try creating directories 01204 $status = $this->initZones( 'public' ); 01205 if ( !$status->isOK() ) { 01206 return $status; 01207 } 01208 01209 $status = $this->newGood( array() ); 01210 01211 $operations = array(); 01212 $sourceFSFilesToDelete = array(); // cleanup for disk source files 01213 // Validate each triplet and get the store operation... 01214 foreach ( $ntuples as $ntuple ) { 01215 list( $srcPath, $dstRel, $archiveRel ) = $ntuple; 01216 $options = isset( $ntuple[3] ) ? $ntuple[3] : array(); 01217 // Resolve source to a storage path if virtual 01218 $srcPath = $this->resolveToStoragePath( $srcPath ); 01219 if ( !$this->validateFilename( $dstRel ) ) { 01220 throw new MWException( 'Validation error in $dstRel' ); 01221 } 01222 if ( !$this->validateFilename( $archiveRel ) ) { 01223 throw new MWException( 'Validation error in $archiveRel' ); 01224 } 01225 01226 $publicRoot = $this->getZonePath( 'public' ); 01227 $dstPath = "$publicRoot/$dstRel"; 01228 $archivePath = "$publicRoot/$archiveRel"; 01229 01230 $dstDir = dirname( $dstPath ); 01231 $archiveDir = dirname( $archivePath ); 01232 // Abort immediately on directory creation errors since they're likely to be repetitive 01233 if ( !$this->initDirectory( $dstDir )->isOK() ) { 01234 return $this->newFatal( 'directorycreateerror', $dstDir ); 01235 } 01236 if ( !$this->initDirectory( $archiveDir )->isOK() ) { 01237 return $this->newFatal( 'directorycreateerror', $archiveDir ); 01238 } 01239 01240 // Set any desired headers to be use in GET/HEAD responses 01241 $headers = isset( $options['headers'] ) ? $options['headers'] : array(); 01242 01243 // Archive destination file if it exists. 01244 // This will check if the archive file also exists and fail if does. 01245 // This is a sanity check to avoid data loss. On Windows and Linux, 01246 // copy() will overwrite, so the existence check is vulnerable to 01247 // race conditions unless an functioning LockManager is used. 01248 // LocalFile also uses SELECT FOR UPDATE for synchronization. 01249 $operations[] = array( 01250 'op' => 'copy', 01251 'src' => $dstPath, 01252 'dst' => $archivePath, 01253 'ignoreMissingSource' => true 01254 ); 01255 01256 // Copy (or move) the source file to the destination 01257 if ( FileBackend::isStoragePath( $srcPath ) ) { 01258 if ( $flags & self::DELETE_SOURCE ) { 01259 $operations[] = array( 01260 'op' => 'move', 01261 'src' => $srcPath, 01262 'dst' => $dstPath, 01263 'overwrite' => true, // replace current 01264 'headers' => $headers 01265 ); 01266 } else { 01267 $operations[] = array( 01268 'op' => 'copy', 01269 'src' => $srcPath, 01270 'dst' => $dstPath, 01271 'overwrite' => true, // replace current 01272 'headers' => $headers 01273 ); 01274 } 01275 } else { // FS source path 01276 $operations[] = array( 01277 'op' => 'store', 01278 'src' => $srcPath, 01279 'dst' => $dstPath, 01280 'overwrite' => true, // replace current 01281 'headers' => $headers 01282 ); 01283 if ( $flags & self::DELETE_SOURCE ) { 01284 $sourceFSFilesToDelete[] = $srcPath; 01285 } 01286 } 01287 } 01288 01289 // Execute the operations for each triplet 01290 $status->merge( $backend->doOperations( $operations ) ); 01291 // Find out which files were archived... 01292 foreach ( $ntuples as $i => $ntuple ) { 01293 list( , , $archiveRel ) = $ntuple; 01294 $archivePath = $this->getZonePath( 'public' ) . "/$archiveRel"; 01295 if ( $this->fileExists( $archivePath ) ) { 01296 $status->value[$i] = 'archived'; 01297 } else { 01298 $status->value[$i] = 'new'; 01299 } 01300 } 01301 // Cleanup for disk source files... 01302 foreach ( $sourceFSFilesToDelete as $file ) { 01303 wfSuppressWarnings(); 01304 unlink( $file ); // FS cleanup 01305 wfRestoreWarnings(); 01306 } 01307 01308 return $status; 01309 } 01310 01318 protected function initDirectory( $dir ) { 01319 $path = $this->resolveToStoragePath( $dir ); 01320 list( , $container, ) = FileBackend::splitStoragePath( $path ); 01321 01322 $params = array( 'dir' => $path ); 01323 if ( $this->isPrivate || $container === $this->zones['deleted']['container'] ) { 01324 # Take all available measures to prevent web accessibility of new deleted 01325 # directories, in case the user has not configured offline storage 01326 $params = array( 'noAccess' => true, 'noListing' => true ) + $params; 01327 } 01328 01329 return $this->backend->prepare( $params ); 01330 } 01331 01338 public function cleanDir( $dir ) { 01339 $this->assertWritableRepo(); // fail out if read-only 01340 01341 $status = $this->newGood(); 01342 $status->merge( $this->backend->clean( 01343 array( 'dir' => $this->resolveToStoragePath( $dir ) ) ) ); 01344 01345 return $status; 01346 } 01347 01354 public function fileExists( $file ) { 01355 $result = $this->fileExistsBatch( array( $file ) ); 01356 01357 return $result[0]; 01358 } 01359 01366 public function fileExistsBatch( array $files ) { 01367 $result = array(); 01368 foreach ( $files as $key => $file ) { 01369 $file = $this->resolveToStoragePath( $file ); 01370 $result[$key] = $this->backend->fileExists( array( 'src' => $file ) ); 01371 } 01372 01373 return $result; 01374 } 01375 01386 public function delete( $srcRel, $archiveRel ) { 01387 $this->assertWritableRepo(); // fail out if read-only 01388 01389 return $this->deleteBatch( array( array( $srcRel, $archiveRel ) ) ); 01390 } 01391 01409 public function deleteBatch( array $sourceDestPairs ) { 01410 $this->assertWritableRepo(); // fail out if read-only 01411 01412 // Try creating directories 01413 $status = $this->initZones( array( 'public', 'deleted' ) ); 01414 if ( !$status->isOK() ) { 01415 return $status; 01416 } 01417 01418 $status = $this->newGood(); 01419 01420 $backend = $this->backend; // convenience 01421 $operations = array(); 01422 // Validate filenames and create archive directories 01423 foreach ( $sourceDestPairs as $pair ) { 01424 list( $srcRel, $archiveRel ) = $pair; 01425 if ( !$this->validateFilename( $srcRel ) ) { 01426 throw new MWException( __METHOD__ . ':Validation error in $srcRel' ); 01427 } elseif ( !$this->validateFilename( $archiveRel ) ) { 01428 throw new MWException( __METHOD__ . ':Validation error in $archiveRel' ); 01429 } 01430 01431 $publicRoot = $this->getZonePath( 'public' ); 01432 $srcPath = "{$publicRoot}/$srcRel"; 01433 01434 $deletedRoot = $this->getZonePath( 'deleted' ); 01435 $archivePath = "{$deletedRoot}/{$archiveRel}"; 01436 $archiveDir = dirname( $archivePath ); // does not touch FS 01437 01438 // Create destination directories 01439 if ( !$this->initDirectory( $archiveDir )->isOK() ) { 01440 return $this->newFatal( 'directorycreateerror', $archiveDir ); 01441 } 01442 01443 $operations[] = array( 01444 'op' => 'move', 01445 'src' => $srcPath, 01446 'dst' => $archivePath, 01447 // We may have 2+ identical files being deleted, 01448 // all of which will map to the same destination file 01449 'overwriteSame' => true // also see bug 31792 01450 ); 01451 } 01452 01453 // Move the files by execute the operations for each pair. 01454 // We're now committed to returning an OK result, which will 01455 // lead to the files being moved in the DB also. 01456 $opts = array( 'force' => true ); 01457 $status->merge( $backend->doOperations( $operations, $opts ) ); 01458 01459 return $status; 01460 } 01461 01467 public function cleanupDeletedBatch( array $storageKeys ) { 01468 $this->assertWritableRepo(); 01469 } 01470 01479 public function getDeletedHashPath( $key ) { 01480 if ( strlen( $key ) < 31 ) { 01481 throw new MWException( "Invalid storage key '$key'." ); 01482 } 01483 $path = ''; 01484 for ( $i = 0; $i < $this->deletedHashLevels; $i++ ) { 01485 $path .= $key[$i] . '/'; 01486 } 01487 01488 return $path; 01489 } 01490 01499 protected function resolveToStoragePath( $path ) { 01500 if ( $this->isVirtualUrl( $path ) ) { 01501 return $this->resolveVirtualUrl( $path ); 01502 } 01503 01504 return $path; 01505 } 01506 01514 public function getLocalCopy( $virtualUrl ) { 01515 $path = $this->resolveToStoragePath( $virtualUrl ); 01516 01517 return $this->backend->getLocalCopy( array( 'src' => $path ) ); 01518 } 01519 01528 public function getLocalReference( $virtualUrl ) { 01529 $path = $this->resolveToStoragePath( $virtualUrl ); 01530 01531 return $this->backend->getLocalReference( array( 'src' => $path ) ); 01532 } 01533 01541 public function getFileProps( $virtualUrl ) { 01542 $path = $this->resolveToStoragePath( $virtualUrl ); 01543 01544 return $this->backend->getFileProps( array( 'src' => $path ) ); 01545 } 01546 01553 public function getFileTimestamp( $virtualUrl ) { 01554 $path = $this->resolveToStoragePath( $virtualUrl ); 01555 01556 return $this->backend->getFileTimestamp( array( 'src' => $path ) ); 01557 } 01558 01565 public function getFileSize( $virtualUrl ) { 01566 $path = $this->resolveToStoragePath( $virtualUrl ); 01567 01568 return $this->backend->getFileSize( array( 'src' => $path ) ); 01569 } 01570 01577 public function getFileSha1( $virtualUrl ) { 01578 $path = $this->resolveToStoragePath( $virtualUrl ); 01579 01580 return $this->backend->getFileSha1Base36( array( 'src' => $path ) ); 01581 } 01582 01590 public function streamFile( $virtualUrl, $headers = array() ) { 01591 $path = $this->resolveToStoragePath( $virtualUrl ); 01592 $params = array( 'src' => $path, 'headers' => $headers ); 01593 01594 return $this->backend->streamFile( $params )->isOK(); 01595 } 01596 01605 public function enumFiles( $callback ) { 01606 $this->enumFilesInStorage( $callback ); 01607 } 01608 01616 protected function enumFilesInStorage( $callback ) { 01617 $publicRoot = $this->getZonePath( 'public' ); 01618 $numDirs = 1 << ( $this->hashLevels * 4 ); 01619 // Use a priori assumptions about directory structure 01620 // to reduce the tree height of the scanning process. 01621 for ( $flatIndex = 0; $flatIndex < $numDirs; $flatIndex++ ) { 01622 $hexString = sprintf( "%0{$this->hashLevels}x", $flatIndex ); 01623 $path = $publicRoot; 01624 for ( $hexPos = 0; $hexPos < $this->hashLevels; $hexPos++ ) { 01625 $path .= '/' . substr( $hexString, 0, $hexPos + 1 ); 01626 } 01627 $iterator = $this->backend->getFileList( array( 'dir' => $path ) ); 01628 foreach ( $iterator as $name ) { 01629 // Each item returned is a public file 01630 call_user_func( $callback, "{$path}/{$name}" ); 01631 } 01632 } 01633 } 01634 01641 public function validateFilename( $filename ) { 01642 if ( strval( $filename ) == '' ) { 01643 return false; 01644 } 01645 01646 return FileBackend::isPathTraversalFree( $filename ); 01647 } 01648 01654 function getErrorCleanupFunction() { 01655 switch ( $this->pathDisclosureProtection ) { 01656 case 'none': 01657 case 'simple': // b/c 01658 $callback = array( $this, 'passThrough' ); 01659 break; 01660 default: // 'paranoid' 01661 $callback = array( $this, 'paranoidClean' ); 01662 } 01663 return $callback; 01664 } 01665 01672 function paranoidClean( $param ) { 01673 return '[hidden]'; 01674 } 01675 01682 function passThrough( $param ) { 01683 return $param; 01684 } 01685 01692 public function newFatal( $message /*, parameters...*/ ) { 01693 $params = func_get_args(); 01694 array_unshift( $params, $this ); 01695 01696 return call_user_func_array( array( 'FileRepoStatus', 'newFatal' ), $params ); 01697 } 01698 01705 public function newGood( $value = null ) { 01706 return FileRepoStatus::newGood( $this, $value ); 01707 } 01708 01717 public function checkRedirect( Title $title ) { 01718 return false; 01719 } 01720 01728 public function invalidateImageRedirect( Title $title ) { 01729 } 01730 01736 public function getDisplayName() { 01737 // We don't name our own repo, return nothing 01738 if ( $this->isLocal() ) { 01739 return null; 01740 } 01741 01742 // 'shared-repo-name-wikimediacommons' is used when $wgUseInstantCommons = true 01743 return wfMessageFallback( 'shared-repo-name-' . $this->name, 'shared-repo' )->text(); 01744 } 01745 01753 public function nameForThumb( $name ) { 01754 if ( strlen( $name ) > $this->abbrvThreshold ) { 01755 $ext = FileBackend::extensionFromPath( $name ); 01756 $name = ( $ext == '' ) ? 'thumbnail' : "thumbnail.$ext"; 01757 } 01758 01759 return $name; 01760 } 01761 01767 public function isLocal() { 01768 return $this->getName() == 'local'; 01769 } 01770 01779 public function getSharedCacheKey( /*...*/ ) { 01780 return false; 01781 } 01782 01790 public function getLocalCacheKey( /*...*/ ) { 01791 $args = func_get_args(); 01792 array_unshift( $args, 'filerepo', $this->getName() ); 01793 01794 return call_user_func_array( 'wfMemcKey', $args ); 01795 } 01796 01805 public function getTempRepo() { 01806 return new TempFileRepo( array( 01807 'name' => "{$this->name}-temp", 01808 'backend' => $this->backend, 01809 'zones' => array( 01810 'public' => array( 01811 'container' => $this->zones['temp']['container'], 01812 'directory' => $this->zones['temp']['directory'] 01813 ), 01814 'thumb' => array( 01815 'container' => $this->zones['thumb']['container'], 01816 'directory' => $this->zones['thumb']['directory'] == '' 01817 ? 'temp' 01818 : $this->zones['thumb']['directory'] . '/temp' 01819 ), 01820 'transcoded' => array( 01821 'container' => $this->zones['transcoded']['container'], 01822 'directory' => $this->zones['transcoded']['directory'] == '' 01823 ? 'temp' 01824 : $this->zones['transcoded']['directory'] . '/temp' 01825 ) 01826 ), 01827 'url' => $this->getZoneUrl( 'temp' ), 01828 'thumbUrl' => $this->getZoneUrl( 'thumb' ) . '/temp', 01829 'transcodedUrl' => $this->getZoneUrl( 'transcoded' ) . '/temp', 01830 'hashLevels' => $this->hashLevels // performance 01831 ) ); 01832 } 01833 01840 public function getUploadStash( User $user = null ) { 01841 return new UploadStash( $this, $user ); 01842 } 01843 01851 protected function assertWritableRepo() { 01852 } 01853 01860 public function getInfo() { 01861 $ret = array( 01862 'name' => $this->getName(), 01863 'displayname' => $this->getDisplayName(), 01864 'rootUrl' => $this->getZoneUrl( 'public' ), 01865 'local' => $this->isLocal(), 01866 ); 01867 01868 $optionalSettings = array( 01869 'url', 'thumbUrl', 'initialCapital', 'descBaseUrl', 'scriptDirUrl', 'articleUrl', 01870 'fetchDescription', 'descriptionCacheExpiry', 'scriptExtension', 'favicon' 01871 ); 01872 foreach ( $optionalSettings as $k ) { 01873 if ( isset( $this->$k ) ) { 01874 $ret[$k] = $this->$k; 01875 } 01876 } 01877 01878 return $ret; 01879 } 01880 } 01881 01885 class TempFileRepo extends FileRepo { 01886 public function getTempRepo() { 01887 throw new MWException( "Cannot get a temp repo from a temp repo." ); 01888 } 01889 }