From 8f416baead93a48e5799e44b8bd2e2c4859f4e04 Mon Sep 17 00:00:00 2001 From: Pierre Schmitz Date: Fri, 14 Sep 2007 13:18:58 +0200 Subject: auf Version 1.11 aktualisiert; Login-Bug behoben --- includes/filerepo/ArchivedFile.php | 108 ++ includes/filerepo/FSRepo.php | 530 +++++++++ includes/filerepo/File.php | 1133 +++++++++++++++++++ includes/filerepo/FileRepo.php | 404 +++++++ includes/filerepo/FileRepoStatus.php | 171 +++ includes/filerepo/ForeignDBFile.php | 42 + includes/filerepo/ForeignDBRepo.php | 57 + includes/filerepo/ICRepo.php | 313 ++++++ includes/filerepo/LocalFile.php | 1573 +++++++++++++++++++++++++++ includes/filerepo/LocalRepo.php | 65 ++ includes/filerepo/OldLocalFile.php | 232 ++++ includes/filerepo/README | 41 + includes/filerepo/RepoGroup.php | 150 +++ includes/filerepo/UnregisteredLocalFile.php | 109 ++ 14 files changed, 4928 insertions(+) create mode 100644 includes/filerepo/ArchivedFile.php create mode 100644 includes/filerepo/FSRepo.php create mode 100644 includes/filerepo/File.php create mode 100644 includes/filerepo/FileRepo.php create mode 100644 includes/filerepo/FileRepoStatus.php create mode 100644 includes/filerepo/ForeignDBFile.php create mode 100644 includes/filerepo/ForeignDBRepo.php create mode 100644 includes/filerepo/ICRepo.php create mode 100644 includes/filerepo/LocalFile.php create mode 100644 includes/filerepo/LocalRepo.php create mode 100644 includes/filerepo/OldLocalFile.php create mode 100644 includes/filerepo/README create mode 100644 includes/filerepo/RepoGroup.php create mode 100644 includes/filerepo/UnregisteredLocalFile.php (limited to 'includes/filerepo') diff --git a/includes/filerepo/ArchivedFile.php b/includes/filerepo/ArchivedFile.php new file mode 100644 index 00000000..bd9ff633 --- /dev/null +++ b/includes/filerepo/ArchivedFile.php @@ -0,0 +1,108 @@ +getNamespace() == NS_IMAGE ) { + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'filearchive', + array( + 'fa_id', + 'fa_name', + 'fa_storage_key', + 'fa_storage_group', + 'fa_size', + 'fa_bits', + 'fa_width', + 'fa_height', + 'fa_metadata', + 'fa_media_type', + 'fa_major_mime', + 'fa_minor_mime', + 'fa_description', + 'fa_user', + 'fa_user_text', + 'fa_timestamp', + 'fa_deleted' ), + array( + 'fa_name' => $title->getDbKey(), + $conds ), + __METHOD__, + array( 'ORDER BY' => 'fa_timestamp DESC' ) ); + + if ( $dbr->numRows( $res ) == 0 ) { + // this revision does not exist? + return; + } + $ret = $dbr->resultObject( $res ); + $row = $ret->fetchObject(); + + // initialize fields for filestore image object + $this->mId = intval($row->fa_id); + $this->mName = $row->fa_name; + $this->mGroup = $row->fa_storage_group; + $this->mKey = $row->fa_storage_key; + $this->mSize = $row->fa_size; + $this->mBits = $row->fa_bits; + $this->mWidth = $row->fa_width; + $this->mHeight = $row->fa_height; + $this->mMetaData = $row->fa_metadata; + $this->mMime = "$row->fa_major_mime/$row->fa_minor_mime"; + $this->mType = $row->fa_media_type; + $this->mDescription = $row->fa_description; + $this->mUser = $row->fa_user; + $this->mUserText = $row->fa_user_text; + $this->mTimestamp = $row->fa_timestamp; + $this->mDeleted = $row->fa_deleted; + } else { + throw new MWException( 'This title does not correspond to an image page.' ); + return; + } + return true; + } + + /** + * int $field one of DELETED_* bitfield constants + * for file or revision rows + * @return bool + */ + function isDeleted( $field ) { + return ($this->mDeleted & $field) == $field; + } + + /** + * Determine if the current user is allowed to view a particular + * field of this FileStore image file, if it's marked as deleted. + * @param int $field + * @return bool + */ + function userCan( $field ) { + if( isset($this->mDeleted) && ($this->mDeleted & $field) == $field ) { + // images + global $wgUser; + $permission = ( $this->mDeleted & File::DELETED_RESTRICTED ) == File::DELETED_RESTRICTED + ? 'hiderevision' + : 'deleterevision'; + wfDebug( "Checking for $permission due to $field match on $this->mDeleted\n" ); + return $wgUser->isAllowed( $permission ); + } else { + return true; + } + } +} + + diff --git a/includes/filerepo/FSRepo.php b/includes/filerepo/FSRepo.php new file mode 100644 index 00000000..84ec9a27 --- /dev/null +++ b/includes/filerepo/FSRepo.php @@ -0,0 +1,530 @@ +directory = $info['directory']; + $this->url = $info['url']; + + // Optional settings + $this->hashLevels = isset( $info['hashLevels'] ) ? $info['hashLevels'] : 2; + $this->deletedHashLevels = isset( $info['deletedHashLevels'] ) ? + $info['deletedHashLevels'] : $this->hashLevels; + $this->deletedDir = isset( $info['deletedDir'] ) ? $info['deletedDir'] : false; + } + + /** + * Get the public root directory of the repository. + */ + function getRootDirectory() { + return $this->directory; + } + + /** + * Get the public root URL of the repository + */ + function getRootUrl() { + return $this->url; + } + + /** + * Returns true if the repository uses a multi-level directory structure + */ + function isHashed() { + return (bool)$this->hashLevels; + } + + /** + * Get the local directory corresponding to one of the three basic zones + */ + function getZonePath( $zone ) { + switch ( $zone ) { + case 'public': + return $this->directory; + case 'temp': + return "{$this->directory}/temp"; + case 'deleted': + return $this->deletedDir; + default: + return false; + } + } + + /** + * Get the URL corresponding to one of the three basic zones + */ + function getZoneUrl( $zone ) { + switch ( $zone ) { + case 'public': + return $this->url; + case 'temp': + return "{$this->url}/temp"; + case 'deleted': + return false; // no public URL + default: + return false; + } + } + + /** + * Get a URL referring to this repository, with the private mwrepo protocol. + * The suffix, if supplied, is considered to be unencoded, and will be + * URL-encoded before being returned. + */ + function getVirtualUrl( $suffix = false ) { + $path = 'mwrepo://' . $this->name; + if ( $suffix !== false ) { + $path .= '/' . rawurlencode( $suffix ); + } + return $path; + } + + /** + * Get the local path corresponding to a virtual URL + */ + function resolveVirtualUrl( $url ) { + if ( substr( $url, 0, 9 ) != 'mwrepo://' ) { + throw new MWException( __METHOD__.': unknown protoocl' ); + } + + $bits = explode( '/', substr( $url, 9 ), 3 ); + if ( count( $bits ) != 3 ) { + throw new MWException( __METHOD__.": invalid mwrepo URL: $url" ); + } + list( $repo, $zone, $rel ) = $bits; + if ( $repo !== $this->name ) { + throw new MWException( __METHOD__.": fetching from a foreign repo is not supported" ); + } + $base = $this->getZonePath( $zone ); + if ( !$base ) { + throw new MWException( __METHOD__.": invalid zone: $zone" ); + } + return $base . '/' . rawurldecode( $rel ); + } + + /** + * Store a batch of files + * + * @param array $triplets (src,zone,dest) triplets as per store() + * @param integer $flags Bitwise combination of the following flags: + * self::DELETE_SOURCE Delete the source file after upload + * self::OVERWRITE Overwrite an existing destination file instead of failing + * self::OVERWRITE_SAME Overwrite the file if the destination exists and has the + * same contents as the source + */ + function storeBatch( $triplets, $flags = 0 ) { + if ( !is_writable( $this->directory ) ) { + return $this->newFatal( 'upload_directory_read_only', $this->directory ); + } + $status = $this->newGood(); + foreach ( $triplets as $i => $triplet ) { + list( $srcPath, $dstZone, $dstRel ) = $triplet; + + $root = $this->getZonePath( $dstZone ); + if ( !$root ) { + throw new MWException( "Invalid zone: $dstZone" ); + } + if ( !$this->validateFilename( $dstRel ) ) { + throw new MWException( 'Validation error in $dstRel' ); + } + $dstPath = "$root/$dstRel"; + $dstDir = dirname( $dstPath ); + + if ( !is_dir( $dstDir ) ) { + if ( !wfMkdirParents( $dstDir ) ) { + return $this->newFatal( 'directorycreateerror', $dstDir ); + } + // In the deleted zone, seed new directories with a blank + // index.html, to prevent crawling + if ( $dstZone == 'deleted' ) { + file_put_contents( "$dstDir/index.html", '' ); + } + } + + if ( self::isVirtualUrl( $srcPath ) ) { + $srcPath = $triplets[$i][0] = $this->resolveVirtualUrl( $srcPath ); + } + if ( !is_file( $srcPath ) ) { + // Make a list of files that don't exist for return to the caller + $status->fatal( 'filenotfound', $srcPath ); + continue; + } + if ( !( $flags & self::OVERWRITE ) && file_exists( $dstPath ) ) { + if ( $flags & self::OVERWRITE_SAME ) { + $hashSource = sha1_file( $srcPath ); + $hashDest = sha1_file( $dstPath ); + if ( $hashSource != $hashDest ) { + $status->fatal( 'fileexistserror', $dstPath ); + } + } else { + $status->fatal( 'fileexistserror', $dstPath ); + } + } + } + + $deleteDest = wfIsWindows() && ( $flags & self::OVERWRITE ); + + // Abort now on failure + if ( !$status->ok ) { + return $status; + } + + foreach ( $triplets as $triplet ) { + list( $srcPath, $dstZone, $dstRel ) = $triplet; + $root = $this->getZonePath( $dstZone ); + $dstPath = "$root/$dstRel"; + $good = true; + + if ( $flags & self::DELETE_SOURCE ) { + if ( $deleteDest ) { + unlink( $dstPath ); + } + if ( !rename( $srcPath, $dstPath ) ) { + $status->error( 'filerenameerror', $srcPath, $dstPath ); + $good = false; + } + } else { + if ( !copy( $srcPath, $dstPath ) ) { + $status->error( 'filecopyerror', $srcPath, $dstPath ); + $good = false; + } + } + if ( $good ) { + chmod( $dstPath, 0644 ); + $status->successCount++; + } else { + $status->failCount++; + } + } + return $status; + } + + /** + * Pick a random name in the temp zone and store a file to it. + * @param string $originalName The base name of the file as specified + * by the user. The file extension will be maintained. + * @param string $srcPath The current location of the file. + * @return FileRepoStatus object with the URL in the value. + */ + function storeTemp( $originalName, $srcPath ) { + $date = gmdate( "YmdHis" ); + $hashPath = $this->getHashPath( $originalName ); + $dstRel = "$hashPath$date!$originalName"; + $dstUrlRel = $hashPath . $date . '!' . rawurlencode( $originalName ); + + $result = $this->store( $srcPath, 'temp', $dstRel ); + $result->value = $this->getVirtualUrl( 'temp' ) . '/' . $dstUrlRel; + return $result; + } + + /** + * Remove a temporary file or mark it for garbage collection + * @param string $virtualUrl The virtual URL returned by storeTemp + * @return boolean True on success, false on failure + */ + function freeTemp( $virtualUrl ) { + $temp = "mwrepo://{$this->name}/temp"; + if ( substr( $virtualUrl, 0, strlen( $temp ) ) != $temp ) { + wfDebug( __METHOD__.": Invalid virtual URL\n" ); + return false; + } + $path = $this->resolveVirtualUrl( $virtualUrl ); + wfSuppressWarnings(); + $success = unlink( $path ); + wfRestoreWarnings(); + return $success; + } + + /** + * Publish a batch of files + * @param array $triplets (source,dest,archive) triplets as per publish() + * @param integer $flags Bitfield, may be FileRepo::DELETE_SOURCE to indicate + * that the source files should be deleted if possible + */ + function publishBatch( $triplets, $flags = 0 ) { + // Perform initial checks + if ( !is_writable( $this->directory ) ) { + return $this->newFatal( 'upload_directory_read_only', $this->directory ); + } + $status = $this->newGood( array() ); + foreach ( $triplets as $i => $triplet ) { + list( $srcPath, $dstRel, $archiveRel ) = $triplet; + + if ( substr( $srcPath, 0, 9 ) == 'mwrepo://' ) { + $triplets[$i][0] = $srcPath = $this->resolveVirtualUrl( $srcPath ); + } + if ( !$this->validateFilename( $dstRel ) ) { + throw new MWException( 'Validation error in $dstRel' ); + } + if ( !$this->validateFilename( $archiveRel ) ) { + throw new MWException( 'Validation error in $archiveRel' ); + } + $dstPath = "{$this->directory}/$dstRel"; + $archivePath = "{$this->directory}/$archiveRel"; + + $dstDir = dirname( $dstPath ); + $archiveDir = dirname( $archivePath ); + // Abort immediately on directory creation errors since they're likely to be repetitive + if ( !is_dir( $dstDir ) && !wfMkdirParents( $dstDir ) ) { + return $this->newFatal( 'directorycreateerror', $dstDir ); + } + if ( !is_dir( $archiveDir ) && !wfMkdirParents( $archiveDir ) ) { + return $this->newFatal( 'directorycreateerror', $archiveDir ); + } + if ( !is_file( $srcPath ) ) { + // Make a list of files that don't exist for return to the caller + $status->fatal( 'filenotfound', $srcPath ); + } + } + + if ( !$status->ok ) { + return $status; + } + + foreach ( $triplets as $i => $triplet ) { + list( $srcPath, $dstRel, $archiveRel ) = $triplet; + $dstPath = "{$this->directory}/$dstRel"; + $archivePath = "{$this->directory}/$archiveRel"; + + // Archive destination file if it exists + if( is_file( $dstPath ) ) { + // Check if the archive file exists + // This is a sanity check to avoid data loss. In UNIX, the rename primitive + // unlinks the destination file if it exists. DB-based synchronisation in + // publishBatch's caller should prevent races. In Windows there's no + // problem because the rename primitive fails if the destination exists. + if ( is_file( $archivePath ) ) { + $success = false; + } else { + wfSuppressWarnings(); + $success = rename( $dstPath, $archivePath ); + wfRestoreWarnings(); + } + + if( !$success ) { + $status->error( 'filerenameerror',$dstPath, $archivePath ); + $status->failCount++; + continue; + } else { + wfDebug(__METHOD__.": moved file $dstPath to $archivePath\n"); + } + $status->value[$i] = 'archived'; + } else { + $status->value[$i] = 'new'; + } + + $good = true; + wfSuppressWarnings(); + if ( $flags & self::DELETE_SOURCE ) { + if ( !rename( $srcPath, $dstPath ) ) { + $status->error( 'filerenameerror', $srcPath, $dstPath ); + $good = false; + } + } else { + if ( !copy( $srcPath, $dstPath ) ) { + $status->error( 'filecopyerror', $srcPath, $dstPath ); + $good = false; + } + } + wfRestoreWarnings(); + + if ( $good ) { + $status->successCount++; + wfDebug(__METHOD__.": wrote tempfile $srcPath to $dstPath\n"); + // Thread-safe override for umask + chmod( $dstPath, 0644 ); + } else { + $status->failCount++; + } + } + return $status; + } + + /** + * Move a group of files to the deletion archive. + * If no valid deletion archive is configured, this may either delete the + * file or throw an exception, depending on the preference of the repository. + * + * @param array $sourceDestPairs Array of source/destination pairs. Each element + * is a two-element array containing the source file path relative to the + * public root in the first element, and the archive file path relative + * to the deleted zone root in the second element. + * @return FileRepoStatus + */ + function deleteBatch( $sourceDestPairs ) { + $status = $this->newGood(); + if ( !$this->deletedDir ) { + throw new MWException( __METHOD__.': no valid deletion archive directory' ); + } + + /** + * Validate filenames and create archive directories + */ + foreach ( $sourceDestPairs as $pair ) { + list( $srcRel, $archiveRel ) = $pair; + if ( !$this->validateFilename( $srcRel ) ) { + throw new MWException( __METHOD__.':Validation error in $srcRel' ); + } + if ( !$this->validateFilename( $archiveRel ) ) { + throw new MWException( __METHOD__.':Validation error in $archiveRel' ); + } + $archivePath = "{$this->deletedDir}/$archiveRel"; + $archiveDir = dirname( $archivePath ); + if ( !is_dir( $archiveDir ) ) { + if ( !wfMkdirParents( $archiveDir ) ) { + $status->fatal( 'directorycreateerror', $archiveDir ); + continue; + } + // Seed new directories with a blank index.html, to prevent crawling + file_put_contents( "$archiveDir/index.html", '' ); + } + // Check if the archive directory is writable + // This doesn't appear to work on NTFS + if ( !is_writable( $archiveDir ) ) { + $status->fatal( 'filedelete-archive-read-only', $archiveDir ); + } + } + if ( !$status->ok ) { + // Abort early + return $status; + } + + /** + * Move the files + * We're now committed to returning an OK result, which will lead to + * the files being moved in the DB also. + */ + foreach ( $sourceDestPairs as $pair ) { + list( $srcRel, $archiveRel ) = $pair; + $srcPath = "{$this->directory}/$srcRel"; + $archivePath = "{$this->deletedDir}/$archiveRel"; + $good = true; + if ( file_exists( $archivePath ) ) { + # A file with this content hash is already archived + if ( !@unlink( $srcPath ) ) { + $status->error( 'filedeleteerror', $srcPath ); + $good = false; + } + } else{ + if ( !@rename( $srcPath, $archivePath ) ) { + $status->error( 'filerenameerror', $srcPath, $archivePath ); + $good = false; + } else { + chmod( $archivePath, 0644 ); + } + } + if ( $good ) { + $status->successCount++; + } else { + $status->failCount++; + } + } + return $status; + } + + /** + * Get a relative path including trailing slash, e.g. f/fa/ + * If the repo is not hashed, returns an empty string + */ + function getHashPath( $name ) { + return FileRepo::getHashPathForLevel( $name, $this->hashLevels ); + } + + /** + * Get a relative path for a deletion archive key, + * e.g. s/z/a/ for sza251lrxrc1jad41h5mgilp8nysje52.jpg + */ + function getDeletedHashPath( $key ) { + $path = ''; + for ( $i = 0; $i < $this->deletedHashLevels; $i++ ) { + $path .= $key[$i] . '/'; + } + return $path; + } + + /** + * Call a callback function for every file in the repository. + * Uses the filesystem even in child classes. + */ + function enumFilesInFS( $callback ) { + $numDirs = 1 << ( $this->hashLevels * 4 ); + for ( $flatIndex = 0; $flatIndex < $numDirs; $flatIndex++ ) { + $hexString = sprintf( "%0{$this->hashLevels}x", $flatIndex ); + $path = $this->directory; + for ( $hexPos = 0; $hexPos < $this->hashLevels; $hexPos++ ) { + $path .= '/' . substr( $hexString, 0, $hexPos + 1 ); + } + if ( !file_exists( $path ) || !is_dir( $path ) ) { + continue; + } + $dir = opendir( $path ); + while ( false !== ( $name = readdir( $dir ) ) ) { + call_user_func( $callback, $path . '/' . $name ); + } + } + } + + /** + * Call a callback function for every file in the repository + * May use either the database or the filesystem + */ + function enumFiles( $callback ) { + $this->enumFilesInFS( $callback ); + } + + /** + * Get properties of a file with a given virtual URL + * The virtual URL must refer to this repo + */ + function getFileProps( $virtualUrl ) { + $path = $this->resolveVirtualUrl( $virtualUrl ); + return File::getPropsFromPath( $path ); + } + + /** + * Path disclosure protection functions + * + * Get a callback function to use for cleaning error message parameters + */ + function getErrorCleanupFunction() { + switch ( $this->pathDisclosureProtection ) { + case 'simple': + $callback = array( $this, 'simpleClean' ); + break; + default: + $callback = parent::getErrorCleanupFunction(); + } + return $callback; + } + + function simpleClean( $param ) { + if ( !isset( $this->simpleCleanPairs ) ) { + global $IP; + $this->simpleCleanPairs = array( + $this->directory => 'public', + "{$this->directory}/temp" => 'temp', + $IP => '$IP', + dirname( __FILE__ ) => '$IP/extensions/WebStore', + ); + if ( $this->deletedDir ) { + $this->simpleCleanPairs[$this->deletedDir] = 'deleted'; + } + } + return strtr( $param, $this->simpleCleanPairs ); + } + +} + + diff --git a/includes/filerepo/File.php b/includes/filerepo/File.php new file mode 100644 index 00000000..21b7a865 --- /dev/null +++ b/includes/filerepo/File.php @@ -0,0 +1,1133 @@ +getLocalRepo()->newFile($title); + * + * The convenience functions wfLocalFile() and wfFindFile() should be sufficient + * in most cases. + * + * @addtogroup FileRepo + */ +abstract class File { + const DELETED_FILE = 1; + const DELETED_COMMENT = 2; + const DELETED_USER = 4; + const DELETED_RESTRICTED = 8; + const RENDER_NOW = 1; + + const DELETE_SOURCE = 1; + + /** + * Some member variables can be lazy-initialised using __get(). The + * initialisation function for these variables is always a function named + * like getVar(), where Var is the variable name with upper-case first + * letter. + * + * The following variables are initialised in this way in this base class: + * name, extension, handler, path, canRender, isSafeFile, + * transformScript, hashPath, pageCount, url + * + * Code within this class should generally use the accessor function + * directly, since __get() isn't re-entrant and therefore causes bugs that + * depend on initialisation order. + */ + + /** + * The following member variables are not lazy-initialised + */ + var $repo, $title, $lastError; + + /** + * Call this constructor from child classes + */ + function __construct( $title, $repo ) { + $this->title = $title; + $this->repo = $repo; + } + + function __get( $name ) { + $function = array( $this, 'get' . ucfirst( $name ) ); + if ( !is_callable( $function ) ) { + return null; + } else { + $this->$name = call_user_func( $function ); + return $this->$name; + } + } + + /** + * Normalize a file extension to the common form, and ensure it's clean. + * Extensions with non-alphanumeric characters will be discarded. + * + * @param $ext string (without the .) + * @return string + */ + static function normalizeExtension( $ext ) { + $lower = strtolower( $ext ); + $squish = array( + 'htm' => 'html', + 'jpeg' => 'jpg', + 'mpeg' => 'mpg', + 'tiff' => 'tif' ); + if( isset( $squish[$lower] ) ) { + return $squish[$lower]; + } elseif( preg_match( '/^[0-9a-z]+$/', $lower ) ) { + return $lower; + } else { + return ''; + } + } + + /** + * Upgrade the database row if there is one + * Called by ImagePage + * STUB + */ + function upgradeRow() {} + + /** + * Split an internet media type into its two components; if not + * a two-part name, set the minor type to 'unknown'. + * + * @param $mime "text/html" etc + * @return array ("text", "html") etc + */ + static function splitMime( $mime ) { + if( strpos( $mime, '/' ) !== false ) { + return explode( '/', $mime, 2 ); + } else { + return array( $mime, 'unknown' ); + } + } + + /** + * Return the name of this file + */ + public function getName() { + if ( !isset( $this->name ) ) { + $this->name = $this->repo->getNameFromTitle( $this->title ); + } + return $this->name; + } + + /** + * Get the file extension, e.g. "svg" + */ + function getExtension() { + if ( !isset( $this->extension ) ) { + $n = strrpos( $this->getName(), '.' ); + $this->extension = self::normalizeExtension( + $n ? substr( $this->getName(), $n + 1 ) : '' ); + } + return $this->extension; + } + + /** + * Return the associated title object + * @public + */ + function getTitle() { return $this->title; } + + /** + * Return the URL of the file + * @public + */ + function getUrl() { + if ( !isset( $this->url ) ) { + $this->url = $this->repo->getZoneUrl( 'public' ) . '/' . $this->getUrlRel(); + } + return $this->url; + } + + function getViewURL() { + if( $this->mustRender()) { + if( $this->canRender() ) { + return $this->createThumb( $this->getWidth() ); + } + else { + wfDebug(__METHOD__.': supposed to render '.$this->getName().' ('.$this->getMimeType()."), but can't!\n"); + return $this->getURL(); #hm... return NULL? + } + } else { + return $this->getURL(); + } + } + + /** + * Return the full filesystem path to the file. Note that this does + * not mean that a file actually exists under that location. + * + * This path depends on whether directory hashing is active or not, + * i.e. whether the files are all found in the same directory, + * or in hashed paths like /images/3/3c. + * + * May return false if the file is not locally accessible. + * + * @public + */ + function getPath() { + if ( !isset( $this->path ) ) { + $this->path = $this->repo->getZonePath('public') . '/' . $this->getRel(); + } + return $this->path; + } + + /** + * Alias for getPath() + * @public + */ + function getFullPath() { + return $this->getPath(); + } + + /** + * Return the width of the image. Returns false if the width is unknown + * or undefined. + * + * STUB + * Overridden by LocalFile, UnregisteredLocalFile + */ + public function getWidth( $page = 1 ) { return false; } + + /** + * Return the height of the image. Returns false if the height is unknown + * or undefined + * + * STUB + * Overridden by LocalFile, UnregisteredLocalFile + */ + public function getHeight( $page = 1 ) { return false; } + + /** + * Get the duration of a media file in seconds + */ + public function getLength() { + $handler = $this->getHandler(); + if ( $handler ) { + return $handler->getLength( $this ); + } else { + return 0; + } + } + + /** + * Get handler-specific metadata + * Overridden by LocalFile, UnregisteredLocalFile + * STUB + */ + function getMetadata() { return false; } + + /** + * Return the size of the image file, in bytes + * Overridden by LocalFile, UnregisteredLocalFile + * STUB + */ + public function getSize() { return false; } + + /** + * Returns the mime type of the file. + * Overridden by LocalFile, UnregisteredLocalFile + * STUB + */ + function getMimeType() { return 'unknown/unknown'; } + + /** + * Return the type of the media in the file. + * Use the value returned by this function with the MEDIATYPE_xxx constants. + * Overridden by LocalFile, + * STUB + */ + function getMediaType() { return MEDIATYPE_UNKNOWN; } + + /** + * Checks if the output of transform() for this file is likely + * to be valid. If this is false, various user elements will + * display a placeholder instead. + * + * Currently, this checks if the file is an image format + * that can be converted to a format + * supported by all browsers (namely GIF, PNG and JPEG), + * or if it is an SVG image and SVG conversion is enabled. + */ + function canRender() { + if ( !isset( $this->canRender ) ) { + $this->canRender = $this->getHandler() && $this->handler->canRender( $this ); + } + return $this->canRender; + } + + /** + * Accessor for __get() + */ + protected function getCanRender() { + return $this->canRender(); + } + + /** + * Return true if the file is of a type that can't be directly + * rendered by typical browsers and needs to be re-rasterized. + * + * This returns true for everything but the bitmap types + * supported by all browsers, i.e. JPEG; GIF and PNG. It will + * also return true for any non-image formats. + * + * @return bool + */ + function mustRender() { + return $this->getHandler() && $this->handler->mustRender( $this ); + } + + /** + * Alias for canRender() + */ + function allowInlineDisplay() { + return $this->canRender(); + } + + /** + * Determines if this media file is in a format that is unlikely to + * contain viruses or malicious content. It uses the global + * $wgTrustedMediaFormats list to determine if the file is safe. + * + * This is used to show a warning on the description page of non-safe files. + * It may also be used to disallow direct [[media:...]] links to such files. + * + * Note that this function will always return true if allowInlineDisplay() + * or isTrustedFile() is true for this file. + */ + function isSafeFile() { + if ( !isset( $this->isSafeFile ) ) { + $this->isSafeFile = $this->_getIsSafeFile(); + } + return $this->isSafeFile; + } + + /** Accessor for __get() */ + protected function getIsSafeFile() { + return $this->isSafeFile(); + } + + /** Uncached accessor */ + protected function _getIsSafeFile() { + if ($this->allowInlineDisplay()) return true; + if ($this->isTrustedFile()) return true; + + global $wgTrustedMediaFormats; + + $type= $this->getMediaType(); + $mime= $this->getMimeType(); + #wfDebug("LocalFile::isSafeFile: type= $type, mime= $mime\n"); + + if (!$type || $type===MEDIATYPE_UNKNOWN) return false; #unknown type, not trusted + if ( in_array( $type, $wgTrustedMediaFormats) ) return true; + + if ($mime==="unknown/unknown") return false; #unknown type, not trusted + if ( in_array( $mime, $wgTrustedMediaFormats) ) return true; + + return false; + } + + /** Returns true if the file is flagged as trusted. Files flagged that way + * can be linked to directly, even if that is not allowed for this type of + * file normally. + * + * This is a dummy function right now and always returns false. It could be + * implemented to extract a flag from the database. The trusted flag could be + * set on upload, if the user has sufficient privileges, to bypass script- + * and html-filters. It may even be coupled with cryptographics signatures + * or such. + */ + function isTrustedFile() { + #this could be implemented to check a flag in the databas, + #look for signatures, etc + return false; + } + + /** + * Returns true if file exists in the repository. + * + * Overridden by LocalFile to avoid unnecessary stat calls. + * + * @return boolean Whether file exists in the repository. + */ + public function exists() { + return $this->getPath() && file_exists( $this->path ); + } + + function getTransformScript() { + if ( !isset( $this->transformScript ) ) { + $this->transformScript = false; + if ( $this->repo ) { + $script = $this->repo->getThumbScriptUrl(); + if ( $script ) { + $this->transformScript = "$script?f=" . urlencode( $this->getName() ); + } + } + } + return $this->transformScript; + } + + /** + * Get a ThumbnailImage which is the same size as the source + */ + function getUnscaledThumb( $page = false ) { + $width = $this->getWidth( $page ); + if ( !$width ) { + return $this->iconThumb(); + } + if ( $page ) { + $params = array( + 'page' => $page, + 'width' => $this->getWidth( $page ) + ); + } else { + $params = array( 'width' => $this->getWidth() ); + } + return $this->transform( $params ); + } + + /** + * Return the file name of a thumbnail with the specified parameters + * + * @param array $params Handler-specific parameters + * @private -ish + */ + function thumbName( $params ) { + if ( !$this->getHandler() ) { + return null; + } + $extension = $this->getExtension(); + list( $thumbExt, $thumbMime ) = $this->handler->getThumbType( $extension, $this->getMimeType() ); + $thumbName = $this->handler->makeParamString( $params ) . '-' . $this->getName(); + if ( $thumbExt != $extension ) { + $thumbName .= ".$thumbExt"; + } + return $thumbName; + } + + /** + * Create a thumbnail of the image having the specified width/height. + * The thumbnail will not be created if the width is larger than the + * image's width. Let the browser do the scaling in this case. + * The thumbnail is stored on disk and is only computed if the thumbnail + * file does not exist OR if it is older than the image. + * Returns the URL. + * + * Keeps aspect ratio of original image. If both width and height are + * specified, the generated image will be no bigger than width x height, + * and will also have correct aspect ratio. + * + * @param integer $width maximum width of the generated thumbnail + * @param integer $height maximum height of the image (optional) + */ + public function createThumb( $width, $height = -1 ) { + $params = array( 'width' => $width ); + if ( $height != -1 ) { + $params['height'] = $height; + } + $thumb = $this->transform( $params ); + if( is_null( $thumb ) || $thumb->isError() ) return ''; + return $thumb->getUrl(); + } + + /** + * As createThumb, but returns a ThumbnailImage object. This can + * provide access to the actual file, the real size of the thumb, + * and can produce a convenient tag for you. + * + * For non-image formats, this may return a filetype-specific icon. + * + * @param integer $width maximum width of the generated thumbnail + * @param integer $height maximum height of the image (optional) + * @param boolean $render True to render the thumbnail if it doesn't exist, + * false to just return the URL + * + * @return ThumbnailImage or null on failure + * + * @deprecated use transform() + */ + public function getThumbnail( $width, $height=-1, $render = true ) { + $params = array( 'width' => $width ); + if ( $height != -1 ) { + $params['height'] = $height; + } + $flags = $render ? self::RENDER_NOW : 0; + return $this->transform( $params, $flags ); + } + + /** + * Transform a media file + * + * @param array $params An associative array of handler-specific parameters. Typical + * keys are width, height and page. + * @param integer $flags A bitfield, may contain self::RENDER_NOW to force rendering + * @return MediaTransformOutput + */ + function transform( $params, $flags = 0 ) { + global $wgUseSquid, $wgIgnoreImageErrors; + + wfProfileIn( __METHOD__ ); + do { + if ( !$this->canRender() ) { + // not a bitmap or renderable image, don't try. + $thumb = $this->iconThumb(); + break; + } + + $script = $this->getTransformScript(); + if ( $script && !($flags & self::RENDER_NOW) ) { + // Use a script to transform on client request + $thumb = $this->handler->getScriptedTransform( $this, $script, $params ); + break; + } + + $normalisedParams = $params; + $this->handler->normaliseParams( $this, $normalisedParams ); + $thumbName = $this->thumbName( $normalisedParams ); + $thumbPath = $this->getThumbPath( $thumbName ); + $thumbUrl = $this->getThumbUrl( $thumbName ); + + if ( $this->repo->canTransformVia404() && !($flags & self::RENDER_NOW ) ) { + $thumb = $this->handler->getTransform( $this, $thumbPath, $thumbUrl, $params ); + break; + } + + wfDebug( __METHOD__.": Doing stat for $thumbPath\n" ); + $this->migrateThumbFile( $thumbName ); + if ( file_exists( $thumbPath ) ) { + $thumb = $this->handler->getTransform( $this, $thumbPath, $thumbUrl, $params ); + break; + } + $thumb = $this->handler->doTransform( $this, $thumbPath, $thumbUrl, $params ); + + // Ignore errors if requested + if ( !$thumb ) { + $thumb = null; + } elseif ( $thumb->isError() ) { + $this->lastError = $thumb->toText(); + if ( $wgIgnoreImageErrors && !($flags & self::RENDER_NOW) ) { + $thumb = $this->handler->getTransform( $this, $thumbPath, $thumbUrl, $params ); + } + } + + if ( $wgUseSquid ) { + wfPurgeSquidServers( array( $thumbUrl ) ); + } + } while (false); + + wfProfileOut( __METHOD__ ); + return $thumb; + } + + /** + * Hook into transform() to allow migration of thumbnail files + * STUB + * Overridden by LocalFile + */ + function migrateThumbFile( $thumbName ) {} + + /** + * Get a MediaHandler instance for this file + */ + function getHandler() { + if ( !isset( $this->handler ) ) { + $this->handler = MediaHandler::getHandler( $this->getMimeType() ); + } + return $this->handler; + } + + /** + * Get a ThumbnailImage representing a file type icon + * @return ThumbnailImage + */ + function iconThumb() { + global $wgStylePath, $wgStyleDirectory; + + $try = array( 'fileicon-' . $this->getExtension() . '.png', 'fileicon.png' ); + foreach( $try as $icon ) { + $path = '/common/images/icons/' . $icon; + $filepath = $wgStyleDirectory . $path; + if( file_exists( $filepath ) ) { + return new ThumbnailImage( $this, $wgStylePath . $path, 120, 120 ); + } + } + return null; + } + + /** + * Get last thumbnailing error. + * Largely obsolete. + */ + function getLastError() { + return $this->lastError; + } + + /** + * Get all thumbnail names previously generated for this file + * STUB + * Overridden by LocalFile + */ + function getThumbnails() { return array(); } + + /** + * Purge shared caches such as thumbnails and DB data caching + * STUB + * Overridden by LocalFile + */ + function purgeCache( $archiveFiles = array() ) {} + + /** + * Purge the file description page, but don't go after + * pages using the file. Use when modifying file history + * but not the current data. + */ + function purgeDescription() { + $title = $this->getTitle(); + if ( $title ) { + $title->invalidateCache(); + $title->purgeSquid(); + } + } + + /** + * Purge metadata and all affected pages when the file is created, + * deleted, or majorly updated. + */ + function purgeEverything() { + // Delete thumbnails and refresh file metadata cache + $this->purgeCache(); + $this->purgeDescription(); + + // Purge cache of all pages using this file + $title = $this->getTitle(); + if ( $title ) { + $update = new HTMLCacheUpdate( $title, 'imagelinks' ); + $update->doUpdate(); + } + } + + /** + * Return the history of this file, line by line. Starts with current version, + * then old versions. Should return an object similar to an image/oldimage + * database row. + * + * STUB + * Overridden in LocalFile + */ + public function nextHistoryLine() { + return false; + } + + /** + * Reset the history pointer to the first element of the history. + * Always call this function after using nextHistoryLine() to free db resources + * STUB + * Overridden in LocalFile. + */ + public function resetHistory() {} + + /** + * Get the filename hash component of the directory including trailing slash, + * e.g. f/fa/ + * If the repository is not hashed, returns an empty string. + */ + function getHashPath() { + if ( !isset( $this->hashPath ) ) { + $this->hashPath = $this->repo->getHashPath( $this->getName() ); + } + return $this->hashPath; + } + + /** + * Get the path of the file relative to the public zone root + */ + function getRel() { + return $this->getHashPath() . $this->getName(); + } + + /** + * Get urlencoded relative path of the file + */ + function getUrlRel() { + return $this->getHashPath() . rawurlencode( $this->getName() ); + } + + /** Get the relative path for an archive file */ + function getArchiveRel( $suffix = false ) { + $path = 'archive/' . $this->getHashPath(); + if ( $suffix === false ) { + $path = substr( $path, 0, -1 ); + } else { + $path .= $suffix; + } + return $path; + } + + /** Get relative path for a thumbnail file */ + function getThumbRel( $suffix = false ) { + $path = 'thumb/' . $this->getRel(); + if ( $suffix !== false ) { + $path .= '/' . $suffix; + } + return $path; + } + + /** Get the path of the archive directory, or a particular file if $suffix is specified */ + function getArchivePath( $suffix = false ) { + return $this->repo->getZonePath('public') . '/' . $this->getArchiveRel(); + } + + /** Get the path of the thumbnail directory, or a particular file if $suffix is specified */ + function getThumbPath( $suffix = false ) { + return $this->repo->getZonePath('public') . '/' . $this->getThumbRel( $suffix ); + } + + /** Get the URL of the archive directory, or a particular file if $suffix is specified */ + function getArchiveUrl( $suffix = false ) { + $path = $this->repo->getZoneUrl('public') . '/archive/' . $this->getHashPath(); + if ( $suffix === false ) { + $path = substr( $path, 0, -1 ); + } else { + $path .= rawurlencode( $suffix ); + } + return $path; + } + + /** Get the URL of the thumbnail directory, or a particular file if $suffix is specified */ + function getThumbUrl( $suffix = false ) { + $path = $this->repo->getZoneUrl('public') . '/thumb/' . $this->getUrlRel(); + if ( $suffix !== false ) { + $path .= '/' . rawurlencode( $suffix ); + } + return $path; + } + + /** Get the virtual URL for an archive file or directory */ + function getArchiveVirtualUrl( $suffix = false ) { + $path = $this->repo->getVirtualUrl() . '/public/archive/' . $this->getHashPath(); + if ( $suffix === false ) { + $path = substr( $path, 0, -1 ); + } else { + $path .= rawurlencode( $suffix ); + } + return $path; + } + + /** Get the virtual URL for a thumbnail file or directory */ + function getThumbVirtualUrl( $suffix = false ) { + $path = $this->repo->getVirtualUrl() . '/public/thumb/' . $this->getUrlRel(); + if ( $suffix !== false ) { + $path .= '/' . rawurlencode( $suffix ); + } + return $path; + } + + /** Get the virtual URL for the file itself */ + function getVirtualUrl( $suffix = false ) { + $path = $this->repo->getVirtualUrl() . '/public/' . $this->getUrlRel(); + if ( $suffix !== false ) { + $path .= '/' . rawurlencode( $suffix ); + } + return $path; + } + + /** + * @return bool + */ + function isHashed() { + return $this->repo->isHashed(); + } + + function readOnlyError() { + throw new MWException( get_class($this) . ': write operations are not supported' ); + } + + /** + * Record a file upload in the upload log and the image table + * STUB + * Overridden by LocalFile + */ + function recordUpload( $oldver, $desc, $license = '', $copyStatus = '', $source = '', $watch = false ) { + $this->readOnlyError(); + } + + /** + * Move or copy a file to its public location. If a file exists at the + * destination, move it to an archive. Returns the archive name on success + * or an empty string if it was a new file, and a wikitext-formatted + * WikiError object on failure. + * + * The archive name should be passed through to recordUpload for database + * registration. + * + * @param string $sourcePath Local filesystem path to the source image + * @param integer $flags A bitwise combination of: + * File::DELETE_SOURCE Delete the source file, i.e. move + * rather than copy + * @return The archive name on success or an empty string if it was a new + * file, and a wikitext-formatted WikiError object on failure. + * + * STUB + * Overridden by LocalFile + */ + function publish( $srcPath, $flags = 0 ) { + $this->readOnlyError(); + } + + /** + * Get an array of Title objects which are articles which use this file + * Also adds their IDs to the link cache + * + * This is mostly copied from Title::getLinksTo() + * + * @deprecated Use HTMLCacheUpdate, this function uses too much memory + */ + function getLinksTo( $options = '' ) { + wfProfileIn( __METHOD__ ); + + // Note: use local DB not repo DB, we want to know local links + if ( $options ) { + $db = wfGetDB( DB_MASTER ); + } else { + $db = wfGetDB( DB_SLAVE ); + } + $linkCache =& LinkCache::singleton(); + + list( $page, $imagelinks ) = $db->tableNamesN( 'page', 'imagelinks' ); + $encName = $db->addQuotes( $this->getName() ); + $sql = "SELECT page_namespace,page_title,page_id FROM $page,$imagelinks WHERE page_id=il_from AND il_to=$encName $options"; + $res = $db->query( $sql, __METHOD__ ); + + $retVal = array(); + if ( $db->numRows( $res ) ) { + while ( $row = $db->fetchObject( $res ) ) { + if ( $titleObj = Title::makeTitle( $row->page_namespace, $row->page_title ) ) { + $linkCache->addGoodLinkObj( $row->page_id, $titleObj ); + $retVal[] = $titleObj; + } + } + } + $db->freeResult( $res ); + wfProfileOut( __METHOD__ ); + return $retVal; + } + + function formatMetadata() { + if ( !$this->getHandler() ) { + return false; + } + return $this->getHandler()->formatMetadata( $this, $this->getMetadata() ); + } + + /** + * Returns true if the file comes from the local file repository. + * + * @return bool + */ + function isLocal() { + return $this->getRepoName() == 'local'; + } + + /** + * Returns the name of the repository. + * + * @return string + */ + function getRepoName() { + return $this->repo ? $this->repo->getName() : 'unknown'; + } + + /** + * Returns true if the image is an old version + * STUB + */ + function isOld() { + return false; + } + + /** + * Is this file a "deleted" file in a private archive? + * STUB + */ + function isDeleted( $field ) { + return false; + } + + /** + * Was this file ever deleted from the wiki? + * + * @return bool + */ + function wasDeleted() { + $title = $this->getTitle(); + return $title && $title->isDeleted() > 0; + } + + /** + * Delete all versions of the file. + * + * Moves the files into an archive directory (or deletes them) + * and removes the database rows. + * + * Cache purging is done; logging is caller's responsibility. + * + * @param $reason + * @return true on success, false on some kind of failure + * STUB + * Overridden by LocalFile + */ + function delete( $reason, $suppress=false ) { + $this->readOnlyError(); + } + + /** + * Restore all or specified deleted revisions to the given file. + * Permissions and logging are left to the caller. + * + * May throw database exceptions on error. + * + * @param $versions set of record ids of deleted items to restore, + * or empty to restore all revisions. + * @return the number of file revisions restored if successful, + * or false on failure + * STUB + * Overridden by LocalFile + */ + function restore( $versions=array(), $Unsuppress=false ) { + $this->readOnlyError(); + } + + /** + * Returns 'true' if this image is a multipage document, e.g. a DJVU + * document. + * + * @return Bool + */ + function isMultipage() { + return $this->getHandler() && $this->handler->isMultiPage( $this ); + } + + /** + * Returns the number of pages of a multipage document, or NULL for + * documents which aren't multipage documents + */ + function pageCount() { + if ( !isset( $this->pageCount ) ) { + if ( $this->getHandler() && $this->handler->isMultiPage( $this ) ) { + $this->pageCount = $this->handler->pageCount( $this ); + } else { + $this->pageCount = false; + } + } + return $this->pageCount; + } + + /** + * Calculate the height of a thumbnail using the source and destination width + */ + static function scaleHeight( $srcWidth, $srcHeight, $dstWidth ) { + // Exact integer multiply followed by division + if ( $srcWidth == 0 ) { + return 0; + } else { + return round( $srcHeight * $dstWidth / $srcWidth ); + } + } + + /** + * Get an image size array like that returned by getimagesize(), or false if it + * can't be determined. + * + * @param string $fileName The filename + * @return array + */ + function getImageSize( $fileName ) { + if ( !$this->getHandler() ) { + return false; + } + return $this->handler->getImageSize( $this, $fileName ); + } + + /** + * Get the URL of the image description page. May return false if it is + * unknown or not applicable. + */ + function getDescriptionUrl() { + return $this->repo->getDescriptionUrl( $this->getName() ); + } + + /** + * Get the HTML text of the description page, if available + */ + function getDescriptionText() { + if ( !$this->repo->fetchDescription ) { + return false; + } + $renderUrl = $this->repo->getDescriptionRenderUrl( $this->getName() ); + if ( $renderUrl ) { + wfDebug( "Fetching shared description from $renderUrl\n" ); + return Http::get( $renderUrl ); + } else { + return false; + } + } + + /** + * Get the 14-character timestamp of the file upload, or false if + * it doesn't exist + */ + function getTimestamp() { + $path = $this->getPath(); + if ( !file_exists( $path ) ) { + return false; + } + return wfTimestamp( filemtime( $path ) ); + } + + /** + * Get the SHA-1 base 36 hash of the file + */ + function getSha1() { + return self::sha1Base36( $this->getPath() ); + } + + /** + * Determine if the current user is allowed to view a particular + * field of this file, if it's marked as deleted. + * STUB + * @param int $field + * @return bool + */ + function userCan( $field ) { + return true; + } + + /** + * Get an associative array containing information about a file in the local filesystem\ + * + * @param string $path Absolute local filesystem path + * @param mixed $ext The file extension, or true to extract it from the filename. + * Set it to false to ignore the extension. + */ + static function getPropsFromPath( $path, $ext = true ) { + wfProfileIn( __METHOD__ ); + wfDebug( __METHOD__.": Getting file info for $path\n" ); + $info = array( + 'fileExists' => file_exists( $path ) && !is_dir( $path ) + ); + $gis = false; + if ( $info['fileExists'] ) { + $magic = MimeMagic::singleton(); + + $info['mime'] = $magic->guessMimeType( $path, $ext ); + list( $info['major_mime'], $info['minor_mime'] ) = self::splitMime( $info['mime'] ); + $info['media_type'] = $magic->getMediaType( $path, $info['mime'] ); + + # Get size in bytes + $info['size'] = filesize( $path ); + + # Height, width and metadata + $handler = MediaHandler::getHandler( $info['mime'] ); + if ( $handler ) { + $tempImage = (object)array(); + $info['metadata'] = $handler->getMetadata( $tempImage, $path ); + $gis = $handler->getImageSize( $tempImage, $path, $info['metadata'] ); + } else { + $gis = false; + $info['metadata'] = ''; + } + $info['sha1'] = self::sha1Base36( $path ); + + wfDebug(__METHOD__.": $path loaded, {$info['size']} bytes, {$info['mime']}.\n"); + } else { + $info['mime'] = NULL; + $info['media_type'] = MEDIATYPE_UNKNOWN; + $info['metadata'] = ''; + $info['sha1'] = ''; + wfDebug(__METHOD__.": $path NOT FOUND!\n"); + } + if( $gis ) { + # NOTE: $gis[2] contains a code for the image type. This is no longer used. + $info['width'] = $gis[0]; + $info['height'] = $gis[1]; + if ( isset( $gis['bits'] ) ) { + $info['bits'] = $gis['bits']; + } else { + $info['bits'] = 0; + } + } else { + $info['width'] = 0; + $info['height'] = 0; + $info['bits'] = 0; + } + wfProfileOut( __METHOD__ ); + return $info; + } + + /** + * Get a SHA-1 hash of a file in the local filesystem, in base-36 lower case + * encoding, zero padded to 31 digits. + * + * 160 log 2 / log 36 = 30.95, so the 160-bit hash fills 31 digits in base 36 + * fairly neatly. + * + * Returns false on failure + */ + static function sha1Base36( $path ) { + wfSuppressWarnings(); + $hash = sha1_file( $path ); + wfRestoreWarnings(); + if ( $hash === false ) { + return false; + } else { + return wfBaseConvert( $hash, 16, 36, 31 ); + } + } + + function getLongDesc() { + $handler = $this->getHandler(); + if ( $handler ) { + return $handler->getLongDesc( $this ); + } else { + return MediaHandler::getLongDesc( $this ); + } + } + + function getShortDesc() { + $handler = $this->getHandler(); + if ( $handler ) { + return $handler->getShortDesc( $this ); + } else { + return MediaHandler::getShortDesc( $this ); + } + } + + function getDimensionsString() { + $handler = $this->getHandler(); + if ( $handler ) { + return $handler->getDimensionsString( $this ); + } else { + return ''; + } + } +} +/** + * Aliases for backwards compatibility with 1.6 + */ +define( 'MW_IMG_DELETED_FILE', File::DELETED_FILE ); +define( 'MW_IMG_DELETED_COMMENT', File::DELETED_COMMENT ); +define( 'MW_IMG_DELETED_USER', File::DELETED_USER ); +define( 'MW_IMG_DELETED_RESTRICTED', File::DELETED_RESTRICTED ); + + diff --git a/includes/filerepo/FileRepo.php b/includes/filerepo/FileRepo.php new file mode 100644 index 00000000..cf6d65c2 --- /dev/null +++ b/includes/filerepo/FileRepo.php @@ -0,0 +1,404 @@ +name = $info['name']; + + // Optional settings + $this->initialCapital = true; // by default + foreach ( array( 'descBaseUrl', 'scriptDirUrl', 'articleUrl', 'fetchDescription', + 'thumbScriptUrl', 'initialCapital', 'pathDisclosureProtection' ) as $var ) + { + if ( isset( $info[$var] ) ) { + $this->$var = $info[$var]; + } + } + $this->transformVia404 = !empty( $info['transformVia404'] ); + } + + /** + * Determine if a string is an mwrepo:// URL + */ + static function isVirtualUrl( $url ) { + return substr( $url, 0, 9 ) == 'mwrepo://'; + } + + /** + * Create a new File object from the local repository + * @param mixed $title Title object or string + * @param mixed $time Time at which the image is supposed to have existed. + * If this is specified, the returned object will be an + * instance of the repository's old file class instead of + * a current file. Repositories not supporting version + * control should return false if this parameter is set. + */ + function newFile( $title, $time = false ) { + if ( !($title instanceof Title) ) { + $title = Title::makeTitleSafe( NS_IMAGE, $title ); + if ( !is_object( $title ) ) { + return null; + } + } + if ( $time ) { + if ( $this->oldFileFactory ) { + return call_user_func( $this->oldFileFactory, $title, $this, $time ); + } else { + return false; + } + } else { + return call_user_func( $this->fileFactory, $title, $this ); + } + } + + /** + * Find an instance of the named file that existed at the specified time + * Returns false if the file did not exist. Repositories not supporting + * version control should return false if the time is specified. + * + * @param mixed $time 14-character timestamp, or false for the current version + */ + function findFile( $title, $time = false ) { + # First try the current version of the file to see if it precedes the timestamp + $img = $this->newFile( $title ); + if ( !$img ) { + return false; + } + if ( $img->exists() && ( !$time || $img->getTimestamp() <= $time ) ) { + return $img; + } + # Now try an old version of the file + $img = $this->newFile( $title, $time ); + if ( $img->exists() ) { + return $img; + } + } + + /** + * Get the URL of thumb.php + */ + function getThumbScriptUrl() { + return $this->thumbScriptUrl; + } + + /** + * Returns true if the repository can transform files via a 404 handler + */ + function canTransformVia404() { + return $this->transformVia404; + } + + /** + * Get the name of an image from its title object + */ + function getNameFromTitle( $title ) { + global $wgCapitalLinks; + if ( $this->initialCapital != $wgCapitalLinks ) { + global $wgContLang; + $name = $title->getUserCaseDBKey(); + if ( $this->initialCapital ) { + $name = $wgContLang->ucfirst( $name ); + } + } else { + $name = $title->getDBkey(); + } + return $name; + } + + static function getHashPathForLevel( $name, $levels ) { + if ( $levels == 0 ) { + return ''; + } else { + $hash = md5( $name ); + $path = ''; + for ( $i = 1; $i <= $levels; $i++ ) { + $path .= substr( $hash, 0, $i ) . '/'; + } + return $path; + } + } + + /** + * Get the name of this repository, as specified by $info['name]' to the constructor + */ + function getName() { + return $this->name; + } + + /** + * Get the file description page base URL, or false if there isn't one. + * @private + */ + function getDescBaseUrl() { + if ( is_null( $this->descBaseUrl ) ) { + if ( !is_null( $this->articleUrl ) ) { + $this->descBaseUrl = str_replace( '$1', + wfUrlencode( Namespace::getCanonicalName( NS_IMAGE ) ) . ':', $this->articleUrl ); + } elseif ( !is_null( $this->scriptDirUrl ) ) { + $this->descBaseUrl = $this->scriptDirUrl . '/index.php?title=' . + wfUrlencode( Namespace::getCanonicalName( NS_IMAGE ) ) . ':'; + } else { + $this->descBaseUrl = false; + } + } + return $this->descBaseUrl; + } + + /** + * Get the URL of an image description page. May return false if it is + * unknown or not applicable. In general this should only be called by the + * File class, since it may return invalid results for certain kinds of + * repositories. Use File::getDescriptionUrl() in user code. + * + * In particular, it uses the article paths as specified to the repository + * constructor, whereas local repositories use the local Title functions. + */ + function getDescriptionUrl( $name ) { + $base = $this->getDescBaseUrl(); + if ( $base ) { + return $base . wfUrlencode( $name ); + } else { + return false; + } + } + + /** + * Get the URL of the content-only fragment of the description page. For + * MediaWiki this means action=render. This should only be called by the + * repository's file class, since it may return invalid results. User code + * should use File::getDescriptionText(). + */ + function getDescriptionRenderUrl( $name ) { + if ( isset( $this->scriptDirUrl ) ) { + return $this->scriptDirUrl . '/index.php?title=' . + wfUrlencode( Namespace::getCanonicalName( NS_IMAGE ) . ':' . $name ) . + '&action=render'; + } else { + $descBase = $this->getDescBaseUrl(); + if ( $descBase ) { + return wfAppendQuery( $descBase . wfUrlencode( $name ), 'action=render' ); + } else { + return false; + } + } + } + + /** + * Store a file to a given destination. + * + * @param string $srcPath Source path or virtual URL + * @param string $dstZone Destination zone + * @param string $dstRel Destination relative path + * @param integer $flags Bitwise combination of the following flags: + * self::DELETE_SOURCE Delete the source file after upload + * self::OVERWRITE Overwrite an existing destination file instead of failing + * self::OVERWRITE_SAME Overwrite the file if the destination exists and has the + * same contents as the source + * @return FileRepoStatus + */ + function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) { + $status = $this->storeBatch( array( array( $srcPath, $dstZone, $dstRel ) ), $flags ); + if ( $status->successCount == 0 ) { + $status->ok = false; + } + return $status; + } + + /** + * Store a batch of files + * + * @param array $triplets (src,zone,dest) triplets as per store() + * @param integer $flags Flags as per store + */ + abstract function storeBatch( $triplets, $flags = 0 ); + + /** + * Pick a random name in the temp zone and store a file to it. + * Returns a FileRepoStatus object with the URL in the value. + * + * @param string $originalName The base name of the file as specified + * by the user. The file extension will be maintained. + * @param string $srcPath The current location of the file. + */ + abstract function storeTemp( $originalName, $srcPath ); + + /** + * Remove a temporary file or mark it for garbage collection + * @param string $virtualUrl The virtual URL returned by storeTemp + * @return boolean True on success, false on failure + * STUB + */ + function freeTemp( $virtualUrl ) { + return true; + } + + /** + * Copy or move a file either from the local filesystem or from an mwrepo:// + * virtual URL, into this repository at the specified destination location. + * + * Returns a FileRepoStatus object. On success, the value contains "new" or + * "archived", to indicate whether the file was new with that name. + * + * @param string $srcPath The source path or URL + * @param string $dstRel The destination relative path + * @param string $archiveRel The relative path where the existing file is to + * be archived, if there is one. Relative to the public zone root. + * @param integer $flags Bitfield, may be FileRepo::DELETE_SOURCE to indicate + * that the source file should be deleted if possible + */ + function publish( $srcPath, $dstRel, $archiveRel, $flags = 0 ) { + $status = $this->publishBatch( array( array( $srcPath, $dstRel, $archiveRel ) ), $flags ); + if ( $status->successCount == 0 ) { + $status->ok = false; + } + if ( isset( $status->value[0] ) ) { + $status->value = $status->value[0]; + } else { + $status->value = false; + } + return $status; + } + + /** + * Publish a batch of files + * @param array $triplets (source,dest,archive) triplets as per publish() + * @param integer $flags Bitfield, may be FileRepo::DELETE_SOURCE to indicate + * that the source files should be deleted if possible + */ + abstract function publishBatch( $triplets, $flags = 0 ); + + /** + * Move a group of files to the deletion archive. + * + * If no valid deletion archive is configured, this may either delete the + * file or throw an exception, depending on the preference of the repository. + * + * The overwrite policy is determined by the repository -- currently FSRepo + * assumes a naming scheme in the deleted zone based on content hash, as + * opposed to the public zone which is assumed to be unique. + * + * @param array $sourceDestPairs Array of source/destination pairs. Each element + * is a two-element array containing the source file path relative to the + * public root in the first element, and the archive file path relative + * to the deleted zone root in the second element. + * @return FileRepoStatus + */ + abstract function deleteBatch( $sourceDestPairs ); + + /** + * Move a file to the deletion archive. + * If no valid deletion archive exists, this may either delete the file + * or throw an exception, depending on the preference of the repository + * @param mixed $srcRel Relative path for the file to be deleted + * @param mixed $archiveRel Relative path for the archive location. + * Relative to a private archive directory. + * @return WikiError object (wikitext-formatted), or true for success + */ + function delete( $srcRel, $archiveRel ) { + return $this->deleteBatch( array( array( $srcRel, $archiveRel ) ) ); + } + + /** + * Get properties of a file with a given virtual URL + * The virtual URL must refer to this repo + * Properties should ultimately be obtained via File::getPropsFromPath() + */ + abstract function getFileProps( $virtualUrl ); + + /** + * Call a callback function for every file in the repository + * May use either the database or the filesystem + * STUB + */ + function enumFiles( $callback ) { + throw new MWException( 'enumFiles is not supported by ' . get_class( $this ) ); + } + + /** + * Determine if a relative path is valid, i.e. not blank or involving directory traveral + */ + function validateFilename( $filename ) { + if ( strval( $filename ) == '' ) { + return false; + } + if ( wfIsWindows() ) { + $filename = strtr( $filename, '\\', '/' ); + } + /** + * Use the same traversal protection as Title::secureAndSplit() + */ + if ( strpos( $filename, '.' ) !== false && + ( $filename === '.' || $filename === '..' || + strpos( $filename, './' ) === 0 || + strpos( $filename, '../' ) === 0 || + strpos( $filename, '/./' ) !== false || + strpos( $filename, '/../' ) !== false ) ) + { + return false; + } else { + return true; + } + } + + /**#@+ + * Path disclosure protection functions + */ + function paranoidClean( $param ) { return '[hidden]'; } + function passThrough( $param ) { return $param; } + + /** + * Get a callback function to use for cleaning error message parameters + */ + function getErrorCleanupFunction() { + switch ( $this->pathDisclosureProtection ) { + case 'none': + $callback = array( $this, 'passThrough' ); + break; + default: // 'paranoid' + $callback = array( $this, 'paranoidClean' ); + } + return $callback; + } + /**#@-*/ + + /** + * Create a new fatal error + */ + function newFatal( $message /*, parameters...*/ ) { + $params = func_get_args(); + array_unshift( $params, $this ); + return call_user_func_array( array( 'FileRepoStatus', 'newFatal' ), $params ); + } + + /** + * Create a new good result + */ + function newGood( $value = null ) { + return FileRepoStatus::newGood( $this, $value ); + } + + /** + * Delete files in the deleted directory if they are not referenced in the filearchive table + * STUB + */ + function cleanupDeletedBatch( $storageKeys ) {} +} + diff --git a/includes/filerepo/FileRepoStatus.php b/includes/filerepo/FileRepoStatus.php new file mode 100644 index 00000000..972b2e46 --- /dev/null +++ b/includes/filerepo/FileRepoStatus.php @@ -0,0 +1,171 @@ +ok = false; + return $result; + } + + static function newGood( $repo = false, $value = null ) { + $result = new self( $repo ); + $result->value = $value; + return $result; + } + + function __construct( $repo = false ) { + if ( $repo ) { + $this->cleanCallback = $repo->getErrorCleanupFunction(); + } + } + + function setResult( $ok, $value = null ) { + $this->ok = $ok; + $this->value = $value; + } + + function isGood() { + return $this->ok && !$this->errors; + } + + function isOK() { + return $this->ok; + } + + function warning( $message /*, parameters... */ ) { + $params = array_slice( func_get_args(), 1 ); + $this->errors[] = array( + 'type' => 'warning', + 'message' => $message, + 'params' => $params ); + } + + /** + * Add an error, do not set fatal flag + * This can be used for non-fatal errors + */ + function error( $message /*, parameters... */ ) { + $params = array_slice( func_get_args(), 1 ); + $this->errors[] = array( + 'type' => 'error', + 'message' => $message, + 'params' => $params ); + } + + /** + * Add an error and set OK to false, indicating that the operation as a whole was fatal + */ + function fatal( $message /*, parameters... */ ) { + $params = array_slice( func_get_args(), 1 ); + $this->errors[] = array( + 'type' => 'error', + 'message' => $message, + 'params' => $params ); + $this->ok = false; + } + + protected function cleanParams( $params ) { + if ( !$this->cleanCallback ) { + return $params; + } + $cleanParams = array(); + foreach ( $params as $i => $param ) { + $cleanParams[$i] = call_user_func( $this->cleanCallback, $param ); + } + return $cleanParams; + } + + protected function getItemXML( $item ) { + $params = $this->cleanParams( $item['params'] ); + $xml = "<{$item['type']}>\n" . + Xml::element( 'message', null, $item['message'] ) . "\n" . + Xml::element( 'text', null, wfMsgReal( $item['message'], $params ) ) ."\n"; + foreach ( $params as $param ) { + $xml .= Xml::element( 'param', null, $param ); + } + $xml .= "type}>\n"; + return $xml; + } + + /** + * Get the error list as XML + */ + function getXML() { + $xml = "\n"; + foreach ( $this->errors as $error ) { + $xml .= $this->getItemXML( $error ); + } + $xml .= "\n"; + return $xml; + } + + /** + * Get the error list as a wikitext formatted list + * @param string $shortContext A short enclosing context message name, to be used + * when there is a single error + * @param string $longContext A long enclosing context message name, for a list + */ + function getWikiText( $shortContext = false, $longContext = false ) { + if ( count( $this->errors ) == 0 ) { + if ( $this->ok ) { + $this->fatal( 'internalerror_info', + __METHOD__." called for a good result, this is incorrect\n" ); + } else { + $this->fatal( 'internalerror_info', + __METHOD__.": Invalid result object: no error text but not OK\n" ); + } + } + if ( count( $this->errors ) == 1 ) { + $params = array_map( 'wfEscapeWikiText', $this->cleanParams( $this->errors[0]['params'] ) ); + $s = wfMsgReal( $this->errors[0]['message'], $params ); + if ( $shortContext ) { + $s = wfMsg( $shortContext, $s ); + } elseif ( $longContext ) { + $s = wfMsg( $longContext, "* $s\n" ); + } + } else { + $s = ''; + foreach ( $this->errors as $error ) { + $params = array_map( 'wfEscapeWikiText', $this->cleanParams( $error['params'] ) ); + $s .= '* ' . wfMsgReal( $error['message'], $params ) . "\n"; + } + if ( $longContext ) { + $s = wfMsg( $longContext, $s ); + } elseif ( $shortContext ) { + $s = wfMsg( $shortContext, "\n* $s\n" ); + } + } + return $s; + } + + /** + * Merge another status object into this one + */ + function merge( $other, $overwriteValue = false ) { + $this->errors = array_merge( $this->errors, $other->errors ); + $this->ok = $this->ok && $other->ok; + if ( $overwriteValue ) { + $this->value = $other->value; + } + $this->successCount += $other->successCount; + $this->failCount += $other->failCount; + } +} diff --git a/includes/filerepo/ForeignDBFile.php b/includes/filerepo/ForeignDBFile.php new file mode 100644 index 00000000..4d11640a --- /dev/null +++ b/includes/filerepo/ForeignDBFile.php @@ -0,0 +1,42 @@ +repo->hasSharedCache ) { + $hashedName = md5($this->name); + return wfForeignMemcKey( $this->repo->dbName, $this->repo->tablePrefix, + 'file', $hashedName ); + } else { + return false; + } + } + + function publish( /*...*/ ) { + $this->readOnlyError(); + } + + function recordUpload( /*...*/ ) { + $this->readOnlyError(); + } + function restore( /*...*/ ) { + $this->readOnlyError(); + } + function delete( /*...*/ ) { + $this->readOnlyError(); + } + + function getDescriptionUrl() { + // Restore remote behaviour + return File::getDescriptionUrl(); + } + + function getDescriptionText() { + // Restore remote behaviour + return File::getDescriptionText(); + } +} + diff --git a/includes/filerepo/ForeignDBRepo.php b/includes/filerepo/ForeignDBRepo.php new file mode 100644 index 00000000..13dcd029 --- /dev/null +++ b/includes/filerepo/ForeignDBRepo.php @@ -0,0 +1,57 @@ +dbType = $info['dbType']; + $this->dbServer = $info['dbServer']; + $this->dbUser = $info['dbUser']; + $this->dbPassword = $info['dbPassword']; + $this->dbName = $info['dbName']; + $this->dbFlags = $info['dbFlags']; + $this->tablePrefix = $info['tablePrefix']; + $this->hasSharedCache = $info['hasSharedCache']; + } + + function getMasterDB() { + if ( !isset( $this->dbConn ) ) { + $class = 'Database' . ucfirst( $this->dbType ); + $this->dbConn = new $class( $this->dbServer, $this->dbUser, + $this->dbPassword, $this->dbName, false, $this->dbFlags, + $this->tablePrefix ); + } + return $this->dbConn; + } + + function getSlaveDB() { + return $this->getMasterDB(); + } + + function hasSharedCache() { + return $this->hasSharedCache; + } + + function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) { + throw new MWException( get_class($this) . ': write operations are not supported' ); + } + function publish( $srcPath, $dstRel, $archiveRel, $flags = 0 ) { + throw new MWException( get_class($this) . ': write operations are not supported' ); + } + function deleteBatch( $fileMap ) { + throw new MWException( get_class($this) . ': write operations are not supported' ); + } +} + + diff --git a/includes/filerepo/ICRepo.php b/includes/filerepo/ICRepo.php new file mode 100644 index 00000000..124fe2b6 --- /dev/null +++ b/includes/filerepo/ICRepo.php @@ -0,0 +1,313 @@ +directory = $info['directory']; + $this->url = $info['url']; + $this->hashLevels = $info['hashLevels']; + if(isset($info['cache'])){ + $this->cache = getcwd().'/images/'.$info['cache']; + } + } +} + +/** + * A file loaded from InstantCommons + */ +class ICFile extends LocalFile{ + static function newFromTitle($title,$repo){ + return new self($title, $repo); + } + + /** + * Returns true if the file comes from the local file repository. + * + * @return bool + */ + function isLocal() { + return true; + } + + function load(){ + if (!$this->dataLoaded ) { + if ( !$this->loadFromCache() ) { + if(!$this->loadFromDB()){ + $this->loadFromIC(); + } + $this->saveToCache(); + } + $this->dataLoaded = true; + } + } + + /** + * Load file metadata from the DB + */ + function loadFromDB() { + wfProfileIn( __METHOD__ ); + + # Unconditionally set loaded=true, we don't want the accessors constantly rechecking + $this->dataLoaded = true; + + $dbr = $this->repo->getSlaveDB(); + + $row = $dbr->selectRow( 'ic_image', $this->getCacheFields( 'img_' ), + array( 'img_name' => $this->getName() ), __METHOD__ ); + if ( $row ) { + if (trim($row->img_media_type)==NULL) { + $this->upgradeRow(); + $this->upgraded = true; + } + $this->loadFromRow( $row ); + //This means that these files are local so the repository locations are local + $this->setUrlPathLocal(); + $this->fileExists = true; + //var_dump($this); exit; + } else { + $this->fileExists = false; + } + + wfProfileOut( __METHOD__ ); + + return $this->fileExists; + } + + /** + * Fix assorted version-related problems with the image row by reloading it from the file + */ + function upgradeRow() { + wfProfileIn( __METHOD__ ); + + $this->loadFromIC(); + + $dbw = $this->repo->getMasterDB(); + list( $major, $minor ) = self::splitMime( $this->mime ); + + wfDebug(__METHOD__.': upgrading '.$this->getName()." to the current schema\n"); + + $dbw->update( 'ic_image', + array( + 'img_width' => $this->width, + 'img_height' => $this->height, + 'img_bits' => $this->bits, + 'img_media_type' => $this->type, + 'img_major_mime' => $major, + 'img_minor_mime' => $minor, + 'img_metadata' => $this->metadata, + ), array( 'img_name' => $this->getName() ), + __METHOD__ + ); + $this->saveToCache(); + wfProfileOut( __METHOD__ ); + } + + function exists(){ + $this->load(); + return $this->fileExists; + + } + + /** + * Fetch the file from the repository. Check local ic_images table first. If not available, check remote server + */ + function loadFromIC(){ + # Unconditionally set loaded=true, we don't want the accessors constantly rechecking + $this->dataLoaded = true; + $icUrl = $this->repo->directory.'&media='.$this->title->mDbkeyform; + if($h = @fopen($icUrl, 'rb')){ + $contents = fread($h, 3000); + $image = $this->api_xml_to_array($contents); + if($image['fileExists']){ + foreach($image as $property=>$value){ + if($property=="url"){$value=$this->repo->url.$value; } + $this->$property = $value; + } + if($this->curl_file_get_contents($this->repo->url.$image['url'], $this->repo->cache.'/'.$image['name'])){ + //Record the image + $this->recordDownload("Downloaded with InstantCommons"); + + //Then cache it + }else{//set fileExists back to false + $this->fileExists = false; + } + } + } + } + + + function setUrlPathLocal(){ + global $wgScriptPath; + $path = $wgScriptPath.'/'.substr($this->repo->cache, strlen($wgScriptPath)); + $this->repo->url = $path;//.'/'.rawurlencode($this->title->mDbkeyform); + $this->repo->directory = $this->repo->cache;//.'/'.rawurlencode($this->title->mDbkeyform); + + } + + function getThumbPath( $suffix=false ){ + $path = $this->repo->cache; + if ( $suffix !== false ) { + $path .= '/thumb/' . rawurlencode( $suffix ); + } + return $path; + } + function getThumbUrl( $suffix=false ){ + global $wgScriptPath; + $path = $wgScriptPath.'/'.substr($this->repo->cache, strlen($wgScriptPath)); + if ( $suffix !== false ) { + $path .= '/thumb/' . rawurlencode( $suffix ); + } + return $path; + } + + /** + * Convert the InstantCommons Server API XML Response to an associative array + */ + function api_xml_to_array($xml){ + preg_match("//",$xml,$match); + preg_match_all("/(.*?=\".*?\")/",$match[1], $matches); + foreach($matches[1] as $match){ + list($key,$value) = split("=",$match); + $image[trim($key,'<" ')]=trim($value,' "'); + } + return $image; + } + + /** + * Use cURL to read the content of a URL into a string + * ref: http://groups-beta.google.com/group/comp.lang.php/browse_thread/thread/8efbbaced3c45e3c/d63c7891cf8e380b?lnk=raot + * @param string $url - the URL to fetch + * @param resource $fp - filename to write file contents to + * @param boolean $bg - call cURL in the background (don't hang page until complete) + * @param int $timeout - cURL connect timeout + */ + function curl_file_get_contents($url, $fp, $bg=TRUE, $timeout = 1) { + { + # Call curl in the background to download the file + $cmd = 'curl '.wfEscapeShellArg($url).' -o '.$fp.' &'; + wfDebug('Curl download initiated='.$cmd ); + $success = false; + $file_contents = array(); + $file_contents['err'] = wfShellExec($cmd, $file_contents['return']); + if($file_contents['err']==0){//Success + $success = true; + } + } + return $success; + } + + function getMasterDB() { + if ( !isset( $this->dbConn ) ) { + $class = 'Database' . ucfirst( $this->dbType ); + $this->dbConn = new $class( $this->dbServer, $this->dbUser, + $this->dbPassword, $this->dbName, false, $this->dbFlags, + $this->tablePrefix ); + } + return $this->dbConn; + } + + /** + * Record a file upload in the upload log and the image table + */ + private function recordDownload($comment='', $timestamp = false ){ + global $wgUser; + + $dbw = $this->repo->getMasterDB(); + + if ( $timestamp === false ) { + $timestamp = $dbw->timestamp(); + } + list( $major, $minor ) = self::splitMime( $this->mime ); + + # Test to see if the row exists using INSERT IGNORE + # This avoids race conditions by locking the row until the commit, and also + # doesn't deadlock. SELECT FOR UPDATE causes a deadlock for every race condition. + $dbw->insert( 'ic_image', + array( + 'img_name' => $this->getName(), + 'img_size'=> $this->size, + 'img_width' => intval( $this->width ), + 'img_height' => intval( $this->height ), + 'img_bits' => $this->bits, + 'img_media_type' => $this->type, + 'img_major_mime' => $major, + 'img_minor_mime' => $minor, + 'img_timestamp' => $timestamp, + 'img_description' => $comment, + 'img_user' => $wgUser->getID(), + 'img_user_text' => $wgUser->getName(), + 'img_metadata' => $this->metadata, + ), + __METHOD__, + 'IGNORE' + ); + + if( $dbw->affectedRows() == 0 ) { + # Collision, this is an update of a file + # Update the current image row + $dbw->update( 'ic_image', + array( /* SET */ + 'img_size' => $this->size, + 'img_width' => intval( $this->width ), + 'img_height' => intval( $this->height ), + 'img_bits' => $this->bits, + 'img_media_type' => $this->media_type, + 'img_major_mime' => $this->major_mime, + 'img_minor_mime' => $this->minor_mime, + 'img_timestamp' => $timestamp, + 'img_description' => $comment, + 'img_user' => $wgUser->getID(), + 'img_user_text' => $wgUser->getName(), + 'img_metadata' => $this->metadata, + ), array( /* WHERE */ + 'img_name' => $this->getName() + ), __METHOD__ + ); + } else { + # This is a new file + # Update the image count + $site_stats = $dbw->tableName( 'site_stats' ); + $dbw->query( "UPDATE $site_stats SET ss_images=ss_images+1", __METHOD__ ); + } + + $descTitle = $this->getTitle(); + $article = new Article( $descTitle ); + + # Add the log entry + $log = new LogPage( 'icdownload' ); + $log->addEntry( 'InstantCommons download', $descTitle, $comment ); + + if( $descTitle->exists() ) { + # Create a null revision + $nullRevision = Revision::newNullRevision( $dbw, $descTitle->getArticleId(), $log->getRcComment(), false ); + $nullRevision->insertOn( $dbw ); + $article->updateRevisionOn( $dbw, $nullRevision ); + + # Invalidate the cache for the description page + $descTitle->invalidateCache(); + $descTitle->purgeSquid(); + } + + + # Commit the transaction now, in case something goes wrong later + # The most important thing is that files don't get lost, especially archives + $dbw->immediateCommit(); + + # Invalidate cache for all pages using this file + $update = new HTMLCacheUpdate( $this->getTitle(), 'imagelinks' ); + $update->doUpdate(); + + return true; + } + +} + diff --git a/includes/filerepo/LocalFile.php b/includes/filerepo/LocalFile.php new file mode 100644 index 00000000..1e5fc449 --- /dev/null +++ b/includes/filerepo/LocalFile.php @@ -0,0 +1,1573 @@ +getLocalRepo()->newFile($title); + * + * The convenience functions wfLocalFile() and wfFindFile() should be sufficient + * in most cases. + * + * @addtogroup FileRepo + */ +class LocalFile extends File +{ + /**#@+ + * @private + */ + var $fileExists, # does the file file exist on disk? (loadFromXxx) + $historyLine, # Number of line to return by nextHistoryLine() (constructor) + $historyRes, # result of the query for the file's history (nextHistoryLine) + $width, # \ + $height, # | + $bits, # --- returned by getimagesize (loadFromXxx) + $attr, # / + $media_type, # MEDIATYPE_xxx (bitmap, drawing, audio...) + $mime, # MIME type, determined by MimeMagic::guessMimeType + $major_mime, # Major mime type + $minor_mime, # Minor mime type + $size, # Size in bytes (loadFromXxx) + $metadata, # Handler-specific metadata + $timestamp, # Upload timestamp + $sha1, # SHA-1 base 36 content hash + $dataLoaded, # Whether or not all this has been loaded from the database (loadFromXxx) + $upgraded, # Whether the row was upgraded on load + $locked; # True if the image row is locked + + /**#@-*/ + + /** + * Create a LocalFile from a title + * Do not call this except from inside a repo class. + */ + static function newFromTitle( $title, $repo ) { + return new self( $title, $repo ); + } + + /** + * Create a LocalFile from a title + * Do not call this except from inside a repo class. + */ + static function newFromRow( $row, $repo ) { + $title = Title::makeTitle( NS_IMAGE, $row->img_name ); + $file = new self( $title, $repo ); + $file->loadFromRow( $row ); + return $file; + } + + /** + * Constructor. + * Do not call this except from inside a repo class. + */ + function __construct( $title, $repo ) { + if( !is_object( $title ) ) { + throw new MWException( __CLASS__.' constructor given bogus title.' ); + } + parent::__construct( $title, $repo ); + $this->metadata = ''; + $this->historyLine = 0; + $this->historyRes = null; + $this->dataLoaded = false; + } + + /** + * Get the memcached key + */ + function getCacheKey() { + $hashedName = md5($this->getName()); + return wfMemcKey( 'file', $hashedName ); + } + + /** + * Try to load file metadata from memcached. Returns true on success. + */ + function loadFromCache() { + global $wgMemc; + wfProfileIn( __METHOD__ ); + $this->dataLoaded = false; + $key = $this->getCacheKey(); + if ( !$key ) { + return false; + } + $cachedValues = $wgMemc->get( $key ); + + // Check if the key existed and belongs to this version of MediaWiki + if ( isset($cachedValues['version']) && ( $cachedValues['version'] == MW_FILE_VERSION ) ) { + wfDebug( "Pulling file metadata from cache key $key\n" ); + $this->fileExists = $cachedValues['fileExists']; + if ( $this->fileExists ) { + unset( $cachedValues['version'] ); + unset( $cachedValues['fileExists'] ); + foreach ( $cachedValues as $name => $value ) { + $this->$name = $value; + } + } + } + if ( $this->dataLoaded ) { + wfIncrStats( 'image_cache_hit' ); + } else { + wfIncrStats( 'image_cache_miss' ); + } + + wfProfileOut( __METHOD__ ); + return $this->dataLoaded; + } + + /** + * Save the file metadata to memcached + */ + function saveToCache() { + global $wgMemc; + $this->load(); + $key = $this->getCacheKey(); + if ( !$key ) { + return; + } + $fields = $this->getCacheFields( '' ); + $cache = array( 'version' => MW_FILE_VERSION ); + $cache['fileExists'] = $this->fileExists; + if ( $this->fileExists ) { + foreach ( $fields as $field ) { + $cache[$field] = $this->$field; + } + } + + $wgMemc->set( $key, $cache, 60 * 60 * 24 * 7 ); // A week + } + + /** + * Load metadata from the file itself + */ + function loadFromFile() { + $this->setProps( self::getPropsFromPath( $this->getPath() ) ); + } + + function getCacheFields( $prefix = 'img_' ) { + static $fields = array( 'size', 'width', 'height', 'bits', 'media_type', + 'major_mime', 'minor_mime', 'metadata', 'timestamp', 'sha1' ); + static $results = array(); + if ( $prefix == '' ) { + return $fields; + } + if ( !isset( $results[$prefix] ) ) { + $prefixedFields = array(); + foreach ( $fields as $field ) { + $prefixedFields[] = $prefix . $field; + } + $results[$prefix] = $prefixedFields; + } + return $results[$prefix]; + } + + /** + * Load file metadata from the DB + */ + function loadFromDB() { + # Polymorphic function name to distinguish foreign and local fetches + $fname = get_class( $this ) . '::' . __FUNCTION__; + wfProfileIn( $fname ); + + # Unconditionally set loaded=true, we don't want the accessors constantly rechecking + $this->dataLoaded = true; + + $dbr = $this->repo->getSlaveDB(); + + $row = $dbr->selectRow( 'image', $this->getCacheFields( 'img_' ), + array( 'img_name' => $this->getName() ), $fname ); + if ( $row ) { + $this->loadFromRow( $row ); + } else { + $this->fileExists = false; + } + + wfProfileOut( $fname ); + } + + /** + * Decode a row from the database (either object or array) to an array + * with timestamps and MIME types decoded, and the field prefix removed. + */ + function decodeRow( $row, $prefix = 'img_' ) { + $array = (array)$row; + $prefixLength = strlen( $prefix ); + // Sanity check prefix once + if ( substr( key( $array ), 0, $prefixLength ) !== $prefix ) { + throw new MWException( __METHOD__. ': incorrect $prefix parameter' ); + } + $decoded = array(); + foreach ( $array as $name => $value ) { + $decoded[substr( $name, $prefixLength )] = $value; + } + $decoded['timestamp'] = wfTimestamp( TS_MW, $decoded['timestamp'] ); + if ( empty( $decoded['major_mime'] ) ) { + $decoded['mime'] = "unknown/unknown"; + } else { + if (!$decoded['minor_mime']) { + $decoded['minor_mime'] = "unknown"; + } + $decoded['mime'] = $decoded['major_mime'].'/'.$decoded['minor_mime']; + } + # Trim zero padding from char/binary field + $decoded['sha1'] = rtrim( $decoded['sha1'], "\0" ); + return $decoded; + } + + /* + * Load file metadata from a DB result row + */ + function loadFromRow( $row, $prefix = 'img_' ) { + $this->dataLoaded = true; + $array = $this->decodeRow( $row, $prefix ); + foreach ( $array as $name => $value ) { + $this->$name = $value; + } + $this->fileExists = true; + // Check for rows from a previous schema, quietly upgrade them + $this->maybeUpgradeRow(); + } + + /** + * Load file metadata from cache or DB, unless already loaded + */ + function load() { + if ( !$this->dataLoaded ) { + if ( !$this->loadFromCache() ) { + $this->loadFromDB(); + $this->saveToCache(); + } + $this->dataLoaded = true; + } + } + + /** + * Upgrade a row if it needs it + */ + function maybeUpgradeRow() { + if ( wfReadOnly() ) { + return; + } + if ( is_null($this->media_type) || + $this->mime == 'image/svg' + ) { + $this->upgradeRow(); + $this->upgraded = true; + } else { + $handler = $this->getHandler(); + if ( $handler && !$handler->isMetadataValid( $this, $this->metadata ) ) { + $this->upgradeRow(); + $this->upgraded = true; + } + } + } + + function getUpgraded() { + return $this->upgraded; + } + + /** + * Fix assorted version-related problems with the image row by reloading it from the file + */ + function upgradeRow() { + wfProfileIn( __METHOD__ ); + + $this->loadFromFile(); + + # Don't destroy file info of missing files + if ( !$this->fileExists ) { + wfDebug( __METHOD__.": file does not exist, aborting\n" ); + return; + } + $dbw = $this->repo->getMasterDB(); + list( $major, $minor ) = self::splitMime( $this->mime ); + + wfDebug(__METHOD__.': upgrading '.$this->getName()." to the current schema\n"); + + $dbw->update( 'image', + array( + 'img_width' => $this->width, + 'img_height' => $this->height, + 'img_bits' => $this->bits, + 'img_media_type' => $this->media_type, + 'img_major_mime' => $major, + 'img_minor_mime' => $minor, + 'img_metadata' => $this->metadata, + 'img_sha1' => $this->sha1, + ), array( 'img_name' => $this->getName() ), + __METHOD__ + ); + $this->saveToCache(); + wfProfileOut( __METHOD__ ); + } + + function setProps( $info ) { + $this->dataLoaded = true; + $fields = $this->getCacheFields( '' ); + $fields[] = 'fileExists'; + foreach ( $fields as $field ) { + if ( isset( $info[$field] ) ) { + $this->$field = $info[$field]; + } + } + // Fix up mime fields + if ( isset( $info['major_mime'] ) ) { + $this->mime = "{$info['major_mime']}/{$info['minor_mime']}"; + } elseif ( isset( $info['mime'] ) ) { + list( $this->major_mime, $this->minor_mime ) = self::splitMime( $this->mime ); + } + } + + /** splitMime inherited */ + /** getName inherited */ + /** getTitle inherited */ + /** getURL inherited */ + /** getViewURL inherited */ + /** getPath inherited */ + + /** + * Return the width of the image + * + * Returns false on error + * @public + */ + function getWidth( $page = 1 ) { + $this->load(); + if ( $this->isMultipage() ) { + $dim = $this->getHandler()->getPageDimensions( $this, $page ); + if ( $dim ) { + return $dim['width']; + } else { + return false; + } + } else { + return $this->width; + } + } + + /** + * Return the height of the image + * + * Returns false on error + * @public + */ + function getHeight( $page = 1 ) { + $this->load(); + if ( $this->isMultipage() ) { + $dim = $this->getHandler()->getPageDimensions( $this, $page ); + if ( $dim ) { + return $dim['height']; + } else { + return false; + } + } else { + return $this->height; + } + } + + /** + * Get handler-specific metadata + */ + function getMetadata() { + $this->load(); + return $this->metadata; + } + + /** + * Return the size of the image file, in bytes + * @public + */ + function getSize() { + $this->load(); + return $this->size; + } + + /** + * Returns the mime type of the file. + */ + function getMimeType() { + $this->load(); + return $this->mime; + } + + /** + * Return the type of the media in the file. + * Use the value returned by this function with the MEDIATYPE_xxx constants. + */ + function getMediaType() { + $this->load(); + return $this->media_type; + } + + /** canRender inherited */ + /** mustRender inherited */ + /** allowInlineDisplay inherited */ + /** isSafeFile inherited */ + /** isTrustedFile inherited */ + + /** + * Returns true if the file file exists on disk. + * @return boolean Whether file file exist on disk. + * @public + */ + function exists() { + $this->load(); + return $this->fileExists; + } + + /** getTransformScript inherited */ + /** getUnscaledThumb inherited */ + /** thumbName inherited */ + /** createThumb inherited */ + /** getThumbnail inherited */ + /** transform inherited */ + + /** + * Fix thumbnail files from 1.4 or before, with extreme prejudice + */ + function migrateThumbFile( $thumbName ) { + $thumbDir = $this->getThumbPath(); + $thumbPath = "$thumbDir/$thumbName"; + if ( is_dir( $thumbPath ) ) { + // Directory where file should be + // This happened occasionally due to broken migration code in 1.5 + // Rename to broken-* + for ( $i = 0; $i < 100 ; $i++ ) { + $broken = $this->repo->getZonePath('public') . "/broken-$i-$thumbName"; + if ( !file_exists( $broken ) ) { + rename( $thumbPath, $broken ); + break; + } + } + // Doesn't exist anymore + clearstatcache(); + } + if ( is_file( $thumbDir ) ) { + // File where directory should be + unlink( $thumbDir ); + // Doesn't exist anymore + clearstatcache(); + } + } + + /** getHandler inherited */ + /** iconThumb inherited */ + /** getLastError inherited */ + + /** + * Get all thumbnail names previously generated for this file + */ + function getThumbnails() { + if ( $this->isHashed() ) { + $this->load(); + $files = array(); + $dir = $this->getThumbPath(); + + if ( is_dir( $dir ) ) { + $handle = opendir( $dir ); + + if ( $handle ) { + while ( false !== ( $file = readdir($handle) ) ) { + if ( $file{0} != '.' ) { + $files[] = $file; + } + } + closedir( $handle ); + } + } + } else { + $files = array(); + } + + return $files; + } + + /** + * Refresh metadata in memcached, but don't touch thumbnails or squid + */ + function purgeMetadataCache() { + $this->loadFromDB(); + $this->saveToCache(); + $this->purgeHistory(); + } + + /** + * Purge the shared history (OldLocalFile) cache + */ + function purgeHistory() { + global $wgMemc; + $hashedName = md5($this->getName()); + $oldKey = wfMemcKey( 'oldfile', $hashedName ); + $wgMemc->delete( $oldKey ); + } + + /** + * Delete all previously generated thumbnails, refresh metadata in memcached and purge the squid + */ + function purgeCache() { + // Refresh metadata cache + $this->purgeMetadataCache(); + + // Delete thumbnails + $this->purgeThumbnails(); + + // Purge squid cache for this file + wfPurgeSquidServers( array( $this->getURL() ) ); + } + + /** + * Delete cached transformed files + */ + function purgeThumbnails() { + global $wgUseSquid; + // Delete thumbnails + $files = $this->getThumbnails(); + $dir = $this->getThumbPath(); + $urls = array(); + foreach ( $files as $file ) { + # Check that the base file name is part of the thumb name + # This is a basic sanity check to avoid erasing unrelated directories + if ( strpos( $file, $this->getName() ) !== false ) { + $url = $this->getThumbUrl( $file ); + $urls[] = $url; + @unlink( "$dir/$file" ); + } + } + + // Purge the squid + if ( $wgUseSquid ) { + wfPurgeSquidServers( $urls ); + } + } + + /** purgeDescription inherited */ + /** purgeEverything inherited */ + + /** + * Return the history of this file, line by line. + * starts with current version, then old versions. + * uses $this->historyLine to check which line to return: + * 0 return line for current version + * 1 query for old versions, return first one + * 2, ... return next old version from above query + * + * @public + */ + function nextHistoryLine() { + $dbr = $this->repo->getSlaveDB(); + + if ( $this->historyLine == 0 ) {// called for the first time, return line from cur + $this->historyRes = $dbr->select( 'image', + array( + '*', + "'' AS oi_archive_name" + ), + array( 'img_name' => $this->title->getDBkey() ), + __METHOD__ + ); + if ( 0 == $dbr->numRows( $this->historyRes ) ) { + $dbr->freeResult($this->historyRes); + $this->historyRes = null; + return FALSE; + } + } else if ( $this->historyLine == 1 ) { + $dbr->freeResult($this->historyRes); + $this->historyRes = $dbr->select( 'oldimage', '*', + array( 'oi_name' => $this->title->getDBkey() ), + __METHOD__, + array( 'ORDER BY' => 'oi_timestamp DESC' ) + ); + } + $this->historyLine ++; + + return $dbr->fetchObject( $this->historyRes ); + } + + /** + * Reset the history pointer to the first element of the history + * @public + */ + function resetHistory() { + $this->historyLine = 0; + if (!is_null($this->historyRes)) { + $this->repo->getSlaveDB()->freeResult($this->historyRes); + $this->historyRes = null; + } + } + + /** getFullPath inherited */ + /** getHashPath inherited */ + /** getRel inherited */ + /** getUrlRel inherited */ + /** getArchiveRel inherited */ + /** getThumbRel inherited */ + /** getArchivePath inherited */ + /** getThumbPath inherited */ + /** getArchiveUrl inherited */ + /** getThumbUrl inherited */ + /** getArchiveVirtualUrl inherited */ + /** getThumbVirtualUrl inherited */ + /** isHashed inherited */ + + /** + * Upload a file and record it in the DB + * @param string $srcPath Source path or virtual URL + * @param string $comment Upload description + * @param string $pageText Text to use for the new description page, if a new description page is created + * @param integer $flags Flags for publish() + * @param array $props File properties, if known. This can be used to reduce the + * upload time when uploading virtual URLs for which the file info + * is already known + * @param string $timestamp Timestamp for img_timestamp, or false to use the current time + * + * @return FileRepoStatus object. On success, the value member contains the + * archive name, or an empty string if it was a new file. + */ + function upload( $srcPath, $comment, $pageText, $flags = 0, $props = false, $timestamp = false ) { + $this->lock(); + $status = $this->publish( $srcPath, $flags ); + if ( $status->ok ) { + if ( !$this->recordUpload2( $status->value, $comment, $pageText, $props, $timestamp ) ) { + $status->fatal( 'filenotfound', $srcPath ); + } + } + $this->unlock(); + return $status; + } + + /** + * Record a file upload in the upload log and the image table + * @deprecated use upload() + */ + function recordUpload( $oldver, $desc, $license = '', $copyStatus = '', $source = '', + $watch = false, $timestamp = false ) + { + $pageText = UploadForm::getInitialPageText( $desc, $license, $copyStatus, $source ); + if ( !$this->recordUpload2( $oldver, $desc, $pageText ) ) { + return false; + } + if ( $watch ) { + global $wgUser; + $wgUser->addWatch( $this->getTitle() ); + } + return true; + + } + + /** + * Record a file upload in the upload log and the image table + */ + function recordUpload2( $oldver, $comment, $pageText, $props = false, $timestamp = false ) + { + global $wgUser; + + $dbw = $this->repo->getMasterDB(); + + if ( !$props ) { + $props = $this->repo->getFileProps( $this->getVirtualUrl() ); + } + $this->setProps( $props ); + + // Delete thumbnails and refresh the metadata cache + $this->purgeThumbnails(); + $this->saveToCache(); + wfPurgeSquidServers( array( $this->getURL() ) ); + + // Fail now if the file isn't there + if ( !$this->fileExists ) { + wfDebug( __METHOD__.": File ".$this->getPath()." went missing!\n" ); + return false; + } + + $reupload = false; + if ( $timestamp === false ) { + $timestamp = $dbw->timestamp(); + } + + # Test to see if the row exists using INSERT IGNORE + # This avoids race conditions by locking the row until the commit, and also + # doesn't deadlock. SELECT FOR UPDATE causes a deadlock for every race condition. + $dbw->insert( 'image', + array( + 'img_name' => $this->getName(), + 'img_size'=> $this->size, + 'img_width' => intval( $this->width ), + 'img_height' => intval( $this->height ), + 'img_bits' => $this->bits, + 'img_media_type' => $this->media_type, + 'img_major_mime' => $this->major_mime, + 'img_minor_mime' => $this->minor_mime, + 'img_timestamp' => $timestamp, + 'img_description' => $comment, + 'img_user' => $wgUser->getID(), + 'img_user_text' => $wgUser->getName(), + 'img_metadata' => $this->metadata, + 'img_sha1' => $this->sha1 + ), + __METHOD__, + 'IGNORE' + ); + + if( $dbw->affectedRows() == 0 ) { + $reupload = true; + + # Collision, this is an update of a file + # Insert previous contents into oldimage + $dbw->insertSelect( 'oldimage', 'image', + array( + 'oi_name' => 'img_name', + 'oi_archive_name' => $dbw->addQuotes( $oldver ), + 'oi_size' => 'img_size', + 'oi_width' => 'img_width', + 'oi_height' => 'img_height', + 'oi_bits' => 'img_bits', + 'oi_timestamp' => 'img_timestamp', + 'oi_description' => 'img_description', + 'oi_user' => 'img_user', + 'oi_user_text' => 'img_user_text', + 'oi_metadata' => 'img_metadata', + 'oi_media_type' => 'img_media_type', + 'oi_major_mime' => 'img_major_mime', + 'oi_minor_mime' => 'img_minor_mime', + 'oi_sha1' => 'img_sha1', + ), array( 'img_name' => $this->getName() ), __METHOD__ + ); + + # Update the current image row + $dbw->update( 'image', + array( /* SET */ + 'img_size' => $this->size, + 'img_width' => intval( $this->width ), + 'img_height' => intval( $this->height ), + 'img_bits' => $this->bits, + 'img_media_type' => $this->media_type, + 'img_major_mime' => $this->major_mime, + 'img_minor_mime' => $this->minor_mime, + 'img_timestamp' => $timestamp, + 'img_description' => $comment, + 'img_user' => $wgUser->getID(), + 'img_user_text' => $wgUser->getName(), + 'img_metadata' => $this->metadata, + 'img_sha1' => $this->sha1 + ), array( /* WHERE */ + 'img_name' => $this->getName() + ), __METHOD__ + ); + } else { + # This is a new file + # Update the image count + $site_stats = $dbw->tableName( 'site_stats' ); + $dbw->query( "UPDATE $site_stats SET ss_images=ss_images+1", __METHOD__ ); + } + + $descTitle = $this->getTitle(); + $article = new Article( $descTitle ); + + # Add the log entry + $log = new LogPage( 'upload' ); + $action = $reupload ? 'overwrite' : 'upload'; + $log->addEntry( $action, $descTitle, $comment ); + + if( $descTitle->exists() ) { + # Create a null revision + $nullRevision = Revision::newNullRevision( $dbw, $descTitle->getArticleId(), $log->getRcComment(), false ); + $nullRevision->insertOn( $dbw ); + $article->updateRevisionOn( $dbw, $nullRevision ); + + # Invalidate the cache for the description page + $descTitle->invalidateCache(); + $descTitle->purgeSquid(); + } else { + // New file; create the description page. + // There's already a log entry, so don't make a second RC entry + $article->doEdit( $pageText, $comment, EDIT_NEW | EDIT_SUPPRESS_RC ); + } + + # Hooks, hooks, the magic of hooks... + wfRunHooks( 'FileUpload', array( $this ) ); + + # Commit the transaction now, in case something goes wrong later + # The most important thing is that files don't get lost, especially archives + $dbw->immediateCommit(); + + # Invalidate cache for all pages using this file + $update = new HTMLCacheUpdate( $this->getTitle(), 'imagelinks' ); + $update->doUpdate(); + + return true; + } + + /** + * Move or copy a file to its public location. If a file exists at the + * destination, move it to an archive. Returns the archive name on success + * or an empty string if it was a new file, and a wikitext-formatted + * WikiError object on failure. + * + * The archive name should be passed through to recordUpload for database + * registration. + * + * @param string $sourcePath Local filesystem path to the source image + * @param integer $flags A bitwise combination of: + * File::DELETE_SOURCE Delete the source file, i.e. move + * rather than copy + * @return FileRepoStatus object. On success, the value member contains the + * archive name, or an empty string if it was a new file. + */ + function publish( $srcPath, $flags = 0 ) { + $this->lock(); + $dstRel = $this->getRel(); + $archiveName = gmdate( 'YmdHis' ) . '!'. $this->getName(); + $archiveRel = 'archive/' . $this->getHashPath() . $archiveName; + $flags = $flags & File::DELETE_SOURCE ? LocalRepo::DELETE_SOURCE : 0; + $status = $this->repo->publish( $srcPath, $dstRel, $archiveRel, $flags ); + if ( $status->value == 'new' ) { + $status->value = ''; + } else { + $status->value = $archiveName; + } + $this->unlock(); + return $status; + } + + /** getLinksTo inherited */ + /** getExifData inherited */ + /** isLocal inherited */ + /** wasDeleted inherited */ + + /** + * Delete all versions of the file. + * + * Moves the files into an archive directory (or deletes them) + * and removes the database rows. + * + * Cache purging is done; logging is caller's responsibility. + * + * @param $reason + * @return FileRepoStatus object. + */ + function delete( $reason ) { + $this->lock(); + $batch = new LocalFileDeleteBatch( $this, $reason ); + $batch->addCurrent(); + + # Get old version relative paths + $dbw = $this->repo->getMasterDB(); + $result = $dbw->select( 'oldimage', + array( 'oi_archive_name' ), + array( 'oi_name' => $this->getName() ) ); + while ( $row = $dbw->fetchObject( $result ) ) { + $batch->addOld( $row->oi_archive_name ); + } + $status = $batch->execute(); + + if ( $status->ok ) { + // Update site_stats + $site_stats = $dbw->tableName( 'site_stats' ); + $dbw->query( "UPDATE $site_stats SET ss_images=ss_images-1", __METHOD__ ); + $this->purgeEverything(); + } + + $this->unlock(); + return $status; + } + + /** + * Delete an old version of the file. + * + * Moves the file into an archive directory (or deletes it) + * and removes the database row. + * + * Cache purging is done; logging is caller's responsibility. + * + * @param $reason + * @throws MWException or FSException on database or filestore failure + * @return FileRepoStatus object. + */ + function deleteOld( $archiveName, $reason ) { + $this->lock(); + $batch = new LocalFileDeleteBatch( $this, $reason ); + $batch->addOld( $archiveName ); + $status = $batch->execute(); + $this->unlock(); + if ( $status->ok ) { + $this->purgeDescription(); + $this->purgeHistory(); + } + return $status; + } + + /** + * Restore all or specified deleted revisions to the given file. + * Permissions and logging are left to the caller. + * + * May throw database exceptions on error. + * + * @param $versions set of record ids of deleted items to restore, + * or empty to restore all revisions. + * @return FileRepoStatus + */ + function restore( $versions = array(), $unsuppress = false ) { + $batch = new LocalFileRestoreBatch( $this ); + if ( !$versions ) { + $batch->addAll(); + } else { + $batch->addIds( $versions ); + } + $status = $batch->execute(); + if ( !$status->ok ) { + return $status; + } + + $cleanupStatus = $batch->cleanup(); + $cleanupStatus->successCount = 0; + $cleanupStatus->failCount = 0; + $status->merge( $cleanupStatus ); + return $status; + } + + /** isMultipage inherited */ + /** pageCount inherited */ + /** scaleHeight inherited */ + /** getImageSize inherited */ + + /** + * Get the URL of the file description page. + */ + function getDescriptionUrl() { + return $this->title->getLocalUrl(); + } + + /** + * Get the HTML text of the description page + * This is not used by ImagePage for local files, since (among other things) + * it skips the parser cache. + */ + function getDescriptionText() { + global $wgParser; + $revision = Revision::newFromTitle( $this->title ); + if ( !$revision ) return false; + $text = $revision->getText(); + if ( !$text ) return false; + $html = $wgParser->parse( $text, new ParserOptions ); + return $html; + } + + function getTimestamp() { + $this->load(); + return $this->timestamp; + } + + function getSha1() { + $this->load(); + // Initialise now if necessary + if ( $this->sha1 == '' && $this->fileExists ) { + $this->sha1 = File::sha1Base36( $this->getPath() ); + if ( strval( $this->sha1 ) != '' ) { + $dbw = $this->repo->getMasterDB(); + $dbw->update( 'image', + array( 'img_sha1' => $this->sha1 ), + array( 'img_name' => $this->getName() ), + __METHOD__ ); + $this->saveToCache(); + } + } + + return $this->sha1; + } + + /** + * Start a transaction and lock the image for update + * Increments a reference counter if the lock is already held + * @return boolean True if the image exists, false otherwise + */ + function lock() { + $dbw = $this->repo->getMasterDB(); + if ( !$this->locked ) { + $dbw->begin(); + $this->locked++; + } + return $dbw->selectField( 'image', '1', array( 'img_name' => $this->getName() ), __METHOD__ ); + } + + /** + * Decrement the lock reference count. If the reference count is reduced to zero, commits + * the transaction and thereby releases the image lock. + */ + function unlock() { + if ( $this->locked ) { + --$this->locked; + if ( !$this->locked ) { + $dbw = $this->repo->getMasterDB(); + $dbw->commit(); + } + } + } + + /** + * Roll back the DB transaction and mark the image unlocked + */ + function unlockAndRollback() { + $this->locked = false; + $dbw = $this->repo->getMasterDB(); + $dbw->rollback(); + } +} // LocalFile class + +#------------------------------------------------------------------------------ + +/** + * Backwards compatibility class + */ +class Image extends LocalFile { + function __construct( $title ) { + $repo = RepoGroup::singleton()->getLocalRepo(); + parent::__construct( $title, $repo ); + } + + /** + * Wrapper for wfFindFile(), for backwards-compatibility only + * Do not use in core code. + * @deprecated + */ + static function newFromTitle( $title, $time = false ) { + $img = wfFindFile( $title, $time ); + if ( !$img ) { + $img = wfLocalFile( $title ); + } + return $img; + } + + /** + * Wrapper for wfFindFile(), for backwards-compatibility only. + * Do not use in core code. + * + * @param string $name name of the image, used to create a title object using Title::makeTitleSafe + * @return image object or null if invalid title + * @deprecated + */ + static function newFromName( $name ) { + $title = Title::makeTitleSafe( NS_IMAGE, $name ); + if ( is_object( $title ) ) { + $img = wfFindFile( $title ); + if ( !$img ) { + $img = wfLocalFile( $title ); + } + return $img; + } else { + return NULL; + } + } + + /** + * Return the URL of an image, provided its name. + * + * Backwards-compatibility for extensions. + * Note that fromSharedDirectory will only use the shared path for files + * that actually exist there now, and will return local paths otherwise. + * + * @param string $name Name of the image, without the leading "Image:" + * @param boolean $fromSharedDirectory Should this be in $wgSharedUploadPath? + * @return string URL of $name image + * @deprecated + */ + static function imageUrl( $name, $fromSharedDirectory = false ) { + $image = null; + if( $fromSharedDirectory ) { + $image = wfFindFile( $name ); + } + if( !$image ) { + $image = wfLocalFile( $name ); + } + return $image->getUrl(); + } +} + +#------------------------------------------------------------------------------ + +/** + * Helper class for file deletion + */ +class LocalFileDeleteBatch { + var $file, $reason, $srcRels = array(), $archiveUrls = array(), $deletionBatch; + var $status; + + function __construct( File $file, $reason = '' ) { + $this->file = $file; + $this->reason = $reason; + $this->status = $file->repo->newGood(); + } + + function addCurrent() { + $this->srcRels['.'] = $this->file->getRel(); + } + + function addOld( $oldName ) { + $this->srcRels[$oldName] = $this->file->getArchiveRel( $oldName ); + $this->archiveUrls[] = $this->file->getArchiveUrl( $oldName ); + } + + function getOldRels() { + if ( !isset( $this->srcRels['.'] ) ) { + $oldRels =& $this->srcRels; + $deleteCurrent = false; + } else { + $oldRels = $this->srcRels; + unset( $oldRels['.'] ); + $deleteCurrent = true; + } + return array( $oldRels, $deleteCurrent ); + } + + /*protected*/ function getHashes() { + $hashes = array(); + list( $oldRels, $deleteCurrent ) = $this->getOldRels(); + if ( $deleteCurrent ) { + $hashes['.'] = $this->file->getSha1(); + } + if ( count( $oldRels ) ) { + $dbw = $this->file->repo->getMasterDB(); + $res = $dbw->select( 'oldimage', array( 'oi_archive_name', 'oi_sha1' ), + 'oi_archive_name IN(' . $dbw->makeList( array_keys( $oldRels ) ) . ')', + __METHOD__ ); + while ( $row = $dbw->fetchObject( $res ) ) { + if ( rtrim( $row->oi_sha1, "\0" ) === '' ) { + // Get the hash from the file + $oldUrl = $this->file->getArchiveVirtualUrl( $row->oi_archive_name ); + $props = $this->file->repo->getFileProps( $oldUrl ); + if ( $props['fileExists'] ) { + // Upgrade the oldimage row + $dbw->update( 'oldimage', + array( 'oi_sha1' => $props['sha1'] ), + array( 'oi_name' => $this->file->getName(), 'oi_archive_name' => $row->oi_archive_name ), + __METHOD__ ); + $hashes[$row->oi_archive_name] = $props['sha1']; + } else { + $hashes[$row->oi_archive_name] = false; + } + } else { + $hashes[$row->oi_archive_name] = $row->oi_sha1; + } + } + } + $missing = array_diff_key( $this->srcRels, $hashes ); + foreach ( $missing as $name => $rel ) { + $this->status->error( 'filedelete-old-unregistered', $name ); + } + foreach ( $hashes as $name => $hash ) { + if ( !$hash ) { + $this->status->error( 'filedelete-missing', $this->srcRels[$name] ); + unset( $hashes[$name] ); + } + } + + return $hashes; + } + + function doDBInserts() { + global $wgUser; + $dbw = $this->file->repo->getMasterDB(); + $encTimestamp = $dbw->addQuotes( $dbw->timestamp() ); + $encUserId = $dbw->addQuotes( $wgUser->getId() ); + $encReason = $dbw->addQuotes( $this->reason ); + $encGroup = $dbw->addQuotes( 'deleted' ); + $ext = $this->file->getExtension(); + $dotExt = $ext === '' ? '' : ".$ext"; + $encExt = $dbw->addQuotes( $dotExt ); + list( $oldRels, $deleteCurrent ) = $this->getOldRels(); + + if ( $deleteCurrent ) { + $where = array( 'img_name' => $this->file->getName() ); + $dbw->insertSelect( 'filearchive', 'image', + array( + 'fa_storage_group' => $encGroup, + 'fa_storage_key' => "IF(img_sha1='', '', CONCAT(img_sha1,$encExt))", + + 'fa_deleted_user' => $encUserId, + 'fa_deleted_timestamp' => $encTimestamp, + 'fa_deleted_reason' => $encReason, + 'fa_deleted' => 0, + + 'fa_name' => 'img_name', + 'fa_archive_name' => 'NULL', + 'fa_size' => 'img_size', + 'fa_width' => 'img_width', + 'fa_height' => 'img_height', + 'fa_metadata' => 'img_metadata', + 'fa_bits' => 'img_bits', + 'fa_media_type' => 'img_media_type', + 'fa_major_mime' => 'img_major_mime', + 'fa_minor_mime' => 'img_minor_mime', + 'fa_description' => 'img_description', + 'fa_user' => 'img_user', + 'fa_user_text' => 'img_user_text', + 'fa_timestamp' => 'img_timestamp' + ), $where, __METHOD__ ); + } + + if ( count( $oldRels ) ) { + $where = array( + 'oi_name' => $this->file->getName(), + 'oi_archive_name IN (' . $dbw->makeList( array_keys( $oldRels ) ) . ')' ); + + $dbw->insertSelect( 'filearchive', 'oldimage', + array( + 'fa_storage_group' => $encGroup, + 'fa_storage_key' => "IF(oi_sha1='', '', CONCAT(oi_sha1,$encExt))", + + 'fa_deleted_user' => $encUserId, + 'fa_deleted_timestamp' => $encTimestamp, + 'fa_deleted_reason' => $encReason, + 'fa_deleted' => 0, + + 'fa_name' => 'oi_name', + 'fa_archive_name' => 'oi_archive_name', + 'fa_size' => 'oi_size', + 'fa_width' => 'oi_width', + 'fa_height' => 'oi_height', + 'fa_metadata' => 'oi_metadata', + 'fa_bits' => 'oi_bits', + 'fa_media_type' => 'oi_media_type', + 'fa_major_mime' => 'oi_major_mime', + 'fa_minor_mime' => 'oi_minor_mime', + 'fa_description' => 'oi_description', + 'fa_user' => 'oi_user', + 'fa_user_text' => 'oi_user_text', + 'fa_timestamp' => 'oi_timestamp' + ), $where, __METHOD__ ); + } + } + + function doDBDeletes() { + $dbw = $this->file->repo->getMasterDB(); + list( $oldRels, $deleteCurrent ) = $this->getOldRels(); + if ( $deleteCurrent ) { + $dbw->delete( 'image', array( 'img_name' => $this->file->getName() ), __METHOD__ ); + } + if ( count( $oldRels ) ) { + $dbw->delete( 'oldimage', + array( + 'oi_name' => $this->file->getName(), + 'oi_archive_name IN (' . $dbw->makeList( array_keys( $oldRels ) ) . ')' + ), __METHOD__ ); + } + } + + /** + * Run the transaction + */ + function execute() { + global $wgUser, $wgUseSquid; + wfProfileIn( __METHOD__ ); + + $this->file->lock(); + + // Prepare deletion batch + $hashes = $this->getHashes(); + $this->deletionBatch = array(); + $ext = $this->file->getExtension(); + $dotExt = $ext === '' ? '' : ".$ext"; + foreach ( $this->srcRels as $name => $srcRel ) { + // Skip files that have no hash (missing source) + if ( isset( $hashes[$name] ) ) { + $hash = $hashes[$name]; + $key = $hash . $dotExt; + $dstRel = $this->file->repo->getDeletedHashPath( $key ) . $key; + $this->deletionBatch[$name] = array( $srcRel, $dstRel ); + } + } + + // Lock the filearchive rows so that the files don't get deleted by a cleanup operation + // We acquire this lock by running the inserts now, before the file operations. + // + // This potentially has poor lock contention characteristics -- an alternative + // scheme would be to insert stub filearchive entries with no fa_name and commit + // them in a separate transaction, then run the file ops, then update the fa_name fields. + $this->doDBInserts(); + + // Execute the file deletion batch + $status = $this->file->repo->deleteBatch( $this->deletionBatch ); + if ( !$status->isGood() ) { + $this->status->merge( $status ); + } + + if ( !$this->status->ok ) { + // Critical file deletion error + // Roll back inserts, release lock and abort + // TODO: delete the defunct filearchive rows if we are using a non-transactional DB + $this->file->unlockAndRollback(); + return $this->status; + } + + // Purge squid + if ( $wgUseSquid ) { + $urls = array(); + foreach ( $this->srcRels as $srcRel ) { + $urlRel = str_replace( '%2F', '/', rawurlencode( $srcRel ) ); + $urls[] = $this->file->repo->getZoneUrl( 'public' ) . '/' . $urlRel; + } + SquidUpdate::purge( $urls ); + } + + // Delete image/oldimage rows + $this->doDBDeletes(); + + // Commit and return + $this->file->unlock(); + wfProfileOut( __METHOD__ ); + return $this->status; + } +} + +#------------------------------------------------------------------------------ + +/** + * Helper class for file undeletion + */ +class LocalFileRestoreBatch { + var $file, $cleanupBatch, $ids, $all, $unsuppress = false; + + function __construct( File $file ) { + $this->file = $file; + $this->cleanupBatch = $this->ids = array(); + $this->ids = array(); + } + + /** + * Add a file by ID + */ + function addId( $fa_id ) { + $this->ids[] = $fa_id; + } + + /** + * Add a whole lot of files by ID + */ + function addIds( $ids ) { + $this->ids = array_merge( $this->ids, $ids ); + } + + /** + * Add all revisions of the file + */ + function addAll() { + $this->all = true; + } + + /** + * Run the transaction, except the cleanup batch. + * The cleanup batch should be run in a separate transaction, because it locks different + * rows and there's no need to keep the image row locked while it's acquiring those locks + * The caller may have its own transaction open. + * So we save the batch and let the caller call cleanup() + */ + function execute() { + global $wgUser, $wgLang; + if ( !$this->all && !$this->ids ) { + // Do nothing + return $this->file->repo->newGood(); + } + + $exists = $this->file->lock(); + $dbw = $this->file->repo->getMasterDB(); + $status = $this->file->repo->newGood(); + + // Fetch all or selected archived revisions for the file, + // sorted from the most recent to the oldest. + $conditions = array( 'fa_name' => $this->file->getName() ); + if( !$this->all ) { + $conditions[] = 'fa_id IN (' . $dbw->makeList( $this->ids ) . ')'; + } + + $result = $dbw->select( 'filearchive', '*', + $conditions, + __METHOD__, + array( 'ORDER BY' => 'fa_timestamp DESC' ) ); + + $idsPresent = array(); + $storeBatch = array(); + $insertBatch = array(); + $insertCurrent = false; + $deleteIds = array(); + $first = true; + $archiveNames = array(); + while( $row = $dbw->fetchObject( $result ) ) { + $idsPresent[] = $row->fa_id; + if ( $this->unsuppress ) { + // Currently, fa_deleted flags fall off upon restore, lets be careful about this + } else if ( ($row->fa_deleted & Revision::DELETED_RESTRICTED) && !$wgUser->isAllowed('hiderevision') ) { + // Skip restoring file revisions that the user cannot restore + continue; + } + if ( $row->fa_name != $this->file->getName() ) { + $status->error( 'undelete-filename-mismatch', $wgLang->timeanddate( $row->fa_timestamp ) ); + $status->failCount++; + continue; + } + if ( $row->fa_storage_key == '' ) { + // Revision was missing pre-deletion + $status->error( 'undelete-bad-store-key', $wgLang->timeanddate( $row->fa_timestamp ) ); + $status->failCount++; + continue; + } + + $deletedRel = $this->file->repo->getDeletedHashPath( $row->fa_storage_key ) . $row->fa_storage_key; + $deletedUrl = $this->file->repo->getVirtualUrl() . '/deleted/' . $deletedRel; + + $sha1 = substr( $row->fa_storage_key, 0, strcspn( $row->fa_storage_key, '.' ) ); + # Fix leading zero + if ( strlen( $sha1 ) == 32 && $sha1[0] == '0' ) { + $sha1 = substr( $sha1, 1 ); + } + + if( is_null( $row->fa_major_mime ) || $row->fa_major_mime == 'unknown' + || is_null( $row->fa_minor_mime ) || $row->fa_minor_mime == 'unknown' + || is_null( $row->fa_media_type ) || $row->fa_media_type == 'UNKNOWN' + || is_null( $row->fa_metadata ) ) { + // Refresh our metadata + // Required for a new current revision; nice for older ones too. :) + $props = RepoGroup::singleton()->getFileProps( $deletedUrl ); + } else { + $props = array( + 'minor_mime' => $row->fa_minor_mime, + 'major_mime' => $row->fa_major_mime, + 'media_type' => $row->fa_media_type, + 'metadata' => $row->fa_metadata ); + } + + if ( $first && !$exists ) { + // This revision will be published as the new current version + $destRel = $this->file->getRel(); + $insertCurrent = array( + 'img_name' => $row->fa_name, + 'img_size' => $row->fa_size, + 'img_width' => $row->fa_width, + 'img_height' => $row->fa_height, + 'img_metadata' => $props['metadata'], + 'img_bits' => $row->fa_bits, + 'img_media_type' => $props['media_type'], + 'img_major_mime' => $props['major_mime'], + 'img_minor_mime' => $props['minor_mime'], + 'img_description' => $row->fa_description, + 'img_user' => $row->fa_user, + 'img_user_text' => $row->fa_user_text, + 'img_timestamp' => $row->fa_timestamp, + 'img_sha1' => $sha1); + } else { + $archiveName = $row->fa_archive_name; + if( $archiveName == '' ) { + // This was originally a current version; we + // have to devise a new archive name for it. + // Format is ! + $timestamp = wfTimestamp( TS_UNIX, $row->fa_deleted_timestamp ); + do { + $archiveName = wfTimestamp( TS_MW, $timestamp ) . '!' . $row->fa_name; + $timestamp++; + } while ( isset( $archiveNames[$archiveName] ) ); + } + $archiveNames[$archiveName] = true; + $destRel = $this->file->getArchiveRel( $archiveName ); + $insertBatch[] = array( + 'oi_name' => $row->fa_name, + 'oi_archive_name' => $archiveName, + 'oi_size' => $row->fa_size, + 'oi_width' => $row->fa_width, + 'oi_height' => $row->fa_height, + 'oi_bits' => $row->fa_bits, + 'oi_description' => $row->fa_description, + 'oi_user' => $row->fa_user, + 'oi_user_text' => $row->fa_user_text, + 'oi_timestamp' => $row->fa_timestamp, + 'oi_metadata' => $props['metadata'], + 'oi_media_type' => $props['media_type'], + 'oi_major_mime' => $props['major_mime'], + 'oi_minor_mime' => $props['minor_mime'], + 'oi_deleted' => $row->fa_deleted, + 'oi_sha1' => $sha1 ); + } + + $deleteIds[] = $row->fa_id; + $storeBatch[] = array( $deletedUrl, 'public', $destRel ); + $this->cleanupBatch[] = $row->fa_storage_key; + $first = false; + } + unset( $result ); + + // Add a warning to the status object for missing IDs + $missingIds = array_diff( $this->ids, $idsPresent ); + foreach ( $missingIds as $id ) { + $status->error( 'undelete-missing-filearchive', $id ); + } + + // Run the store batch + // Use the OVERWRITE_SAME flag to smooth over a common error + $storeStatus = $this->file->repo->storeBatch( $storeBatch, FileRepo::OVERWRITE_SAME ); + $status->merge( $storeStatus ); + + if ( !$status->ok ) { + // Store batch returned a critical error -- this usually means nothing was stored + // Stop now and return an error + $this->file->unlock(); + return $status; + } + + // Run the DB updates + // Because we have locked the image row, key conflicts should be rare. + // If they do occur, we can roll back the transaction at this time with + // no data loss, but leaving unregistered files scattered throughout the + // public zone. + // This is not ideal, which is why it's important to lock the image row. + if ( $insertCurrent ) { + $dbw->insert( 'image', $insertCurrent, __METHOD__ ); + } + if ( $insertBatch ) { + $dbw->insert( 'oldimage', $insertBatch, __METHOD__ ); + } + if ( $deleteIds ) { + $dbw->delete( 'filearchive', + array( 'fa_id IN (' . $dbw->makeList( $deleteIds ) . ')' ), + __METHOD__ ); + } + + if( $status->successCount > 0 ) { + if( !$exists ) { + wfDebug( __METHOD__." restored {$status->successCount} items, creating a new current\n" ); + + // Update site_stats + $site_stats = $dbw->tableName( 'site_stats' ); + $dbw->query( "UPDATE $site_stats SET ss_images=ss_images+1", __METHOD__ ); + + $this->file->purgeEverything(); + } else { + wfDebug( __METHOD__." restored {$status->successCount} as archived versions\n" ); + $this->file->purgeDescription(); + $this->file->purgeHistory(); + } + } + $this->file->unlock(); + return $status; + } + + /** + * Delete unused files in the deleted zone. + * This should be called from outside the transaction in which execute() was called. + */ + function cleanup() { + if ( !$this->cleanupBatch ) { + return $this->file->repo->newGood(); + } + $status = $this->file->repo->cleanupDeletedBatch( $this->cleanupBatch ); + return $status; + } +} diff --git a/includes/filerepo/LocalRepo.php b/includes/filerepo/LocalRepo.php new file mode 100644 index 00000000..72f9e9a6 --- /dev/null +++ b/includes/filerepo/LocalRepo.php @@ -0,0 +1,65 @@ +img_name ) ) { + return LocalFile::newFromRow( $row, $this ); + } elseif ( isset( $row->oi_name ) ) { + return OldLocalFile::newFromRow( $row, $this ); + } else { + throw new MWException( __METHOD__.': invalid row' ); + } + } + + function newFromArchiveName( $title, $archiveName ) { + return OldLocalFile::newFromArchiveName( $title, $this, $archiveName ); + } + + /** + * Delete files in the deleted directory if they are not referenced in the + * filearchive table. This needs to be done in the repo because it needs to + * interleave database locks with file operations, which is potentially a + * remote operation. + * @return FileRepoStatus + */ + function cleanupDeletedBatch( $storageKeys ) { + $root = $this->getZonePath( 'deleted' ); + $dbw = $this->getMasterDB(); + $status = $this->newGood(); + $storageKeys = array_unique($storageKeys); + foreach ( $storageKeys as $key ) { + $hashPath = $this->getDeletedHashPath( $key ); + $path = "$root/$hashPath$key"; + $dbw->begin(); + $inuse = $dbw->selectField( 'filearchive', '1', + array( 'fa_storage_group' => 'deleted', 'fa_storage_key' => $key ), + __METHOD__, array( 'FOR UPDATE' ) ); + if ( !$inuse ) { + wfDebug( __METHOD__ . ": deleting $key\n" ); + if ( !@unlink( $path ) ) { + $status->error( 'undelete-cleanup-error', $path ); + $status->failCount++; + } + } else { + wfDebug( __METHOD__ . ": $key still in use\n" ); + $status->successCount++; + } + $dbw->commit(); + } + return $status; + } +} diff --git a/includes/filerepo/OldLocalFile.php b/includes/filerepo/OldLocalFile.php new file mode 100644 index 00000000..850a8d8a --- /dev/null +++ b/includes/filerepo/OldLocalFile.php @@ -0,0 +1,232 @@ +oi_name ); + $file = new self( $title, $repo, null, $row->oi_archive_name ); + $file->loadFromRow( $row, 'oi_' ); + return $file; + } + + /** + * @param Title $title + * @param FileRepo $repo + * @param string $time Timestamp or null to load by archive name + * @param string $archiveName Archive name or null to load by timestamp + */ + function __construct( $title, $repo, $time, $archiveName ) { + parent::__construct( $title, $repo ); + $this->requestedTime = $time; + $this->archive_name = $archiveName; + if ( is_null( $time ) && is_null( $archiveName ) ) { + throw new MWException( __METHOD__.': must specify at least one of $time or $archiveName' ); + } + } + + function getCacheKey() { + $hashedName = md5($this->getName()); + return wfMemcKey( 'oldfile', $hashedName ); + } + + function getArchiveName() { + if ( !isset( $this->archive_name ) ) { + $this->load(); + } + return $this->archive_name; + } + + function isOld() { + return true; + } + + /** + * Try to load file metadata from memcached. Returns true on success. + */ + function loadFromCache() { + global $wgMemc; + wfProfileIn( __METHOD__ ); + $this->dataLoaded = false; + $key = $this->getCacheKey(); + if ( !$key ) { + return false; + } + $oldImages = $wgMemc->get( $key ); + + if ( isset( $oldImages['version'] ) && $oldImages['version'] == self::CACHE_VERSION ) { + unset( $oldImages['version'] ); + $more = isset( $oldImages['more'] ); + unset( $oldImages['more'] ); + $found = false; + if ( is_null( $this->requestedTime ) ) { + foreach ( $oldImages as $timestamp => $info ) { + if ( $info['archive_name'] == $this->archive_name ) { + $found = true; + break; + } + } + } else { + krsort( $oldImages ); + foreach ( $oldImages as $timestamp => $info ) { + if ( $timestamp <= $this->requestedTime ) { + $found = true; + break; + } + } + } + if ( $found ) { + wfDebug( "Pulling file metadata from cache key {$key}[{$timestamp}]\n" ); + $this->dataLoaded = true; + $this->fileExists = true; + foreach ( $info as $name => $value ) { + $this->$name = $value; + } + } elseif ( $more ) { + wfDebug( "Cache key was truncated, oldimage row might be found in the database\n" ); + } else { + wfDebug( "Image did not exist at the specified time.\n" ); + $this->fileExists = false; + $this->dataLoaded = true; + } + } + + if ( $this->dataLoaded ) { + wfIncrStats( 'image_cache_hit' ); + } else { + wfIncrStats( 'image_cache_miss' ); + } + + wfProfileOut( __METHOD__ ); + return $this->dataLoaded; + } + + function saveToCache() { + // If a timestamp was specified, cache the entire history of the image (up to MAX_CACHE_ROWS). + if ( is_null( $this->requestedTime ) ) { + return; + } + // This is expensive, so we only do it if $wgMemc is real + global $wgMemc; + if ( $wgMemc instanceof FakeMemcachedClient ) { + return; + } + $key = $this->getCacheKey(); + if ( !$key ) { + return; + } + wfProfileIn( __METHOD__ ); + + $dbr = $this->repo->getSlaveDB(); + $res = $dbr->select( 'oldimage', $this->getCacheFields( 'oi_' ), + array( 'oi_name' => $this->getName() ), __METHOD__, + array( + 'LIMIT' => self::MAX_CACHE_ROWS + 1, + 'ORDER BY' => 'oi_timestamp DESC', + )); + $cache = array( 'version' => self::CACHE_VERSION ); + $numRows = $dbr->numRows( $res ); + if ( $numRows > self::MAX_CACHE_ROWS ) { + $cache['more'] = true; + $numRows--; + } + for ( $i = 0; $i < $numRows; $i++ ) { + $row = $dbr->fetchObject( $res ); + $decoded = $this->decodeRow( $row, 'oi_' ); + $cache[$row->oi_timestamp] = $decoded; + } + $dbr->freeResult( $res ); + $wgMemc->set( $key, $cache, 7*86400 /* 1 week */ ); + wfProfileOut( __METHOD__ ); + } + + function loadFromDB() { + wfProfileIn( __METHOD__ ); + $this->dataLoaded = true; + $dbr = $this->repo->getSlaveDB(); + $conds = array( 'oi_name' => $this->getName() ); + if ( is_null( $this->requestedTime ) ) { + $conds['oi_archive_name'] = $this->archive_name; + } else { + $conds[] = 'oi_timestamp <= ' . $dbr->addQuotes( $this->requestedTime ); + } + $row = $dbr->selectRow( 'oldimage', $this->getCacheFields( 'oi_' ), + $conds, __METHOD__, array( 'ORDER BY' => 'oi_timestamp DESC' ) ); + if ( $row ) { + $this->loadFromRow( $row, 'oi_' ); + } else { + $this->fileExists = false; + } + wfProfileOut( __METHOD__ ); + } + + function getCacheFields( $prefix = 'img_' ) { + $fields = parent::getCacheFields( $prefix ); + $fields[] = $prefix . 'archive_name'; + + // XXX: Temporary hack before schema update + //$fields = array_diff( $fields, array( + // 'oi_media_type', 'oi_major_mime', 'oi_minor_mime', 'oi_metadata' ) ); + return $fields; + } + + function getRel() { + return 'archive/' . $this->getHashPath() . $this->getArchiveName(); + } + + function getUrlRel() { + return 'archive/' . $this->getHashPath() . urlencode( $this->getArchiveName() ); + } + + function upgradeRow() { + wfProfileIn( __METHOD__ ); + $this->loadFromFile(); + + # Don't destroy file info of missing files + if ( !$this->fileExists ) { + wfDebug( __METHOD__.": file does not exist, aborting\n" ); + wfProfileOut( __METHOD__ ); + return; + } + + $dbw = $this->repo->getMasterDB(); + list( $major, $minor ) = self::splitMime( $this->mime ); + + wfDebug(__METHOD__.': upgrading '.$this->archive_name." to the current schema\n"); + $dbw->update( 'oldimage', + array( + 'oi_width' => $this->width, + 'oi_height' => $this->height, + 'oi_bits' => $this->bits, + 'oi_media_type' => $this->media_type, + 'oi_major_mime' => $major, + 'oi_minor_mime' => $minor, + 'oi_metadata' => $this->metadata, + 'oi_sha1' => $this->sha1, + ), array( + 'oi_name' => $this->getName(), + 'oi_archive_name' => $this->archive_name ), + __METHOD__ + ); + wfProfileOut( __METHOD__ ); + } +} + + + diff --git a/includes/filerepo/README b/includes/filerepo/README new file mode 100644 index 00000000..03cb8b3b --- /dev/null +++ b/includes/filerepo/README @@ -0,0 +1,41 @@ +Some quick notes on the file/repository architecture. + +Functionality is, as always, driven by data model. + +* The repository object stores configuration information about a file storage + method. + +* The file object is a process-local cache of information about a particular + file. + +Thus the file object is the primary public entry point for obtaining information +about files, since access via the file object can be cached, whereas access via +the repository should not be cached. + +Functions which can act on any file specified in their parameters typically find +their place either in the repository object, where reference to +repository-specific configuration is needed, or in static members of File or +FileRepo, where no such configuration is needed. + +File objects are generated by a factory function from the repository. The +repository thus has full control over the behaviour of its subsidiary file +class, since it can subclass the file class and override functionality at its +whim. Thus there is no need for the File subclass to query its parent repository +for information about repository-class-dependent behaviour -- the file subclass +is generally fully aware of the static preferences of its repository. Limited +exceptions can be made to this rule to permit sharing of functions, or perhaps +even entire classes, between repositories. + +These rules alone still do lead to some ambiguity -- it may not be clear whether +to implement some functionality in a repository function with a filename +parameter, or in the file object itself. + +So we introduce the following rule: the file subclass is smarter than the +repository subclass. The repository should in general provide a minimal API +needed to access the storage backend efficiently. + +In particular, note that I have not implemented any database access in +LocalRepo.php. LocalRepo provides only file access, and LocalFile provides +database access and higher-level functions such as cache management. + +Tim Starling, June 2007 diff --git a/includes/filerepo/RepoGroup.php b/includes/filerepo/RepoGroup.php new file mode 100644 index 00000000..23d222af --- /dev/null +++ b/includes/filerepo/RepoGroup.php @@ -0,0 +1,150 @@ +localInfo = $localInfo; + $this->foreignInfo = $foreignInfo; + } + + /** + * Search repositories for an image. + * You can also use wfGetFile() to do this. + * @param mixed $title Title object or string + * @param mixed $time The 14-char timestamp before which the file should + * have been uploaded, or false for the current version + * @return File object or false if it is not found + */ + function findFile( $title, $time = false ) { + if ( !$this->reposInitialised ) { + $this->initialiseRepos(); + } + + $image = $this->localRepo->findFile( $title, $time ); + if ( $image ) { + return $image; + } + foreach ( $this->foreignRepos as $repo ) { + $image = $repo->findFile( $title, $time ); + if ( $image ) { + return $image; + } + } + return false; + } + + /** + * Get the repo instance with a given key. + */ + function getRepo( $index ) { + if ( !$this->reposInitialised ) { + $this->initialiseRepos(); + } + if ( $index == 'local' ) { + return $this->localRepo; + } elseif ( isset( $this->foreignRepos[$index] ) ) { + return $this->foreignRepos[$index]; + } else { + return false; + } + } + + /** + * Get the local repository, i.e. the one corresponding to the local image + * table. Files are typically uploaded to the local repository. + */ + function getLocalRepo() { + return $this->getRepo( 'local' ); + } + + /** + * Initialise the $repos array + */ + function initialiseRepos() { + if ( $this->reposInitialised ) { + return; + } + $this->reposInitialised = true; + + $this->localRepo = $this->newRepo( $this->localInfo ); + $this->foreignRepos = array(); + foreach ( $this->foreignInfo as $key => $info ) { + $this->foreignRepos[$key] = $this->newRepo( $info ); + } + } + + /** + * Create a repo class based on an info structure + */ + protected function newRepo( $info ) { + $class = $info['class']; + return new $class( $info ); + } + + /** + * Split a virtual URL into repo, zone and rel parts + * @return an array containing repo, zone and rel + */ + function splitVirtualUrl( $url ) { + if ( substr( $url, 0, 9 ) != 'mwrepo://' ) { + throw new MWException( __METHOD__.': unknown protoocl' ); + } + + $bits = explode( '/', substr( $url, 9 ), 3 ); + if ( count( $bits ) != 3 ) { + throw new MWException( __METHOD__.": invalid mwrepo URL: $url" ); + } + return $bits; + } + + function getFileProps( $fileName ) { + if ( FileRepo::isVirtualUrl( $fileName ) ) { + list( $repoName, /* $zone */, /* $rel */ ) = $this->splitVirtualUrl( $fileName ); + if ( $repoName === '' ) { + $repoName = 'local'; + } + $repo = $this->getRepo( $repoName ); + return $repo->getFileProps( $fileName ); + } else { + return File::getPropsFromPath( $fileName ); + } + } +} + + diff --git a/includes/filerepo/UnregisteredLocalFile.php b/includes/filerepo/UnregisteredLocalFile.php new file mode 100644 index 00000000..419c61f6 --- /dev/null +++ b/includes/filerepo/UnregisteredLocalFile.php @@ -0,0 +1,109 @@ +title = $title; + $this->name = $repo->getNameFromTitle( $title ); + } else { + $this->name = basename( $path ); + $this->title = Title::makeTitleSafe( NS_IMAGE, $this->name ); + } + $this->repo = $repo; + if ( $path ) { + $this->path = $path; + } else { + $this->path = $repo->getRootDirectory() . '/' . $repo->getHashPath( $this->name ) . $this->name; + } + if ( $mime ) { + $this->mime = $mime; + } + $this->dims = array(); + } + + function getPageDimensions( $page = 1 ) { + if ( !isset( $this->dims[$page] ) ) { + if ( !$this->getHandler() ) { + return false; + } + $this->dims[$page] = $this->handler->getPageDimensions( $this, $page ); + } + return $this->dims[$page]; + } + + function getWidth( $page = 1 ) { + $dim = $this->getPageDimensions( $page ); + return $dim['width']; + } + + function getHeight( $page = 1 ) { + $dim = $this->getPageDimensions( $page ); + return $dim['height']; + } + + function getMimeType() { + if ( !isset( $this->mime ) ) { + $magic = MimeMagic::singleton(); + $this->mime = $magic->guessMimeType( $this->path ); + } + return $this->mime; + } + + function getImageSize( $filename ) { + if ( !$this->getHandler() ) { + return false; + } + return $this->handler->getImageSize( $this, $this->getPath() ); + } + + function getMetadata() { + if ( !isset( $this->metadata ) ) { + if ( !$this->getHandler() ) { + $this->metadata = false; + } else { + $this->metadata = $this->handler->getMetadata( $this, $this->getPath() ); + } + } + return $this->metadata; + } + + function getURL() { + if ( $this->repo ) { + return $this->repo->getZoneUrl( 'public' ) . '/' . $this->repo->getHashPath( $this->name ) . urlencode( $this->name ); + } else { + return false; + } + } + + function getSize() { + if ( file_exists( $this->path ) ) { + return filesize( $this->path ); + } else { + return false; + } + } +} + -- cgit v1.2.2