MediaWiki
master
|
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 00044 protected $backend; 00046 protected $zones = array(); 00047 00048 var $thumbScriptUrl, $transformVia404; 00049 var $descBaseUrl, $scriptDirUrl, $scriptExtension, $articleUrl; 00050 var $fetchDescription, $initialCapital; 00051 var $pathDisclosureProtection = 'simple'; // 'paranoid' 00052 var $descriptionCacheExpiry, $url, $thumbUrl; 00053 var $hashLevels, $deletedHashLevels; 00054 protected $abbrvThreshold; 00055 00060 var $fileFactory = array( 'UnregisteredLocalFile', 'newFromTitle' ); 00061 var $oldFileFactory = false; 00062 var $fileFactoryKey = false, $oldFileFactoryKey = false; 00063 00068 public function __construct( array $info = null ) { 00069 // Verify required settings presence 00070 if( 00071 $info === null 00072 || !array_key_exists( 'name', $info ) 00073 || !array_key_exists( 'backend', $info ) 00074 ) { 00075 throw new MWException( __CLASS__ . " requires an array of options having both 'name' and 'backend' keys.\n" ); 00076 } 00077 00078 // Required settings 00079 $this->name = $info['name']; 00080 if ( $info['backend'] instanceof FileBackend ) { 00081 $this->backend = $info['backend']; // useful for testing 00082 } else { 00083 $this->backend = FileBackendGroup::singleton()->get( $info['backend'] ); 00084 } 00085 00086 // Optional settings that can have no value 00087 $optionalSettings = array( 00088 'descBaseUrl', 'scriptDirUrl', 'articleUrl', 'fetchDescription', 00089 'thumbScriptUrl', 'pathDisclosureProtection', 'descriptionCacheExpiry', 00090 'scriptExtension' 00091 ); 00092 foreach ( $optionalSettings as $var ) { 00093 if ( isset( $info[$var] ) ) { 00094 $this->$var = $info[$var]; 00095 } 00096 } 00097 00098 // Optional settings that have a default 00099 $this->initialCapital = isset( $info['initialCapital'] ) 00100 ? $info['initialCapital'] 00101 : MWNamespace::isCapitalized( NS_FILE ); 00102 $this->url = isset( $info['url'] ) 00103 ? $info['url'] 00104 : false; // a subclass may set the URL (e.g. ForeignAPIRepo) 00105 if ( isset( $info['thumbUrl'] ) ) { 00106 $this->thumbUrl = $info['thumbUrl']; 00107 } else { 00108 $this->thumbUrl = $this->url ? "{$this->url}/thumb" : false; 00109 } 00110 $this->hashLevels = isset( $info['hashLevels'] ) 00111 ? $info['hashLevels'] 00112 : 2; 00113 $this->deletedHashLevels = isset( $info['deletedHashLevels'] ) 00114 ? $info['deletedHashLevels'] 00115 : $this->hashLevels; 00116 $this->transformVia404 = !empty( $info['transformVia404'] ); 00117 $this->abbrvThreshold = isset( $info['abbrvThreshold'] ) 00118 ? $info['abbrvThreshold'] 00119 : 255; 00120 $this->isPrivate = !empty( $info['isPrivate'] ); 00121 // Give defaults for the basic zones... 00122 $this->zones = isset( $info['zones'] ) ? $info['zones'] : array(); 00123 foreach ( array( 'public', 'thumb', 'temp', 'deleted' ) as $zone ) { 00124 if ( !isset( $this->zones[$zone]['container'] ) ) { 00125 $this->zones[$zone]['container'] = "{$this->name}-{$zone}"; 00126 } 00127 if ( !isset( $this->zones[$zone]['directory'] ) ) { 00128 $this->zones[$zone]['directory'] = ''; 00129 } 00130 if ( !isset( $this->zones[$zone]['urlsByExt'] ) ) { 00131 $this->zones[$zone]['urlsByExt'] = array(); 00132 } 00133 } 00134 } 00135 00141 public function getBackend() { 00142 return $this->backend; 00143 } 00144 00151 public function getReadOnlyReason() { 00152 return $this->backend->getReadOnlyReason(); 00153 } 00154 00162 protected function initZones( $doZones = array() ) { 00163 $status = $this->newGood(); 00164 foreach ( (array)$doZones as $zone ) { 00165 $root = $this->getZonePath( $zone ); 00166 if ( $root === null ) { 00167 throw new MWException( "No '$zone' zone defined in the {$this->name} repo." ); 00168 } 00169 } 00170 return $status; 00171 } 00172 00179 public static function isVirtualUrl( $url ) { 00180 return substr( $url, 0, 9 ) == 'mwrepo://'; 00181 } 00182 00191 public function getVirtualUrl( $suffix = false ) { 00192 $path = 'mwrepo://' . $this->name; 00193 if ( $suffix !== false ) { 00194 $path .= '/' . rawurlencode( $suffix ); 00195 } 00196 return $path; 00197 } 00198 00206 public function getZoneUrl( $zone, $ext = null ) { 00207 if ( in_array( $zone, array( 'public', 'temp', 'thumb' ) ) ) { // standard public zones 00208 if ( $ext !== null && isset( $this->zones[$zone]['urlsByExt'][$ext] ) ) { 00209 return $this->zones[$zone]['urlsByExt'][$ext]; // custom URL for extension/zone 00210 } elseif ( isset( $this->zones[$zone]['url'] ) ) { 00211 return $this->zones[$zone]['url']; // custom URL for zone 00212 } 00213 } 00214 switch ( $zone ) { 00215 case 'public': 00216 return $this->url; 00217 case 'temp': 00218 return "{$this->url}/temp"; 00219 case 'deleted': 00220 return false; // no public URL 00221 case 'thumb': 00222 return $this->thumbUrl; 00223 default: 00224 return false; 00225 } 00226 } 00227 00241 public function getZoneHandlerUrl( $zone ) { 00242 if ( isset( $this->zones[$zone]['handlerUrl'] ) 00243 && in_array( $zone, array( 'public', 'temp', 'thumb' ) ) ) 00244 { 00245 return $this->zones[$zone]['handlerUrl']; 00246 } 00247 return false; 00248 } 00249 00258 public function resolveVirtualUrl( $url ) { 00259 if ( substr( $url, 0, 9 ) != 'mwrepo://' ) { 00260 throw new MWException( __METHOD__.': unknown protocol' ); 00261 } 00262 $bits = explode( '/', substr( $url, 9 ), 3 ); 00263 if ( count( $bits ) != 3 ) { 00264 throw new MWException( __METHOD__.": invalid mwrepo URL: $url" ); 00265 } 00266 list( $repo, $zone, $rel ) = $bits; 00267 if ( $repo !== $this->name ) { 00268 throw new MWException( __METHOD__.": fetching from a foreign repo is not supported" ); 00269 } 00270 $base = $this->getZonePath( $zone ); 00271 if ( !$base ) { 00272 throw new MWException( __METHOD__.": invalid zone: $zone" ); 00273 } 00274 return $base . '/' . rawurldecode( $rel ); 00275 } 00276 00283 protected function getZoneLocation( $zone ) { 00284 if ( !isset( $this->zones[$zone] ) ) { 00285 return array( null, null ); // bogus 00286 } 00287 return array( $this->zones[$zone]['container'], $this->zones[$zone]['directory'] ); 00288 } 00289 00296 public function getZonePath( $zone ) { 00297 list( $container, $base ) = $this->getZoneLocation( $zone ); 00298 if ( $container === null || $base === null ) { 00299 return null; 00300 } 00301 $backendName = $this->backend->getName(); 00302 if ( $base != '' ) { // may not be set 00303 $base = "/{$base}"; 00304 } 00305 return "mwstore://$backendName/{$container}{$base}"; 00306 } 00307 00319 public function newFile( $title, $time = false ) { 00320 $title = File::normalizeTitle( $title ); 00321 if ( !$title ) { 00322 return null; 00323 } 00324 if ( $time ) { 00325 if ( $this->oldFileFactory ) { 00326 return call_user_func( $this->oldFileFactory, $title, $this, $time ); 00327 } else { 00328 return false; 00329 } 00330 } else { 00331 return call_user_func( $this->fileFactory, $title, $this ); 00332 } 00333 } 00334 00353 public function findFile( $title, $options = array() ) { 00354 $title = File::normalizeTitle( $title ); 00355 if ( !$title ) { 00356 return false; 00357 } 00358 $time = isset( $options['time'] ) ? $options['time'] : false; 00359 # First try the current version of the file to see if it precedes the timestamp 00360 $img = $this->newFile( $title ); 00361 if ( !$img ) { 00362 return false; 00363 } 00364 if ( $img->exists() && ( !$time || $img->getTimestamp() == $time ) ) { 00365 return $img; 00366 } 00367 # Now try an old version of the file 00368 if ( $time !== false ) { 00369 $img = $this->newFile( $title, $time ); 00370 if ( $img && $img->exists() ) { 00371 if ( !$img->isDeleted( File::DELETED_FILE ) ) { 00372 return $img; // always OK 00373 } elseif ( !empty( $options['private'] ) && $img->userCan( File::DELETED_FILE ) ) { 00374 return $img; 00375 } 00376 } 00377 } 00378 00379 # Now try redirects 00380 if ( !empty( $options['ignoreRedirect'] ) ) { 00381 return false; 00382 } 00383 $redir = $this->checkRedirect( $title ); 00384 if ( $redir && $title->getNamespace() == NS_FILE) { 00385 $img = $this->newFile( $redir ); 00386 if ( !$img ) { 00387 return false; 00388 } 00389 if ( $img->exists() ) { 00390 $img->redirectedFrom( $title->getDBkey() ); 00391 return $img; 00392 } 00393 } 00394 return false; 00395 } 00396 00408 public function findFiles( array $items ) { 00409 $result = array(); 00410 foreach ( $items as $item ) { 00411 if ( is_array( $item ) ) { 00412 $title = $item['title']; 00413 $options = $item; 00414 unset( $options['title'] ); 00415 } else { 00416 $title = $item; 00417 $options = array(); 00418 } 00419 $file = $this->findFile( $title, $options ); 00420 if ( $file ) { 00421 $result[$file->getTitle()->getDBkey()] = $file; 00422 } 00423 } 00424 return $result; 00425 } 00426 00436 public function findFileFromKey( $sha1, $options = array() ) { 00437 $time = isset( $options['time'] ) ? $options['time'] : false; 00438 # First try to find a matching current version of a file... 00439 if ( $this->fileFactoryKey ) { 00440 $img = call_user_func( $this->fileFactoryKey, $sha1, $this, $time ); 00441 } else { 00442 return false; // find-by-sha1 not supported 00443 } 00444 if ( $img && $img->exists() ) { 00445 return $img; 00446 } 00447 # Now try to find a matching old version of a file... 00448 if ( $time !== false && $this->oldFileFactoryKey ) { // find-by-sha1 supported? 00449 $img = call_user_func( $this->oldFileFactoryKey, $sha1, $this, $time ); 00450 if ( $img && $img->exists() ) { 00451 if ( !$img->isDeleted( File::DELETED_FILE ) ) { 00452 return $img; // always OK 00453 } elseif ( !empty( $options['private'] ) && $img->userCan( File::DELETED_FILE ) ) { 00454 return $img; 00455 } 00456 } 00457 } 00458 return false; 00459 } 00460 00469 public function findBySha1( $hash ) { 00470 return array(); 00471 } 00472 00480 public function findBySha1s( array $hashes ) { 00481 $result = array(); 00482 foreach ( $hashes as $hash ) { 00483 $files = $this->findBySha1( $hash ); 00484 if ( count( $files ) ) { 00485 $result[$hash] = $files; 00486 } 00487 } 00488 return $result; 00489 } 00490 00497 public function getRootUrl() { 00498 return $this->getZoneUrl( 'public' ); 00499 } 00500 00506 public function getThumbScriptUrl() { 00507 return $this->thumbScriptUrl; 00508 } 00509 00515 public function canTransformVia404() { 00516 return $this->transformVia404; 00517 } 00518 00525 public function getNameFromTitle( Title $title ) { 00526 global $wgContLang; 00527 if ( $this->initialCapital != MWNamespace::isCapitalized( NS_FILE ) ) { 00528 $name = $title->getUserCaseDBKey(); 00529 if ( $this->initialCapital ) { 00530 $name = $wgContLang->ucfirst( $name ); 00531 } 00532 } else { 00533 $name = $title->getDBkey(); 00534 } 00535 return $name; 00536 } 00537 00543 public function getRootDirectory() { 00544 return $this->getZonePath( 'public' ); 00545 } 00546 00554 public function getHashPath( $name ) { 00555 return self::getHashPathForLevel( $name, $this->hashLevels ); 00556 } 00557 00565 public function getTempHashPath( $suffix ) { 00566 $parts = explode( '!', $suffix, 2 ); // format is <timestamp>!<name> or just <name> 00567 $name = isset( $parts[1] ) ? $parts[1] : $suffix; // hash path is not based on timestamp 00568 return self::getHashPathForLevel( $name, $this->hashLevels ); 00569 } 00570 00576 protected static function getHashPathForLevel( $name, $levels ) { 00577 if ( $levels == 0 ) { 00578 return ''; 00579 } else { 00580 $hash = md5( $name ); 00581 $path = ''; 00582 for ( $i = 1; $i <= $levels; $i++ ) { 00583 $path .= substr( $hash, 0, $i ) . '/'; 00584 } 00585 return $path; 00586 } 00587 } 00588 00594 public function getHashLevels() { 00595 return $this->hashLevels; 00596 } 00597 00603 public function getName() { 00604 return $this->name; 00605 } 00606 00614 public function makeUrl( $query = '', $entry = 'index' ) { 00615 if ( isset( $this->scriptDirUrl ) ) { 00616 $ext = isset( $this->scriptExtension ) ? $this->scriptExtension : '.php'; 00617 return wfAppendQuery( "{$this->scriptDirUrl}/{$entry}{$ext}", $query ); 00618 } 00619 return false; 00620 } 00621 00634 public function getDescriptionUrl( $name ) { 00635 $encName = wfUrlencode( $name ); 00636 if ( !is_null( $this->descBaseUrl ) ) { 00637 # "http://example.com/wiki/Image:" 00638 return $this->descBaseUrl . $encName; 00639 } 00640 if ( !is_null( $this->articleUrl ) ) { 00641 # "http://example.com/wiki/$1" 00642 # 00643 # We use "Image:" as the canonical namespace for 00644 # compatibility across all MediaWiki versions. 00645 return str_replace( '$1', 00646 "Image:$encName", $this->articleUrl ); 00647 } 00648 if ( !is_null( $this->scriptDirUrl ) ) { 00649 # "http://example.com/w" 00650 # 00651 # We use "Image:" as the canonical namespace for 00652 # compatibility across all MediaWiki versions, 00653 # and just sort of hope index.php is right. ;) 00654 return $this->makeUrl( "title=Image:$encName" ); 00655 } 00656 return false; 00657 } 00658 00669 public function getDescriptionRenderUrl( $name, $lang = null ) { 00670 $query = 'action=render'; 00671 if ( !is_null( $lang ) ) { 00672 $query .= '&uselang=' . $lang; 00673 } 00674 if ( isset( $this->scriptDirUrl ) ) { 00675 return $this->makeUrl( 00676 'title=' . 00677 wfUrlencode( 'Image:' . $name ) . 00678 "&$query" ); 00679 } else { 00680 $descUrl = $this->getDescriptionUrl( $name ); 00681 if ( $descUrl ) { 00682 return wfAppendQuery( $descUrl, $query ); 00683 } else { 00684 return false; 00685 } 00686 } 00687 } 00688 00694 public function getDescriptionStylesheetUrl() { 00695 if ( isset( $this->scriptDirUrl ) ) { 00696 return $this->makeUrl( 'title=MediaWiki:Filepage.css&' . 00697 wfArrayToCGI( Skin::getDynamicStylesheetQuery() ) ); 00698 } 00699 return false; 00700 } 00701 00716 public function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) { 00717 $this->assertWritableRepo(); // fail out if read-only 00718 00719 $status = $this->storeBatch( array( array( $srcPath, $dstZone, $dstRel ) ), $flags ); 00720 if ( $status->successCount == 0 ) { 00721 $status->ok = false; 00722 } 00723 00724 return $status; 00725 } 00726 00740 public function storeBatch( array $triplets, $flags = 0 ) { 00741 $this->assertWritableRepo(); // fail out if read-only 00742 00743 $status = $this->newGood(); 00744 $backend = $this->backend; // convenience 00745 00746 $operations = array(); 00747 $sourceFSFilesToDelete = array(); // cleanup for disk source files 00748 // Validate each triplet and get the store operation... 00749 foreach ( $triplets as $triplet ) { 00750 list( $srcPath, $dstZone, $dstRel ) = $triplet; 00751 wfDebug( __METHOD__ 00752 . "( \$src='$srcPath', \$dstZone='$dstZone', \$dstRel='$dstRel' )\n" 00753 ); 00754 00755 // Resolve destination path 00756 $root = $this->getZonePath( $dstZone ); 00757 if ( !$root ) { 00758 throw new MWException( "Invalid zone: $dstZone" ); 00759 } 00760 if ( !$this->validateFilename( $dstRel ) ) { 00761 throw new MWException( 'Validation error in $dstRel' ); 00762 } 00763 $dstPath = "$root/$dstRel"; 00764 $dstDir = dirname( $dstPath ); 00765 // Create destination directories for this triplet 00766 if ( !$this->initDirectory( $dstDir )->isOK() ) { 00767 return $this->newFatal( 'directorycreateerror', $dstDir ); 00768 } 00769 00770 // Resolve source to a storage path if virtual 00771 $srcPath = $this->resolveToStoragePath( $srcPath ); 00772 00773 // Get the appropriate file operation 00774 if ( FileBackend::isStoragePath( $srcPath ) ) { 00775 $opName = ( $flags & self::DELETE_SOURCE ) ? 'move' : 'copy'; 00776 } else { 00777 $opName = 'store'; 00778 if ( $flags & self::DELETE_SOURCE ) { 00779 $sourceFSFilesToDelete[] = $srcPath; 00780 } 00781 } 00782 $operations[] = array( 00783 'op' => $opName, 00784 'src' => $srcPath, 00785 'dst' => $dstPath, 00786 'overwrite' => $flags & self::OVERWRITE, 00787 'overwriteSame' => $flags & self::OVERWRITE_SAME, 00788 ); 00789 } 00790 00791 // Execute the store operation for each triplet 00792 $opts = array( 'force' => true ); 00793 if ( $flags & self::SKIP_LOCKING ) { 00794 $opts['nonLocking'] = true; 00795 } 00796 $status->merge( $backend->doOperations( $operations, $opts ) ); 00797 // Cleanup for disk source files... 00798 foreach ( $sourceFSFilesToDelete as $file ) { 00799 wfSuppressWarnings(); 00800 unlink( $file ); // FS cleanup 00801 wfRestoreWarnings(); 00802 } 00803 00804 return $status; 00805 } 00806 00817 public function cleanupBatch( array $files, $flags = 0 ) { 00818 $this->assertWritableRepo(); // fail out if read-only 00819 00820 $status = $this->newGood(); 00821 00822 $operations = array(); 00823 foreach ( $files as $path ) { 00824 if ( is_array( $path ) ) { 00825 // This is a pair, extract it 00826 list( $zone, $rel ) = $path; 00827 $path = $this->getZonePath( $zone ) . "/$rel"; 00828 } else { 00829 // Resolve source to a storage path if virtual 00830 $path = $this->resolveToStoragePath( $path ); 00831 } 00832 $operations[] = array( 'op' => 'delete', 'src' => $path ); 00833 } 00834 // Actually delete files from storage... 00835 $opts = array( 'force' => true ); 00836 if ( $flags & self::SKIP_LOCKING ) { 00837 $opts['nonLocking'] = true; 00838 } 00839 $status->merge( $this->backend->doOperations( $operations, $opts ) ); 00840 00841 return $status; 00842 } 00843 00855 final public function quickImport( $src, $dst, $disposition = null ) { 00856 return $this->quickImportBatch( array( array( $src, $dst, $disposition ) ) ); 00857 } 00858 00867 final public function quickPurge( $path ) { 00868 return $this->quickPurgeBatch( array( $path ) ); 00869 } 00870 00878 public function quickCleanDir( $dir ) { 00879 $status = $this->newGood(); 00880 $status->merge( $this->backend->clean( 00881 array( 'dir' => $this->resolveToStoragePath( $dir ) ) ) ); 00882 00883 return $status; 00884 } 00885 00898 public function quickImportBatch( array $triples ) { 00899 $status = $this->newGood(); 00900 $operations = array(); 00901 foreach ( $triples as $triple ) { 00902 list( $src, $dst ) = $triple; 00903 $src = $this->resolveToStoragePath( $src ); 00904 $dst = $this->resolveToStoragePath( $dst ); 00905 $operations[] = array( 00906 'op' => FileBackend::isStoragePath( $src ) ? 'copy' : 'store', 00907 'src' => $src, 00908 'dst' => $dst, 00909 'disposition' => isset( $triple[2] ) ? $triple[2] : null 00910 ); 00911 $status->merge( $this->initDirectory( dirname( $dst ) ) ); 00912 } 00913 $status->merge( $this->backend->doQuickOperations( $operations ) ); 00914 00915 return $status; 00916 } 00917 00926 public function quickPurgeBatch( array $paths ) { 00927 $status = $this->newGood(); 00928 $operations = array(); 00929 foreach ( $paths as $path ) { 00930 $operations[] = array( 00931 'op' => 'delete', 00932 'src' => $this->resolveToStoragePath( $path ), 00933 'ignoreMissingSource' => true 00934 ); 00935 } 00936 $status->merge( $this->backend->doQuickOperations( $operations ) ); 00937 00938 return $status; 00939 } 00940 00951 public function storeTemp( $originalName, $srcPath ) { 00952 $this->assertWritableRepo(); // fail out if read-only 00953 00954 $date = gmdate( "YmdHis" ); 00955 $hashPath = $this->getHashPath( $originalName ); 00956 $dstRel = "{$hashPath}{$date}!{$originalName}"; 00957 $dstUrlRel = $hashPath . $date . '!' . rawurlencode( $originalName ); 00958 $virtualUrl = $this->getVirtualUrl( 'temp' ) . '/' . $dstUrlRel; 00959 00960 $result = $this->quickImport( $srcPath, $virtualUrl ); 00961 $result->value = $virtualUrl; 00962 00963 return $result; 00964 } 00965 00972 public function freeTemp( $virtualUrl ) { 00973 $this->assertWritableRepo(); // fail out if read-only 00974 00975 $temp = $this->getVirtualUrl( 'temp' ); 00976 if ( substr( $virtualUrl, 0, strlen( $temp ) ) != $temp ) { 00977 wfDebug( __METHOD__.": Invalid temp virtual URL\n" ); 00978 return false; 00979 } 00980 00981 return $this->quickPurge( $virtualUrl )->isOK(); 00982 } 00983 00993 public function concatenate( array $srcPaths, $dstPath, $flags = 0 ) { 00994 $this->assertWritableRepo(); // fail out if read-only 00995 00996 $status = $this->newGood(); 00997 00998 $sources = array(); 00999 foreach ( $srcPaths as $srcPath ) { 01000 // Resolve source to a storage path if virtual 01001 $source = $this->resolveToStoragePath( $srcPath ); 01002 $sources[] = $source; // chunk to merge 01003 } 01004 01005 // Concatenate the chunks into one FS file 01006 $params = array( 'srcs' => $sources, 'dst' => $dstPath ); 01007 $status->merge( $this->backend->concatenate( $params ) ); 01008 if ( !$status->isOK() ) { 01009 return $status; 01010 } 01011 01012 // Delete the sources if required 01013 if ( $flags & self::DELETE_SOURCE ) { 01014 $status->merge( $this->quickPurgeBatch( $srcPaths ) ); 01015 } 01016 01017 // Make sure status is OK, despite any quickPurgeBatch() fatals 01018 $status->setResult( true ); 01019 01020 return $status; 01021 } 01022 01038 public function publish( $srcPath, $dstRel, $archiveRel, $flags = 0 ) { 01039 $this->assertWritableRepo(); // fail out if read-only 01040 01041 $status = $this->publishBatch( array( array( $srcPath, $dstRel, $archiveRel ) ), $flags ); 01042 if ( $status->successCount == 0 ) { 01043 $status->ok = false; 01044 } 01045 if ( isset( $status->value[0] ) ) { 01046 $status->value = $status->value[0]; 01047 } else { 01048 $status->value = false; 01049 } 01050 01051 return $status; 01052 } 01053 01063 public function publishBatch( array $triplets, $flags = 0 ) { 01064 $this->assertWritableRepo(); // fail out if read-only 01065 01066 $backend = $this->backend; // convenience 01067 // Try creating directories 01068 $status = $this->initZones( 'public' ); 01069 if ( !$status->isOK() ) { 01070 return $status; 01071 } 01072 01073 $status = $this->newGood( array() ); 01074 01075 $operations = array(); 01076 $sourceFSFilesToDelete = array(); // cleanup for disk source files 01077 // Validate each triplet and get the store operation... 01078 foreach ( $triplets as $i => $triplet ) { 01079 list( $srcPath, $dstRel, $archiveRel ) = $triplet; 01080 // Resolve source to a storage path if virtual 01081 $srcPath = $this->resolveToStoragePath( $srcPath ); 01082 if ( !$this->validateFilename( $dstRel ) ) { 01083 throw new MWException( 'Validation error in $dstRel' ); 01084 } 01085 if ( !$this->validateFilename( $archiveRel ) ) { 01086 throw new MWException( 'Validation error in $archiveRel' ); 01087 } 01088 01089 $publicRoot = $this->getZonePath( 'public' ); 01090 $dstPath = "$publicRoot/$dstRel"; 01091 $archivePath = "$publicRoot/$archiveRel"; 01092 01093 $dstDir = dirname( $dstPath ); 01094 $archiveDir = dirname( $archivePath ); 01095 // Abort immediately on directory creation errors since they're likely to be repetitive 01096 if ( !$this->initDirectory( $dstDir )->isOK() ) { 01097 return $this->newFatal( 'directorycreateerror', $dstDir ); 01098 } 01099 if ( !$this->initDirectory($archiveDir )->isOK() ) { 01100 return $this->newFatal( 'directorycreateerror', $archiveDir ); 01101 } 01102 01103 // Archive destination file if it exists. 01104 // This will check if the archive file also exists and fail if does. 01105 // This is a sanity check to avoid data loss. On Windows and Linux, 01106 // copy() will overwrite, so the existence check is vulnerable to 01107 // race conditions unless an functioning LockManager is used. 01108 // LocalFile also uses SELECT FOR UPDATE for synchronization. 01109 $operations[] = array( 01110 'op' => 'copy', 01111 'src' => $dstPath, 01112 'dst' => $archivePath, 01113 'ignoreMissingSource' => true 01114 ); 01115 01116 // Copy (or move) the source file to the destination 01117 if ( FileBackend::isStoragePath( $srcPath ) ) { 01118 if ( $flags & self::DELETE_SOURCE ) { 01119 $operations[] = array( 01120 'op' => 'move', 01121 'src' => $srcPath, 01122 'dst' => $dstPath, 01123 'overwrite' => true // replace current 01124 ); 01125 } else { 01126 $operations[] = array( 01127 'op' => 'copy', 01128 'src' => $srcPath, 01129 'dst' => $dstPath, 01130 'overwrite' => true // replace current 01131 ); 01132 } 01133 } else { // FS source path 01134 $operations[] = array( 01135 'op' => 'store', 01136 'src' => $srcPath, 01137 'dst' => $dstPath, 01138 'overwrite' => true // replace current 01139 ); 01140 if ( $flags & self::DELETE_SOURCE ) { 01141 $sourceFSFilesToDelete[] = $srcPath; 01142 } 01143 } 01144 } 01145 01146 // Execute the operations for each triplet 01147 $status->merge( $backend->doOperations( $operations ) ); 01148 // Find out which files were archived... 01149 foreach ( $triplets as $i => $triplet ) { 01150 list( $srcPath, $dstRel, $archiveRel ) = $triplet; 01151 $archivePath = $this->getZonePath( 'public' ) . "/$archiveRel"; 01152 if ( $this->fileExists( $archivePath ) ) { 01153 $status->value[$i] = 'archived'; 01154 } else { 01155 $status->value[$i] = 'new'; 01156 } 01157 } 01158 // Cleanup for disk source files... 01159 foreach ( $sourceFSFilesToDelete as $file ) { 01160 wfSuppressWarnings(); 01161 unlink( $file ); // FS cleanup 01162 wfRestoreWarnings(); 01163 } 01164 01165 return $status; 01166 } 01167 01175 protected function initDirectory( $dir ) { 01176 $path = $this->resolveToStoragePath( $dir ); 01177 list( $b, $container, $r ) = FileBackend::splitStoragePath( $path ); 01178 01179 $params = array( 'dir' => $path ); 01180 if ( $this->isPrivate || $container === $this->zones['deleted']['container'] ) { 01181 # Take all available measures to prevent web accessibility of new deleted 01182 # directories, in case the user has not configured offline storage 01183 $params = array( 'noAccess' => true, 'noListing' => true ) + $params; 01184 } 01185 01186 return $this->backend->prepare( $params ); 01187 } 01188 01195 public function cleanDir( $dir ) { 01196 $this->assertWritableRepo(); // fail out if read-only 01197 01198 $status = $this->newGood(); 01199 $status->merge( $this->backend->clean( 01200 array( 'dir' => $this->resolveToStoragePath( $dir ) ) ) ); 01201 01202 return $status; 01203 } 01204 01211 public function fileExists( $file ) { 01212 $result = $this->fileExistsBatch( array( $file ) ); 01213 return $result[0]; 01214 } 01215 01222 public function fileExistsBatch( array $files ) { 01223 $result = array(); 01224 foreach ( $files as $key => $file ) { 01225 $file = $this->resolveToStoragePath( $file ); 01226 $result[$key] = $this->backend->fileExists( array( 'src' => $file ) ); 01227 } 01228 return $result; 01229 } 01230 01241 public function delete( $srcRel, $archiveRel ) { 01242 $this->assertWritableRepo(); // fail out if read-only 01243 01244 return $this->deleteBatch( array( array( $srcRel, $archiveRel ) ) ); 01245 } 01246 01264 public function deleteBatch( array $sourceDestPairs ) { 01265 $this->assertWritableRepo(); // fail out if read-only 01266 01267 // Try creating directories 01268 $status = $this->initZones( array( 'public', 'deleted' ) ); 01269 if ( !$status->isOK() ) { 01270 return $status; 01271 } 01272 01273 $status = $this->newGood(); 01274 01275 $backend = $this->backend; // convenience 01276 $operations = array(); 01277 // Validate filenames and create archive directories 01278 foreach ( $sourceDestPairs as $pair ) { 01279 list( $srcRel, $archiveRel ) = $pair; 01280 if ( !$this->validateFilename( $srcRel ) ) { 01281 throw new MWException( __METHOD__.':Validation error in $srcRel' ); 01282 } elseif ( !$this->validateFilename( $archiveRel ) ) { 01283 throw new MWException( __METHOD__.':Validation error in $archiveRel' ); 01284 } 01285 01286 $publicRoot = $this->getZonePath( 'public' ); 01287 $srcPath = "{$publicRoot}/$srcRel"; 01288 01289 $deletedRoot = $this->getZonePath( 'deleted' ); 01290 $archivePath = "{$deletedRoot}/{$archiveRel}"; 01291 $archiveDir = dirname( $archivePath ); // does not touch FS 01292 01293 // Create destination directories 01294 if ( !$this->initDirectory( $archiveDir )->isOK() ) { 01295 return $this->newFatal( 'directorycreateerror', $archiveDir ); 01296 } 01297 01298 $operations[] = array( 01299 'op' => 'move', 01300 'src' => $srcPath, 01301 'dst' => $archivePath, 01302 // We may have 2+ identical files being deleted, 01303 // all of which will map to the same destination file 01304 'overwriteSame' => true // also see bug 31792 01305 ); 01306 } 01307 01308 // Move the files by execute the operations for each pair. 01309 // We're now committed to returning an OK result, which will 01310 // lead to the files being moved in the DB also. 01311 $opts = array( 'force' => true ); 01312 $status->merge( $backend->doOperations( $operations, $opts ) ); 01313 01314 return $status; 01315 } 01316 01322 public function cleanupDeletedBatch( array $storageKeys ) { 01323 $this->assertWritableRepo(); 01324 } 01325 01334 public function getDeletedHashPath( $key ) { 01335 if ( strlen( $key ) < 31 ) { 01336 throw new MWException( "Invalid storage key '$key'." ); 01337 } 01338 $path = ''; 01339 for ( $i = 0; $i < $this->deletedHashLevels; $i++ ) { 01340 $path .= $key[$i] . '/'; 01341 } 01342 return $path; 01343 } 01344 01353 protected function resolveToStoragePath( $path ) { 01354 if ( $this->isVirtualUrl( $path ) ) { 01355 return $this->resolveVirtualUrl( $path ); 01356 } 01357 return $path; 01358 } 01359 01367 public function getLocalCopy( $virtualUrl ) { 01368 $path = $this->resolveToStoragePath( $virtualUrl ); 01369 return $this->backend->getLocalCopy( array( 'src' => $path ) ); 01370 } 01371 01380 public function getLocalReference( $virtualUrl ) { 01381 $path = $this->resolveToStoragePath( $virtualUrl ); 01382 return $this->backend->getLocalReference( array( 'src' => $path ) ); 01383 } 01384 01392 public function getFileProps( $virtualUrl ) { 01393 $path = $this->resolveToStoragePath( $virtualUrl ); 01394 return $this->backend->getFileProps( array( 'src' => $path ) ); 01395 } 01396 01403 public function getFileTimestamp( $virtualUrl ) { 01404 $path = $this->resolveToStoragePath( $virtualUrl ); 01405 return $this->backend->getFileTimestamp( array( 'src' => $path ) ); 01406 } 01407 01414 public function getFileSha1( $virtualUrl ) { 01415 $path = $this->resolveToStoragePath( $virtualUrl ); 01416 return $this->backend->getFileSha1Base36( array( 'src' => $path ) ); 01417 } 01418 01426 public function streamFile( $virtualUrl, $headers = array() ) { 01427 $path = $this->resolveToStoragePath( $virtualUrl ); 01428 $params = array( 'src' => $path, 'headers' => $headers ); 01429 return $this->backend->streamFile( $params )->isOK(); 01430 } 01431 01440 public function enumFiles( $callback ) { 01441 $this->enumFilesInStorage( $callback ); 01442 } 01443 01451 protected function enumFilesInStorage( $callback ) { 01452 $publicRoot = $this->getZonePath( 'public' ); 01453 $numDirs = 1 << ( $this->hashLevels * 4 ); 01454 // Use a priori assumptions about directory structure 01455 // to reduce the tree height of the scanning process. 01456 for ( $flatIndex = 0; $flatIndex < $numDirs; $flatIndex++ ) { 01457 $hexString = sprintf( "%0{$this->hashLevels}x", $flatIndex ); 01458 $path = $publicRoot; 01459 for ( $hexPos = 0; $hexPos < $this->hashLevels; $hexPos++ ) { 01460 $path .= '/' . substr( $hexString, 0, $hexPos + 1 ); 01461 } 01462 $iterator = $this->backend->getFileList( array( 'dir' => $path ) ); 01463 foreach ( $iterator as $name ) { 01464 // Each item returned is a public file 01465 call_user_func( $callback, "{$path}/{$name}" ); 01466 } 01467 } 01468 } 01469 01476 public function validateFilename( $filename ) { 01477 if ( strval( $filename ) == '' ) { 01478 return false; 01479 } 01480 return FileBackend::isPathTraversalFree( $filename ); 01481 } 01482 01488 function getErrorCleanupFunction() { 01489 switch ( $this->pathDisclosureProtection ) { 01490 case 'none': 01491 case 'simple': // b/c 01492 $callback = array( $this, 'passThrough' ); 01493 break; 01494 default: // 'paranoid' 01495 $callback = array( $this, 'paranoidClean' ); 01496 } 01497 return $callback; 01498 } 01499 01506 function paranoidClean( $param ) { 01507 return '[hidden]'; 01508 } 01509 01516 function passThrough( $param ) { 01517 return $param; 01518 } 01519 01525 public function newFatal( $message /*, parameters...*/ ) { 01526 $params = func_get_args(); 01527 array_unshift( $params, $this ); 01528 return MWInit::callStaticMethod( 'FileRepoStatus', 'newFatal', $params ); 01529 } 01530 01537 public function newGood( $value = null ) { 01538 return FileRepoStatus::newGood( $this, $value ); 01539 } 01540 01549 public function checkRedirect( Title $title ) { 01550 return false; 01551 } 01552 01560 public function invalidateImageRedirect( Title $title ) {} 01561 01567 public function getDisplayName() { 01568 // We don't name our own repo, return nothing 01569 if ( $this->isLocal() ) { 01570 return null; 01571 } 01572 // 'shared-repo-name-wikimediacommons' is used when $wgUseInstantCommons = true 01573 return wfMessageFallback( 'shared-repo-name-' . $this->name, 'shared-repo' )->text(); 01574 } 01575 01583 public function nameForThumb( $name ) { 01584 if ( strlen( $name ) > $this->abbrvThreshold ) { 01585 $ext = FileBackend::extensionFromPath( $name ); 01586 $name = ( $ext == '' ) ? 'thumbnail' : "thumbnail.$ext"; 01587 } 01588 return $name; 01589 } 01590 01596 public function isLocal() { 01597 return $this->getName() == 'local'; 01598 } 01599 01608 public function getSharedCacheKey( /*...*/ ) { 01609 return false; 01610 } 01611 01619 public function getLocalCacheKey( /*...*/ ) { 01620 $args = func_get_args(); 01621 array_unshift( $args, 'filerepo', $this->getName() ); 01622 return call_user_func_array( 'wfMemcKey', $args ); 01623 } 01624 01633 public function getTempRepo() { 01634 return new TempFileRepo( array( 01635 'name' => "{$this->name}-temp", 01636 'backend' => $this->backend, 01637 'zones' => array( 01638 'public' => array( 01639 'container' => $this->zones['temp']['container'], 01640 'directory' => $this->zones['temp']['directory'] 01641 ), 01642 'thumb' => array( 01643 'container' => $this->zones['thumb']['container'], 01644 'directory' => ( $this->zones['thumb']['directory'] == '' ) 01645 ? 'temp' 01646 : $this->zones['thumb']['directory'] . '/temp' 01647 ) 01648 ), 01649 'url' => $this->getZoneUrl( 'temp' ), 01650 'thumbUrl' => $this->getZoneUrl( 'thumb' ) . '/temp', 01651 'hashLevels' => $this->hashLevels // performance 01652 ) ); 01653 } 01654 01660 public function getUploadStash() { 01661 return new UploadStash( $this ); 01662 } 01663 01671 protected function assertWritableRepo() {} 01672 } 01673 01677 class TempFileRepo extends FileRepo { 01678 public function getTempRepo() { 01679 throw new MWException( "Cannot get a temp repo from a temp repo." ); 01680 } 01681 }