From: Brion Vibber Date: Fri, 16 Jun 2006 01:16:45 +0000 (+0000) Subject: (bug 2099) Deleted files can now be archived and undeleted, if you set up an appropri... X-Git-Tag: 1.31.0-rc.0~56771 X-Git-Url: https://git.cyclocoop.org/%242?a=commitdiff_plain;h=8a42f0b1a877a29e71167333bff1dab338942682;p=lhc%2Fweb%2Fwiklou.git (bug 2099) Deleted files can now be archived and undeleted, if you set up an appropriate non-web-accessible directory. Set $wgSaveDeletedFiles on and an appropriate directory path in $wgFileStore['deleted']['directory'] --- diff --git a/RELEASE-NOTES b/RELEASE-NOTES index 36335b0ab5..212fb50406 100644 --- a/RELEASE-NOTES +++ b/RELEASE-NOTES @@ -23,6 +23,15 @@ it from source control: http://www.mediawiki.org/wiki/Download_from_SVN Some default configuration options have changed: * $wgAllowExternalImages now defaults to off for increased security. +== Major new features == + +* Deleted files can now be archived and undeleted, if you set up + an appropriate non-web-accessible directory. + Set $wgSaveDeletedFiles on and an appropriate directory path in + $wgFileStore['deleted']['directory'] + + + == Changes since 1.6 == @@ -498,6 +507,10 @@ Some default configuration options have changed: * (bug 6095) Introduce RunUnknownJob hook, see docs/hooks.txt for more information * (bug 4054) Add "boteditletter" to recent changes flags * Update to Catalan localization (ca) +* (bug 2099) Deleted image files can now be archived and undeleted. + Set $wgSaveDeletedFiles on and an appropriate directory path in + $wgFileStore['deleted']['directory'] + == Compatibility == diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php index cd9d0cfe63..d60339a4d2 100644 --- a/includes/AutoLoader.php +++ b/includes/AutoLoader.php @@ -70,6 +70,9 @@ function __autoload($class_name) { 'ChannelFeed' => 'Feed.php', 'RSSFeed' => 'Feed.php', 'AtomFeed' => 'Feed.php', + 'FileStore' => 'FileStore.php', + 'FSException' => 'FileStore.php', + 'FSTransaction' => 'FileStore.php', 'ReplacerCallback' => 'GlobalFunctions.php', 'Group' => 'Group.php', 'HTMLForm' => 'HTMLForm.php', diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 334085f591..d9af202a75 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -125,6 +125,29 @@ $wgTmpDirectory = "{$wgUploadDirectory}/tmp"; $wgUploadBaseUrl = ""; /**#@-*/ + +/** + * By default deleted files are simply discarded; to save them and + * make it possible to undelete images, create a directory which + * is writable to the web server but is not exposed to the internet. + * + * Set $wgSaveDeletedFiles to true and set up the save path in + * $wgFileStore['deleted']['directory']. + */ +$wgSaveDeletedFiles = false; + +/** + * New file storage paths; currently used only for deleted files. + * Set it like this: + * + * $wgFileStore['deleted']['directory'] = '/var/wiki/private/deleted'; + * + */ +$wgFileStore = array(); +$wgFileStore['deleted']['directory'] = null; // Don't forget to set this. +$wgFileStore['deleted']['url'] = null; // Private +$wgFileStore['deleted']['hash'] = 3; // 3-level subdirectory split + /** * Allowed title characters -- regex character class * Don't change this unless you know what you're doing diff --git a/includes/FileStore.php b/includes/FileStore.php new file mode 100644 index 0000000000..85aaedfe15 --- /dev/null +++ b/includes/FileStore.php @@ -0,0 +1,377 @@ +mGroup = $group; + $this->mDirectory = $directory; + $this->mPath = $path; + $this->mHashLevel = $hash; + } + + /** + * Acquire a lock; use when performing write operations on a store. + * This is attached to your master database connection, so if you + * suffer an uncaught error the lock will be released when the + * connection is closed. + * + * @fixme Probably only works on MySQL. Abstract to the Database class? + */ + static function lock() { + $fname = __CLASS__ . '::' . __FUNCTION__; + + $dbw = wfGetDB( DB_MASTER ); + $lockname = $dbw->addQuotes( FileStore::lockName() ); + $result = $dbw->query( "SELECT GET_LOCK($lockname, 5) AS lockstatus", $fname ); + $row = $dbw->fetchObject( $result ); + $dbw->freeResult( $result ); + + if( $row->lockstatus == 1 ) { + return true; + } else { + wfDebug( "$fname failed to acquire lock\n" ); + return false; + } + } + + /** + * Release the global file store lock. + */ + static function unlock() { + $fname = __CLASS__ . '::' . __FUNCTION__; + + $dbw = wfGetDB( DB_MASTER ); + $lockname = $dbw->addQuotes( FileStore::lockName() ); + $result = $dbw->query( "SELECT RELEASE_LOCK($lockname)", $fname ); + $row = $dbw->fetchObject( $result ); + $dbw->freeResult( $result ); + } + + private static function lockName() { + global $wgDBname, $wgDBprefix; + return "MediaWiki.{$wgDBname}.{$wgDBprefix}FileStore"; + } + + /** + * Copy a file into the file store from elsewhere in the filesystem. + * Should be protected by FileStore::lock() to avoid race conditions. + * + * @param $key storage key string + * @param $flags + * DELETE_ORIGINAL - remove the source file on transaction commit. + * + * @throws FSException if copy can't be completed + * @return FSTransaction + */ + function insert( $key, $sourcePath, $flags=0 ) { + $destPath = $this->filePath( $key ); + return $this->copyFile( $sourcePath, $destPath, $flags ); + } + + /** + * Copy a file from the file store to elsewhere in the filesystem. + * Should be protected by FileStore::lock() to avoid race conditions. + * + * @param $key storage key string + * @param $flags + * DELETE_ORIGINAL - remove the source file on transaction commit. + * + * @throws FSException if copy can't be completed + * @return FSTransaction on success + */ + function export( $key, $destPath, $flags=0 ) { + $sourcePath = $this->filePath( $key ); + return $this->copyFile( $sourcePath, $destPath, $flags ); + } + + private function copyFile( $sourcePath, $destPath, $flags=0 ) { + $fname = __CLASS__ . '::' . __FUNCTION__; + + if( !file_exists( $sourcePath ) ) { + // Abort! Abort! + throw new FSException( "missing source file '$sourcePath'\n" ); + } + + $transaction = new FSTransaction(); + + if( $flags & self::DELETE_ORIGINAL ) { + $transaction->addCommit( FSTransaction::DELETE_FILE, $sourcePath ); + } + + if( file_exists( $destPath ) ) { + // An identical file is already present; no need to copy. + } else { + if( !file_exists( dirname( $destPath ) ) ) { + wfSuppressWarnings(); + $ok = mkdir( dirname( $destPath ), 0777, true ); + wfRestoreWarnings(); + + if( !$ok ) { + throw new FSException( + "failed to create directory for '$destPath'\n" ); + } + } + + wfSuppressWarnings(); + $ok = copy( $sourcePath, $destPath ); + wfRestoreWarnings(); + + if( $ok ) { + wfDebug( "$fname copied '$sourcePath' to '$destPath'\n" ); + $transaction->addRollback( FSTransaction::DELETE_FILE, $destPath ); + } else { + throw new FSException( + "$fname failed to copy '$sourcePath' to '$destPath'\n" ); + } + } + + return $transaction; + } + + /** + * Delete a file from the file store. + * Caller's responsibility to make sure it's not being used by another row. + * + * File is not actually removed until transaction commit. + * Should be protected by FileStore::lock() to avoid race conditions. + * + * @param $key storage key string + * @throws FSException if file can't be deleted + * @return FSTransaction + */ + function delete( $key ) { + $destPath = $this->filePath( $key ); + if( false === $destPath ) { + throw new FSExcepton( "file store does not contain file '$key'" ); + } else { + return FileStore::deleteFile( $destPath ); + } + } + + /** + * Delete a non-managed file on a transactional basis. + * + * File is not actually removed until transaction commit. + * Should be protected by FileStore::lock() to avoid race conditions. + * + * @param $path file to remove + * @throws FSException if file can't be deleted + * @return FSTransaction + * + * @fixme Might be worth preliminary permissions check + */ + static function deleteFile( $path ) { + if( file_exists( $path ) ) { + $transaction = new FSTransaction(); + $transaction->addCommit( FSTransaction::DELETE_FILE, $path ); + return $transaction; + } else { + throw new FSException( "cannot delete missing file '$path'" ); + } + } + + /** + * Stream a contained file directly to HTTP output. + * Will throw a 404 if file is missing; 400 if invalid key. + * @return true on success, false on failure + */ + function stream( $key ) { + $path = $this->filePath( $key ); + if( $path === false ) { + wfHttpError( 400, "Bad request", "Invalid or badly-formed filename." ); + return false; + } + + if( file_exists( $path ) ) { + // Set the filename for more convenient save behavior from browsers + // FIXME: Is this safe? + header( 'Content-Disposition: inline; filename="' . $key . '"' ); + + require_once 'StreamFile.php'; + wfStreamFile( $path ); + } else { + return wfHttpError( 404, "Not found", + "The requested resource does not exist." ); + } + } + + /** + * Confirm that the given file key is valid. + * Note that a valid key may refer to a file that does not exist. + * + * Key should consist of a 32-digit base-36 SHA-1 hash and + * an optional alphanumeric extension, all lowercase. + * The whole must not exceed 64 characters. + * + * @param $key + * @return boolean + */ + static function validKey( $key ) { + return preg_match( '/^[0-9a-z]{32}(\.[0-9a-z]{1,31})?$/', $key ); + } + + + /** + * Calculate file storage key from a file on disk. + * You must pass an extension to it, as some files may be calculated + * out of a temporary file etc. + * + * @param $path to file + * @param $extension + * @return string or false if could not open file or bad extension + */ + static function calculateKey( $path, $extension ) { + $fname = __CLASS__ . '::' . __FUNCTION__; + + wfSuppressWarnings(); + $hash = sha1_file( $path ); + wfRestoreWarnings(); + if( $hash === false ) { + wfDebug( "$fname: couldn't hash file '$path'\n" ); + return false; + } + + $base36 = wfBaseConvert( $hash, 16, 36, 32 ); + if( $extension == '' ) { + $key = $base36; + } else { + $key = $base36 . '.' . $extension; + } + + // Sanity check + if( self::validKey( $key ) ) { + return $key; + } else { + wfDebug( "$fname: generated bad key '$key'\n" ); + return false; + } + } + + /** + * Return filesystem path to the given file. + * Note that the file may or may not exist. + * @return string or false if an invalid key + */ + function filePath( $key ) { + if( self::validKey( $key ) ) { + return $this->mDirectory . DIRECTORY_SEPARATOR . + $this->hashPath( $key, DIRECTORY_SEPARATOR ); + } else { + return false; + } + } + + /** + * Return URL path to the given file, if the store is public. + * @return string or false if not public + */ + function urlPath( $key ) { + if( $this->mUrl && self::validKey( $key ) ) { + return $this->mUrl . '/' . $this->hashPath( $key, '/' ); + } else { + return false; + } + } + + private function hashPath( $key, $separator ) { + $parts = array(); + for( $i = 0; $i < $this->mHashLevel; $i++ ) { + $parts[] = $key{$i}; + } + $parts[] = $key; + return implode( $separator, $parts ); + } +} + +/** + * Wrapper for file store transaction stuff. + * + * FileStore methods may return one of these for undoable operations; + * you can then call its rollback() or commit() methods to perform + * final cleanup if dependent database work fails or succeeds. + */ +class FSTransaction { + const DELETE_FILE = 1; + + /** + * Combine more items into a fancier transaction + */ + function add( FSTransaction $transaction ) { + $this->mOnCommit = array_merge( + $this->mOnCommit, $transaction->mOnCommit ); + $this->mOnRollback = array_merge( + $this->mOnRollback, $transaction->mOnRollback ); + } + + /** + * Perform final actions for success. + * @return true if actions applied ok, false if errors + */ + function commit() { + return $this->apply( $this->mOnCommit ); + } + + /** + * Perform final actions for failure. + * @return true if actions applied ok, false if errors + */ + function rollback() { + return $this->apply( $this->mOnRollback ); + } + + // --- Private and friend functions below... + + function __construct() { + $this->mOnCommit = array(); + $this->mOnRollback = array(); + } + + function addCommit( $action, $path ) { + $this->mOnCommit[] = array( $action, $path ); + } + + function addRollback( $action, $path ) { + $this->mOnRollback[] = array( $action, $path ); + } + + private function apply( $actions ) { + $fname = __CLASS__ . '::' . __FUNCTION__; + $result = true; + foreach( $actions as $item ) { + list( $action, $path ) = $item; + if( $action == self::DELETE_FILE ) { + wfSuppressWarnings(); + $ok = unlink( $path ); + wfRestoreWarnings(); + if( $ok ) + wfDebug( "$fname: deleting file '$path'\n" ); + else + wfDebug( "$fname: failed to delete file '$path'\n" ); + $result = $result && $ok; + } + } + return $result; + } +} + +class FSException extends MWException { } + +?> \ No newline at end of file diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index bab5d354b6..f6919991ca 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -1853,4 +1853,91 @@ class ReplacerCallback { } } + +/** + * Convert an arbitrarily-long digit string from one numeric base + * to another, optionally zero-padding to a minimum column width. + * + * Supports base 2 through 36; digit values 10-36 are represented + * as lowercase letters a-z. Input is case-insensitive. + * + * @param $input string of digits + * @param $sourceBase int 2-36 + * @param $destBase int 2-36 + * @param $pad int 1 or greater + * @return string or false on invalid input + */ +function wfBaseConvert( $input, $sourceBase, $destBase, $pad=1 ) { + if( $sourceBase < 2 || + $sourceBase > 36 || + $destBase < 2 || + $destBase > 36 || + $pad < 1 || + $sourceBase != intval( $sourceBase ) || + $destBase != intval( $destBase ) || + $pad != intval( $pad ) || + !is_string( $input ) || + $input == '' ) { + return false; + } + + $digitChars = '0123456789abcdefghijklmnopqrstuvwxyz'; + $inDigits = array(); + $outChars = ''; + + // Decode and validate input string + $input = strtolower( $input ); + for( $i = 0; $i < strlen( $input ); $i++ ) { + $n = strpos( $digitChars, $input{$i} ); + if( $n === false || $n > $sourceBase ) { + return false; + } + $inDigits[] = $n; + } + + // Iterate over the input, modulo-ing out an output digit + // at a time until input is gone. + while( count( $inDigits ) ) { + $work = 0; + $workDigits = array(); + + // Long division... + foreach( $inDigits as $digit ) { + $work *= $sourceBase; + $work += $digit; + + if( $work < $destBase ) { + // Gonna need to pull another digit. + if( count( $workDigits ) ) { + // Avoid zero-padding; this lets us find + // the end of the input very easily when + // length drops to zero. + $workDigits[] = 0; + } + } else { + // Finally! Actual division! + $workDigits[] = intval( $work / $destBase ); + + // Isn't it annoying that most programming languages + // don't have a single divide-and-remainder operator, + // even though the CPU implements it that way? + $work = $work % $destBase; + } + } + + // All that division leaves us with a remainder, + // which is conveniently our next output digit. + $outChars .= $digitChars[$work]; + + // And we continue! + $inDigits = $workDigits; + } + + while( strlen( $outChars ) < $pad ) { + $outChars .= '0'; + } + + return strrev( $outChars ); +} + ?> diff --git a/includes/Image.php b/includes/Image.php index 9f4fc7f2d0..dc56d8eded 100644 --- a/includes/Image.php +++ b/includes/Image.php @@ -68,6 +68,7 @@ class Image /** * Obsolete factory function, use constructor + * @deprecated */ function newFromTitle( $title ) { return new Image( $title ); @@ -82,12 +83,37 @@ class Image $this->metadata = serialize ( array() ) ; $n = strrpos( $this->name, '.' ); - $this->extension = strtolower( $n ? substr( $this->name, $n + 1 ) : '' ); + $this->extension = Image::normalizeExtension( $n ? + substr( $this->name, $n + 1 ) : '' ); $this->historyLine = 0; $this->dataLoaded = false; } + + /** + * 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 ''; + } + } + /** * Get the memcached keys * Returns an array, first element is the local cache key, second is the shared cache key, if there is one @@ -287,8 +313,7 @@ class Image $this->dataLoaded = true; - if ($this->fileExists && $wgShowEXIF) $this->metadata = serialize ( $this->retrieveExifData() ) ; - else $this->metadata = serialize ( array() ) ; + $this->metadata = serialize( $this->retrieveExifData( $this->imagePath ) ); if ( isset( $gis['bits'] ) ) $this->bits = $gis['bits']; else $this->bits = 0; @@ -433,13 +458,7 @@ class Image $this->checkDBSchema($dbw); - if (strpos($this->mime,'/')!==false) { - list($major,$minor)= explode('/',$this->mime,2); - } - else { - $major= $this->mime; - $minor= "unknown"; - } + list( $major, $minor ) = self::splitMime( $this->mime ); wfDebug("$fname: upgrading ".$this->name." to 1.5 schema\n"); @@ -459,6 +478,21 @@ class Image } wfProfileOut( $fname ); } + + /** + * 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 image @@ -1262,6 +1296,40 @@ class Image wfPurgeSquidServers( $urls ); } } + + /** + * Purge the image description page, but don't go after + * pages using the image. Use when modifying file history + * but not the current data. + */ + function purgeDescription() { + $page = Title::makeTitle( NS_IMAGE, $this->name ); + $page->invalidateCache(); + } + + /** + * Purge metadata and all affected pages when the image is created, + * deleted, or majorly updated. A set of additional URLs may be + * passed to purge, such as specific image files which have changed. + * @param $urlArray array + */ + function purgeEverything( $urlArr=array() ) { + // Delete thumbnails and refresh image metadata cache + $this->purgeCache(); + $this->purgeDescription(); + + // Purge cache of all pages using this image + $linksTo = $this->getLinksTo(); + global $wgUseSquid; + if ( $wgUseSquid ) { + $u = SquidUpdate::newFromTitles( $linksTo, $urlArr ); + array_push( $wgPostCommitUpdateList, $u ); + } + + // Invalidate parser cache and client cache for pages using this image + // This is left until relatively late to reduce lock time + Title::touchArray( $linksTo ); + } function checkDBSchema(&$db) { global $wgCheckDBSchema; @@ -1579,20 +1647,28 @@ class Image wfProfileOut( $fname ); return $retVal; } + /** - * Retrive Exif data from the database - * - * Retrive Exif data from the database and prune unrecognized tags + * Retrive Exif data from the file and prune unrecognized tags * and/or tags with invalid contents * + * @param $filename * @return array */ - function retrieveExifData() { + private function retrieveExifData( $filename ) { + global $wgShowEXIF; + + /* if ( $this->getMimeType() !== "image/jpeg" ) return array(); + */ - $exif = new Exif( $this->imagePath ); - return $exif->getFilteredData(); + if( $wgShowEXIF && file_exists( $filename ) ) { + $exif = new Exif( $filename ); + return $exif->getFilteredData(); + } + + return array(); } function getExifData() { @@ -1624,7 +1700,7 @@ class Image return; # Get EXIF data from image - $exif = $this->retrieveExifData(); + $exif = $this->retrieveExifData( $this->imagePath ); if ( count( $exif ) ) { $exif['MEDIAWIKI_EXIF_VERSION'] = $version; $this->metadata = serialize( $exif ); @@ -1660,11 +1736,445 @@ class Image * @return bool */ function wasDeleted() { - $dbw =& wfGetDB( DB_MASTER ); - $del = $dbw->selectField( 'archive', 'COUNT(*) AS count', array( 'ar_namespace' => NS_IMAGE, 'ar_title' => $this->title->getDBkey() ), 'Image::wasDeleted' ); - return $del > 0; + $title = Title::makeTitle( NS_IMAGE, $this->name ); + return ( $title->isDeleted() > 0 ); } + + /** + * Delete all versions of the image. + * + * 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 + */ + function delete( $reason ) { + $fname = __CLASS__ . '::' . __FUNCTION__; + $transaction = new FSTransaction(); + $urlArr = array(); + + if( !FileStore::lock() ) { + wfDebug( "$fname: failed to acquire file store lock, aborting\n" ); + return false; + } + + try { + $dbw = wfGetDB( DB_MASTER ); + $dbw->begin(); + + // Delete old versions + $result = $dbw->select( 'oldimage', + array( 'oi_archive_name' ), + array( 'oi_name' => $this->name ) ); + + while( $row = $dbw->fetchObject( $result ) ) { + $oldName = $row->oi_archive_name; + + $transaction->add( $this->prepareDeleteOld( $oldName, $reason ) ); + + // We'll need to purge this URL from caches... + $urlArr[] = wfImageArchiveUrl( $oldName ); + } + $dbw->freeResult( $result ); + + // And the current version... + $transaction->add( $this->prepareDeleteCurrent( $reason ) ); + + $dbw->immediateCommit(); + } catch( MWException $e ) { + wfDebug( "$fname: db error, rolling back file transactions\n" ); + $transaction->rollback(); + FileStore::unlock(); + throw $e; + } + + wfDebug( "$fname: deleted db items, applying file transactions\n" ); + $transaction->commit(); + FileStore::unlock(); + + // Update site_stats + $site_stats = $dbw->tableName( 'site_stats' ); + $dbw->query( "UPDATE $site_stats SET ss_images=ss_images-1", $fname ); + + $this->purgeEverything( $urlArr ); + + return true; + } + + + /** + * Delete an old version of the image. + * + * 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 true on success, false on some kind of failure + */ + function deleteOld( $archiveName, $reason ) { + $fname = __CLASS__ . '::' . __FUNCTION__; + $transaction = new FSTransaction(); + $urlArr = array(); + + if( !FileStore::lock() ) { + wfDebug( "$fname: failed to acquire file store lock, aborting\n" ); + return false; + } + + $transaction = new FSTransaction(); + try { + $dbw = wfGetDB( DB_MASTER ); + $dbw->begin(); + $transaction->add( $this->prepareDeleteOld( $archiveName, $reason ) ); + $dbw->immediateCommit(); + } catch( MWException $e ) { + wfDebug( "$fname: db error, rolling back file transaction\n" ); + $transaction->rollback(); + FileStore::unlock(); + throw $e; + } + + wfDebug( "$fname: deleted db items, applying file transaction\n" ); + $transaction->commit(); + FileStore::unlock(); + + $this->purgeDescription(); + + // Squid purging + global $wgUseSquid; + if ( $wgUseSquid ) { + $urlArr = array( + wfImageArchiveUrl( $archiveName ), + $page->getInternalURL() + ); + wfPurgeSquidServers( $urlArr ); + } + return true; + } + + /** + * Delete the current version of a file. + * May throw a database error. + * @return true on success, false on failure + */ + private function prepareDeleteCurrent( $reason ) { + $fname = __CLASS__ . '::' . __FUNCTION__; + return $this->prepareDeleteVersion( + $this->getFullPath(), + $reason, + 'image', + array( + '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' ), + array( 'img_name' => $this->name ), + $fname ); + } + + /** + * Delete a given older version of a file. + * May throw a database error. + * @return true on success, false on failure + */ + private function prepareDeleteOld( $archiveName, $reason ) { + $fname = __CLASS__ . '::' . __FUNCTION__; + $oldpath = wfImageArchiveDir( $this->name ) . + DIRECTORY_SEPARATOR . $archiveName; + return $this->prepareDeleteVersion( + $oldpath, + $reason, + 'oldimage', + array( + 'fa_name' => 'oi_name', + 'fa_archive_name' => 'oi_archive_name', + 'fa_size' => 'oi_size', + 'fa_width' => 'oi_width', + 'fa_height' => 'oi_height', + 'fa_metadata' => 'NULL', + 'fa_bits' => 'oi_bits', + 'fa_media_type' => 'NULL', + 'fa_major_mime' => 'NULL', + 'fa_minor_mime' => 'NULL', + 'fa_description' => 'oi_description', + 'fa_user' => 'oi_user', + 'fa_user_text' => 'oi_user_text', + 'fa_timestamp' => 'oi_timestamp' ), + array( + 'oi_name' => $this->name, + 'oi_archive_name' => $archiveName ), + $fname ); + } + + /** + * Do the dirty work of backing up an image row and its file + * (if $wgSaveDeletedFiles is on) and removing the originals. + * + * Must be run while the file store is locked and a database + * transaction is open to avoid race conditions. + * + * @return FSTransaction + */ + private function prepareDeleteVersion( $path, $reason, $table, $fieldMap, $where, $fname ) { + global $wgUser, $wgSaveDeletedFiles; + + // Dupe the file into the file store + if( file_exists( $path ) ) { + if( $wgSaveDeletedFiles ) { + $group = 'deleted'; + + $store = FileStore::get( $group ); + $key = FileStore::calculateKey( $path, $this->extension ); + $transaction = $store->insert( $key, $path, + FileStore::DELETE_ORIGINAL ); + } else { + $group = null; + $key = null; + $transaction = FileStore::deleteFile( $path ); + } + } else { + wfDebug( "$fname deleting already-missing '$path'; moving on to database\n" ); + $group = null; + $key = null; + $transaction = new FSTransaction(); // empty + } + + if( $transaction === false ) { + // Fail to restore? + wfDebug( "$fname: import to file store failed, aborting\n" ); + throw new MWException( "Could not archive and delete file $path" ); + return false; + } + + $dbw = wfGetDB( DB_MASTER ); + $storageMap = array( + 'fa_storage_group' => $dbw->addQuotes( $group ), + 'fa_storage_key' => $dbw->addQuotes( $key ), + + 'fa_deleted_user' => $dbw->addQuotes( $wgUser->getId() ), + 'fa_deleted_timestamp' => $dbw->timestamp(), + 'fa_deleted_reason' => $dbw->addQuotes( $reason ) ); + $allFields = array_merge( $storageMap, $fieldMap ); + + try { + if( $wgSaveDeletedFiles ) { + $dbw->insertSelect( 'filearchive', $table, $allFields, $where, $fname ); + } + $dbw->delete( $table, $where, $fname ); + } catch( DBQueryError $e ) { + // Something went horribly wrong! + // Leave the file as it was... + wfDebug( "$fname: database error, rolling back file transaction\n" ); + $transaction->rollback(); + throw $e; + } + + return $transaction; + } + + /** + * 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 + */ + function restore( $versions=array() ) { + $fname = __CLASS__ . '::' . __FUNCTION__; + if( !FileStore::lock() ) { + wfDebug( "$fname could not acquire filestore lock\n" ); + return false; + } + + $transaction = new FSTransaction(); + try { + $dbw = wfGetDB( DB_MASTER ); + $dbw->begin(); + + // Re-confirm whether this image presently exists; + // if no we'll need to create an image record for the + // first item we restore. + $exists = $dbw->selectField( 'image', '1', + array( 'img_name' => $this->name ), + $fname ); + + // Fetch all or selected archived revisions for the file, + // sorted from the most recent to the oldest. + $conditions = array( 'fa_name' => $this->name ); + if( $versions ) { + $conditions['fa_id'] = $versions; + } + + $result = $dbw->select( 'filearchive', '*', + $conditions, + $fname, + array( 'ORDER BY' => 'fa_timestamp DESC' ) ); + + if( $dbw->numRows( $result ) < count( $versions ) ) { + // There's some kind of conflict or confusion; + // we can't restore everything we were asked to. + wfDebug( "$fname: couldn't find requested items\n" ); + $dbw->rollback(); + FileStore::unlock(); + return false; + } + + if( $dbw->numRows( $result ) == 0 ) { + // Nothing to do. + wfDebug( "$fname: nothing to do\n" ); + $dbw->rollback(); + FileStore::unlock(); + return true; + } + + $revisions = 0; + while( $row = $dbw->fetchObject( $result ) ) { + $revisions++; + $store = FileStore::get( $row->fa_storage_group ); + if( !$store ) { + wfDebug( "$fname: skipping row with no file.\n" ); + continue; + } + + if( $revisions == 1 && !$exists ) { + $destPath = wfImageDir( $row->fa_name ) . + DIRECTORY_SEPARATOR . + $row->fa_name; + + // We may have to fill in data if this was originally + // an archived file revision. + if( is_null( $row->fa_metadata ) ) { + $tempFile = $store->filePath( $row->fa_storage_key ); + $metadata = serialize( $this->retrieveExifData( $tempFile ) ); + + $magic = wfGetMimeMagic(); + $mime = $magic->guessMimeType( $tempFile, true ); + $media_type = $magic->getMediaType( $tempFile, $mime ); + list( $major_mime, $minor_mime ) = self::splitMime( $mime ); + } else { + $metadata = $row->fa_metadata; + $major_mime = $row->fa_major_mime; + $minor_mime = $row->fa_minor_mime; + $media_type = $row->fa_media_type; + } + + $table = 'image'; + $fields = array( + 'img_name' => $row->fa_name, + 'img_size' => $row->fa_size, + 'img_width' => $row->fa_width, + 'img_height' => $row->fa_height, + 'img_metadata' => $metadata, + 'img_bits' => $row->fa_bits, + 'img_media_type' => $media_type, + 'img_major_mime' => $major_mime, + 'img_minor_mime' => $minor_mime, + 'img_description' => $row->fa_description, + 'img_user' => $row->fa_user, + 'img_user_text' => $row->fa_user_text, + 'img_timestamp' => $row->fa_timestamp ); + } 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 ! + $archiveName = + wfTimestamp( TS_MW, $row->fa_deleted_timestamp ) . + '!' . $row->fa_name; + } + $destPath = wfImageArchiveDir( $row->fa_name ) . + DIRECTORY_SEPARATOR . $archiveName; + + $table = 'oldimage'; + $fields = 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 ); + } + + $dbw->insert( $table, $fields, $fname ); + /// @fixme this delete is not totally safe, potentially + $dbw->delete( 'filearchive', + array( 'fa_id' => $row->fa_id ), + $fname ); + + // Check if any other stored revisions use this file; + // if so, we shouldn't remove the file from the deletion + // archives so they will still work. + $useCount = $dbw->selectField( 'filearchive', + 'COUNT(*)', + array( + 'fa_storage_group' => $row->fa_storage_group, + 'fa_storage_key' => $row->fa_storage_key ), + $fname ); + if( $useCount == 0 ) { + wfDebug( "$fname: nothing else using {$row->fa_storage_key}, will deleting after\n" ); + $flags = FileStore::DELETE_ORIGINAL; + } else { + $flags = 0; + } + + $transaction->add( $store->export( $row->fa_storage_key, + $destPath, $flags ) ); + } + + $dbw->immediateCommit(); + } catch( MWException $e ) { + wfDebug( "$fname caught error, aborting\n" ); + $transaction->rollback(); + throw $e; + } + + $transaction->commit(); + FileStore::unlock(); + + if( $revisions > 0 ) { + if( !$exists ) { + wfDebug( "$fname restored $revisions 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", $fname ); + + $this->purgeEverything(); + } else { + wfDebug( "$fname restored $revisions as archived versions\n" ); + $this->purgeDescription(); + } + } + + return $revisions; + } + } //class diff --git a/includes/ImagePage.php b/includes/ImagePage.php index 244f7c00fd..fc1f1a6b24 100644 --- a/includes/ImagePage.php +++ b/includes/ImagePage.php @@ -499,79 +499,25 @@ END $wgOut->showUnexpectedValueError( 'oldimage', htmlspecialchars($oldimage) ); return; } - - # Invalidate description page cache - $this->mTitle->invalidateCache(); - - # Squid purging - if ( $wgUseSquid ) { - $urlArr = array( - wfImageArchiveUrl( $oldimage ), - $this->mTitle->getInternalURL() - ); - wfPurgeSquidServers($urlArr); - } if ( !$this->doDeleteOldImage( $oldimage ) ) { return; } - $dbw->delete( 'oldimage', array( 'oi_archive_name' => $oldimage ) ); $deleted = $oldimage; } else { - $image = $this->mTitle->getDBkey(); - $dest = wfImageDir( $image ); - $archive = wfImageDir( $image ); - - # Delete the image file if it exists; due to sync problems - # or manual trimming sometimes the file will be missing. - $targetFile = "{$dest}/{$image}"; - if( file_exists( $targetFile ) && ! @unlink( $targetFile ) ) { + $ok = $this->img->delete( $reason ); + if( !$ok ) { # If the deletion operation actually failed, bug out: - $wgOut->showFileDeleteError( $targetFile ); + $wgOut->showFileDeleteError( $this->img->getName() ); return; } - $dbw->delete( 'image', array( 'img_name' => $image ) ); - - if ( $dbw->affectedRows() ) { - # Update site_stats - $site_stats = $dbw->tableName( 'site_stats' ); - $dbw->query( "UPDATE $site_stats SET ss_images=ss_images-1", $fname ); - } - - $res = $dbw->select( 'oldimage', array( 'oi_archive_name' ), array( 'oi_name' => $image ) ); - - # Purge archive URLs from the squid - $urlArr = Array(); - while ( $s = $dbw->fetchObject( $res ) ) { - if ( !$this->doDeleteOldImage( $s->oi_archive_name ) ) { - return; - } - $urlArr[] = wfImageArchiveUrl( $s->oi_archive_name ); - } - - # And also the HTML of all pages using this image - $linksTo = $this->img->getLinksTo(); - if ( $wgUseSquid ) { - $u = SquidUpdate::newFromTitles( $linksTo, $urlArr ); - array_push( $wgPostCommitUpdateList, $u ); - } - - $dbw->delete( 'oldimage', array( 'oi_name' => $image ) ); - # Image itself is now gone, and database is cleaned. # Now we remove the image description page. - + $article = new Article( $this->mTitle ); $article->doDeleteArticle( $reason ); # ignore errors - # Invalidate parser cache and client cache for pages using this image - # This is left until relatively late to reduce lock time - Title::touchArray( $linksTo ); - - /* Delete thumbnails and refresh image metadata cache */ - $this->img->purgeCache(); - - $deleted = $image; + $deleted = $this->img->getName(); } $wgOut->setPagetitle( wfMsg( 'actioncomplete' ) ); @@ -592,27 +538,17 @@ END { global $wgOut; - $name = substr( $oldimage, 15 ); - $archive = wfImageArchiveDir( $name ); - - # Delete the image if it exists. Sometimes the file will be missing - # due to manual intervention or weird sync problems; treat that - # condition gracefully and continue to delete the database entry. - # Also some records may end up with an empty oi_archive_name field - # if the original file was missing when a new upload was made; - # don't try to delete the directory then! - # - $targetFile = "{$archive}/{$oldimage}"; - if( $oldimage != '' && file_exists( $targetFile ) && !@unlink( $targetFile ) ) { + $ok = $this->img->deleteOld( $oldimage, '' ); + if( !$ok ) { # If we actually have a file and can't delete it, throw an error. - $wgOut->showFileDeleteError( "{$archive}/{$oldimage}" ); - return false; + # Something went awry... + $wgOut->showFileDeleteError( "$oldimage" ); } else { # Log the deletion $log = new LogPage( 'delete' ); $log->addEntry( 'delete', $this->mTitle, wfMsg('deletedrevision',$oldimage) ); - return true; } + return $ok; } function revert() { diff --git a/includes/SpecialUndelete.php b/includes/SpecialUndelete.php index ade8d75414..f64634fd34 100644 --- a/includes/SpecialUndelete.php +++ b/includes/SpecialUndelete.php @@ -67,6 +67,39 @@ class PageArchive { $ret = $dbr->resultObject( $res ); return $ret; } + + /** + * List the deleted file revisions for this page, if it's a file page. + * Returns a result wrapper with various filearchive fields, or null + * if not a file page. + * + * @return ResultWrapper + * @fixme Does this belong in Image for fuller encapsulation? + */ + function listFiles() { + $fname = __CLASS__ . '::' . __FUNCTION__; + if( $this->title->getNamespace() == NS_IMAGE ) { + $dbr =& wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'filearchive', + array( + 'fa_id', + 'fa_name', + 'fa_storage_key', + 'fa_size', + 'fa_width', + 'fa_height', + 'fa_description', + 'fa_user', + 'fa_user_text', + 'fa_timestamp' ), + array( 'fa_name' => $this->title->getDbKey() ), + $fname, + array( 'ORDER BY' => 'fa_timestamp DESC' ) ); + $ret = $dbr->resultObject( $res ); + return $ret; + } + return null; + } /** * Fetch (and decompress if necessary) the stored text for the deleted @@ -83,7 +116,11 @@ class PageArchive { 'ar_title' => $this->title->getDbkey(), 'ar_timestamp' => $dbr->timestamp( $timestamp ) ), $fname ); - return $this->getTextFromRow( $row ); + if( $row ) { + return $this->getTextFromRow( $row ); + } else { + return null; + } } /** @@ -143,24 +180,81 @@ class PageArchive { return ($n > 0); } + /** + * Restore the given (or all) text and file revisions for the page. + * Once restored, the items will be removed from the archive tables. + * The deletion log will be updated with an undeletion notice. + * + * @param array $timestamps Pass an empty array to restore all revisions, otherwise list the ones to undelete. + * @param string $comment + * @param array $fileVersions + * + * @return true on success. + */ + function undelete( $timestamps, $comment = '', $fileVersions = array() ) { + // If both the set of text revisions and file revisions are empty, + // restore everything. Otherwise, just restore the requested items. + $restoreAll = empty( $timestamps ) && empty( $fileVersions ); + + $restoreText = $restoreAll || !empty( $timestamps ); + $restoreFiles = $restoreAll || !empty( $fileVersions ); + + if( $restoreFiles && $this->title->getNamespace() == NS_IMAGE ) { + $img = new Image( $this->title ); + $filesRestored = $img->restore( $fileVersions ); + } else { + $filesRestored = 0; + } + + if( $restoreText ) { + $textRestored = $this->undeleteRevisions( $timestamps ); + } else { + $textRestored = 0; + } + + // Touch the log! + global $wgContLang; + $log = new LogPage( 'delete' ); + + if( $textRestored && $filesRestored ) { + $reason = wfMsgForContent( 'undeletedrevisions-files', + $wgContLang->formatNum( $textRestored ), + $wgContLang->formatNum( $filesRestored ) ); + } elseif( $textRestored ) { + $reason = wfMsgForContent( 'undeletedrevisions', + $wgContLang->formatNum( $textRestored ) ); + } elseif( $filesRestored ) { + $reason = wfMsgForContent( 'undeletedfiles', + $wgContLang->formatNum( $filesRestored ) ); + } else { + wfDebug( "Undelete: nothing undeleted...\n" ); + return false; + } + + if( trim( $comment ) != '' ) + $reason .= ": {$comment}"; + $log->addEntry( 'restore', $this->title, $reason ); + + return true; + } + /** * This is the meaty bit -- restores archived revisions of the given page * to the cur/old tables. If the page currently exists, all revisions will * be stuffed into old, otherwise the most recent will go into cur. - * The deletion log will be updated with an undeletion notice. - * - * Returns true on success. * * @param array $timestamps Pass an empty array to restore all revisions, otherwise list the ones to undelete. - * @return bool + * @param string $comment + * @param array $fileVersions + * + * @return int number of revisions restored */ - function undelete( $timestamps, $comment = '' ) { + private function undeleteRevisions( $timestamps ) { global $wgParser, $wgDBtype; - $fname = "doUndeleteArticle"; + $fname = __CLASS__ . '::' . __FUNCTION__; $restoreAll = empty( $timestamps ); - $restoreRevisions = count( $timestamps ); - + $dbw =& wfGetDB( DB_MASTER ); extract( $dbw->tableNames( 'page', 'archive' ) ); @@ -221,8 +315,14 @@ class PageArchive { /* options */ array( 'ORDER BY' => 'ar_timestamp' ) ); + if( $dbw->numRows( $result ) < count( $timestamps ) ) { + wfDebug( "$fname: couldn't find all requested rows\n" ); + return false; + } + $revision = null; $newRevId = $previousRevId; + $restored = 0; while( $row = $dbw->fetchObject( $result ) ) { if( $row->ar_text_id ) { @@ -249,6 +349,7 @@ class PageArchive { 'text_id' => $row->ar_text_id, ) ); $newRevId = $revision->insertOn( $dbw ); + $restored++; } if( $revision ) { @@ -284,19 +385,9 @@ class PageArchive { $oldones ), $fname ); - # Touch the log! - $log = new LogPage( 'delete' ); - if( $restoreAll ) { - $reason = $comment; - } else { - $reason = wfMsgForContent( 'undeletedrevisions', $restoreRevisions ); - if( trim( $comment ) != '' ) - $reason .= ": {$comment}"; - } - $log->addEntry( 'restore', $this->title, $reason ); - - return true; + return $restored; } + } /** @@ -313,6 +404,7 @@ class UndeleteForm { $this->mAction = $request->getText( 'action' ); $this->mTarget = $request->getText( 'target' ); $this->mTimestamp = $request->getText( 'timestamp' ); + $this->mFile = $request->getVal( 'file' ); $posted = $request->wasPosted() && $wgUser->matchEditToken( $request->getVal( 'wpEditToken' ) ); @@ -337,10 +429,15 @@ class UndeleteForm { } if( $this->mRestore ) { $timestamps = array(); + $this->mFileVersions = array(); foreach( $_REQUEST as $key => $val ) { if( preg_match( '/^ts(\d{14})$/', $key, $matches ) ) { array_push( $timestamps, $matches[1] ); } + + if( preg_match( '/^fileid(\d+)$/', $key, $matches ) ) { + $this->mFileVersions[] = intval( $matches[1] ); + } } rsort( $timestamps ); $this->mTargetTimestamp = $timestamps; @@ -355,6 +452,9 @@ class UndeleteForm { if( $this->mTimestamp !== '' ) { return $this->showRevision( $this->mTimestamp ); } + if( $this->mFile !== null ) { + return $this->showFile( $this->mFile ); + } if( $this->mRestore && $this->mAction == "submit" ) { return $this->undelete(); } @@ -446,6 +546,17 @@ class UndeleteForm { wfCloseElement( 'form' ) . wfCloseElement( 'div' ) ); } + + /** + * Show a deleted file version requested by the visitor. + */ + function showFile( $key ) { + global $wgOut; + $wgOut->disable(); + + $store = FileStore::get( 'deleted' ); + $store->stream( $key ); + } /* private */ function showHistory() { global $wgLang, $wgUser, $wgOut; @@ -459,10 +570,12 @@ class UndeleteForm { $archive = new PageArchive( $this->mTargetObj ); $text = $archive->getLastRevisionText(); + /* if( is_null( $text ) ) { $wgOut->addWikiText( wfMsg( "nohistory" ) ); return; } + */ if ( $this->mAllowed ) { $wgOut->addWikiText( wfMsg( "undeletehistory" ) ); } else { @@ -471,9 +584,13 @@ class UndeleteForm { # List all stored revisions $revisions = $archive->listRevisions(); + $files = $archive->listFiles(); + + $haveRevisions = $revisions && $revisions->numRows() > 0; + $haveFiles = $files && $files->numRows() > 0; # Batch existence check on user and talk pages - if( $revisions->numRows() > 0 ) { + if( $haveRevisions ) { $batch = new LinkBatch(); while( $row = $revisions->fetchObject() ) { $batch->addObj( Title::makeTitleSafe( NS_USER, $row->ar_user_text ) ); @@ -482,6 +599,15 @@ class UndeleteForm { $batch->execute(); $revisions->seek( 0 ); } + if( $haveFiles ) { + $batch = new LinkBatch(); + while( $row = $files->fetchObject() ) { + $batch->addObj( Title::makeTitleSafe( NS_USER, $row->fa_user_text ) ); + $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->fa_user_text ) ); + } + $batch->execute(); + $files->seek( 0 ); + } if ( $this->mAllowed ) { $titleObj = Title::makeTitle( NS_SPECIAL, "Undelete" ); @@ -498,12 +624,10 @@ class UndeleteForm { new LogReader( new FauxRequest( array( 'page' => $this->mTargetObj->getPrefixedText(), - 'type' => 'delete' ) ) ) ); + 'type' => 'delete' ) ) ) ); $logViewer->showList( $wgOut ); - $wgOut->addHTML( "

