Index: maintenance/tables.sql =================================================================== --- maintenance/tables.sql (revision 14775) +++ maintenance/tables.sql (working copy) @@ -671,7 +671,54 @@ ) TYPE=InnoDB; +-- +-- Record of deleted file data +-- +CREATE TABLE /*$wgDBprefix*/filearchive ( + -- 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 '', + + 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, -- this table contains some additional info on edits from Index: languages/Messages.php =================================================================== --- languages/Messages.php (revision 14775) +++ languages/Messages.php (working copy) @@ -1216,6 +1216,9 @@ '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.", Index: includes/GlobalFunctions.php =================================================================== --- includes/GlobalFunctions.php (revision 14775) +++ includes/GlobalFunctions.php (working copy) @@ -1853,4 +1853,91 @@ } } + +/** + * 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 ); +} + ?> Index: includes/ImagePage.php =================================================================== --- includes/ImagePage.php (revision 14775) +++ includes/ImagePage.php (working copy) @@ -499,79 +499,25 @@ $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 @@ { 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() { Index: includes/AutoLoader.php =================================================================== --- includes/AutoLoader.php (revision 14775) +++ includes/AutoLoader.php (working copy) @@ -70,6 +70,9 @@ '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', Index: includes/SpecialUndelete.php =================================================================== --- includes/SpecialUndelete.php (revision 14775) +++ includes/SpecialUndelete.php (working copy) @@ -67,6 +67,37 @@ $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 + */ + function listFiles() { + $fname = __CLASS__ . '::' . __FUNCTION__; + if( $this->title->getNamespace() == NS_IMAGE ) { + $dbr =& wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'filearchive', + array( + '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 +114,11 @@ 'ar_title' => $this->title->getDbkey(), 'ar_timestamp' => $dbr->timestamp( $timestamp ) ), $fname ); - return $this->getTextFromRow( $row ); + if( $row ) { + return $this->getTextFromRow( $row ); + } else { + return null; + } } /** @@ -144,23 +179,80 @@ } /** + * 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. + * @param string $comment + * @param array $fileVersions * - * @param array $timestamps Pass an empty array to restore all revisions, otherwise list the ones to undelete. - * @return bool + * @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 +313,14 @@ /* 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 +347,7 @@ 'text_id' => $row->ar_text_id, ) ); $newRevId = $revision->insertOn( $dbw ); + $restored++; } if( $revision ) { @@ -284,19 +383,9 @@ $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 $restored; + } - return true; - } } /** @@ -313,6 +402,7 @@ $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,13 +427,19 @@ } if( $this->mRestore ) { $timestamps = array(); + $fileTimestamps = array(); foreach( $_REQUEST as $key => $val ) { if( preg_match( '/^ts(\d{14})$/', $key, $matches ) ) { array_push( $timestamps, $matches[1] ); } + + if( preg_match( '/^filets(\d{14})$/', $key, $matches ) ) { + array_push( $fileTimestamps, $matches[1] ); + } } rsort( $timestamps ); $this->mTargetTimestamp = $timestamps; + $this->mFileTimestamp = $fileTimestamps; } } @@ -355,6 +451,9 @@ 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 +545,17 @@ 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 +569,12 @@ $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 +583,13 @@ # 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 +598,15 @@ $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 +623,10 @@ 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 +639,61 @@ $table .= '
'; $wgOut->addHtml( $table ); } + + $wgOut->addHTML( "

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

\n" ); + + if( $haveRevisions ) { + # The page's stored (deleted) history: + $wgOut->addHTML(""); + } else { + $wgOut->addWikiText( wfMsg( "nohistory" ) ); + } - # The page's stored (deleted) history: - $wgOut->addHTML(""); if ( $this->mAllowed ) { # Slip in the hidden controls here @@ -553,16 +709,17 @@ 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->mFileTimestamp ); + + 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; } } Index: includes/Title.php =================================================================== --- includes/Title.php (revision 14775) +++ includes/Title.php (working copy) @@ -1256,6 +1256,10 @@ $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; } Index: includes/Image.php =================================================================== --- includes/Image.php (revision 14775) +++ includes/Image.php (working copy) @@ -68,6 +68,7 @@ /** * Obsolete factory function, use constructor + * @deprecated */ function newFromTitle( $title ) { return new Image( $title ); @@ -82,13 +83,38 @@ $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 @@ $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 @@ $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 @@ } 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,7 +1296,41 @@ 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; if (!$wgCheckDBSchema) { @@ -1579,20 +1647,28 @@ 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 @@ 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,447 @@ * @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 $timestamps set of timestamps 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( $timestamps=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( $timestamps ) { + $conditions['fa_timestamp'] = $timestamps; + } + + $result = $dbw->select( 'filearchive', '*', + $conditions, + $fname, + array( 'ORDER BY' => 'fa_timestamp DESC' ) ); + + if( $dbw->numRows( $result ) < count( $timestamps ) ) { + // 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_name' => $row->fa_name, + 'fa_timestamp' => $row->fa_timestamp ), + $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 Index: includes/DefaultSettings.php =================================================================== --- includes/DefaultSettings.php (revision 14775) +++ includes/DefaultSettings.php (working copy) @@ -125,7 +125,30 @@ $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 * Index: includes/FileStore.php =================================================================== --- includes/FileStore.php (revision 0) +++ includes/FileStore.php (revision 0) @@ -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 Property changes on: includes/FileStore.php ___________________________________________________________________ Name: svn:eol-style + native