MediaWiki  REL1_23
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 
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 }