" . htmlspecialchars( wfMsg( "history" ) ) . "

\n" ); - - if( $this->mAllowed ) { + if( $this->mAllowed && ( $haveRevisions || $haveFiles ) ) { # Format the user-visible controls (comment field, submission button) # in a nice little table $table = '
'; @@ -516,28 +640,61 @@ class UndeleteForm { $table .= '
'; $wgOut->addHtml( $table ); } - - # The page's stored (deleted) history: - $wgOut->addHTML("
    "); - $target = urlencode( $this->mTarget ); - while( $row = $revisions->fetchObject() ) { - $ts = wfTimestamp( TS_MW, $row->ar_timestamp ); - if ( $this->mAllowed ) { - $checkBox = ""; - $pageLink = $sk->makeKnownLinkObj( $titleObj, - $wgLang->timeanddate( $ts, true ), - "target=$target×tamp=$ts" ); - } else { - $checkBox = ''; - $pageLink = $wgLang->timeanddate( $ts, true ); + + $wgOut->addHTML( "

    " . htmlspecialchars( wfMsg( "history" ) ) . "

    \n" ); + + if( $haveRevisions ) { + # The page's stored (deleted) history: + $wgOut->addHTML("
      "); + $target = urlencode( $this->mTarget ); + while( $row = $revisions->fetchObject() ) { + $ts = wfTimestamp( TS_MW, $row->ar_timestamp ); + if ( $this->mAllowed ) { + $checkBox = wfCheck( "ts$ts" ); + $pageLink = $sk->makeKnownLinkObj( $titleObj, + $wgLang->timeanddate( $ts, true ), + "target=$target×tamp=$ts" ); + } else { + $checkBox = ''; + $pageLink = $wgLang->timeanddate( $ts, true ); + } + $userLink = $sk->userLink( $row->ar_user, $row->ar_user_text ); + $comment = $sk->commentBlock( $row->ar_comment ); + $wgOut->addHTML( "
    • $checkBox $pageLink . . $userLink $comment
    • \n" ); + } - $userLink = $sk->userLink( $row->ar_user, $row->ar_user_text ); - $comment = $sk->commentBlock( $row->ar_comment ); - $wgOut->addHTML( "
    • $checkBox $pageLink . . $userLink $comment
    • \n" ); + $revisions->free(); + $wgOut->addHTML("
    "); + } else { + $wgOut->addWikiText( wfMsg( "nohistory" ) ); + } + + if( $haveFiles ) { + $wgOut->addHtml( "

    " . wfMsgHtml( 'imghistory' ) . "

    \n" ); + $wgOut->addHtml( "
      " ); + while( $row = $files->fetchObject() ) { + $ts = wfTimestamp( TS_MW, $row->fa_timestamp ); + if ( $this->mAllowed && $row->fa_storage_key ) { + $checkBox = wfCheck( "fileid" . $row->fa_id ); + $key = urlencode( $row->fa_storage_key ); + $target = urlencode( $this->mTarget ); + $pageLink = $sk->makeKnownLinkObj( $titleObj, + $wgLang->timeanddate( $ts, true ), + "target=$target&file=$key" ); + } else { + $checkBox = ''; + $pageLink = $wgLang->timeanddate( $ts, true ); + } + $userLink = $sk->userLink( $row->fa_user, $row->fa_user_text ); + $data = $row->fa_width . 'x' . $row->fa_height . " (" . + $row->fa_size . " bytes)"; + $comment = $sk->commentBlock( $row->fa_description ); + $wgOut->addHTML( "
    • $checkBox $pageLink . . $userLink $data $comment
    • \n" ); + } + $files->free(); + $wgOut->addHTML( "
    " ); } - $revisions->free(); - $wgOut->addHTML("
"); if ( $this->mAllowed ) { # Slip in the hidden controls here @@ -553,16 +710,17 @@ class UndeleteForm { global $wgOut, $wgUser; if( !is_null( $this->mTargetObj ) ) { $archive = new PageArchive( $this->mTargetObj ); - if( $archive->undelete( $this->mTargetTimestamp, $this->mComment ) ) { + $ok = true; + + $ok = $archive->undelete( + $this->mTargetTimestamp, + $this->mComment, + $this->mFileVersions ); + + if( $ok ) { $skin =& $wgUser->getSkin(); $link = $skin->makeKnownLinkObj( $this->mTargetObj ); $wgOut->addHtml( wfMsgWikiHtml( 'undeletedpage', $link ) ); - - if (NS_IMAGE == $this->mTargetObj->getNamespace()) { - /* refresh image metadata cache */ - new Image( $this->mTargetObj ); - } - return true; } } diff --git a/includes/Title.php b/includes/Title.php index de971d5d3a..c377798de3 100644 --- a/includes/Title.php +++ b/includes/Title.php @@ -1256,6 +1256,10 @@ class Title { $dbr =& wfGetDB( DB_SLAVE ); $n = $dbr->selectField( 'archive', 'COUNT(*)', array( 'ar_namespace' => $this->getNamespace(), 'ar_title' => $this->getDBkey() ), $fname ); + if( $this->getNamespace() == NS_IMAGE ) { + $n += $dbr->selectField( 'filearchive', 'COUNT(*)', + array( 'fa_name' => $this->getDBkey() ), $fname ); + } } return (int)$n; } diff --git a/languages/Messages.php b/languages/Messages.php index 3b1c11364d..63daf1a93d 100644 --- a/languages/Messages.php +++ b/languages/Messages.php @@ -1216,6 +1216,9 @@ before deletion. The actual text of these deleted revisions is only available to 'undeletecomment' => 'Comment:', 'undeletedarticle' => "restored \"[[$1]]\"", 'undeletedrevisions' => "$1 revisions restored", +'undeletedrevisions-files' => "$1 revisions and $2 file(s) restored", +'undeletedfiles' => "$1 file(s) restored", +'cannotundelete' => 'Undelete failed; someone else may have undeleted the page first.', 'undeletedpage' => "'''$1 has been restored''' Consult the [[Special:Log/delete|deletion log]] for a record of recent deletions and restorations.", diff --git a/maintenance/archives/patch-filearchive.sql b/maintenance/archives/patch-filearchive.sql new file mode 100644 index 0000000000..4bf09366da --- /dev/null +++ b/maintenance/archives/patch-filearchive.sql @@ -0,0 +1,51 @@ +-- +-- Record of deleted file data +-- +CREATE TABLE /*$wgDBprefix*/filearchive ( + -- Unique row id + fa_id int not null auto_increment, + + -- Original base filename; key to image.img_name, page.page_title, etc + fa_name varchar(255) binary NOT NULL default '', + + -- Filename of archived file, if an old revision + fa_archive_name varchar(255) binary default '', + + -- Which storage bin (directory tree or object store) the file data + -- is stored in. Should be 'deleted' for files that have been deleted; + -- any other bin is not yet in use. + fa_storage_group varchar(16), + + -- SHA-1 of the file contents plus extension, used as a key for storage. + -- eg 8f8a562add37052a1848ff7771a2c515db94baa9.jpg + -- + -- If NULL, the file was missing at deletion time or has been purged + -- from the archival storage. + fa_storage_key varchar(64) binary default '', + + -- Deletion information, if this file is deleted. + fa_deleted_user int, + fa_deleted_timestamp char(14) binary default '', + fa_deleted_reason text, + + -- Duped fields from image + fa_size int(8) unsigned default '0', + fa_width int(5) default '0', + fa_height int(5) default '0', + fa_metadata mediumblob, + fa_bits int(3) default '0', + fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL, + fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown", + fa_minor_mime varchar(32) default "unknown", + fa_description tinyblob default '', + fa_user int(5) unsigned default '0', + fa_user_text varchar(255) binary default '', + fa_timestamp char(14) binary default '', + + PRIMARY KEY (fa_id), + INDEX (fa_name, fa_timestamp), -- pick out by image name + INDEX (fa_storage_group, fa_storage_key), -- pick out dupe files + INDEX (fa_deleted_timestamp), -- sort by deletion time + INDEX (fa_deleted_user) -- sort by deleter + +) TYPE=InnoDB; diff --git a/maintenance/mysql5/tables.sql b/maintenance/mysql5/tables.sql index 2402e3a0a6..e11ccb012e 100644 --- a/maintenance/mysql5/tables.sql +++ b/maintenance/mysql5/tables.sql @@ -685,6 +685,58 @@ CREATE TABLE /*$wgDBprefix*/oldimage ( ) TYPE=InnoDB, DEFAULT CHARSET=utf8; +-- +-- Record of deleted file data +-- +CREATE TABLE /*$wgDBprefix*/filearchive ( + -- Unique row id + fa_id int not null auto_increment, + + -- Original base filename; key to image.img_name, page.page_title, etc + fa_name varchar(255) binary NOT NULL default '', + + -- Filename of archived file, if an old revision + fa_archive_name varchar(255) binary default '', + + -- Which storage bin (directory tree or object store) the file data + -- is stored in. Should be 'deleted' for files that have been deleted; + -- any other bin is not yet in use. + fa_storage_group varchar(16), + + -- SHA-1 of the file contents plus extension, used as a key for storage. + -- eg 8f8a562add37052a1848ff7771a2c515db94baa9.jpg + -- + -- If NULL, the file was missing at deletion time or has been purged + -- from the archival storage. + fa_storage_key varchar(64) binary default '', + + -- Deletion information, if this file is deleted. + fa_deleted_user int, + fa_deleted_timestamp char(14) binary default '', + fa_deleted_reason text, + + -- Duped fields from image + fa_size int(8) unsigned default '0', + fa_width int(5) default '0', + fa_height int(5) default '0', + fa_metadata mediumblob, + fa_bits int(3) default '0', + fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL, + fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown", + fa_minor_mime varchar(32) default "unknown", + fa_description tinyblob default '', + fa_user int(5) unsigned default '0', + fa_user_text varchar(255) binary default '', + fa_timestamp char(14) binary default '', + + PRIMARY KEY (fa_id), + INDEX (fa_name, fa_timestamp), -- pick out by image name + INDEX (fa_storage_group, fa_storage_key), -- pick out dupe files + INDEX (fa_deleted_timestamp), -- sort by deletion time + INDEX (fa_deleted_user) -- sort by deleter + +) TYPE=InnoDB, DEFAULT CHARSET=utf8; + -- -- Primarily a summary table for Special:Recentchanges, -- this table contains some additional info on edits from diff --git a/maintenance/tables.sql b/maintenance/tables.sql index d0b4b0d8ee..c998f78305 100644 --- a/maintenance/tables.sql +++ b/maintenance/tables.sql @@ -671,6 +671,57 @@ CREATE TABLE /*$wgDBprefix*/oldimage ( ) TYPE=InnoDB; +-- +-- Record of deleted file data +-- +CREATE TABLE /*$wgDBprefix*/filearchive ( + -- Unique row id + fa_id int not null auto_increment, + + -- Original base filename; key to image.img_name, page.page_title, etc + fa_name varchar(255) binary NOT NULL default '', + + -- Filename of archived file, if an old revision + fa_archive_name varchar(255) binary default '', + + -- Which storage bin (directory tree or object store) the file data + -- is stored in. Should be 'deleted' for files that have been deleted; + -- any other bin is not yet in use. + fa_storage_group varchar(16), + + -- SHA-1 of the file contents plus extension, used as a key for storage. + -- eg 8f8a562add37052a1848ff7771a2c515db94baa9.jpg + -- + -- If NULL, the file was missing at deletion time or has been purged + -- from the archival storage. + fa_storage_key varchar(64) binary default '', + + -- Deletion information, if this file is deleted. + fa_deleted_user int, + fa_deleted_timestamp char(14) binary default '', + fa_deleted_reason text, + + -- Duped fields from image + fa_size int(8) unsigned default '0', + fa_width int(5) default '0', + fa_height int(5) default '0', + fa_metadata mediumblob, + fa_bits int(3) default '0', + fa_media_type ENUM("UNKNOWN", "BITMAP", "DRAWING", "AUDIO", "VIDEO", "MULTIMEDIA", "OFFICE", "TEXT", "EXECUTABLE", "ARCHIVE") default NULL, + fa_major_mime ENUM("unknown", "application", "audio", "image", "text", "video", "message", "model", "multipart") default "unknown", + fa_minor_mime varchar(32) default "unknown", + fa_description tinyblob default '', + fa_user int(5) unsigned default '0', + fa_user_text varchar(255) binary default '', + fa_timestamp char(14) binary default '', + + PRIMARY KEY (fa_id), + INDEX (fa_name, fa_timestamp), -- pick out by image name + INDEX (fa_storage_group, fa_storage_key), -- pick out dupe files + INDEX (fa_deleted_timestamp), -- sort by deletion time + INDEX (fa_deleted_user) -- sort by deleter + +) TYPE=InnoDB; -- -- Primarily a summary table for Special:Recentchanges, diff --git a/maintenance/updaters.inc b/maintenance/updaters.inc index fc0bac67a7..949451d95a 100644 --- a/maintenance/updaters.inc +++ b/maintenance/updaters.inc @@ -30,6 +30,7 @@ $wgNewTables = array( array( 'job', 'patch-job.sql' ), array( 'langlinks', 'patch-langlinks.sql' ), array( 'querycache_info', 'patch-querycacheinfo.sql' ), + array( 'filearchive', 'patch-filearchive.sql' ), ); $wgNewFields = array(