MediaWiki  REL1_24
FileRepo.php
Go to the documentation of this file.
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 }