diff options
author | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
---|---|---|
committer | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
commit | fc7369835258467bf97eb64f184b93691f9a9fd5 (patch) | |
tree | daabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/includes/page |
first commit
Diffstat (limited to 'www/wiki/includes/page')
-rw-r--r-- | www/wiki/includes/page/Article.php | 2667 | ||||
-rw-r--r-- | www/wiki/includes/page/CategoryPage.php | 128 | ||||
-rw-r--r-- | www/wiki/includes/page/ImageHistoryList.php | 326 | ||||
-rw-r--r-- | www/wiki/includes/page/ImageHistoryPseudoPager.php | 228 | ||||
-rw-r--r-- | www/wiki/includes/page/ImagePage.php | 1229 | ||||
-rw-r--r-- | www/wiki/includes/page/Page.php | 25 | ||||
-rw-r--r-- | www/wiki/includes/page/PageArchive.php | 752 | ||||
-rw-r--r-- | www/wiki/includes/page/WikiCategoryPage.php | 64 | ||||
-rw-r--r-- | www/wiki/includes/page/WikiFilePage.php | 259 | ||||
-rw-r--r-- | www/wiki/includes/page/WikiPage.php | 3768 |
10 files changed, 9446 insertions, 0 deletions
diff --git a/www/wiki/includes/page/Article.php b/www/wiki/includes/page/Article.php new file mode 100644 index 00000000..8fff6147 --- /dev/null +++ b/www/wiki/includes/page/Article.php @@ -0,0 +1,2667 @@ +<?php +/** + * User interface for page actions. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ +use MediaWiki\MediaWikiServices; + +/** + * Class for viewing MediaWiki article and history. + * + * This maintains WikiPage functions for backwards compatibility. + * + * @todo Move and rewrite code to an Action class + * + * See design.txt for an overview. + * Note: edit user interface and cache support functions have been + * moved to separate EditPage and HTMLFileCache classes. + */ +class Article implements Page { + /** @var IContextSource The context this Article is executed in */ + protected $mContext; + + /** @var WikiPage The WikiPage object of this instance */ + protected $mPage; + + /** @var ParserOptions ParserOptions object for $wgUser articles */ + public $mParserOptions; + + /** + * @var string Text of the revision we are working on + * @todo BC cruft + */ + public $mContent; + + /** + * @var Content Content of the revision we are working on + * @since 1.21 + */ + public $mContentObject; + + /** @var bool Is the content ($mContent) already loaded? */ + public $mContentLoaded = false; + + /** @var int|null The oldid of the article that is to be shown, 0 for the current revision */ + public $mOldId; + + /** @var Title Title from which we were redirected here */ + public $mRedirectedFrom = null; + + /** @var string|bool URL to redirect to or false if none */ + public $mRedirectUrl = false; + + /** @var int Revision ID of revision we are working on */ + public $mRevIdFetched = 0; + + /** @var Revision Revision we are working on */ + public $mRevision = null; + + /** @var ParserOutput */ + public $mParserOutput; + + /** + * @var bool Whether render() was called. With the way subclasses work + * here, there doesn't seem to be any other way to stop calling + * OutputPage::enableSectionEditLinks() and still have it work as it did before. + */ + private $disableSectionEditForRender = false; + + /** + * Constructor and clear the article + * @param Title $title Reference to a Title object. + * @param int $oldId Revision ID, null to fetch from request, zero for current + */ + public function __construct( Title $title, $oldId = null ) { + $this->mOldId = $oldId; + $this->mPage = $this->newPage( $title ); + } + + /** + * @param Title $title + * @return WikiPage + */ + protected function newPage( Title $title ) { + return new WikiPage( $title ); + } + + /** + * Constructor from a page id + * @param int $id Article ID to load + * @return Article|null + */ + public static function newFromID( $id ) { + $t = Title::newFromID( $id ); + return $t == null ? null : new static( $t ); + } + + /** + * Create an Article object of the appropriate class for the given page. + * + * @param Title $title + * @param IContextSource $context + * @return Article + */ + public static function newFromTitle( $title, IContextSource $context ) { + if ( NS_MEDIA == $title->getNamespace() ) { + // FIXME: where should this go? + $title = Title::makeTitle( NS_FILE, $title->getDBkey() ); + } + + $page = null; + Hooks::run( 'ArticleFromTitle', [ &$title, &$page, $context ] ); + if ( !$page ) { + switch ( $title->getNamespace() ) { + case NS_FILE: + $page = new ImagePage( $title ); + break; + case NS_CATEGORY: + $page = new CategoryPage( $title ); + break; + default: + $page = new Article( $title ); + } + } + $page->setContext( $context ); + + return $page; + } + + /** + * Create an Article object of the appropriate class for the given page. + * + * @param WikiPage $page + * @param IContextSource $context + * @return Article + */ + public static function newFromWikiPage( WikiPage $page, IContextSource $context ) { + $article = self::newFromTitle( $page->getTitle(), $context ); + $article->mPage = $page; // override to keep process cached vars + return $article; + } + + /** + * Get the page this view was redirected from + * @return Title|null + * @since 1.28 + */ + public function getRedirectedFrom() { + return $this->mRedirectedFrom; + } + + /** + * Tell the page view functions that this view was redirected + * from another page on the wiki. + * @param Title $from + */ + public function setRedirectedFrom( Title $from ) { + $this->mRedirectedFrom = $from; + } + + /** + * Get the title object of the article + * + * @return Title Title object of this page + */ + public function getTitle() { + return $this->mPage->getTitle(); + } + + /** + * Get the WikiPage object of this instance + * + * @since 1.19 + * @return WikiPage + */ + public function getPage() { + return $this->mPage; + } + + /** + * Clear the object + */ + public function clear() { + $this->mContentLoaded = false; + + $this->mRedirectedFrom = null; # Title object if set + $this->mRevIdFetched = 0; + $this->mRedirectUrl = false; + + $this->mPage->clear(); + } + + /** + * Returns a Content object representing the pages effective display content, + * not necessarily the revision's content! + * + * Note that getContent does not follow redirects anymore. + * If you need to fetch redirectable content easily, try + * the shortcut in WikiPage::getRedirectTarget() + * + * This function has side effects! Do not use this function if you + * only want the real revision text if any. + * + * @return Content Return the content of this revision + * + * @since 1.21 + */ + protected function getContentObject() { + if ( $this->mPage->getId() === 0 ) { + # If this is a MediaWiki:x message, then load the messages + # and return the message value for x. + if ( $this->getTitle()->getNamespace() == NS_MEDIAWIKI ) { + $text = $this->getTitle()->getDefaultMessageText(); + if ( $text === false ) { + $text = ''; + } + + $content = ContentHandler::makeContent( $text, $this->getTitle() ); + } else { + $message = $this->getContext()->getUser()->isLoggedIn() ? 'noarticletext' : 'noarticletextanon'; + $content = new MessageContent( $message, null, 'parsemag' ); + } + } else { + $this->fetchContentObject(); + $content = $this->mContentObject; + } + + return $content; + } + + /** + * @return int The oldid of the article that is to be shown, 0 for the current revision + */ + public function getOldID() { + if ( is_null( $this->mOldId ) ) { + $this->mOldId = $this->getOldIDFromRequest(); + } + + return $this->mOldId; + } + + /** + * Sets $this->mRedirectUrl to a correct URL if the query parameters are incorrect + * + * @return int The old id for the request + */ + public function getOldIDFromRequest() { + $this->mRedirectUrl = false; + + $request = $this->getContext()->getRequest(); + $oldid = $request->getIntOrNull( 'oldid' ); + + if ( $oldid === null ) { + return 0; + } + + if ( $oldid !== 0 ) { + # Load the given revision and check whether the page is another one. + # In that case, update this instance to reflect the change. + if ( $oldid === $this->mPage->getLatest() ) { + $this->mRevision = $this->mPage->getRevision(); + } else { + $this->mRevision = Revision::newFromId( $oldid ); + if ( $this->mRevision !== null ) { + // Revision title doesn't match the page title given? + if ( $this->mPage->getId() != $this->mRevision->getPage() ) { + $function = [ get_class( $this->mPage ), 'newFromID' ]; + $this->mPage = call_user_func( $function, $this->mRevision->getPage() ); + } + } + } + } + + if ( $request->getVal( 'direction' ) == 'next' ) { + $nextid = $this->getTitle()->getNextRevisionID( $oldid ); + if ( $nextid ) { + $oldid = $nextid; + $this->mRevision = null; + } else { + $this->mRedirectUrl = $this->getTitle()->getFullURL( 'redirect=no' ); + } + } elseif ( $request->getVal( 'direction' ) == 'prev' ) { + $previd = $this->getTitle()->getPreviousRevisionID( $oldid ); + if ( $previd ) { + $oldid = $previd; + $this->mRevision = null; + } + } + + return $oldid; + } + + /** + * Get text content object + * Does *NOT* follow redirects. + * @todo When is this null? + * + * @note Code that wants to retrieve page content from the database should + * use WikiPage::getContent(). + * + * @return Content|null|bool + * + * @since 1.21 + */ + protected function fetchContentObject() { + if ( $this->mContentLoaded ) { + return $this->mContentObject; + } + + $this->mContentLoaded = true; + $this->mContent = null; + + $oldid = $this->getOldID(); + + # Pre-fill content with error message so that if something + # fails we'll have something telling us what we intended. + // XXX: this isn't page content but a UI message. horrible. + $this->mContentObject = new MessageContent( 'missing-revision', [ $oldid ] ); + + if ( $oldid ) { + # $this->mRevision might already be fetched by getOldIDFromRequest() + if ( !$this->mRevision ) { + $this->mRevision = Revision::newFromId( $oldid ); + if ( !$this->mRevision ) { + wfDebug( __METHOD__ . " failed to retrieve specified revision, id $oldid\n" ); + return false; + } + } + } else { + $oldid = $this->mPage->getLatest(); + if ( !$oldid ) { + wfDebug( __METHOD__ . " failed to find page data for title " . + $this->getTitle()->getPrefixedText() . "\n" ); + return false; + } + + # Update error message with correct oldid + $this->mContentObject = new MessageContent( 'missing-revision', [ $oldid ] ); + + $this->mRevision = $this->mPage->getRevision(); + + if ( !$this->mRevision ) { + wfDebug( __METHOD__ . " failed to retrieve current page, rev_id $oldid\n" ); + return false; + } + } + + // @todo FIXME: Horrible, horrible! This content-loading interface just plain sucks. + // We should instead work with the Revision object when we need it... + // Loads if user is allowed + $content = $this->mRevision->getContent( + Revision::FOR_THIS_USER, + $this->getContext()->getUser() + ); + + if ( !$content ) { + wfDebug( __METHOD__ . " failed to retrieve content of revision " . + $this->mRevision->getId() . "\n" ); + return false; + } + + $this->mContentObject = $content; + $this->mRevIdFetched = $this->mRevision->getId(); + + // Avoid PHP 7.1 warning of passing $this by reference + $articlePage = $this; + + Hooks::run( + 'ArticleAfterFetchContentObject', + [ &$articlePage, &$this->mContentObject ] + ); + + return $this->mContentObject; + } + + /** + * Returns true if the currently-referenced revision is the current edit + * to this page (and it exists). + * @return bool + */ + public function isCurrent() { + # If no oldid, this is the current version. + if ( $this->getOldID() == 0 ) { + return true; + } + + return $this->mPage->exists() && $this->mRevision && $this->mRevision->isCurrent(); + } + + /** + * Get the fetched Revision object depending on request parameters or null + * on failure. + * + * @since 1.19 + * @return Revision|null + */ + public function getRevisionFetched() { + $this->fetchContentObject(); + + return $this->mRevision; + } + + /** + * Use this to fetch the rev ID used on page views + * + * @return int Revision ID of last article revision + */ + public function getRevIdFetched() { + if ( $this->mRevIdFetched ) { + return $this->mRevIdFetched; + } else { + return $this->mPage->getLatest(); + } + } + + /** + * This is the default action of the index.php entry point: just view the + * page of the given title. + */ + public function view() { + global $wgUseFileCache, $wgDebugToolbar; + + # Get variables from query string + # As side effect this will load the revision and update the title + # in a revision ID is passed in the request, so this should remain + # the first call of this method even if $oldid is used way below. + $oldid = $this->getOldID(); + + $user = $this->getContext()->getUser(); + # Another whitelist check in case getOldID() is altering the title + $permErrors = $this->getTitle()->getUserPermissionsErrors( 'read', $user ); + if ( count( $permErrors ) ) { + wfDebug( __METHOD__ . ": denied on secondary read check\n" ); + throw new PermissionsError( 'read', $permErrors ); + } + + $outputPage = $this->getContext()->getOutput(); + # getOldID() may as well want us to redirect somewhere else + if ( $this->mRedirectUrl ) { + $outputPage->redirect( $this->mRedirectUrl ); + wfDebug( __METHOD__ . ": redirecting due to oldid\n" ); + + return; + } + + # If we got diff in the query, we want to see a diff page instead of the article. + if ( $this->getContext()->getRequest()->getCheck( 'diff' ) ) { + wfDebug( __METHOD__ . ": showing diff page\n" ); + $this->showDiffPage(); + + return; + } + + # Set page title (may be overridden by DISPLAYTITLE) + $outputPage->setPageTitle( $this->getTitle()->getPrefixedText() ); + + $outputPage->setArticleFlag( true ); + # Allow frames by default + $outputPage->allowClickjacking(); + + $parserCache = MediaWikiServices::getInstance()->getParserCache(); + + $parserOptions = $this->getParserOptions(); + $poOptions = []; + # Render printable version, use printable version cache + if ( $outputPage->isPrintable() ) { + $parserOptions->setIsPrintable( true ); + $poOptions['enableSectionEditLinks'] = false; + } elseif ( $this->disableSectionEditForRender + || !$this->isCurrent() || !$this->getTitle()->quickUserCan( 'edit', $user ) + ) { + $poOptions['enableSectionEditLinks'] = false; + } + + # Try client and file cache + if ( !$wgDebugToolbar && $oldid === 0 && $this->mPage->checkTouched() ) { + # Try to stream the output from file cache + if ( $wgUseFileCache && $this->tryFileCache() ) { + wfDebug( __METHOD__ . ": done file cache\n" ); + # tell wgOut that output is taken care of + $outputPage->disable(); + $this->mPage->doViewUpdates( $user, $oldid ); + + return; + } + } + + # Should the parser cache be used? + $useParserCache = $this->mPage->shouldCheckParserCache( $parserOptions, $oldid ); + wfDebug( 'Article::view using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" ); + if ( $user->getStubThreshold() ) { + MediaWikiServices::getInstance()->getStatsdDataFactory()->increment( 'pcache_miss_stub' ); + } + + $this->showRedirectedFromHeader(); + $this->showNamespaceHeader(); + + # Iterate through the possible ways of constructing the output text. + # Keep going until $outputDone is set, or we run out of things to do. + $pass = 0; + $outputDone = false; + $this->mParserOutput = false; + + while ( !$outputDone && ++$pass ) { + switch ( $pass ) { + case 1: + // Avoid PHP 7.1 warning of passing $this by reference + $articlePage = $this; + Hooks::run( 'ArticleViewHeader', [ &$articlePage, &$outputDone, &$useParserCache ] ); + break; + case 2: + # Early abort if the page doesn't exist + if ( !$this->mPage->exists() ) { + wfDebug( __METHOD__ . ": showing missing article\n" ); + $this->showMissingArticle(); + $this->mPage->doViewUpdates( $user ); + return; + } + + # Try the parser cache + if ( $useParserCache ) { + $this->mParserOutput = $parserCache->get( $this->mPage, $parserOptions ); + + if ( $this->mParserOutput !== false ) { + if ( $oldid ) { + wfDebug( __METHOD__ . ": showing parser cache contents for current rev permalink\n" ); + $this->setOldSubtitle( $oldid ); + } else { + wfDebug( __METHOD__ . ": showing parser cache contents\n" ); + } + $outputPage->addParserOutput( $this->mParserOutput, $poOptions ); + # Ensure that UI elements requiring revision ID have + # the correct version information. + $outputPage->setRevisionId( $this->mPage->getLatest() ); + # Preload timestamp to avoid a DB hit + $cachedTimestamp = $this->mParserOutput->getTimestamp(); + if ( $cachedTimestamp !== null ) { + $outputPage->setRevisionTimestamp( $cachedTimestamp ); + $this->mPage->setTimestamp( $cachedTimestamp ); + } + $outputDone = true; + } + } + break; + case 3: + # This will set $this->mRevision if needed + $this->fetchContentObject(); + + # Are we looking at an old revision + if ( $oldid && $this->mRevision ) { + $this->setOldSubtitle( $oldid ); + + if ( !$this->showDeletedRevisionHeader() ) { + wfDebug( __METHOD__ . ": cannot view deleted revision\n" ); + return; + } + } + + # Ensure that UI elements requiring revision ID have + # the correct version information. + $outputPage->setRevisionId( $this->getRevIdFetched() ); + # Preload timestamp to avoid a DB hit + $outputPage->setRevisionTimestamp( $this->mPage->getTimestamp() ); + + # Pages containing custom CSS or JavaScript get special treatment + if ( $this->getTitle()->isSiteConfigPage() || $this->getTitle()->isUserConfigPage() ) { + $dir = $this->getContext()->getLanguage()->getDir(); + $lang = $this->getContext()->getLanguage()->getHtmlCode(); + + $outputPage->wrapWikiMsg( + "<div id='mw-clearyourcache' lang='$lang' dir='$dir' class='mw-content-$dir'>\n$1\n</div>", + 'clearyourcache' + ); + } elseif ( !Hooks::run( 'ArticleContentViewCustom', + [ $this->fetchContentObject(), $this->getTitle(), $outputPage ] ) + ) { + # Allow extensions do their own custom view for certain pages + $outputDone = true; + } + break; + case 4: + # Run the parse, protected by a pool counter + wfDebug( __METHOD__ . ": doing uncached parse\n" ); + + $content = $this->getContentObject(); + $poolArticleView = new PoolWorkArticleView( $this->getPage(), $parserOptions, + $this->getRevIdFetched(), $useParserCache, $content ); + + if ( !$poolArticleView->execute() ) { + $error = $poolArticleView->getError(); + if ( $error ) { + $outputPage->clearHTML(); // for release() errors + $outputPage->enableClientCache( false ); + $outputPage->setRobotPolicy( 'noindex,nofollow' ); + + $errortext = $error->getWikiText( false, 'view-pool-error' ); + $outputPage->addWikiText( Html::errorBox( $errortext ) ); + } + # Connection or timeout error + return; + } + + $this->mParserOutput = $poolArticleView->getParserOutput(); + $outputPage->addParserOutput( $this->mParserOutput, $poOptions ); + if ( $content->getRedirectTarget() ) { + $outputPage->addSubtitle( "<span id=\"redirectsub\">" . + $this->getContext()->msg( 'redirectpagesub' )->parse() . "</span>" ); + } + + # Don't cache a dirty ParserOutput object + if ( $poolArticleView->getIsDirty() ) { + $outputPage->setCdnMaxage( 0 ); + $outputPage->addHTML( "<!-- parser cache is expired, " . + "sending anyway due to pool overload-->\n" ); + } + + $outputDone = true; + break; + # Should be unreachable, but just in case... + default: + break 2; + } + } + + # Get the ParserOutput actually *displayed* here. + # Note that $this->mParserOutput is the *current*/oldid version output. + $pOutput = ( $outputDone instanceof ParserOutput ) + ? $outputDone // object fetched by hook + : $this->mParserOutput; + + # Adjust title for main page & pages with displaytitle + if ( $pOutput ) { + $this->adjustDisplayTitle( $pOutput ); + } + + # For the main page, overwrite the <title> element with the con- + # tents of 'pagetitle-view-mainpage' instead of the default (if + # that's not empty). + # This message always exists because it is in the i18n files + if ( $this->getTitle()->isMainPage() ) { + $msg = wfMessage( 'pagetitle-view-mainpage' )->inContentLanguage(); + if ( !$msg->isDisabled() ) { + $outputPage->setHTMLTitle( $msg->title( $this->getTitle() )->text() ); + } + } + + # Use adaptive TTLs for CDN so delayed/failed purges are noticed less often. + # This could use getTouched(), but that could be scary for major template edits. + $outputPage->adaptCdnTTL( $this->mPage->getTimestamp(), IExpiringStore::TTL_DAY ); + + # Check for any __NOINDEX__ tags on the page using $pOutput + $policy = $this->getRobotPolicy( 'view', $pOutput ); + $outputPage->setIndexPolicy( $policy['index'] ); + $outputPage->setFollowPolicy( $policy['follow'] ); + + $this->showViewFooter(); + $this->mPage->doViewUpdates( $user, $oldid ); + + # Load the postEdit module if the user just saved this revision + # See also EditPage::setPostEditCookie + $request = $this->getContext()->getRequest(); + $cookieKey = EditPage::POST_EDIT_COOKIE_KEY_PREFIX . $this->getRevIdFetched(); + $postEdit = $request->getCookie( $cookieKey ); + if ( $postEdit ) { + # Clear the cookie. This also prevents caching of the response. + $request->response()->clearCookie( $cookieKey ); + $outputPage->addJsConfigVars( 'wgPostEdit', $postEdit ); + $outputPage->addModules( 'mediawiki.action.view.postEdit' ); + } + } + + /** + * Adjust title for pages with displaytitle, -{T|}- or language conversion + * @param ParserOutput $pOutput + */ + public function adjustDisplayTitle( ParserOutput $pOutput ) { + # Adjust the title if it was set by displaytitle, -{T|}- or language conversion + $titleText = $pOutput->getTitleText(); + if ( strval( $titleText ) !== '' ) { + $this->getContext()->getOutput()->setPageTitle( $titleText ); + } + } + + /** + * Show a diff page according to current request variables. For use within + * Article::view() only, other callers should use the DifferenceEngine class. + */ + protected function showDiffPage() { + $request = $this->getContext()->getRequest(); + $user = $this->getContext()->getUser(); + $diff = $request->getVal( 'diff' ); + $rcid = $request->getVal( 'rcid' ); + $diffOnly = $request->getBool( 'diffonly', $user->getOption( 'diffonly' ) ); + $purge = $request->getVal( 'action' ) == 'purge'; + $unhide = $request->getInt( 'unhide' ) == 1; + $oldid = $this->getOldID(); + + $rev = $this->getRevisionFetched(); + + if ( !$rev ) { + $this->getContext()->getOutput()->setPageTitle( wfMessage( 'errorpagetitle' ) ); + $msg = $this->getContext()->msg( 'difference-missing-revision' ) + ->params( $oldid ) + ->numParams( 1 ) + ->parseAsBlock(); + $this->getContext()->getOutput()->addHTML( $msg ); + return; + } + + $contentHandler = $rev->getContentHandler(); + $de = $contentHandler->createDifferenceEngine( + $this->getContext(), + $oldid, + $diff, + $rcid, + $purge, + $unhide + ); + + // DifferenceEngine directly fetched the revision: + $this->mRevIdFetched = $de->mNewid; + $de->showDiffPage( $diffOnly ); + + // Run view updates for the newer revision being diffed (and shown + // below the diff if not $diffOnly). + list( $old, $new ) = $de->mapDiffPrevNext( $oldid, $diff ); + // New can be false, convert it to 0 - this conveniently means the latest revision + $this->mPage->doViewUpdates( $user, (int)$new ); + } + + /** + * Get the robot policy to be used for the current view + * @param string $action The action= GET parameter + * @param ParserOutput|null $pOutput + * @return array The policy that should be set + * @todo actions other than 'view' + */ + public function getRobotPolicy( $action, $pOutput = null ) { + global $wgArticleRobotPolicies, $wgNamespaceRobotPolicies, $wgDefaultRobotPolicy; + + $ns = $this->getTitle()->getNamespace(); + + # Don't index user and user talk pages for blocked users (T13443) + if ( ( $ns == NS_USER || $ns == NS_USER_TALK ) && !$this->getTitle()->isSubpage() ) { + $specificTarget = null; + $vagueTarget = null; + $titleText = $this->getTitle()->getText(); + if ( IP::isValid( $titleText ) ) { + $vagueTarget = $titleText; + } else { + $specificTarget = $titleText; + } + if ( Block::newFromTarget( $specificTarget, $vagueTarget ) instanceof Block ) { + return [ + 'index' => 'noindex', + 'follow' => 'nofollow' + ]; + } + } + + if ( $this->mPage->getId() === 0 || $this->getOldID() ) { + # Non-articles (special pages etc), and old revisions + return [ + 'index' => 'noindex', + 'follow' => 'nofollow' + ]; + } elseif ( $this->getContext()->getOutput()->isPrintable() ) { + # Discourage indexing of printable versions, but encourage following + return [ + 'index' => 'noindex', + 'follow' => 'follow' + ]; + } elseif ( $this->getContext()->getRequest()->getInt( 'curid' ) ) { + # For ?curid=x urls, disallow indexing + return [ + 'index' => 'noindex', + 'follow' => 'follow' + ]; + } + + # Otherwise, construct the policy based on the various config variables. + $policy = self::formatRobotPolicy( $wgDefaultRobotPolicy ); + + if ( isset( $wgNamespaceRobotPolicies[$ns] ) ) { + # Honour customised robot policies for this namespace + $policy = array_merge( + $policy, + self::formatRobotPolicy( $wgNamespaceRobotPolicies[$ns] ) + ); + } + if ( $this->getTitle()->canUseNoindex() && is_object( $pOutput ) && $pOutput->getIndexPolicy() ) { + # __INDEX__ and __NOINDEX__ magic words, if allowed. Incorporates + # a final sanity check that we have really got the parser output. + $policy = array_merge( + $policy, + [ 'index' => $pOutput->getIndexPolicy() ] + ); + } + + if ( isset( $wgArticleRobotPolicies[$this->getTitle()->getPrefixedText()] ) ) { + # (T16900) site config can override user-defined __INDEX__ or __NOINDEX__ + $policy = array_merge( + $policy, + self::formatRobotPolicy( $wgArticleRobotPolicies[$this->getTitle()->getPrefixedText()] ) + ); + } + + return $policy; + } + + /** + * Converts a String robot policy into an associative array, to allow + * merging of several policies using array_merge(). + * @param array|string $policy Returns empty array on null/false/'', transparent + * to already-converted arrays, converts string. + * @return array 'index' => \<indexpolicy\>, 'follow' => \<followpolicy\> + */ + public static function formatRobotPolicy( $policy ) { + if ( is_array( $policy ) ) { + return $policy; + } elseif ( !$policy ) { + return []; + } + + $policy = explode( ',', $policy ); + $policy = array_map( 'trim', $policy ); + + $arr = []; + foreach ( $policy as $var ) { + if ( in_array( $var, [ 'index', 'noindex' ] ) ) { + $arr['index'] = $var; + } elseif ( in_array( $var, [ 'follow', 'nofollow' ] ) ) { + $arr['follow'] = $var; + } + } + + return $arr; + } + + /** + * If this request is a redirect view, send "redirected from" subtitle to + * the output. Returns true if the header was needed, false if this is not + * a redirect view. Handles both local and remote redirects. + * + * @return bool + */ + public function showRedirectedFromHeader() { + global $wgRedirectSources; + + $context = $this->getContext(); + $outputPage = $context->getOutput(); + $request = $context->getRequest(); + $rdfrom = $request->getVal( 'rdfrom' ); + + // Construct a URL for the current page view, but with the target title + $query = $request->getValues(); + unset( $query['rdfrom'] ); + unset( $query['title'] ); + if ( $this->getTitle()->isRedirect() ) { + // Prevent double redirects + $query['redirect'] = 'no'; + } + $redirectTargetUrl = $this->getTitle()->getLinkURL( $query ); + + if ( isset( $this->mRedirectedFrom ) ) { + // Avoid PHP 7.1 warning of passing $this by reference + $articlePage = $this; + + // This is an internally redirected page view. + // We'll need a backlink to the source page for navigation. + if ( Hooks::run( 'ArticleViewRedirect', [ &$articlePage ] ) ) { + $redir = Linker::linkKnown( + $this->mRedirectedFrom, + null, + [], + [ 'redirect' => 'no' ] + ); + + $outputPage->addSubtitle( "<span class=\"mw-redirectedfrom\">" . + $context->msg( 'redirectedfrom' )->rawParams( $redir )->parse() + . "</span>" ); + + // Add the script to update the displayed URL and + // set the fragment if one was specified in the redirect + $outputPage->addJsConfigVars( [ + 'wgInternalRedirectTargetUrl' => $redirectTargetUrl, + ] ); + $outputPage->addModules( 'mediawiki.action.view.redirect' ); + + // Add a <link rel="canonical"> tag + $outputPage->setCanonicalUrl( $this->getTitle()->getCanonicalURL() ); + + // Tell the output object that the user arrived at this article through a redirect + $outputPage->setRedirectedFrom( $this->mRedirectedFrom ); + + return true; + } + } elseif ( $rdfrom ) { + // This is an externally redirected view, from some other wiki. + // If it was reported from a trusted site, supply a backlink. + if ( $wgRedirectSources && preg_match( $wgRedirectSources, $rdfrom ) ) { + $redir = Linker::makeExternalLink( $rdfrom, $rdfrom ); + $outputPage->addSubtitle( "<span class=\"mw-redirectedfrom\">" . + $context->msg( 'redirectedfrom' )->rawParams( $redir )->parse() + . "</span>" ); + + // Add the script to update the displayed URL + $outputPage->addJsConfigVars( [ + 'wgInternalRedirectTargetUrl' => $redirectTargetUrl, + ] ); + $outputPage->addModules( 'mediawiki.action.view.redirect' ); + + return true; + } + } + + return false; + } + + /** + * Show a header specific to the namespace currently being viewed, like + * [[MediaWiki:Talkpagetext]]. For Article::view(). + */ + public function showNamespaceHeader() { + if ( $this->getTitle()->isTalkPage() ) { + if ( !wfMessage( 'talkpageheader' )->isDisabled() ) { + $this->getContext()->getOutput()->wrapWikiMsg( + "<div class=\"mw-talkpageheader\">\n$1\n</div>", + [ 'talkpageheader' ] + ); + } + } + } + + /** + * Show the footer section of an ordinary page view + */ + public function showViewFooter() { + # check if we're displaying a [[User talk:x.x.x.x]] anonymous talk page + if ( $this->getTitle()->getNamespace() == NS_USER_TALK + && IP::isValid( $this->getTitle()->getText() ) + ) { + $this->getContext()->getOutput()->addWikiMsg( 'anontalkpagetext' ); + } + + // Show a footer allowing the user to patrol the shown revision or page if possible + $patrolFooterShown = $this->showPatrolFooter(); + + Hooks::run( 'ArticleViewFooter', [ $this, $patrolFooterShown ] ); + } + + /** + * If patrol is possible, output a patrol UI box. This is called from the + * footer section of ordinary page views. If patrol is not possible or not + * desired, does nothing. + * Side effect: When the patrol link is build, this method will call + * OutputPage::preventClickjacking() and load mediawiki.page.patrol.ajax. + * + * @return bool + */ + public function showPatrolFooter() { + global $wgUseNPPatrol, $wgUseRCPatrol, $wgUseFilePatrol, $wgEnableAPI, $wgEnableWriteAPI; + + $outputPage = $this->getContext()->getOutput(); + $user = $this->getContext()->getUser(); + $title = $this->getTitle(); + $rc = false; + + if ( !$title->quickUserCan( 'patrol', $user ) + || !( $wgUseRCPatrol || $wgUseNPPatrol + || ( $wgUseFilePatrol && $title->inNamespace( NS_FILE ) ) ) + ) { + // Patrolling is disabled or the user isn't allowed to + return false; + } + + if ( $this->mRevision + && !RecentChange::isInRCLifespan( $this->mRevision->getTimestamp(), 21600 ) + ) { + // The current revision is already older than what could be in the RC table + // 6h tolerance because the RC might not be cleaned out regularly + return false; + } + + // Check for cached results + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); + $key = $cache->makeKey( 'unpatrollable-page', $title->getArticleID() ); + if ( $cache->get( $key ) ) { + return false; + } + + $dbr = wfGetDB( DB_REPLICA ); + $oldestRevisionTimestamp = $dbr->selectField( + 'revision', + 'MIN( rev_timestamp )', + [ 'rev_page' => $title->getArticleID() ], + __METHOD__ + ); + + // New page patrol: Get the timestamp of the oldest revison which + // the revision table holds for the given page. Then we look + // whether it's within the RC lifespan and if it is, we try + // to get the recentchanges row belonging to that entry + // (with rc_new = 1). + $recentPageCreation = false; + if ( $oldestRevisionTimestamp + && RecentChange::isInRCLifespan( $oldestRevisionTimestamp, 21600 ) + ) { + // 6h tolerance because the RC might not be cleaned out regularly + $recentPageCreation = true; + $rc = RecentChange::newFromConds( + [ + 'rc_new' => 1, + 'rc_timestamp' => $oldestRevisionTimestamp, + 'rc_namespace' => $title->getNamespace(), + 'rc_cur_id' => $title->getArticleID() + ], + __METHOD__ + ); + if ( $rc ) { + // Use generic patrol message for new pages + $markPatrolledMsg = wfMessage( 'markaspatrolledtext' ); + } + } + + // File patrol: Get the timestamp of the latest upload for this page, + // check whether it is within the RC lifespan and if it is, we try + // to get the recentchanges row belonging to that entry + // (with rc_type = RC_LOG, rc_log_type = upload). + $recentFileUpload = false; + if ( ( !$rc || $rc->getAttribute( 'rc_patrolled' ) ) && $wgUseFilePatrol + && $title->getNamespace() === NS_FILE ) { + // Retrieve timestamp of most recent upload + $newestUploadTimestamp = $dbr->selectField( + 'image', + 'MAX( img_timestamp )', + [ 'img_name' => $title->getDBkey() ], + __METHOD__ + ); + if ( $newestUploadTimestamp + && RecentChange::isInRCLifespan( $newestUploadTimestamp, 21600 ) + ) { + // 6h tolerance because the RC might not be cleaned out regularly + $recentFileUpload = true; + $rc = RecentChange::newFromConds( + [ + 'rc_type' => RC_LOG, + 'rc_log_type' => 'upload', + 'rc_timestamp' => $newestUploadTimestamp, + 'rc_namespace' => NS_FILE, + 'rc_cur_id' => $title->getArticleID() + ], + __METHOD__ + ); + if ( $rc ) { + // Use patrol message specific to files + $markPatrolledMsg = wfMessage( 'markaspatrolledtext-file' ); + } + } + } + + if ( !$recentPageCreation && !$recentFileUpload ) { + // Page creation and latest upload (for files) is too old to be in RC + + // We definitely can't patrol so cache the information + // When a new file version is uploaded, the cache is cleared + $cache->set( $key, '1' ); + + return false; + } + + if ( !$rc ) { + // Don't cache: This can be hit if the page gets accessed very fast after + // its creation / latest upload or in case we have high replica DB lag. In case + // the revision is too old, we will already return above. + return false; + } + + if ( $rc->getAttribute( 'rc_patrolled' ) ) { + // Patrolled RC entry around + + // Cache the information we gathered above in case we can't patrol + // Don't cache in case we can patrol as this could change + $cache->set( $key, '1' ); + + return false; + } + + if ( $rc->getPerformer()->equals( $user ) ) { + // Don't show a patrol link for own creations/uploads. If the user could + // patrol them, they already would be patrolled + return false; + } + + $outputPage->preventClickjacking(); + if ( $wgEnableAPI && $wgEnableWriteAPI && $user->isAllowed( 'writeapi' ) ) { + $outputPage->addModules( 'mediawiki.page.patrol.ajax' ); + } + + $link = Linker::linkKnown( + $title, + $markPatrolledMsg->escaped(), + [], + [ + 'action' => 'markpatrolled', + 'rcid' => $rc->getAttribute( 'rc_id' ), + ] + ); + + $outputPage->addHTML( + "<div class='patrollink' data-mw='interface'>" . + wfMessage( 'markaspatrolledlink' )->rawParams( $link )->escaped() . + '</div>' + ); + + return true; + } + + /** + * Purge the cache used to check if it is worth showing the patrol footer + * For example, it is done during re-uploads when file patrol is used. + * @param int $articleID ID of the article to purge + * @since 1.27 + */ + public static function purgePatrolFooterCache( $articleID ) { + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); + $cache->delete( $cache->makeKey( 'unpatrollable-page', $articleID ) ); + } + + /** + * Show the error text for a missing article. For articles in the MediaWiki + * namespace, show the default message text. To be called from Article::view(). + */ + public function showMissingArticle() { + global $wgSend404Code; + + $outputPage = $this->getContext()->getOutput(); + // Whether the page is a root user page of an existing user (but not a subpage) + $validUserPage = false; + + $title = $this->getTitle(); + + # Show info in user (talk) namespace. Does the user exist? Is he blocked? + if ( $title->getNamespace() == NS_USER + || $title->getNamespace() == NS_USER_TALK + ) { + $rootPart = explode( '/', $title->getText() )[0]; + $user = User::newFromName( $rootPart, false /* allow IP users */ ); + $ip = User::isIP( $rootPart ); + $block = Block::newFromTarget( $user, $user ); + + if ( !( $user && $user->isLoggedIn() ) && !$ip ) { # User does not exist + $outputPage->wrapWikiMsg( "<div class=\"mw-userpage-userdoesnotexist error\">\n\$1\n</div>", + [ 'userpage-userdoesnotexist-view', wfEscapeWikiText( $rootPart ) ] ); + } elseif ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) { + # Show log extract if the user is currently blocked + LogEventsList::showLogExtract( + $outputPage, + 'block', + MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget(), + '', + [ + 'lim' => 1, + 'showIfEmpty' => false, + 'msgKey' => [ + 'blocked-notice-logextract', + $user->getName() # Support GENDER in notice + ] + ] + ); + $validUserPage = !$title->isSubpage(); + } else { + $validUserPage = !$title->isSubpage(); + } + } + + Hooks::run( 'ShowMissingArticle', [ $this ] ); + + # Show delete and move logs if there were any such events. + # The logging query can DOS the site when bots/crawlers cause 404 floods, + # so be careful showing this. 404 pages must be cheap as they are hard to cache. + $cache = MediaWikiServices::getInstance()->getMainObjectStash(); + $key = $cache->makeKey( 'page-recent-delete', md5( $title->getPrefixedText() ) ); + $loggedIn = $this->getContext()->getUser()->isLoggedIn(); + $sessionExists = $this->getContext()->getRequest()->getSession()->isPersistent(); + if ( $loggedIn || $cache->get( $key ) || $sessionExists ) { + $logTypes = [ 'delete', 'move', 'protect' ]; + + $dbr = wfGetDB( DB_REPLICA ); + + $conds = [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ]; + // Give extensions a chance to hide their (unrelated) log entries + Hooks::run( 'Article::MissingArticleConditions', [ &$conds, $logTypes ] ); + LogEventsList::showLogExtract( + $outputPage, + $logTypes, + $title, + '', + [ + 'lim' => 10, + 'conds' => $conds, + 'showIfEmpty' => false, + 'msgKey' => [ $loggedIn || $sessionExists + ? 'moveddeleted-notice' + : 'moveddeleted-notice-recent' + ] + ] + ); + } + + if ( !$this->mPage->hasViewableContent() && $wgSend404Code && !$validUserPage ) { + // If there's no backing content, send a 404 Not Found + // for better machine handling of broken links. + $this->getContext()->getRequest()->response()->statusHeader( 404 ); + } + + // Also apply the robot policy for nonexisting pages (even if a 404 was used for sanity) + $policy = $this->getRobotPolicy( 'view' ); + $outputPage->setIndexPolicy( $policy['index'] ); + $outputPage->setFollowPolicy( $policy['follow'] ); + + $hookResult = Hooks::run( 'BeforeDisplayNoArticleText', [ $this ] ); + + if ( !$hookResult ) { + return; + } + + # Show error message + $oldid = $this->getOldID(); + if ( !$oldid && $title->getNamespace() === NS_MEDIAWIKI && $title->hasSourceText() ) { + $outputPage->addParserOutput( $this->getContentObject()->getParserOutput( $title ) ); + } else { + if ( $oldid ) { + $text = wfMessage( 'missing-revision', $oldid )->plain(); + } elseif ( $title->quickUserCan( 'create', $this->getContext()->getUser() ) + && $title->quickUserCan( 'edit', $this->getContext()->getUser() ) + ) { + $message = $this->getContext()->getUser()->isLoggedIn() ? 'noarticletext' : 'noarticletextanon'; + $text = wfMessage( $message )->plain(); + } else { + $text = wfMessage( 'noarticletext-nopermission' )->plain(); + } + + $dir = $this->getContext()->getLanguage()->getDir(); + $lang = $this->getContext()->getLanguage()->getHtmlCode(); + $outputPage->addWikiText( Xml::openElement( 'div', [ + 'class' => "noarticletext mw-content-$dir", + 'dir' => $dir, + 'lang' => $lang, + ] ) . "\n$text\n</div>" ); + } + } + + /** + * If the revision requested for view is deleted, check permissions. + * Send either an error message or a warning header to the output. + * + * @return bool True if the view is allowed, false if not. + */ + public function showDeletedRevisionHeader() { + if ( !$this->mRevision->isDeleted( Revision::DELETED_TEXT ) ) { + // Not deleted + return true; + } + + $outputPage = $this->getContext()->getOutput(); + $user = $this->getContext()->getUser(); + // If the user is not allowed to see it... + if ( !$this->mRevision->userCan( Revision::DELETED_TEXT, $user ) ) { + $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", + 'rev-deleted-text-permission' ); + + return false; + // If the user needs to confirm that they want to see it... + } elseif ( $this->getContext()->getRequest()->getInt( 'unhide' ) != 1 ) { + # Give explanation and add a link to view the revision... + $oldid = intval( $this->getOldID() ); + $link = $this->getTitle()->getFullURL( "oldid={$oldid}&unhide=1" ); + $msg = $this->mRevision->isDeleted( Revision::DELETED_RESTRICTED ) ? + 'rev-suppressed-text-unhide' : 'rev-deleted-text-unhide'; + $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", + [ $msg, $link ] ); + + return false; + // We are allowed to see... + } else { + $msg = $this->mRevision->isDeleted( Revision::DELETED_RESTRICTED ) ? + 'rev-suppressed-text-view' : 'rev-deleted-text-view'; + $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", $msg ); + + return true; + } + } + + /** + * Generate the navigation links when browsing through an article revisions + * It shows the information as: + * Revision as of \<date\>; view current revision + * \<- Previous version | Next Version -\> + * + * @param int $oldid Revision ID of this article revision + */ + public function setOldSubtitle( $oldid = 0 ) { + // Avoid PHP 7.1 warning of passing $this by reference + $articlePage = $this; + + if ( !Hooks::run( 'DisplayOldSubtitle', [ &$articlePage, &$oldid ] ) ) { + return; + } + + $context = $this->getContext(); + $unhide = $context->getRequest()->getInt( 'unhide' ) == 1; + + # Cascade unhide param in links for easy deletion browsing + $extraParams = []; + if ( $unhide ) { + $extraParams['unhide'] = 1; + } + + if ( $this->mRevision && $this->mRevision->getId() === $oldid ) { + $revision = $this->mRevision; + } else { + $revision = Revision::newFromId( $oldid ); + } + + $timestamp = $revision->getTimestamp(); + + $current = ( $oldid == $this->mPage->getLatest() ); + $language = $context->getLanguage(); + $user = $context->getUser(); + + $td = $language->userTimeAndDate( $timestamp, $user ); + $tddate = $language->userDate( $timestamp, $user ); + $tdtime = $language->userTime( $timestamp, $user ); + + # Show user links if allowed to see them. If hidden, then show them only if requested... + $userlinks = Linker::revUserTools( $revision, !$unhide ); + + $infomsg = $current && !$context->msg( 'revision-info-current' )->isDisabled() + ? 'revision-info-current' + : 'revision-info'; + + $outputPage = $context->getOutput(); + $revisionInfo = "<div id=\"mw-{$infomsg}\">" . + $context->msg( $infomsg, $td ) + ->rawParams( $userlinks ) + ->params( $revision->getId(), $tddate, $tdtime, $revision->getUserText() ) + ->rawParams( Linker::revComment( $revision, true, true ) ) + ->parse() . + "</div>"; + + $lnk = $current + ? $context->msg( 'currentrevisionlink' )->escaped() + : Linker::linkKnown( + $this->getTitle(), + $context->msg( 'currentrevisionlink' )->escaped(), + [], + $extraParams + ); + $curdiff = $current + ? $context->msg( 'diff' )->escaped() + : Linker::linkKnown( + $this->getTitle(), + $context->msg( 'diff' )->escaped(), + [], + [ + 'diff' => 'cur', + 'oldid' => $oldid + ] + $extraParams + ); + $prev = $this->getTitle()->getPreviousRevisionID( $oldid ); + $prevlink = $prev + ? Linker::linkKnown( + $this->getTitle(), + $context->msg( 'previousrevision' )->escaped(), + [], + [ + 'direction' => 'prev', + 'oldid' => $oldid + ] + $extraParams + ) + : $context->msg( 'previousrevision' )->escaped(); + $prevdiff = $prev + ? Linker::linkKnown( + $this->getTitle(), + $context->msg( 'diff' )->escaped(), + [], + [ + 'diff' => 'prev', + 'oldid' => $oldid + ] + $extraParams + ) + : $context->msg( 'diff' )->escaped(); + $nextlink = $current + ? $context->msg( 'nextrevision' )->escaped() + : Linker::linkKnown( + $this->getTitle(), + $context->msg( 'nextrevision' )->escaped(), + [], + [ + 'direction' => 'next', + 'oldid' => $oldid + ] + $extraParams + ); + $nextdiff = $current + ? $context->msg( 'diff' )->escaped() + : Linker::linkKnown( + $this->getTitle(), + $context->msg( 'diff' )->escaped(), + [], + [ + 'diff' => 'next', + 'oldid' => $oldid + ] + $extraParams + ); + + $cdel = Linker::getRevDeleteLink( $user, $revision, $this->getTitle() ); + if ( $cdel !== '' ) { + $cdel .= ' '; + } + + // the outer div is need for styling the revision info and nav in MobileFrontend + $outputPage->addSubtitle( "<div class=\"mw-revision\">" . $revisionInfo . + "<div id=\"mw-revision-nav\">" . $cdel . + $context->msg( 'revision-nav' )->rawParams( + $prevdiff, $prevlink, $lnk, $curdiff, $nextlink, $nextdiff + )->escaped() . "</div></div>" ); + } + + /** + * Return the HTML for the top of a redirect page + * + * Chances are you should just be using the ParserOutput from + * WikitextContent::getParserOutput instead of calling this for redirects. + * + * @param Title|array $target Destination(s) to redirect + * @param bool $appendSubtitle [optional] + * @param bool $forceKnown Should the image be shown as a bluelink regardless of existence? + * @return string Containing HTML with redirect link + * + * @deprecated since 1.30 + */ + public function viewRedirect( $target, $appendSubtitle = true, $forceKnown = false ) { + $lang = $this->getTitle()->getPageLanguage(); + $out = $this->getContext()->getOutput(); + if ( $appendSubtitle ) { + $out->addSubtitle( wfMessage( 'redirectpagesub' ) ); + } + $out->addModuleStyles( 'mediawiki.action.view.redirectPage' ); + return static::getRedirectHeaderHtml( $lang, $target, $forceKnown ); + } + + /** + * Return the HTML for the top of a redirect page + * + * Chances are you should just be using the ParserOutput from + * WikitextContent::getParserOutput instead of calling this for redirects. + * + * @since 1.23 + * @param Language $lang + * @param Title|array $target Destination(s) to redirect + * @param bool $forceKnown Should the image be shown as a bluelink regardless of existence? + * @return string Containing HTML with redirect link + */ + public static function getRedirectHeaderHtml( Language $lang, $target, $forceKnown = false ) { + if ( !is_array( $target ) ) { + $target = [ $target ]; + } + + $html = '<ul class="redirectText">'; + /** @var Title $title */ + foreach ( $target as $title ) { + $html .= '<li>' . Linker::link( + $title, + htmlspecialchars( $title->getFullText() ), + [], + // Make sure wiki page redirects are not followed + $title->isRedirect() ? [ 'redirect' => 'no' ] : [], + ( $forceKnown ? [ 'known', 'noclasses' ] : [] ) + ) . '</li>'; + } + $html .= '</ul>'; + + $redirectToText = wfMessage( 'redirectto' )->inLanguage( $lang )->escaped(); + + return '<div class="redirectMsg">' . + '<p>' . $redirectToText . '</p>' . + $html . + '</div>'; + } + + /** + * Adds help link with an icon via page indicators. + * Link target can be overridden by a local message containing a wikilink: + * the message key is: 'namespace-' + namespace number + '-helppage'. + * @param string $to Target MediaWiki.org page title or encoded URL. + * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o. + * @since 1.25 + */ + public function addHelpLink( $to, $overrideBaseUrl = false ) { + $msg = wfMessage( + 'namespace-' . $this->getTitle()->getNamespace() . '-helppage' + ); + + $out = $this->getContext()->getOutput(); + if ( !$msg->isDisabled() ) { + $helpUrl = Skin::makeUrl( $msg->plain() ); + $out->addHelpLink( $helpUrl, true ); + } else { + $out->addHelpLink( $to, $overrideBaseUrl ); + } + } + + /** + * Handle action=render + */ + public function render() { + $this->getContext()->getRequest()->response()->header( 'X-Robots-Tag: noindex' ); + $this->getContext()->getOutput()->setArticleBodyOnly( true ); + $this->disableSectionEditForRender = true; + $this->view(); + } + + /** + * action=protect handler + */ + public function protect() { + $form = new ProtectionForm( $this ); + $form->execute(); + } + + /** + * action=unprotect handler (alias) + */ + public function unprotect() { + $this->protect(); + } + + /** + * UI entry point for page deletion + */ + public function delete() { + # This code desperately needs to be totally rewritten + + $title = $this->getTitle(); + $context = $this->getContext(); + $user = $context->getUser(); + $request = $context->getRequest(); + + # Check permissions + $permissionErrors = $title->getUserPermissionsErrors( 'delete', $user ); + if ( count( $permissionErrors ) ) { + throw new PermissionsError( 'delete', $permissionErrors ); + } + + # Read-only check... + if ( wfReadOnly() ) { + throw new ReadOnlyError; + } + + # Better double-check that it hasn't been deleted yet! + $this->mPage->loadPageData( + $request->wasPosted() ? WikiPage::READ_LATEST : WikiPage::READ_NORMAL + ); + if ( !$this->mPage->exists() ) { + $deleteLogPage = new LogPage( 'delete' ); + $outputPage = $context->getOutput(); + $outputPage->setPageTitle( $context->msg( 'cannotdelete-title', $title->getPrefixedText() ) ); + $outputPage->wrapWikiMsg( "<div class=\"error mw-error-cannotdelete\">\n$1\n</div>", + [ 'cannotdelete', wfEscapeWikiText( $title->getPrefixedText() ) ] + ); + $outputPage->addHTML( + Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) + ); + LogEventsList::showLogExtract( + $outputPage, + 'delete', + $title + ); + + return; + } + + $deleteReasonList = $request->getText( 'wpDeleteReasonList', 'other' ); + $deleteReason = $request->getText( 'wpReason' ); + + if ( $deleteReasonList == 'other' ) { + $reason = $deleteReason; + } elseif ( $deleteReason != '' ) { + // Entry from drop down menu + additional comment + $colonseparator = wfMessage( 'colon-separator' )->inContentLanguage()->text(); + $reason = $deleteReasonList . $colonseparator . $deleteReason; + } else { + $reason = $deleteReasonList; + } + + if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ), + [ 'delete', $this->getTitle()->getPrefixedText() ] ) + ) { + # Flag to hide all contents of the archived revisions + $suppress = $request->getCheck( 'wpSuppress' ) && $user->isAllowed( 'suppressrevision' ); + + $this->doDelete( $reason, $suppress ); + + WatchAction::doWatchOrUnwatch( $request->getCheck( 'wpWatch' ), $title, $user ); + + return; + } + + // Generate deletion reason + $hasHistory = false; + if ( !$reason ) { + try { + $reason = $this->generateReason( $hasHistory ); + } catch ( Exception $e ) { + # if a page is horribly broken, we still want to be able to + # delete it. So be lenient about errors here. + wfDebug( "Error while building auto delete summary: $e" ); + $reason = ''; + } + } + + // If the page has a history, insert a warning + if ( $hasHistory ) { + $title = $this->getTitle(); + + // The following can use the real revision count as this is only being shown for users + // that can delete this page. + // This, as a side-effect, also makes sure that the following query isn't being run for + // pages with a larger history, unless the user has the 'bigdelete' right + // (and is about to delete this page). + $dbr = wfGetDB( DB_REPLICA ); + $revisions = $edits = (int)$dbr->selectField( + 'revision', + 'COUNT(rev_page)', + [ 'rev_page' => $title->getArticleID() ], + __METHOD__ + ); + + // @todo FIXME: i18n issue/patchwork message + $context->getOutput()->addHTML( + '<strong class="mw-delete-warning-revisions">' . + $context->msg( 'historywarning' )->numParams( $revisions )->parse() . + $context->msg( 'word-separator' )->escaped() . Linker::linkKnown( $title, + $context->msg( 'history' )->escaped(), + [], + [ 'action' => 'history' ] ) . + '</strong>' + ); + + if ( $title->isBigDeletion() ) { + global $wgDeleteRevisionsLimit; + $context->getOutput()->wrapWikiMsg( "<div class='error'>\n$1\n</div>\n", + [ + 'delete-warning-toobig', + $context->getLanguage()->formatNum( $wgDeleteRevisionsLimit ) + ] + ); + } + } + + $this->confirmDelete( $reason ); + } + + /** + * Output deletion confirmation dialog + * @todo FIXME: Move to another file? + * @param string $reason Prefilled reason + */ + public function confirmDelete( $reason ) { + wfDebug( "Article::confirmDelete\n" ); + + $title = $this->getTitle(); + $ctx = $this->getContext(); + $outputPage = $ctx->getOutput(); + $outputPage->setPageTitle( wfMessage( 'delete-confirm', $title->getPrefixedText() ) ); + $outputPage->addBacklinkSubtitle( $title ); + $outputPage->setRobotPolicy( 'noindex,nofollow' ); + $outputPage->addModules( 'mediawiki.action.delete' ); + + $backlinkCache = $title->getBacklinkCache(); + if ( $backlinkCache->hasLinks( 'pagelinks' ) || $backlinkCache->hasLinks( 'templatelinks' ) ) { + $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", + 'deleting-backlinks-warning' ); + } + + $subpageQueryLimit = 51; + $subpages = $title->getSubpages( $subpageQueryLimit ); + $subpageCount = count( $subpages ); + if ( $subpageCount > 0 ) { + $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", + [ 'deleting-subpages-warning', Message::numParam( $subpageCount ) ] ); + } + $outputPage->addWikiMsg( 'confirmdeletetext' ); + + Hooks::run( 'ArticleConfirmDelete', [ $this, $outputPage, &$reason ] ); + + $user = $this->getContext()->getUser(); + $checkWatch = $user->getBoolOption( 'watchdeletion' ) || $user->isWatched( $title ); + + $outputPage->enableOOUI(); + + $options = Xml::listDropDownOptions( + $ctx->msg( 'deletereason-dropdown' )->inContentLanguage()->text(), + [ 'other' => $ctx->msg( 'deletereasonotherlist' )->inContentLanguage()->text() ] + ); + $options = Xml::listDropDownOptionsOoui( $options ); + + $fields[] = new OOUI\FieldLayout( + new OOUI\DropdownInputWidget( [ + 'name' => 'wpDeleteReasonList', + 'inputId' => 'wpDeleteReasonList', + 'tabIndex' => 1, + 'infusable' => true, + 'value' => '', + 'options' => $options + ] ), + [ + 'label' => $ctx->msg( 'deletecomment' )->text(), + 'align' => 'top', + ] + ); + + // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP + // (e.g. emojis) count for two each. This limit is overridden in JS to instead count + // Unicode codepoints (or 255 UTF-8 bytes for old schema). + $conf = $this->getContext()->getConfig(); + $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD; + $fields[] = new OOUI\FieldLayout( + new OOUI\TextInputWidget( [ + 'name' => 'wpReason', + 'inputId' => 'wpReason', + 'tabIndex' => 2, + 'maxLength' => $oldCommentSchema ? 255 : CommentStore::COMMENT_CHARACTER_LIMIT, + 'infusable' => true, + 'value' => $reason, + 'autofocus' => true, + ] ), + [ + 'label' => $ctx->msg( 'deleteotherreason' )->text(), + 'align' => 'top', + ] + ); + + if ( $user->isLoggedIn() ) { + $fields[] = new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpWatch', + 'inputId' => 'wpWatch', + 'tabIndex' => 3, + 'selected' => $checkWatch, + ] ), + [ + 'label' => $ctx->msg( 'watchthis' )->text(), + 'align' => 'inline', + 'infusable' => true, + ] + ); + } + + if ( $user->isAllowed( 'suppressrevision' ) ) { + $fields[] = new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpSuppress', + 'inputId' => 'wpSuppress', + 'tabIndex' => 4, + ] ), + [ + 'label' => $ctx->msg( 'revdelete-suppress' )->text(), + 'align' => 'inline', + 'infusable' => true, + ] + ); + } + + $fields[] = new OOUI\FieldLayout( + new OOUI\ButtonInputWidget( [ + 'name' => 'wpConfirmB', + 'inputId' => 'wpConfirmB', + 'tabIndex' => 5, + 'value' => $ctx->msg( 'deletepage' )->text(), + 'label' => $ctx->msg( 'deletepage' )->text(), + 'flags' => [ 'primary', 'destructive' ], + 'type' => 'submit', + ] ), + [ + 'align' => 'top', + ] + ); + + $fieldset = new OOUI\FieldsetLayout( [ + 'label' => $ctx->msg( 'delete-legend' )->text(), + 'id' => 'mw-delete-table', + 'items' => $fields, + ] ); + + $form = new OOUI\FormLayout( [ + 'method' => 'post', + 'action' => $title->getLocalURL( 'action=delete' ), + 'id' => 'deleteconfirm', + ] ); + $form->appendContent( + $fieldset, + new OOUI\HtmlSnippet( + Html::hidden( 'wpEditToken', $user->getEditToken( [ 'delete', $title->getPrefixedText() ] ) ) + ) + ); + + $outputPage->addHTML( + new OOUI\PanelLayout( [ + 'classes' => [ 'deletepage-wrapper' ], + 'expanded' => false, + 'padded' => true, + 'framed' => true, + 'content' => $form, + ] ) + ); + + if ( $user->isAllowed( 'editinterface' ) ) { + $link = Linker::linkKnown( + $ctx->msg( 'deletereason-dropdown' )->inContentLanguage()->getTitle(), + wfMessage( 'delete-edit-reasonlist' )->escaped(), + [], + [ 'action' => 'edit' ] + ); + $outputPage->addHTML( '<p class="mw-delete-editreasons">' . $link . '</p>' ); + } + + $deleteLogPage = new LogPage( 'delete' ); + $outputPage->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) ); + LogEventsList::showLogExtract( $outputPage, 'delete', $title ); + } + + /** + * Perform a deletion and output success or failure messages + * @param string $reason + * @param bool $suppress + */ + public function doDelete( $reason, $suppress = false ) { + $error = ''; + $context = $this->getContext(); + $outputPage = $context->getOutput(); + $user = $context->getUser(); + $status = $this->mPage->doDeleteArticleReal( $reason, $suppress, 0, true, $error, $user ); + + if ( $status->isGood() ) { + $deleted = $this->getTitle()->getPrefixedText(); + + $outputPage->setPageTitle( wfMessage( 'actioncomplete' ) ); + $outputPage->setRobotPolicy( 'noindex,nofollow' ); + + $loglink = '[[Special:Log/delete|' . wfMessage( 'deletionlog' )->text() . ']]'; + + $outputPage->addWikiMsg( 'deletedtext', wfEscapeWikiText( $deleted ), $loglink ); + + Hooks::run( 'ArticleDeleteAfterSuccess', [ $this->getTitle(), $outputPage ] ); + + $outputPage->returnToMain( false ); + } else { + $outputPage->setPageTitle( + wfMessage( 'cannotdelete-title', + $this->getTitle()->getPrefixedText() ) + ); + + if ( $error == '' ) { + $outputPage->addWikiText( + "<div class=\"error mw-error-cannotdelete\">\n" . $status->getWikiText() . "\n</div>" + ); + $deleteLogPage = new LogPage( 'delete' ); + $outputPage->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) ); + + LogEventsList::showLogExtract( + $outputPage, + 'delete', + $this->getTitle() + ); + } else { + $outputPage->addHTML( $error ); + } + } + } + + /* Caching functions */ + + /** + * checkLastModified returns true if it has taken care of all + * output to the client that is necessary for this request. + * (that is, it has sent a cached version of the page) + * + * @return bool True if cached version send, false otherwise + */ + protected function tryFileCache() { + static $called = false; + + if ( $called ) { + wfDebug( "Article::tryFileCache(): called twice!?\n" ); + return false; + } + + $called = true; + if ( $this->isFileCacheable() ) { + $cache = new HTMLFileCache( $this->getTitle(), 'view' ); + if ( $cache->isCacheGood( $this->mPage->getTouched() ) ) { + wfDebug( "Article::tryFileCache(): about to load file\n" ); + $cache->loadFromFileCache( $this->getContext() ); + return true; + } else { + wfDebug( "Article::tryFileCache(): starting buffer\n" ); + ob_start( [ &$cache, 'saveToFileCache' ] ); + } + } else { + wfDebug( "Article::tryFileCache(): not cacheable\n" ); + } + + return false; + } + + /** + * Check if the page can be cached + * @param int $mode One of the HTMLFileCache::MODE_* constants (since 1.28) + * @return bool + */ + public function isFileCacheable( $mode = HTMLFileCache::MODE_NORMAL ) { + $cacheable = false; + + if ( HTMLFileCache::useFileCache( $this->getContext(), $mode ) ) { + $cacheable = $this->mPage->getId() + && !$this->mRedirectedFrom && !$this->getTitle()->isRedirect(); + // Extension may have reason to disable file caching on some pages. + if ( $cacheable ) { + // Avoid PHP 7.1 warning of passing $this by reference + $articlePage = $this; + $cacheable = Hooks::run( 'IsFileCacheable', [ &$articlePage ] ); + } + } + + return $cacheable; + } + + /**#@-*/ + + /** + * Lightweight method to get the parser output for a page, checking the parser cache + * and so on. Doesn't consider most of the stuff that WikiPage::view is forced to + * consider, so it's not appropriate to use there. + * + * @since 1.16 (r52326) for LiquidThreads + * + * @param int|null $oldid Revision ID or null + * @param User $user The relevant user + * @return ParserOutput|bool ParserOutput or false if the given revision ID is not found + */ + public function getParserOutput( $oldid = null, User $user = null ) { + // XXX: bypasses mParserOptions and thus setParserOptions() + + if ( $user === null ) { + $parserOptions = $this->getParserOptions(); + } else { + $parserOptions = $this->mPage->makeParserOptions( $user ); + } + + return $this->mPage->getParserOutput( $parserOptions, $oldid ); + } + + /** + * Override the ParserOptions used to render the primary article wikitext. + * + * @param ParserOptions $options + * @throws MWException If the parser options where already initialized. + */ + public function setParserOptions( ParserOptions $options ) { + if ( $this->mParserOptions ) { + throw new MWException( "can't change parser options after they have already been set" ); + } + + // clone, so if $options is modified later, it doesn't confuse the parser cache. + $this->mParserOptions = clone $options; + } + + /** + * Get parser options suitable for rendering the primary article wikitext + * @return ParserOptions + */ + public function getParserOptions() { + if ( !$this->mParserOptions ) { + $this->mParserOptions = $this->mPage->makeParserOptions( $this->getContext() ); + } + // Clone to allow modifications of the return value without affecting cache + return clone $this->mParserOptions; + } + + /** + * Sets the context this Article is executed in + * + * @param IContextSource $context + * @since 1.18 + */ + public function setContext( $context ) { + $this->mContext = $context; + } + + /** + * Gets the context this Article is executed in + * + * @return IContextSource + * @since 1.18 + */ + public function getContext() { + if ( $this->mContext instanceof IContextSource ) { + return $this->mContext; + } else { + wfDebug( __METHOD__ . " called and \$mContext is null. " . + "Return RequestContext::getMain(); for sanity\n" ); + return RequestContext::getMain(); + } + } + + /** + * Use PHP's magic __get handler to handle accessing of + * raw WikiPage fields for backwards compatibility. + * + * @param string $fname Field name + * @return mixed + */ + public function __get( $fname ) { + if ( property_exists( $this->mPage, $fname ) ) { + # wfWarn( "Access to raw $fname field " . __CLASS__ ); + return $this->mPage->$fname; + } + trigger_error( 'Inaccessible property via __get(): ' . $fname, E_USER_NOTICE ); + } + + /** + * Use PHP's magic __set handler to handle setting of + * raw WikiPage fields for backwards compatibility. + * + * @param string $fname Field name + * @param mixed $fvalue New value + */ + public function __set( $fname, $fvalue ) { + if ( property_exists( $this->mPage, $fname ) ) { + # wfWarn( "Access to raw $fname field of " . __CLASS__ ); + $this->mPage->$fname = $fvalue; + // Note: extensions may want to toss on new fields + } elseif ( !in_array( $fname, [ 'mContext', 'mPage' ] ) ) { + $this->mPage->$fname = $fvalue; + } else { + trigger_error( 'Inaccessible property via __set(): ' . $fname, E_USER_NOTICE ); + } + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::checkFlags + */ + public function checkFlags( $flags ) { + return $this->mPage->checkFlags( $flags ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::checkTouched + */ + public function checkTouched() { + return $this->mPage->checkTouched(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::clearPreparedEdit + */ + public function clearPreparedEdit() { + $this->mPage->clearPreparedEdit(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::doDeleteArticleReal + */ + public function doDeleteArticleReal( + $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null, + $tags = [] + ) { + return $this->mPage->doDeleteArticleReal( + $reason, $suppress, $u1, $u2, $error, $user, $tags + ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::doDeleteUpdates + */ + public function doDeleteUpdates( $id, Content $content = null ) { + return $this->mPage->doDeleteUpdates( $id, $content ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @deprecated since 1.29. Use WikiPage::doEditContent() directly instead + * @see WikiPage::doEditContent + */ + public function doEditContent( Content $content, $summary, $flags = 0, $baseRevId = false, + User $user = null, $serialFormat = null + ) { + wfDeprecated( __METHOD__, '1.29' ); + return $this->mPage->doEditContent( $content, $summary, $flags, $baseRevId, + $user, $serialFormat + ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::doEditUpdates + */ + public function doEditUpdates( Revision $revision, User $user, array $options = [] ) { + return $this->mPage->doEditUpdates( $revision, $user, $options ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::doPurge + * @note In 1.28 (and only 1.28), this took a $flags parameter that + * controlled how much purging was done. + */ + public function doPurge() { + return $this->mPage->doPurge(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::doViewUpdates + */ + public function doViewUpdates( User $user, $oldid = 0 ) { + $this->mPage->doViewUpdates( $user, $oldid ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::exists + */ + public function exists() { + return $this->mPage->exists(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::followRedirect + */ + public function followRedirect() { + return $this->mPage->followRedirect(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see ContentHandler::getActionOverrides + */ + public function getActionOverrides() { + return $this->mPage->getActionOverrides(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getAutoDeleteReason + */ + public function getAutoDeleteReason( &$hasHistory ) { + return $this->mPage->getAutoDeleteReason( $hasHistory ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getCategories + */ + public function getCategories() { + return $this->mPage->getCategories(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getComment + */ + public function getComment( $audience = Revision::FOR_PUBLIC, User $user = null ) { + return $this->mPage->getComment( $audience, $user ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getContentHandler + */ + public function getContentHandler() { + return $this->mPage->getContentHandler(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getContentModel + */ + public function getContentModel() { + return $this->mPage->getContentModel(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getContributors + */ + public function getContributors() { + return $this->mPage->getContributors(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getCreator + */ + public function getCreator( $audience = Revision::FOR_PUBLIC, User $user = null ) { + return $this->mPage->getCreator( $audience, $user ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getDeletionUpdates + */ + public function getDeletionUpdates( Content $content = null ) { + return $this->mPage->getDeletionUpdates( $content ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getHiddenCategories + */ + public function getHiddenCategories() { + return $this->mPage->getHiddenCategories(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getId + */ + public function getId() { + return $this->mPage->getId(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getLatest + */ + public function getLatest() { + return $this->mPage->getLatest(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getLinksTimestamp + */ + public function getLinksTimestamp() { + return $this->mPage->getLinksTimestamp(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getMinorEdit + */ + public function getMinorEdit() { + return $this->mPage->getMinorEdit(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getOldestRevision + */ + public function getOldestRevision() { + return $this->mPage->getOldestRevision(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getRedirectTarget + */ + public function getRedirectTarget() { + return $this->mPage->getRedirectTarget(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getRedirectURL + */ + public function getRedirectURL( $rt ) { + return $this->mPage->getRedirectURL( $rt ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getRevision + */ + public function getRevision() { + return $this->mPage->getRevision(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getTimestamp + */ + public function getTimestamp() { + return $this->mPage->getTimestamp(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getTouched + */ + public function getTouched() { + return $this->mPage->getTouched(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getUndoContent + */ + public function getUndoContent( Revision $undo, Revision $undoafter = null ) { + return $this->mPage->getUndoContent( $undo, $undoafter ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getUser + */ + public function getUser( $audience = Revision::FOR_PUBLIC, User $user = null ) { + return $this->mPage->getUser( $audience, $user ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::getUserText + */ + public function getUserText( $audience = Revision::FOR_PUBLIC, User $user = null ) { + return $this->mPage->getUserText( $audience, $user ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::hasViewableContent + */ + public function hasViewableContent() { + return $this->mPage->hasViewableContent(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::insertOn + */ + public function insertOn( $dbw, $pageId = null ) { + return $this->mPage->insertOn( $dbw, $pageId ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::insertProtectNullRevision + */ + public function insertProtectNullRevision( $revCommentMsg, array $limit, + array $expiry, $cascade, $reason, $user = null + ) { + return $this->mPage->insertProtectNullRevision( $revCommentMsg, $limit, + $expiry, $cascade, $reason, $user + ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::insertRedirect + */ + public function insertRedirect() { + return $this->mPage->insertRedirect(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::insertRedirectEntry + */ + public function insertRedirectEntry( Title $rt, $oldLatest = null ) { + return $this->mPage->insertRedirectEntry( $rt, $oldLatest ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::isCountable + */ + public function isCountable( $editInfo = false ) { + return $this->mPage->isCountable( $editInfo ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::isRedirect + */ + public function isRedirect() { + return $this->mPage->isRedirect(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::loadFromRow + */ + public function loadFromRow( $data, $from ) { + return $this->mPage->loadFromRow( $data, $from ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::loadPageData + */ + public function loadPageData( $from = 'fromdb' ) { + $this->mPage->loadPageData( $from ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::lockAndGetLatest + */ + public function lockAndGetLatest() { + return $this->mPage->lockAndGetLatest(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::makeParserOptions + */ + public function makeParserOptions( $context ) { + return $this->mPage->makeParserOptions( $context ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::pageDataFromId + */ + public function pageDataFromId( $dbr, $id, $options = [] ) { + return $this->mPage->pageDataFromId( $dbr, $id, $options ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::pageDataFromTitle + */ + public function pageDataFromTitle( $dbr, $title, $options = [] ) { + return $this->mPage->pageDataFromTitle( $dbr, $title, $options ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::prepareContentForEdit + */ + public function prepareContentForEdit( + Content $content, $revision = null, User $user = null, + $serialFormat = null, $useCache = true + ) { + return $this->mPage->prepareContentForEdit( + $content, $revision, $user, + $serialFormat, $useCache + ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::protectDescription + */ + public function protectDescription( array $limit, array $expiry ) { + return $this->mPage->protectDescription( $limit, $expiry ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::protectDescriptionLog + */ + public function protectDescriptionLog( array $limit, array $expiry ) { + return $this->mPage->protectDescriptionLog( $limit, $expiry ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::replaceSectionAtRev + */ + public function replaceSectionAtRev( $sectionId, Content $sectionContent, + $sectionTitle = '', $baseRevId = null + ) { + return $this->mPage->replaceSectionAtRev( $sectionId, $sectionContent, + $sectionTitle, $baseRevId + ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::replaceSectionContent + */ + public function replaceSectionContent( + $sectionId, Content $sectionContent, $sectionTitle = '', $edittime = null + ) { + return $this->mPage->replaceSectionContent( + $sectionId, $sectionContent, $sectionTitle, $edittime + ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::setTimestamp + */ + public function setTimestamp( $ts ) { + return $this->mPage->setTimestamp( $ts ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::shouldCheckParserCache + */ + public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) { + return $this->mPage->shouldCheckParserCache( $parserOptions, $oldId ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::supportsSections + */ + public function supportsSections() { + return $this->mPage->supportsSections(); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::triggerOpportunisticLinksUpdate + */ + public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) { + return $this->mPage->triggerOpportunisticLinksUpdate( $parserOutput ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::updateCategoryCounts + */ + public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) { + return $this->mPage->updateCategoryCounts( $added, $deleted, $id ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::updateIfNewerOn + */ + public function updateIfNewerOn( $dbw, $revision ) { + return $this->mPage->updateIfNewerOn( $dbw, $revision ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::updateRedirectOn + */ + public function updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect = null ) { + return $this->mPage->updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect ); + } + + /** + * Call to WikiPage function for backwards compatibility. + * @see WikiPage::updateRevisionOn + */ + public function updateRevisionOn( $dbw, $revision, $lastRevision = null, + $lastRevIsRedirect = null + ) { + return $this->mPage->updateRevisionOn( $dbw, $revision, $lastRevision, + $lastRevIsRedirect + ); + } + + /** + * @param array $limit + * @param array $expiry + * @param bool &$cascade + * @param string $reason + * @param User $user + * @return Status + */ + public function doUpdateRestrictions( array $limit, array $expiry, &$cascade, + $reason, User $user + ) { + return $this->mPage->doUpdateRestrictions( $limit, $expiry, $cascade, $reason, $user ); + } + + /** + * @param array $limit + * @param string $reason + * @param int &$cascade + * @param array $expiry + * @return bool + */ + public function updateRestrictions( $limit = [], $reason = '', + &$cascade = 0, $expiry = [] + ) { + return $this->mPage->doUpdateRestrictions( + $limit, + $expiry, + $cascade, + $reason, + $this->getContext()->getUser() + ); + } + + /** + * @param string $reason + * @param bool $suppress + * @param int $u1 Unused + * @param bool $u2 Unused + * @param string &$error + * @return bool + */ + public function doDeleteArticle( + $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '' + ) { + return $this->mPage->doDeleteArticle( $reason, $suppress, $u1, $u2, $error ); + } + + /** + * @param string $fromP + * @param string $summary + * @param string $token + * @param bool $bot + * @param array &$resultDetails + * @param User|null $user + * @return array + */ + public function doRollback( $fromP, $summary, $token, $bot, &$resultDetails, User $user = null ) { + $user = is_null( $user ) ? $this->getContext()->getUser() : $user; + return $this->mPage->doRollback( $fromP, $summary, $token, $bot, $resultDetails, $user ); + } + + /** + * @param string $fromP + * @param string $summary + * @param bool $bot + * @param array &$resultDetails + * @param User|null $guser + * @return array + */ + public function commitRollback( $fromP, $summary, $bot, &$resultDetails, User $guser = null ) { + $guser = is_null( $guser ) ? $this->getContext()->getUser() : $guser; + return $this->mPage->commitRollback( $fromP, $summary, $bot, $resultDetails, $guser ); + } + + /** + * @param bool &$hasHistory + * @return mixed + */ + public function generateReason( &$hasHistory ) { + $title = $this->mPage->getTitle(); + $handler = ContentHandler::getForTitle( $title ); + return $handler->getAutoDeleteReason( $title, $hasHistory ); + } + + // ****** +} diff --git a/www/wiki/includes/page/CategoryPage.php b/www/wiki/includes/page/CategoryPage.php new file mode 100644 index 00000000..491726be --- /dev/null +++ b/www/wiki/includes/page/CategoryPage.php @@ -0,0 +1,128 @@ +<?php +/** + * Special handling for category description pages. + * Modelled after ImagePage.php. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +/** + * Special handling for category description pages, showing pages, + * subcategories and file that belong to the category + */ +class CategoryPage extends Article { + # Subclasses can change this to override the viewer class. + protected $mCategoryViewerClass = CategoryViewer::class; + + /** + * @var WikiCategoryPage + */ + protected $mPage; + + /** + * @param Title $title + * @return WikiCategoryPage + */ + protected function newPage( Title $title ) { + // Overload mPage with a category-specific page + return new WikiCategoryPage( $title ); + } + + function view() { + $request = $this->getContext()->getRequest(); + $diff = $request->getVal( 'diff' ); + $diffOnly = $request->getBool( 'diffonly', + $this->getContext()->getUser()->getOption( 'diffonly' ) ); + + if ( $diff !== null && $diffOnly ) { + parent::view(); + return; + } + + // Avoid PHP 7.1 warning of passing $this by reference + $categoryPage = $this; + + if ( !Hooks::run( 'CategoryPageView', [ &$categoryPage ] ) ) { + return; + } + + $title = $this->getTitle(); + if ( $title->inNamespace( NS_CATEGORY ) ) { + $this->openShowCategory(); + } + + parent::view(); + + if ( $title->inNamespace( NS_CATEGORY ) ) { + $this->closeShowCategory(); + } + + # Use adaptive TTLs for CDN so delayed/failed purges are noticed less often + $outputPage = $this->getContext()->getOutput(); + $outputPage->adaptCdnTTL( $this->mPage->getTouched(), IExpiringStore::TTL_MINUTE ); + } + + function openShowCategory() { + # For overloading + } + + function closeShowCategory() { + // Use these as defaults for back compat --catrope + $request = $this->getContext()->getRequest(); + $oldFrom = $request->getVal( 'from' ); + $oldUntil = $request->getVal( 'until' ); + + $reqArray = $request->getValues(); + + $from = $until = []; + foreach ( [ 'page', 'subcat', 'file' ] as $type ) { + $from[$type] = $request->getVal( "{$type}from", $oldFrom ); + $until[$type] = $request->getVal( "{$type}until", $oldUntil ); + + // Do not want old-style from/until propagating in nav links. + if ( !isset( $reqArray["{$type}from"] ) && isset( $reqArray["from"] ) ) { + $reqArray["{$type}from"] = $reqArray["from"]; + } + if ( !isset( $reqArray["{$type}to"] ) && isset( $reqArray["to"] ) ) { + $reqArray["{$type}to"] = $reqArray["to"]; + } + } + + unset( $reqArray["from"] ); + unset( $reqArray["to"] ); + + $viewer = new $this->mCategoryViewerClass( + $this->getContext()->getTitle(), + $this->getContext(), + $from, + $until, + $reqArray + ); + $out = $this->getContext()->getOutput(); + $out->addHTML( $viewer->getHTML() ); + $this->addHelpLink( 'Help:Categories' ); + } + + function getCategoryViewerClass() { + return $this->mCategoryViewerClass; + } + + function setCategoryViewerClass( $class ) { + $this->mCategoryViewerClass = $class; + } +} diff --git a/www/wiki/includes/page/ImageHistoryList.php b/www/wiki/includes/page/ImageHistoryList.php new file mode 100644 index 00000000..bb8ed242 --- /dev/null +++ b/www/wiki/includes/page/ImageHistoryList.php @@ -0,0 +1,326 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +/** + * Builds the image revision log shown on image pages + * + * @ingroup Media + */ +class ImageHistoryList extends ContextSource { + + /** + * @var Title + */ + protected $title; + + /** + * @var File + */ + protected $img; + + /** + * @var ImagePage + */ + protected $imagePage; + + /** + * @var File + */ + protected $current; + + protected $repo, $showThumb; + protected $preventClickjacking = false; + + /** + * @param ImagePage $imagePage + */ + public function __construct( $imagePage ) { + global $wgShowArchiveThumbnails; + $this->current = $imagePage->getPage()->getFile(); + $this->img = $imagePage->getDisplayedFile(); + $this->title = $imagePage->getTitle(); + $this->imagePage = $imagePage; + $this->showThumb = $wgShowArchiveThumbnails && $this->img->canRender(); + $this->setContext( $imagePage->getContext() ); + } + + /** + * @return ImagePage + */ + public function getImagePage() { + return $this->imagePage; + } + + /** + * @return File + */ + public function getFile() { + return $this->img; + } + + /** + * @param string $navLinks + * @return string + */ + public function beginImageHistoryList( $navLinks = '' ) { + return Xml::element( 'h2', [ 'id' => 'filehistory' ], $this->msg( 'filehist' )->text() ) + . "\n" + . "<div id=\"mw-imagepage-section-filehistory\">\n" + . $this->msg( 'filehist-help' )->parseAsBlock() + . $navLinks . "\n" + . Xml::openElement( 'table', [ 'class' => 'wikitable filehistory' ] ) . "\n" + . '<tr><th></th>' + . ( $this->current->isLocal() + && ( $this->getUser()->isAllowedAny( 'delete', 'deletedhistory' ) ) ? '<th></th>' : '' ) + . '<th>' . $this->msg( 'filehist-datetime' )->escaped() . '</th>' + . ( $this->showThumb ? '<th>' . $this->msg( 'filehist-thumb' )->escaped() . '</th>' : '' ) + . '<th>' . $this->msg( 'filehist-dimensions' )->escaped() . '</th>' + . '<th>' . $this->msg( 'filehist-user' )->escaped() . '</th>' + . '<th>' . $this->msg( 'filehist-comment' )->escaped() . '</th>' + . "</tr>\n"; + } + + /** + * @param string $navLinks + * @return string + */ + public function endImageHistoryList( $navLinks = '' ) { + return "</table>\n$navLinks\n</div>\n"; + } + + /** + * @param bool $iscur + * @param File $file + * @return string + */ + public function imageHistoryLine( $iscur, $file ) { + global $wgContLang; + + $user = $this->getUser(); + $lang = $this->getLanguage(); + $timestamp = wfTimestamp( TS_MW, $file->getTimestamp() ); + $img = $iscur ? $file->getName() : $file->getArchiveName(); + $userId = $file->getUser( 'id' ); + $userText = $file->getUser( 'text' ); + $description = $file->getDescription( File::FOR_THIS_USER, $user ); + + $local = $this->current->isLocal(); + $row = $selected = ''; + + // Deletion link + if ( $local && ( $user->isAllowedAny( 'delete', 'deletedhistory' ) ) ) { + $row .= '<td>'; + # Link to remove from history + if ( $user->isAllowed( 'delete' ) ) { + $q = [ 'action' => 'delete' ]; + if ( !$iscur ) { + $q['oldimage'] = $img; + } + $row .= Linker::linkKnown( + $this->title, + $this->msg( $iscur ? 'filehist-deleteall' : 'filehist-deleteone' )->escaped(), + [], $q + ); + } + # Link to hide content. Don't show useless link to people who cannot hide revisions. + $canHide = $user->isAllowed( 'deleterevision' ); + if ( $canHide || ( $user->isAllowed( 'deletedhistory' ) && $file->getVisibility() ) ) { + if ( $user->isAllowed( 'delete' ) ) { + $row .= '<br />'; + } + // If file is top revision or locked from this user, don't link + if ( $iscur || !$file->userCan( File::DELETED_RESTRICTED, $user ) ) { + $del = Linker::revDeleteLinkDisabled( $canHide ); + } else { + list( $ts, ) = explode( '!', $img, 2 ); + $query = [ + 'type' => 'oldimage', + 'target' => $this->title->getPrefixedText(), + 'ids' => $ts, + ]; + $del = Linker::revDeleteLink( $query, + $file->isDeleted( File::DELETED_RESTRICTED ), $canHide ); + } + $row .= $del; + } + $row .= '</td>'; + } + + // Reversion link/current indicator + $row .= '<td>'; + if ( $iscur ) { + $row .= $this->msg( 'filehist-current' )->escaped(); + } elseif ( $local && $this->title->quickUserCan( 'edit', $user ) + && $this->title->quickUserCan( 'upload', $user ) + ) { + if ( $file->isDeleted( File::DELETED_FILE ) ) { + $row .= $this->msg( 'filehist-revert' )->escaped(); + } else { + $row .= Linker::linkKnown( + $this->title, + $this->msg( 'filehist-revert' )->escaped(), + [], + [ + 'action' => 'revert', + 'oldimage' => $img, + ] + ); + } + } + $row .= '</td>'; + + // Date/time and image link + if ( $file->getTimestamp() === $this->img->getTimestamp() ) { + $selected = "class='filehistory-selected'"; + } + $row .= "<td $selected style='white-space: nowrap;'>"; + if ( !$file->userCan( File::DELETED_FILE, $user ) ) { + # Don't link to unviewable files + $row .= '<span class="history-deleted">' + . $lang->userTimeAndDate( $timestamp, $user ) . '</span>'; + } elseif ( $file->isDeleted( File::DELETED_FILE ) ) { + if ( $local ) { + $this->preventClickjacking(); + $revdel = SpecialPage::getTitleFor( 'Revisiondelete' ); + # Make a link to review the image + $url = Linker::linkKnown( + $revdel, + $lang->userTimeAndDate( $timestamp, $user ), + [], + [ + 'target' => $this->title->getPrefixedText(), + 'file' => $img, + 'token' => $user->getEditToken( $img ) + ] + ); + } else { + $url = $lang->userTimeAndDate( $timestamp, $user ); + } + $row .= '<span class="history-deleted">' . $url . '</span>'; + } elseif ( !$file->exists() ) { + $row .= '<span class="mw-file-missing">' + . $lang->userTimeAndDate( $timestamp, $user ) . '</span>'; + } else { + $url = $iscur ? $this->current->getUrl() : $this->current->getArchiveUrl( $img ); + $row .= Xml::element( + 'a', + [ 'href' => $url ], + $lang->userTimeAndDate( $timestamp, $user ) + ); + } + $row .= "</td>"; + + // Thumbnail + if ( $this->showThumb ) { + $row .= '<td>' . $this->getThumbForLine( $file ) . '</td>'; + } + + // Image dimensions + size + $row .= '<td>'; + $row .= htmlspecialchars( $file->getDimensionsString() ); + $row .= $this->msg( 'word-separator' )->escaped(); + $row .= '<span style="white-space: nowrap;">'; + $row .= $this->msg( 'parentheses' )->sizeParams( $file->getSize() )->escaped(); + $row .= '</span>'; + $row .= '</td>'; + + // Uploading user + $row .= '<td>'; + // Hide deleted usernames + if ( $file->isDeleted( File::DELETED_USER ) ) { + $row .= '<span class="history-deleted">' + . $this->msg( 'rev-deleted-user' )->escaped() . '</span>'; + } else { + if ( $local ) { + $row .= Linker::userLink( $userId, $userText ); + $row .= '<span style="white-space: nowrap;">'; + $row .= Linker::userToolLinks( $userId, $userText ); + $row .= '</span>'; + } else { + $row .= htmlspecialchars( $userText ); + } + } + $row .= '</td>'; + + // Don't show deleted descriptions + if ( $file->isDeleted( File::DELETED_COMMENT ) ) { + $row .= '<td><span class="history-deleted">' . + $this->msg( 'rev-deleted-comment' )->escaped() . '</span></td>'; + } else { + $row .= '<td dir="' . $wgContLang->getDir() . '">' . + Linker::formatComment( $description, $this->title ) . '</td>'; + } + + $rowClass = null; + Hooks::run( 'ImagePageFileHistoryLine', [ $this, $file, &$row, &$rowClass ] ); + $classAttr = $rowClass ? " class='$rowClass'" : ''; + + return "<tr{$classAttr}>{$row}</tr>\n"; + } + + /** + * @param File $file + * @return string + */ + protected function getThumbForLine( $file ) { + $lang = $this->getLanguage(); + $user = $this->getUser(); + if ( $file->allowInlineDisplay() && $file->userCan( File::DELETED_FILE, $user ) + && !$file->isDeleted( File::DELETED_FILE ) + ) { + $params = [ + 'width' => '120', + 'height' => '120', + ]; + $timestamp = wfTimestamp( TS_MW, $file->getTimestamp() ); + + $thumbnail = $file->transform( $params ); + $options = [ + 'alt' => $this->msg( 'filehist-thumbtext', + $lang->userTimeAndDate( $timestamp, $user ), + $lang->userDate( $timestamp, $user ), + $lang->userTime( $timestamp, $user ) )->text(), + 'file-link' => true, + ]; + + if ( !$thumbnail ) { + return $this->msg( 'filehist-nothumb' )->escaped(); + } + + return $thumbnail->toHtml( $options ); + } else { + return $this->msg( 'filehist-nothumb' )->escaped(); + } + } + + /** + * @param bool $enable + */ + protected function preventClickjacking( $enable = true ) { + $this->preventClickjacking = $enable; + } + + /** + * @return bool + */ + public function getPreventClickjacking() { + return $this->preventClickjacking; + } +} diff --git a/www/wiki/includes/page/ImageHistoryPseudoPager.php b/www/wiki/includes/page/ImageHistoryPseudoPager.php new file mode 100644 index 00000000..20bc614b --- /dev/null +++ b/www/wiki/includes/page/ImageHistoryPseudoPager.php @@ -0,0 +1,228 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +class ImageHistoryPseudoPager extends ReverseChronologicalPager { + protected $preventClickjacking = false; + + /** + * @var File + */ + protected $mImg; + + /** + * @var Title + */ + protected $mTitle; + + /** + * @since 1.14 + * @var ImagePage + */ + public $mImagePage; + + /** + * @since 1.14 + * @var File[] + */ + public $mHist; + + /** + * @since 1.14 + * @var int[] + */ + public $mRange; + + /** + * @param ImagePage $imagePage + */ + public function __construct( $imagePage ) { + parent::__construct( $imagePage->getContext() ); + $this->mImagePage = $imagePage; + $this->mTitle = $imagePage->getTitle()->createFragmentTarget( 'filehistory' ); + $this->mImg = null; + $this->mHist = []; + $this->mRange = [ 0, 0 ]; // display range + + // Only display 10 revisions at once by default, otherwise the list is overwhelming + $this->mLimitsShown = array_merge( [ 10 ], $this->mLimitsShown ); + $this->mDefaultLimit = 10; + list( $this->mLimit, /* $offset */ ) = + $this->mRequest->getLimitOffset( $this->mDefaultLimit, '' ); + } + + /** + * @return Title + */ + public function getTitle() { + return $this->mTitle; + } + + public function getQueryInfo() { + return false; + } + + /** + * @return string + */ + public function getIndexField() { + return ''; + } + + /** + * @param object $row + * @return string + */ + public function formatRow( $row ) { + return ''; + } + + /** + * @return string + */ + public function getBody() { + $s = ''; + $this->doQuery(); + if ( count( $this->mHist ) ) { + if ( $this->mImg->isLocal() ) { + // Do a batch existence check for user pages and talkpages + $linkBatch = new LinkBatch(); + for ( $i = $this->mRange[0]; $i <= $this->mRange[1]; $i++ ) { + $file = $this->mHist[$i]; + $user = $file->getUser( 'text' ); + $linkBatch->add( NS_USER, $user ); + $linkBatch->add( NS_USER_TALK, $user ); + } + $linkBatch->execute(); + } + + $list = new ImageHistoryList( $this->mImagePage ); + # Generate prev/next links + $navLink = $this->getNavigationBar(); + $s = $list->beginImageHistoryList( $navLink ); + // Skip rows there just for paging links + for ( $i = $this->mRange[0]; $i <= $this->mRange[1]; $i++ ) { + $file = $this->mHist[$i]; + $s .= $list->imageHistoryLine( !$file->isOld(), $file ); + } + $s .= $list->endImageHistoryList( $navLink ); + + if ( $list->getPreventClickjacking() ) { + $this->preventClickjacking(); + } + } + return $s; + } + + public function doQuery() { + if ( $this->mQueryDone ) { + return; + } + $this->mImg = $this->mImagePage->getPage()->getFile(); // ensure loading + if ( !$this->mImg->exists() ) { + return; + } + $queryLimit = $this->mLimit + 1; // limit plus extra row + if ( $this->mIsBackwards ) { + // Fetch the file history + $this->mHist = $this->mImg->getHistory( $queryLimit, null, $this->mOffset, false ); + // The current rev may not meet the offset/limit + $numRows = count( $this->mHist ); + if ( $numRows <= $this->mLimit && $this->mImg->getTimestamp() > $this->mOffset ) { + $this->mHist = array_merge( [ $this->mImg ], $this->mHist ); + } + } else { + // The current rev may not meet the offset + if ( !$this->mOffset || $this->mImg->getTimestamp() < $this->mOffset ) { + $this->mHist[] = $this->mImg; + } + // Old image versions (fetch extra row for nav links) + $oiLimit = count( $this->mHist ) ? $this->mLimit : $this->mLimit + 1; + // Fetch the file history + $this->mHist = array_merge( $this->mHist, + $this->mImg->getHistory( $oiLimit, $this->mOffset, null, false ) ); + } + $numRows = count( $this->mHist ); // Total number of query results + if ( $numRows ) { + # Index value of top item in the list + $firstIndex = $this->mIsBackwards ? + $this->mHist[$numRows - 1]->getTimestamp() : $this->mHist[0]->getTimestamp(); + # Discard the extra result row if there is one + if ( $numRows > $this->mLimit && $numRows > 1 ) { + if ( $this->mIsBackwards ) { + # Index value of item past the index + $this->mPastTheEndIndex = $this->mHist[0]->getTimestamp(); + # Index value of bottom item in the list + $lastIndex = $this->mHist[1]->getTimestamp(); + # Display range + $this->mRange = [ 1, $numRows - 1 ]; + } else { + # Index value of item past the index + $this->mPastTheEndIndex = $this->mHist[$numRows - 1]->getTimestamp(); + # Index value of bottom item in the list + $lastIndex = $this->mHist[$numRows - 2]->getTimestamp(); + # Display range + $this->mRange = [ 0, $numRows - 2 ]; + } + } else { + # Setting indexes to an empty string means that they will be + # omitted if they would otherwise appear in URLs. It just so + # happens that this is the right thing to do in the standard + # UI, in all the relevant cases. + $this->mPastTheEndIndex = ''; + # Index value of bottom item in the list + $lastIndex = $this->mIsBackwards ? + $this->mHist[0]->getTimestamp() : $this->mHist[$numRows - 1]->getTimestamp(); + # Display range + $this->mRange = [ 0, $numRows - 1 ]; + } + } else { + $firstIndex = ''; + $lastIndex = ''; + $this->mPastTheEndIndex = ''; + } + if ( $this->mIsBackwards ) { + $this->mIsFirst = ( $numRows < $queryLimit ); + $this->mIsLast = ( $this->mOffset == '' ); + $this->mLastShown = $firstIndex; + $this->mFirstShown = $lastIndex; + } else { + $this->mIsFirst = ( $this->mOffset == '' ); + $this->mIsLast = ( $numRows < $queryLimit ); + $this->mLastShown = $lastIndex; + $this->mFirstShown = $firstIndex; + } + $this->mQueryDone = true; + } + + /** + * @param bool $enable + */ + protected function preventClickjacking( $enable = true ) { + $this->preventClickjacking = $enable; + } + + /** + * @return bool + */ + public function getPreventClickjacking() { + return $this->preventClickjacking; + } + +} diff --git a/www/wiki/includes/page/ImagePage.php b/www/wiki/includes/page/ImagePage.php new file mode 100644 index 00000000..b5ff8059 --- /dev/null +++ b/www/wiki/includes/page/ImagePage.php @@ -0,0 +1,1229 @@ +<?php +/** + * Special handling for file description pages. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +use Wikimedia\Rdbms\ResultWrapper; + +/** + * Class for viewing MediaWiki file description pages + * + * @ingroup Media + */ +class ImagePage extends Article { + /** @var File */ + private $displayImg; + + /** @var FileRepo */ + private $repo; + + /** @var bool */ + private $fileLoaded; + + /** @var bool */ + protected $mExtraDescription = false; + + /** + * @var WikiFilePage + */ + protected $mPage; + + /** + * @param Title $title + * @return WikiFilePage + */ + protected function newPage( Title $title ) { + // Overload mPage with a file-specific page + return new WikiFilePage( $title ); + } + + /** + * @param File $file + * @return void + */ + public function setFile( $file ) { + $this->mPage->setFile( $file ); + $this->displayImg = $file; + $this->fileLoaded = true; + } + + protected function loadFile() { + if ( $this->fileLoaded ) { + return; + } + $this->fileLoaded = true; + + $this->displayImg = $img = false; + + Hooks::run( 'ImagePageFindFile', [ $this, &$img, &$this->displayImg ] ); + if ( !$img ) { // not set by hook? + $img = wfFindFile( $this->getTitle() ); + if ( !$img ) { + $img = wfLocalFile( $this->getTitle() ); + } + } + $this->mPage->setFile( $img ); + if ( !$this->displayImg ) { // not set by hook? + $this->displayImg = $img; + } + $this->repo = $img->getRepo(); + } + + /** + * Handler for action=render + * Include body text only; none of the image extras + */ + public function render() { + $this->getContext()->getOutput()->setArticleBodyOnly( true ); + parent::view(); + } + + public function view() { + global $wgShowEXIF; + + $out = $this->getContext()->getOutput(); + $request = $this->getContext()->getRequest(); + $diff = $request->getVal( 'diff' ); + $diffOnly = $request->getBool( + 'diffonly', + $this->getContext()->getUser()->getOption( 'diffonly' ) + ); + + if ( $this->getTitle()->getNamespace() != NS_FILE || ( $diff !== null && $diffOnly ) ) { + parent::view(); + return; + } + + $this->loadFile(); + + if ( $this->getTitle()->getNamespace() == NS_FILE && $this->mPage->getFile()->getRedirected() ) { + if ( $this->getTitle()->getDBkey() == $this->mPage->getFile()->getName() || $diff !== null ) { + $request->setVal( 'diffonly', 'true' ); + } + + parent::view(); + return; + } + + if ( $wgShowEXIF && $this->displayImg->exists() ) { + // @todo FIXME: Bad interface, see note on MediaHandler::formatMetadata(). + $formattedMetadata = $this->displayImg->formatMetadata( $this->getContext() ); + $showmeta = $formattedMetadata !== false; + } else { + $showmeta = false; + } + + if ( !$diff && $this->displayImg->exists() ) { + $out->addHTML( $this->showTOC( $showmeta ) ); + } + + if ( !$diff ) { + $this->openShowImage(); + } + + # No need to display noarticletext, we use our own message, output in openShowImage() + if ( $this->mPage->getId() ) { + # NS_FILE is in the user language, but this section (the actual wikitext) + # should be in page content language + $pageLang = $this->getTitle()->getPageViewLanguage(); + $out->addHTML( Xml::openElement( 'div', [ 'id' => 'mw-imagepage-content', + 'lang' => $pageLang->getHtmlCode(), 'dir' => $pageLang->getDir(), + 'class' => 'mw-content-' . $pageLang->getDir() ] ) ); + + parent::view(); + + $out->addHTML( Xml::closeElement( 'div' ) ); + } else { + # Just need to set the right headers + $out->setArticleFlag( true ); + $out->setPageTitle( $this->getTitle()->getPrefixedText() ); + $this->mPage->doViewUpdates( $this->getContext()->getUser(), $this->getOldID() ); + } + + # Show shared description, if needed + if ( $this->mExtraDescription ) { + $fol = $this->getContext()->msg( 'shareddescriptionfollows' ); + if ( !$fol->isDisabled() ) { + $out->addWikiText( $fol->plain() ); + } + $out->addHTML( '<div id="shared-image-desc">' . $this->mExtraDescription . "</div>\n" ); + } + + $this->closeShowImage(); + $this->imageHistory(); + // TODO: Cleanup the following + + $out->addHTML( Xml::element( 'h2', + [ 'id' => 'filelinks' ], + $this->getContext()->msg( 'imagelinks' )->text() ) . "\n" ); + $this->imageDupes(); + # @todo FIXME: For some freaky reason, we can't redirect to foreign images. + # Yet we return metadata about the target. Definitely an issue in the FileRepo + $this->imageLinks(); + + # Allow extensions to add something after the image links + $html = ''; + Hooks::run( 'ImagePageAfterImageLinks', [ $this, &$html ] ); + if ( $html ) { + $out->addHTML( $html ); + } + + if ( $showmeta ) { + $out->addHTML( Xml::element( + 'h2', + [ 'id' => 'metadata' ], + $this->getContext()->msg( 'metadata' )->text() ) . "\n" ); + $out->addWikiText( $this->makeMetadataTable( $formattedMetadata ) ); + $out->addModules( [ 'mediawiki.action.view.metadata' ] ); + } + + // Add remote Filepage.css + if ( !$this->repo->isLocal() ) { + $css = $this->repo->getDescriptionStylesheetUrl(); + if ( $css ) { + $out->addStyle( $css ); + } + } + + $out->addModuleStyles( [ + 'filepage', // always show the local local Filepage.css, T31277 + 'mediawiki.action.view.filepage', // Add MediaWiki styles for a file page + ] ); + } + + /** + * @return File + */ + public function getDisplayedFile() { + $this->loadFile(); + return $this->displayImg; + } + + /** + * Create the TOC + * + * @param bool $metadata Whether or not to show the metadata link + * @return string + */ + protected function showTOC( $metadata ) { + $r = [ + '<li><a href="#file">' . $this->getContext()->msg( 'file-anchor-link' )->escaped() . '</a></li>', + '<li><a href="#filehistory">' . $this->getContext()->msg( 'filehist' )->escaped() . '</a></li>', + '<li><a href="#filelinks">' . $this->getContext()->msg( 'imagelinks' )->escaped() . '</a></li>', + ]; + + Hooks::run( 'ImagePageShowTOC', [ $this, &$r ] ); + + if ( $metadata ) { + $r[] = '<li><a href="#metadata">' . + $this->getContext()->msg( 'metadata' )->escaped() . + '</a></li>'; + } + + return '<ul id="filetoc">' . implode( "\n", $r ) . '</ul>'; + } + + /** + * Make a table with metadata to be shown in the output page. + * + * @todo FIXME: Bad interface, see note on MediaHandler::formatMetadata(). + * + * @param array $metadata The array containing the Exif data + * @return string The metadata table. This is treated as Wikitext (!) + */ + protected function makeMetadataTable( $metadata ) { + $r = "<div class=\"mw-imagepage-section-metadata\">"; + $r .= $this->getContext()->msg( 'metadata-help' )->plain(); + // Intial state is collapsed + // see filepage.css and mediawiki.action.view.metadata module. + $r .= "<table id=\"mw_metadata\" class=\"mw_metadata collapsed\">\n"; + foreach ( $metadata as $type => $stuff ) { + foreach ( $stuff as $v ) { + $class = str_replace( ' ', '_', $v['id'] ); + if ( $type == 'collapsed' ) { + $class .= ' mw-metadata-collapsible'; + } + $r .= Html::rawElement( 'tr', + [ 'class' => $class ], + Html::rawElement( 'th', [], $v['name'] ) + . Html::rawElement( 'td', [], $v['value'] ) + ); + } + } + $r .= "</table>\n</div>\n"; + return $r; + } + + /** + * Overloading Article's getContentObject method. + * + * Omit noarticletext if sharedupload; text will be fetched from the + * shared upload server if possible. + * @return string + */ + public function getContentObject() { + $this->loadFile(); + if ( $this->mPage->getFile() && !$this->mPage->getFile()->isLocal() && 0 == $this->getId() ) { + return null; + } + return parent::getContentObject(); + } + + private function getLanguageForRendering( WebRequest $request, File $file ) { + $handler = $this->displayImg->getHandler(); + if ( !$handler ) { + return null; + } + + $requestLanguage = $request->getVal( 'lang' ); + if ( !is_null( $requestLanguage ) ) { + if ( $handler->validateParam( 'lang', $requestLanguage ) ) { + return $requestLanguage; + } + } + + return $handler->getDefaultRenderLanguage( $this->displayImg ); + } + + protected function openShowImage() { + global $wgEnableUploads, $wgSend404Code, $wgSVGMaxSize; + + $this->loadFile(); + $out = $this->getContext()->getOutput(); + $user = $this->getContext()->getUser(); + $lang = $this->getContext()->getLanguage(); + $dirmark = $lang->getDirMarkEntity(); + $request = $this->getContext()->getRequest(); + + $max = $this->getImageLimitsFromOption( $user, 'imagesize' ); + $maxWidth = $max[0]; + $maxHeight = $max[1]; + + if ( $this->displayImg->exists() ) { + # image + $page = $request->getIntOrNull( 'page' ); + if ( is_null( $page ) ) { + $params = []; + $page = 1; + } else { + $params = [ 'page' => $page ]; + } + + $renderLang = $this->getLanguageForRendering( $request, $this->displayImg ); + if ( !is_null( $renderLang ) ) { + $params['lang'] = $renderLang; + } + + $width_orig = $this->displayImg->getWidth( $page ); + $width = $width_orig; + $height_orig = $this->displayImg->getHeight( $page ); + $height = $height_orig; + + $filename = wfEscapeWikiText( $this->displayImg->getName() ); + $linktext = $filename; + + // Avoid PHP 7.1 warning from passing $this by reference + $imagePage = $this; + + Hooks::run( 'ImageOpenShowImageInlineBefore', [ &$imagePage, &$out ] ); + + if ( $this->displayImg->allowInlineDisplay() ) { + # image + # "Download high res version" link below the image + # $msgsize = $this->getContext()->msg( 'file-info-size', $width_orig, $height_orig, + # Linker::formatSize( $this->displayImg->getSize() ), $mime )->escaped(); + # We'll show a thumbnail of this image + if ( $width > $maxWidth || $height > $maxHeight || $this->displayImg->isVectorized() ) { + list( $width, $height ) = $this->getDisplayWidthHeight( + $maxWidth, $maxHeight, $width, $height + ); + $linktext = $this->getContext()->msg( 'show-big-image' )->escaped(); + + $thumbSizes = $this->getThumbSizes( $width_orig, $height_orig ); + # Generate thumbnails or thumbnail links as needed... + $otherSizes = []; + foreach ( $thumbSizes as $size ) { + // We include a thumbnail size in the list, if it is + // less than or equal to the original size of the image + // asset ($width_orig/$height_orig). We also exclude + // the current thumbnail's size ($width/$height) + // since that is added to the message separately, so + // it can be denoted as the current size being shown. + // Vectorized images are limited by $wgSVGMaxSize big, + // so all thumbs less than or equal that are shown. + if ( ( ( $size[0] <= $width_orig && $size[1] <= $height_orig ) + || ( $this->displayImg->isVectorized() + && max( $size[0], $size[1] ) <= $wgSVGMaxSize ) + ) + && $size[0] != $width && $size[1] != $height + ) { + $sizeLink = $this->makeSizeLink( $params, $size[0], $size[1] ); + if ( $sizeLink ) { + $otherSizes[] = $sizeLink; + } + } + } + $otherSizes = array_unique( $otherSizes ); + + $sizeLinkBigImagePreview = $this->makeSizeLink( $params, $width, $height ); + $msgsmall = $this->getThumbPrevText( $params, $sizeLinkBigImagePreview ); + if ( count( $otherSizes ) ) { + $msgsmall .= ' ' . + Html::rawElement( + 'span', + [ 'class' => 'mw-filepage-other-resolutions' ], + $this->getContext()->msg( 'show-big-image-other' ) + ->rawParams( $lang->pipeList( $otherSizes ) ) + ->params( count( $otherSizes ) ) + ->parse() + ); + } + } elseif ( $width == 0 && $height == 0 ) { + # Some sort of audio file that doesn't have dimensions + # Don't output a no hi res message for such a file + $msgsmall = ''; + } else { + # Image is small enough to show full size on image page + $msgsmall = $this->getContext()->msg( 'file-nohires' )->parse(); + } + + $params['width'] = $width; + $params['height'] = $height; + $thumbnail = $this->displayImg->transform( $params ); + Linker::processResponsiveImages( $this->displayImg, $thumbnail, $params ); + + $anchorclose = Html::rawElement( + 'div', + [ 'class' => 'mw-filepage-resolutioninfo' ], + $msgsmall + ); + + $isMulti = $this->displayImg->isMultipage() && $this->displayImg->pageCount() > 1; + if ( $isMulti ) { + $out->addModules( 'mediawiki.page.image.pagination' ); + $out->addHTML( '<table class="multipageimage"><tr><td>' ); + } + + if ( $thumbnail ) { + $options = [ + 'alt' => $this->displayImg->getTitle()->getPrefixedText(), + 'file-link' => true, + ]; + $out->addHTML( '<div class="fullImageLink" id="file">' . + $thumbnail->toHtml( $options ) . + $anchorclose . "</div>\n" ); + } + + if ( $isMulti ) { + $count = $this->displayImg->pageCount(); + + if ( $page > 1 ) { + $label = $this->getContext()->msg( 'imgmultipageprev' )->text(); + // on the client side, this link is generated in ajaxifyPageNavigation() + // in the mediawiki.page.image.pagination module + $link = Linker::linkKnown( + $this->getTitle(), + $label, + [], + [ 'page' => $page - 1 ] + ); + $thumb1 = Linker::makeThumbLinkObj( + $this->getTitle(), + $this->displayImg, + $link, + $label, + 'none', + [ 'page' => $page - 1 ] + ); + } else { + $thumb1 = ''; + } + + if ( $page < $count ) { + $label = $this->getContext()->msg( 'imgmultipagenext' )->text(); + $link = Linker::linkKnown( + $this->getTitle(), + $label, + [], + [ 'page' => $page + 1 ] + ); + $thumb2 = Linker::makeThumbLinkObj( + $this->getTitle(), + $this->displayImg, + $link, + $label, + 'none', + [ 'page' => $page + 1 ] + ); + } else { + $thumb2 = ''; + } + + global $wgScript; + + $formParams = [ + 'name' => 'pageselector', + 'action' => $wgScript, + ]; + $options = []; + for ( $i = 1; $i <= $count; $i++ ) { + $options[] = Xml::option( $lang->formatNum( $i ), $i, $i == $page ); + } + $select = Xml::tags( 'select', + [ 'id' => 'pageselector', 'name' => 'page' ], + implode( "\n", $options ) ); + + $out->addHTML( + '</td><td><div class="multipageimagenavbox">' . + Xml::openElement( 'form', $formParams ) . + Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . + $this->getContext()->msg( 'imgmultigoto' )->rawParams( $select )->parse() . + $this->getContext()->msg( 'word-separator' )->escaped() . + Xml::submitButton( $this->getContext()->msg( 'imgmultigo' )->text() ) . + Xml::closeElement( 'form' ) . + "<hr />$thumb1\n$thumb2<br style=\"clear: both\" /></div></td></tr></table>" + ); + } + } elseif ( $this->displayImg->isSafeFile() ) { + # if direct link is allowed but it's not a renderable image, show an icon. + $icon = $this->displayImg->iconThumb(); + + $out->addHTML( '<div class="fullImageLink" id="file">' . + $icon->toHtml( [ 'file-link' => true ] ) . + "</div>\n" ); + } + + $longDesc = $this->getContext()->msg( 'parentheses', $this->displayImg->getLongDesc() )->text(); + + $handler = $this->displayImg->getHandler(); + + // If this is a filetype with potential issues, warn the user. + if ( $handler ) { + $warningConfig = $handler->getWarningConfig( $this->displayImg ); + + if ( $warningConfig !== null ) { + // The warning will be displayed via CSS and JavaScript. + // We just need to tell the client side what message to use. + $output = $this->getContext()->getOutput(); + $output->addJsConfigVars( 'wgFileWarning', $warningConfig ); + $output->addModules( $warningConfig['module'] ); + $output->addModules( 'mediawiki.filewarning' ); + } + } + + $medialink = "[[Media:$filename|$linktext]]"; + + if ( !$this->displayImg->isSafeFile() ) { + $warning = $this->getContext()->msg( 'mediawarning' )->plain(); + // dirmark is needed here to separate the file name, which + // most likely ends in Latin characters, from the description, + // which may begin with the file type. In RTL environment + // this will get messy. + // The dirmark, however, must not be immediately adjacent + // to the filename, because it can get copied with it. + // See T27277. + // phpcs:disable Generic.Files.LineLength + $out->addWikiText( <<<EOT +<div class="fullMedia"><span class="dangerousLink">{$medialink}</span> $dirmark<span class="fileInfo">$longDesc</span></div> +<div class="mediaWarning">$warning</div> +EOT + ); + // phpcs:enable + } else { + $out->addWikiText( <<<EOT +<div class="fullMedia">{$medialink} {$dirmark}<span class="fileInfo">$longDesc</span> +</div> +EOT + ); + } + + $renderLangOptions = $this->displayImg->getAvailableLanguages(); + if ( count( $renderLangOptions ) >= 1 ) { + $out->addHTML( $this->doRenderLangOpt( $renderLangOptions, $renderLang ) ); + } + + // Add cannot animate thumbnail warning + if ( !$this->displayImg->canAnimateThumbIfAppropriate() ) { + // Include the extension so wiki admins can + // customize it on a per file-type basis + // (aka say things like use format X instead). + // additionally have a specific message for + // file-no-thumb-animation-gif + $ext = $this->displayImg->getExtension(); + $noAnimMesg = wfMessageFallback( + 'file-no-thumb-animation-' . $ext, + 'file-no-thumb-animation' + )->plain(); + + $out->addWikiText( <<<EOT +<div class="mw-noanimatethumb">{$noAnimMesg}</div> +EOT + ); + } + + if ( !$this->displayImg->isLocal() ) { + $this->printSharedImageText(); + } + } else { + # Image does not exist + if ( !$this->getId() ) { + $dbr = wfGetDB( DB_REPLICA ); + + # No article exists either + # Show deletion log to be consistent with normal articles + LogEventsList::showLogExtract( + $out, + [ 'delete', 'move', 'protect' ], + $this->getTitle()->getPrefixedText(), + '', + [ 'lim' => 10, + 'conds' => [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ], + 'showIfEmpty' => false, + 'msgKey' => [ 'moveddeleted-notice' ] + ] + ); + } + + if ( $wgEnableUploads && $user->isAllowed( 'upload' ) ) { + // Only show an upload link if the user can upload + $uploadTitle = SpecialPage::getTitleFor( 'Upload' ); + $nofile = [ + 'filepage-nofile-link', + $uploadTitle->getFullURL( [ 'wpDestFile' => $this->mPage->getFile()->getName() ] ) + ]; + } else { + $nofile = 'filepage-nofile'; + } + // Note, if there is an image description page, but + // no image, then this setRobotPolicy is overridden + // by Article::View(). + $out->setRobotPolicy( 'noindex,nofollow' ); + $out->wrapWikiMsg( "<div id='mw-imagepage-nofile' class='plainlinks'>\n$1\n</div>", $nofile ); + if ( !$this->getId() && $wgSend404Code ) { + // If there is no image, no shared image, and no description page, + // output a 404, to be consistent with Article::showMissingArticle. + $request->response()->statusHeader( 404 ); + } + } + $out->setFileVersion( $this->displayImg ); + } + + /** + * Make the text under the image to say what size preview + * + * @param array $params parameters for thumbnail + * @param string $sizeLinkBigImagePreview HTML for the current size + * @return string HTML output + */ + protected function getThumbPrevText( $params, $sizeLinkBigImagePreview ) { + if ( $sizeLinkBigImagePreview ) { + // Show a different message of preview is different format from original. + $previewTypeDiffers = false; + $origExt = $thumbExt = $this->displayImg->getExtension(); + if ( $this->displayImg->getHandler() ) { + $origMime = $this->displayImg->getMimeType(); + $typeParams = $params; + $this->displayImg->getHandler()->normaliseParams( $this->displayImg, $typeParams ); + list( $thumbExt, $thumbMime ) = $this->displayImg->getHandler()->getThumbType( + $origExt, $origMime, $typeParams ); + if ( $thumbMime !== $origMime ) { + $previewTypeDiffers = true; + } + } + if ( $previewTypeDiffers ) { + return $this->getContext()->msg( 'show-big-image-preview-differ' )-> + rawParams( $sizeLinkBigImagePreview )-> + params( strtoupper( $origExt ) )-> + params( strtoupper( $thumbExt ) )-> + parse(); + } else { + return $this->getContext()->msg( 'show-big-image-preview' )-> + rawParams( $sizeLinkBigImagePreview )-> + parse(); + } + } else { + return ''; + } + } + + /** + * Creates an thumbnail of specified size and returns an HTML link to it + * @param array $params Scaler parameters + * @param int $width + * @param int $height + * @return string + */ + protected function makeSizeLink( $params, $width, $height ) { + $params['width'] = $width; + $params['height'] = $height; + $thumbnail = $this->displayImg->transform( $params ); + if ( $thumbnail && !$thumbnail->isError() ) { + return Html::rawElement( 'a', [ + 'href' => $thumbnail->getUrl(), + 'class' => 'mw-thumbnail-link' + ], $this->getContext()->msg( 'show-big-image-size' )->numParams( + $thumbnail->getWidth(), $thumbnail->getHeight() + )->parse() ); + } else { + return ''; + } + } + + /** + * Show a notice that the file is from a shared repository + */ + protected function printSharedImageText() { + $out = $this->getContext()->getOutput(); + $this->loadFile(); + + $descUrl = $this->mPage->getFile()->getDescriptionUrl(); + $descText = $this->mPage->getFile()->getDescriptionText( $this->getContext()->getLanguage() ); + + /* Add canonical to head if there is no local page for this shared file */ + if ( $descUrl && $this->mPage->getId() == 0 ) { + $out->setCanonicalUrl( $descUrl ); + } + + $wrap = "<div class=\"sharedUploadNotice\">\n$1\n</div>\n"; + $repo = $this->mPage->getFile()->getRepo()->getDisplayName(); + + if ( $descUrl && + $descText && + $this->getContext()->msg( 'sharedupload-desc-here' )->plain() !== '-' + ) { + $out->wrapWikiMsg( $wrap, [ 'sharedupload-desc-here', $repo, $descUrl ] ); + } elseif ( $descUrl && + $this->getContext()->msg( 'sharedupload-desc-there' )->plain() !== '-' + ) { + $out->wrapWikiMsg( $wrap, [ 'sharedupload-desc-there', $repo, $descUrl ] ); + } else { + $out->wrapWikiMsg( $wrap, [ 'sharedupload', $repo ], ''/*BACKCOMPAT*/ ); + } + + if ( $descText ) { + $this->mExtraDescription = $descText; + } + } + + public function getUploadUrl() { + $this->loadFile(); + $uploadTitle = SpecialPage::getTitleFor( 'Upload' ); + return $uploadTitle->getFullURL( [ + 'wpDestFile' => $this->mPage->getFile()->getName(), + 'wpForReUpload' => 1 + ] ); + } + + /** + * Print out the various links at the bottom of the image page, e.g. reupload, + * external editing (and instructions link) etc. + */ + protected function uploadLinksBox() { + global $wgEnableUploads; + + if ( !$wgEnableUploads ) { + return; + } + + $this->loadFile(); + if ( !$this->mPage->getFile()->isLocal() ) { + return; + } + + $out = $this->getContext()->getOutput(); + $out->addHTML( "<ul>\n" ); + + # "Upload a new version of this file" link + $canUpload = $this->getTitle()->quickUserCan( 'upload', $this->getContext()->getUser() ); + if ( $canUpload && UploadBase::userCanReUpload( + $this->getContext()->getUser(), + $this->mPage->getFile() ) + ) { + $ulink = Linker::makeExternalLink( + $this->getUploadUrl(), + $this->getContext()->msg( 'uploadnewversion-linktext' )->text() + ); + $out->addHTML( "<li id=\"mw-imagepage-reupload-link\">" + . "<div class=\"plainlinks\">{$ulink}</div></li>\n" ); + } else { + $out->addHTML( "<li id=\"mw-imagepage-upload-disallowed\">" + . $this->getContext()->msg( 'upload-disallowed-here' )->escaped() . "</li>\n" ); + } + + $out->addHTML( "</ul>\n" ); + } + + /** + * For overloading + */ + protected function closeShowImage() { + } + + /** + * If the page we've just displayed is in the "Image" namespace, + * we follow it with an upload history of the image and its usage. + */ + protected function imageHistory() { + $this->loadFile(); + $out = $this->getContext()->getOutput(); + $pager = new ImageHistoryPseudoPager( $this ); + $out->addHTML( $pager->getBody() ); + $out->preventClickjacking( $pager->getPreventClickjacking() ); + + $this->mPage->getFile()->resetHistory(); // free db resources + + # Exist check because we don't want to show this on pages where an image + # doesn't exist along with the noimage message, that would suck. -ævar + if ( $this->mPage->getFile()->exists() ) { + $this->uploadLinksBox(); + } + } + + /** + * @param string $target + * @param int $limit + * @return ResultWrapper + */ + protected function queryImageLinks( $target, $limit ) { + $dbr = wfGetDB( DB_REPLICA ); + + return $dbr->select( + [ 'imagelinks', 'page' ], + [ 'page_namespace', 'page_title', 'il_to' ], + [ 'il_to' => $target, 'il_from = page_id' ], + __METHOD__, + [ 'LIMIT' => $limit + 1, 'ORDER BY' => 'il_from', ] + ); + } + + protected function imageLinks() { + $limit = 100; + + $out = $this->getContext()->getOutput(); + + $rows = []; + $redirects = []; + foreach ( $this->getTitle()->getRedirectsHere( NS_FILE ) as $redir ) { + $redirects[$redir->getDBkey()] = []; + $rows[] = (object)[ + 'page_namespace' => NS_FILE, + 'page_title' => $redir->getDBkey(), + ]; + } + + $res = $this->queryImageLinks( $this->getTitle()->getDBkey(), $limit + 1 ); + foreach ( $res as $row ) { + $rows[] = $row; + } + $count = count( $rows ); + + $hasMore = $count > $limit; + if ( !$hasMore && count( $redirects ) ) { + $res = $this->queryImageLinks( array_keys( $redirects ), + $limit - count( $rows ) + 1 ); + foreach ( $res as $row ) { + $redirects[$row->il_to][] = $row; + $count++; + } + $hasMore = ( $res->numRows() + count( $rows ) ) > $limit; + } + + if ( $count == 0 ) { + $out->wrapWikiMsg( + Html::rawElement( 'div', + [ 'id' => 'mw-imagepage-nolinkstoimage' ], "\n$1\n" ), + 'nolinkstoimage' + ); + return; + } + + $out->addHTML( "<div id='mw-imagepage-section-linkstoimage'>\n" ); + if ( !$hasMore ) { + $out->addWikiMsg( 'linkstoimage', $count ); + } else { + // More links than the limit. Add a link to [[Special:Whatlinkshere]] + $out->addWikiMsg( 'linkstoimage-more', + $this->getContext()->getLanguage()->formatNum( $limit ), + $this->getTitle()->getPrefixedDBkey() + ); + } + + $out->addHTML( + Html::openElement( 'ul', + [ 'class' => 'mw-imagepage-linkstoimage' ] ) . "\n" + ); + $count = 0; + + // Sort the list by namespace:title + usort( $rows, [ $this, 'compare' ] ); + + // Create links for every element + $currentCount = 0; + foreach ( $rows as $element ) { + $currentCount++; + if ( $currentCount > $limit ) { + break; + } + + $query = []; + # Add a redirect=no to make redirect pages reachable + if ( isset( $redirects[$element->page_title] ) ) { + $query['redirect'] = 'no'; + } + $link = Linker::linkKnown( + Title::makeTitle( $element->page_namespace, $element->page_title ), + null, [], $query + ); + if ( !isset( $redirects[$element->page_title] ) ) { + # No redirects + $liContents = $link; + } elseif ( count( $redirects[$element->page_title] ) === 0 ) { + # Redirect without usages + $liContents = $this->getContext()->msg( 'linkstoimage-redirect' ) + ->rawParams( $link, '' ) + ->parse(); + } else { + # Redirect with usages + $li = ''; + foreach ( $redirects[$element->page_title] as $row ) { + $currentCount++; + if ( $currentCount > $limit ) { + break; + } + + $link2 = Linker::linkKnown( Title::makeTitle( $row->page_namespace, $row->page_title ) ); + $li .= Html::rawElement( + 'li', + [ 'class' => 'mw-imagepage-linkstoimage-ns' . $element->page_namespace ], + $link2 + ) . "\n"; + } + + $ul = Html::rawElement( + 'ul', + [ 'class' => 'mw-imagepage-redirectstofile' ], + $li + ) . "\n"; + $liContents = $this->getContext()->msg( 'linkstoimage-redirect' )->rawParams( + $link, $ul )->parse(); + } + $out->addHTML( Html::rawElement( + 'li', + [ 'class' => 'mw-imagepage-linkstoimage-ns' . $element->page_namespace ], + $liContents + ) . "\n" + ); + + }; + $out->addHTML( Html::closeElement( 'ul' ) . "\n" ); + $res->free(); + + // Add a links to [[Special:Whatlinkshere]] + if ( $count > $limit ) { + $out->addWikiMsg( 'morelinkstoimage', $this->getTitle()->getPrefixedDBkey() ); + } + $out->addHTML( Html::closeElement( 'div' ) . "\n" ); + } + + protected function imageDupes() { + $this->loadFile(); + $out = $this->getContext()->getOutput(); + + $dupes = $this->mPage->getDuplicates(); + if ( count( $dupes ) == 0 ) { + return; + } + + $out->addHTML( "<div id='mw-imagepage-section-duplicates'>\n" ); + $out->addWikiMsg( 'duplicatesoffile', + $this->getContext()->getLanguage()->formatNum( count( $dupes ) ), $this->getTitle()->getDBkey() + ); + $out->addHTML( "<ul class='mw-imagepage-duplicates'>\n" ); + + /** + * @var $file File + */ + foreach ( $dupes as $file ) { + $fromSrc = ''; + if ( $file->isLocal() ) { + $link = Linker::linkKnown( $file->getTitle() ); + } else { + $link = Linker::makeExternalLink( $file->getDescriptionUrl(), + $file->getTitle()->getPrefixedText() ); + $fromSrc = $this->getContext()->msg( + 'shared-repo-from', + $file->getRepo()->getDisplayName() + )->escaped(); + } + $out->addHTML( "<li>{$link} {$fromSrc}</li>\n" ); + } + $out->addHTML( "</ul></div>\n" ); + } + + /** + * Delete the file, or an earlier version of it + */ + public function delete() { + $file = $this->mPage->getFile(); + if ( !$file->exists() || !$file->isLocal() || $file->getRedirected() ) { + // Standard article deletion + parent::delete(); + return; + } + + $deleter = new FileDeleteForm( $file ); + $deleter->execute(); + } + + /** + * Display an error with a wikitext description + * + * @param string $description + */ + function showError( $description ) { + $out = $this->getContext()->getOutput(); + $out->setPageTitle( $this->getContext()->msg( 'internalerror' ) ); + $out->setRobotPolicy( 'noindex,nofollow' ); + $out->setArticleRelated( false ); + $out->enableClientCache( false ); + $out->addWikiText( $description ); + } + + /** + * Callback for usort() to do link sorts by (namespace, title) + * Function copied from Title::compare() + * + * @param object $a Object page to compare with + * @param object $b Object page to compare with + * @return int Result of string comparison, or namespace comparison + */ + protected function compare( $a, $b ) { + if ( $a->page_namespace == $b->page_namespace ) { + return strcmp( $a->page_title, $b->page_title ); + } else { + return $a->page_namespace - $b->page_namespace; + } + } + + /** + * Returns the corresponding $wgImageLimits entry for the selected user option + * + * @param User $user + * @param string $optionName Name of a option to check, typically imagesize or thumbsize + * @return array + * @since 1.21 + */ + public function getImageLimitsFromOption( $user, $optionName ) { + global $wgImageLimits; + + $option = $user->getIntOption( $optionName ); + if ( !isset( $wgImageLimits[$option] ) ) { + $option = User::getDefaultOption( $optionName ); + } + + // The user offset might still be incorrect, specially if + // $wgImageLimits got changed (see bug #8858). + if ( !isset( $wgImageLimits[$option] ) ) { + // Default to the first offset in $wgImageLimits + $option = 0; + } + + return isset( $wgImageLimits[$option] ) + ? $wgImageLimits[$option] + : [ 800, 600 ]; // if nothing is set, fallback to a hardcoded default + } + + /** + * Output a drop-down box for language options for the file + * + * @param array $langChoices Array of string language codes + * @param string $renderLang Language code for the language we want the file to rendered in. + * @return string HTML to insert underneath image. + */ + protected function doRenderLangOpt( array $langChoices, $renderLang ) { + global $wgScript; + $opts = ''; + + $matchedRenderLang = $this->displayImg->getMatchedLanguage( $renderLang ); + + foreach ( $langChoices as $lang ) { + $opts .= $this->createXmlOptionStringForLanguage( + $lang, + $matchedRenderLang === $lang + ); + } + + // Allow for the default case in an svg <switch> that is displayed if no + // systemLanguage attribute matches + $opts .= "\n" . + Xml::option( + $this->getContext()->msg( 'img-lang-default' )->text(), + 'und', + is_null( $matchedRenderLang ) + ); + + $select = Html::rawElement( + 'select', + [ 'id' => 'mw-imglangselector', 'name' => 'lang' ], + $opts + ); + $submit = Xml::submitButton( $this->getContext()->msg( 'img-lang-go' )->text() ); + + $formContents = $this->getContext()->msg( 'img-lang-info' ) + ->rawParams( $select, $submit ) + ->parse(); + $formContents .= Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ); + + $langSelectLine = Html::rawElement( 'div', [ 'id' => 'mw-imglangselector-line' ], + Html::rawElement( 'form', [ 'action' => $wgScript ], $formContents ) + ); + return $langSelectLine; + } + + /** + * @param $lang string + * @param $selected bool + * @return string + */ + private function createXmlOptionStringForLanguage( $lang, $selected ) { + $code = LanguageCode::bcp47( $lang ); + $name = Language::fetchLanguageName( $code, $this->getContext()->getLanguage()->getCode() ); + if ( $name !== '' ) { + $display = $this->getContext()->msg( 'img-lang-opt', $code, $name )->text(); + } else { + $display = $code; + } + return "\n" . + Xml::option( + $display, + $lang, + $selected + ); + } + + /** + * Get the width and height to display image at. + * + * @note This method assumes that it is only called if one + * of the dimensions are bigger than the max, or if the + * image is vectorized. + * + * @param int $maxWidth Max width to display at + * @param int $maxHeight Max height to display at + * @param int $width Actual width of the image + * @param int $height Actual height of the image + * @throws MWException + * @return array Array (width, height) + */ + protected function getDisplayWidthHeight( $maxWidth, $maxHeight, $width, $height ) { + if ( !$maxWidth || !$maxHeight ) { + // should never happen + throw new MWException( 'Using a choice from $wgImageLimits that is 0x0' ); + } + + if ( !$width || !$height ) { + return [ 0, 0 ]; + } + + # Calculate the thumbnail size. + if ( $width <= $maxWidth && $height <= $maxHeight ) { + // Vectorized image, do nothing. + } elseif ( $width / $height >= $maxWidth / $maxHeight ) { + # The limiting factor is the width, not the height. + $height = round( $height * $maxWidth / $width ); + $width = $maxWidth; + # Note that $height <= $maxHeight now. + } else { + $newwidth = floor( $width * $maxHeight / $height ); + $height = round( $height * $newwidth / $width ); + $width = $newwidth; + # Note that $height <= $maxHeight now, but might not be identical + # because of rounding. + } + return [ $width, $height ]; + } + + /** + * Get alternative thumbnail sizes. + * + * @note This will only list several alternatives if thumbnails are rendered on 404 + * @param int $origWidth Actual width of image + * @param int $origHeight Actual height of image + * @return array An array of [width, height] pairs. + */ + protected function getThumbSizes( $origWidth, $origHeight ) { + global $wgImageLimits; + if ( $this->displayImg->getRepo()->canTransformVia404() ) { + $thumbSizes = $wgImageLimits; + // Also include the full sized resolution in the list, so + // that users know they can get it. This will link to the + // original file asset if mustRender() === false. In the case + // that we mustRender, some users have indicated that they would + // find it useful to have the full size image in the rendered + // image format. + $thumbSizes[] = [ $origWidth, $origHeight ]; + } else { + # Creating thumb links triggers thumbnail generation. + # Just generate the thumb for the current users prefs. + $thumbSizes = [ + $this->getImageLimitsFromOption( $this->getContext()->getUser(), 'thumbsize' ) + ]; + if ( !$this->displayImg->mustRender() ) { + // We can safely include a link to the "full-size" preview, + // without actually rendering. + $thumbSizes[] = [ $origWidth, $origHeight ]; + } + } + return $thumbSizes; + } + + /** + * @see WikiFilePage::getFile + * @return bool|File + */ + public function getFile() { + return $this->mPage->getFile(); + } + + /** + * @see WikiFilePage::isLocal + * @return bool + */ + public function isLocal() { + return $this->mPage->isLocal(); + } + + /** + * @see WikiFilePage::getDuplicates + * @return array|null + */ + public function getDuplicates() { + return $this->mPage->getDuplicates(); + } + + /** + * @see WikiFilePage::getForeignCategories + * @return TitleArray|Title[] + */ + public function getForeignCategories() { + $this->mPage->getForeignCategories(); + } + +} diff --git a/www/wiki/includes/page/Page.php b/www/wiki/includes/page/Page.php new file mode 100644 index 00000000..2cb1fc03 --- /dev/null +++ b/www/wiki/includes/page/Page.php @@ -0,0 +1,25 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +/** + * Interface for type hinting (accepts WikiPage, Article, ImagePage, CategoryPage) + */ +interface Page { +} diff --git a/www/wiki/includes/page/PageArchive.php b/www/wiki/includes/page/PageArchive.php new file mode 100644 index 00000000..8b42020a --- /dev/null +++ b/www/wiki/includes/page/PageArchive.php @@ -0,0 +1,752 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * Used to show archived pages and eventually restore them. + */ +class PageArchive { + /** @var Title */ + protected $title; + + /** @var Status */ + protected $fileStatus; + + /** @var Status */ + protected $revisionStatus; + + /** @var Config */ + protected $config; + + public function __construct( $title, Config $config = null ) { + if ( is_null( $title ) ) { + throw new MWException( __METHOD__ . ' given a null title.' ); + } + $this->title = $title; + if ( $config === null ) { + wfDebug( __METHOD__ . ' did not have a Config object passed to it' ); + $config = MediaWikiServices::getInstance()->getMainConfig(); + } + $this->config = $config; + } + + public function doesWrites() { + return true; + } + + /** + * List all deleted pages recorded in the archive table. Returns result + * wrapper with (ar_namespace, ar_title, count) fields, ordered by page + * namespace/title. + * + * @return ResultWrapper + */ + public static function listAllPages() { + $dbr = wfGetDB( DB_REPLICA ); + + return self::listPages( $dbr, '' ); + } + + /** + * List deleted pages recorded in the archive matching the + * given term, using search engine archive. + * Returns result wrapper with (ar_namespace, ar_title, count) fields. + * + * @param string $term Search term + * @return ResultWrapper + */ + public static function listPagesBySearch( $term ) { + $title = Title::newFromText( $term ); + if ( $title ) { + $ns = $title->getNamespace(); + $termMain = $title->getText(); + $termDb = $title->getDBkey(); + } else { + // Prolly won't work too good + // @todo handle bare namespace names cleanly? + $ns = 0; + $termMain = $termDb = $term; + } + + // Try search engine first + $engine = MediaWikiServices::getInstance()->newSearchEngine(); + $engine->setLimitOffset( 100 ); + $engine->setNamespaces( [ $ns ] ); + $results = $engine->searchArchiveTitle( $termMain ); + if ( !$results->isOK() ) { + $results = []; + } else { + $results = $results->getValue(); + } + + if ( !$results ) { + // Fall back to regular prefix search + return self::listPagesByPrefix( $term ); + } + + $dbr = wfGetDB( DB_REPLICA ); + $condTitles = array_unique( array_map( function ( Title $t ) { + return $t->getDBkey(); + }, $results ) ); + $conds = [ + 'ar_namespace' => $ns, + $dbr->makeList( [ 'ar_title' => $condTitles ], LIST_OR ) . " OR ar_title " . + $dbr->buildLike( $termDb, $dbr->anyString() ) + ]; + + return self::listPages( $dbr, $conds ); + } + + /** + * List deleted pages recorded in the archive table matching the + * given title prefix. + * Returns result wrapper with (ar_namespace, ar_title, count) fields. + * + * @param string $prefix Title prefix + * @return ResultWrapper + */ + public static function listPagesByPrefix( $prefix ) { + $dbr = wfGetDB( DB_REPLICA ); + + $title = Title::newFromText( $prefix ); + if ( $title ) { + $ns = $title->getNamespace(); + $prefix = $title->getDBkey(); + } else { + // Prolly won't work too good + // @todo handle bare namespace names cleanly? + $ns = 0; + } + + $conds = [ + 'ar_namespace' => $ns, + 'ar_title' . $dbr->buildLike( $prefix, $dbr->anyString() ), + ]; + + return self::listPages( $dbr, $conds ); + } + + /** + * @param IDatabase $dbr + * @param string|array $condition + * @return bool|ResultWrapper + */ + protected static function listPages( $dbr, $condition ) { + return $dbr->select( + [ 'archive' ], + [ + 'ar_namespace', + 'ar_title', + 'count' => 'COUNT(*)' + ], + $condition, + __METHOD__, + [ + 'GROUP BY' => [ 'ar_namespace', 'ar_title' ], + 'ORDER BY' => [ 'ar_namespace', 'ar_title' ], + 'LIMIT' => 100, + ] + ); + } + + /** + * List the revisions of the given page. Returns result wrapper with + * various archive table fields. + * + * @return ResultWrapper + */ + public function listRevisions() { + $revisionStore = MediaWikiServices::getInstance()->getRevisionStore(); + $queryInfo = $revisionStore->getArchiveQueryInfo(); + + $conds = [ + 'ar_namespace' => $this->title->getNamespace(), + 'ar_title' => $this->title->getDBkey(), + ]; + $options = [ 'ORDER BY' => 'ar_timestamp DESC' ]; + + ChangeTags::modifyDisplayQuery( + $queryInfo['tables'], + $queryInfo['fields'], + $conds, + $queryInfo['joins'], + $options, + '' + ); + + $dbr = wfGetDB( DB_REPLICA ); + return $dbr->select( + $queryInfo['tables'], + $queryInfo['fields'], + $conds, + __METHOD__, + $options, + $queryInfo['joins'] + ); + } + + /** + * 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 + * @todo Does this belong in Image for fuller encapsulation? + */ + public function listFiles() { + if ( $this->title->getNamespace() != NS_FILE ) { + return null; + } + + $dbr = wfGetDB( DB_REPLICA ); + $fileQuery = ArchivedFile::getQueryInfo(); + return $dbr->select( + $fileQuery['tables'], + $fileQuery['fields'], + [ 'fa_name' => $this->title->getDBkey() ], + __METHOD__, + [ 'ORDER BY' => 'fa_timestamp DESC' ], + $fileQuery['joins'] + ); + } + + /** + * Return a Revision object containing data for the deleted revision. + * Note that the result *may* or *may not* have a null page ID. + * + * @param string $timestamp + * @return Revision|null + */ + public function getRevision( $timestamp ) { + $dbr = wfGetDB( DB_REPLICA ); + $arQuery = Revision::getArchiveQueryInfo(); + + $row = $dbr->selectRow( + $arQuery['tables'], + $arQuery['fields'], + [ + 'ar_namespace' => $this->title->getNamespace(), + 'ar_title' => $this->title->getDBkey(), + 'ar_timestamp' => $dbr->timestamp( $timestamp ) + ], + __METHOD__, + [], + $arQuery['joins'] + ); + + if ( $row ) { + return Revision::newFromArchiveRow( $row, [ 'title' => $this->title ] ); + } + + return null; + } + + /** + * Return the most-previous revision, either live or deleted, against + * the deleted revision given by timestamp. + * + * May produce unexpected results in case of history merges or other + * unusual time issues. + * + * @param string $timestamp + * @return Revision|null Null when there is no previous revision + */ + public function getPreviousRevision( $timestamp ) { + $dbr = wfGetDB( DB_REPLICA ); + + // Check the previous deleted revision... + $row = $dbr->selectRow( 'archive', + 'ar_timestamp', + [ 'ar_namespace' => $this->title->getNamespace(), + 'ar_title' => $this->title->getDBkey(), + 'ar_timestamp < ' . + $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ], + __METHOD__, + [ + 'ORDER BY' => 'ar_timestamp DESC', + 'LIMIT' => 1 ] ); + $prevDeleted = $row ? wfTimestamp( TS_MW, $row->ar_timestamp ) : false; + + $row = $dbr->selectRow( [ 'page', 'revision' ], + [ 'rev_id', 'rev_timestamp' ], + [ + 'page_namespace' => $this->title->getNamespace(), + 'page_title' => $this->title->getDBkey(), + 'page_id = rev_page', + 'rev_timestamp < ' . + $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ], + __METHOD__, + [ + 'ORDER BY' => 'rev_timestamp DESC', + 'LIMIT' => 1 ] ); + $prevLive = $row ? wfTimestamp( TS_MW, $row->rev_timestamp ) : false; + $prevLiveId = $row ? intval( $row->rev_id ) : null; + + if ( $prevLive && $prevLive > $prevDeleted ) { + // Most prior revision was live + return Revision::newFromId( $prevLiveId ); + } elseif ( $prevDeleted ) { + // Most prior revision was deleted + return $this->getRevision( $prevDeleted ); + } + + // No prior revision on this page. + return null; + } + + /** + * Get the text from an archive row containing ar_text_id + * + * @deprecated since 1.31 + * @param object $row Database row + * @return string + */ + public function getTextFromRow( $row ) { + $dbr = wfGetDB( DB_REPLICA ); + $text = $dbr->selectRow( 'text', + [ 'old_text', 'old_flags' ], + [ 'old_id' => $row->ar_text_id ], + __METHOD__ ); + + return Revision::getRevisionText( $text ); + } + + /** + * Fetch (and decompress if necessary) the stored text of the most + * recently edited deleted revision of the page. + * + * If there are no archived revisions for the page, returns NULL. + * + * @return string|null + */ + public function getLastRevisionText() { + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( + [ 'archive', 'text' ], + [ 'old_text', 'old_flags' ], + [ 'ar_namespace' => $this->title->getNamespace(), + 'ar_title' => $this->title->getDBkey() ], + __METHOD__, + [ 'ORDER BY' => 'ar_timestamp DESC, ar_id DESC' ], + [ 'text' => [ 'JOIN', 'old_id = ar_text_id' ] ] + ); + + if ( $row ) { + return Revision::getRevisionText( $row ); + } + + return null; + } + + /** + * Quick check if any archived revisions are present for the page. + * + * @return bool + */ + public function isDeleted() { + $dbr = wfGetDB( DB_REPLICA ); + $n = $dbr->selectField( 'archive', 'COUNT(ar_title)', + [ 'ar_namespace' => $this->title->getNamespace(), + 'ar_title' => $this->title->getDBkey() ], + __METHOD__ + ); + + 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. + * + * This also sets Status objects, $this->fileStatus and $this->revisionStatus + * (depending what operations are attempted). + * + * @param array $timestamps Pass an empty array to restore all revisions, + * otherwise list the ones to undelete. + * @param string $comment + * @param array $fileVersions + * @param bool $unsuppress + * @param User $user User performing the action, or null to use $wgUser + * @param string|string[] $tags Change tags to add to log entry + * ($user should be able to add the specified tags before this is called) + * @return array|bool array(number of file revisions restored, number of image revisions + * restored, log message) on success, false on failure. + */ + public function undelete( $timestamps, $comment = '', $fileVersions = [], + $unsuppress = false, User $user = null, $tags = null + ) { + // 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_FILE ) { + $img = wfLocalFile( $this->title ); + $img->load( File::READ_LATEST ); + $this->fileStatus = $img->restore( $fileVersions, $unsuppress ); + if ( !$this->fileStatus->isOK() ) { + return false; + } + $filesRestored = $this->fileStatus->successCount; + } else { + $filesRestored = 0; + } + + if ( $restoreText ) { + $this->revisionStatus = $this->undeleteRevisions( $timestamps, $unsuppress, $comment ); + if ( !$this->revisionStatus->isOK() ) { + return false; + } + + $textRestored = $this->revisionStatus->getValue(); + } else { + $textRestored = 0; + } + + // Touch the log! + + if ( !$textRestored && !$filesRestored ) { + wfDebug( "Undelete: nothing undeleted...\n" ); + + return false; + } + + if ( $user === null ) { + global $wgUser; + $user = $wgUser; + } + + $logEntry = new ManualLogEntry( 'delete', 'restore' ); + $logEntry->setPerformer( $user ); + $logEntry->setTarget( $this->title ); + $logEntry->setComment( $comment ); + $logEntry->setTags( $tags ); + $logEntry->setParameters( [ + ':assoc:count' => [ + 'revisions' => $textRestored, + 'files' => $filesRestored, + ], + ] ); + + Hooks::run( 'ArticleUndeleteLogEntry', [ $this, &$logEntry, $user ] ); + + $logid = $logEntry->insert(); + $logEntry->publish( $logid ); + + return [ $textRestored, $filesRestored, $comment ]; + } + + /** + * This is the meaty bit -- It restores archived revisions of the given page + * to the revision table. + * + * @param array $timestamps Pass an empty array to restore all revisions, + * otherwise list the ones to undelete. + * @param bool $unsuppress Remove all ar_deleted/fa_deleted restrictions of seletected revs + * @param string $comment + * @throws ReadOnlyError + * @return Status Status object containing the number of revisions restored on success + */ + private function undeleteRevisions( $timestamps, $unsuppress = false, $comment = '' ) { + if ( wfReadOnly() ) { + throw new ReadOnlyError(); + } + + $dbw = wfGetDB( DB_MASTER ); + $dbw->startAtomic( __METHOD__ ); + + $restoreAll = empty( $timestamps ); + + # Does this page already exist? We'll have to update it... + $article = WikiPage::factory( $this->title ); + # Load latest data for the current page (T33179) + $article->loadPageData( 'fromdbmaster' ); + $oldcountable = $article->isCountable(); + + $page = $dbw->selectRow( 'page', + [ 'page_id', 'page_latest' ], + [ 'page_namespace' => $this->title->getNamespace(), + 'page_title' => $this->title->getDBkey() ], + __METHOD__, + [ 'FOR UPDATE' ] // lock page + ); + + if ( $page ) { + $makepage = false; + # Page already exists. Import the history, and if necessary + # we'll update the latest revision field in the record. + + # Get the time span of this page + $previousTimestamp = $dbw->selectField( 'revision', 'rev_timestamp', + [ 'rev_id' => $page->page_latest ], + __METHOD__ ); + + if ( $previousTimestamp === false ) { + wfDebug( __METHOD__ . ": existing page refers to a page_latest that does not exist\n" ); + + $status = Status::newGood( 0 ); + $status->warning( 'undeleterevision-missing' ); + $dbw->endAtomic( __METHOD__ ); + + return $status; + } + } else { + # Have to create a new article... + $makepage = true; + $previousTimestamp = 0; + } + + $oldWhere = [ + 'ar_namespace' => $this->title->getNamespace(), + 'ar_title' => $this->title->getDBkey(), + ]; + if ( !$restoreAll ) { + $oldWhere['ar_timestamp'] = array_map( [ &$dbw, 'timestamp' ], $timestamps ); + } + + $revisionStore = MediaWikiServices::getInstance()->getRevisionStore(); + $queryInfo = $revisionStore->getArchiveQueryInfo(); + $queryInfo['tables'][] = 'revision'; + $queryInfo['fields'][] = 'rev_id'; + $queryInfo['joins']['revision'] = [ 'LEFT JOIN', 'ar_rev_id=rev_id' ]; + + /** + * Select each archived revision... + */ + $result = $dbw->select( + $queryInfo['tables'], + $queryInfo['fields'], + $oldWhere, + __METHOD__, + /* options */ + [ 'ORDER BY' => 'ar_timestamp' ], + $queryInfo['joins'] + ); + + $rev_count = $result->numRows(); + if ( !$rev_count ) { + wfDebug( __METHOD__ . ": no revisions to restore\n" ); + + $status = Status::newGood( 0 ); + $status->warning( "undelete-no-results" ); + $dbw->endAtomic( __METHOD__ ); + + return $status; + } + + // We use ar_id because there can be duplicate ar_rev_id even for the same + // page. In this case, we may be able to restore the first one. + $restoreFailedArIds = []; + + // Map rev_id to the ar_id that is allowed to use it. When checking later, + // if it doesn't match, the current ar_id can not be restored. + + // Value can be an ar_id or -1 (-1 means no ar_id can use it, since the + // rev_id is taken before we even start the restore). + $allowedRevIdToArIdMap = []; + + $latestRestorableRow = null; + + foreach ( $result as $row ) { + if ( $row->ar_rev_id ) { + // rev_id is taken even before we start restoring. + if ( $row->ar_rev_id === $row->rev_id ) { + $restoreFailedArIds[] = $row->ar_id; + $allowedRevIdToArIdMap[$row->ar_rev_id] = -1; + } else { + // rev_id is not taken yet in the DB, but it might be taken + // by a prior revision in the same restore operation. If + // not, we need to reserve it. + if ( isset( $allowedRevIdToArIdMap[$row->ar_rev_id] ) ) { + $restoreFailedArIds[] = $row->ar_id; + } else { + $allowedRevIdToArIdMap[$row->ar_rev_id] = $row->ar_id; + $latestRestorableRow = $row; + } + } + } else { + // If ar_rev_id is null, there can't be a collision, and a + // rev_id will be chosen automatically. + $latestRestorableRow = $row; + } + } + + $result->seek( 0 ); // move back + + $oldPageId = 0; + if ( $latestRestorableRow !== null ) { + $oldPageId = (int)$latestRestorableRow->ar_page_id; // pass this to ArticleUndelete hook + + // grab the content to check consistency with global state before restoring the page. + $revision = Revision::newFromArchiveRow( $latestRestorableRow, + [ + 'title' => $article->getTitle(), // used to derive default content model + ] + ); + $user = User::newFromName( $revision->getUserText( Revision::RAW ), false ); + $content = $revision->getContent( Revision::RAW ); + + // NOTE: article ID may not be known yet. prepareSave() should not modify the database. + $status = $content->prepareSave( $article, 0, -1, $user ); + if ( !$status->isOK() ) { + $dbw->endAtomic( __METHOD__ ); + + return $status; + } + } + + $newid = false; // newly created page ID + $restored = 0; // number of revisions restored + /** @var Revision $revision */ + $revision = null; + $restoredPages = []; + // If there are no restorable revisions, we can skip most of the steps. + if ( $latestRestorableRow === null ) { + $failedRevisionCount = $rev_count; + } else { + if ( $makepage ) { + // Check the state of the newest to-be version... + if ( !$unsuppress + && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT ) + ) { + $dbw->endAtomic( __METHOD__ ); + + return Status::newFatal( "undeleterevdel" ); + } + // Safe to insert now... + $newid = $article->insertOn( $dbw, $latestRestorableRow->ar_page_id ); + if ( $newid === false ) { + // The old ID is reserved; let's pick another + $newid = $article->insertOn( $dbw ); + } + $pageId = $newid; + } else { + // Check if a deleted revision will become the current revision... + if ( $latestRestorableRow->ar_timestamp > $previousTimestamp ) { + // Check the state of the newest to-be version... + if ( !$unsuppress + && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT ) + ) { + $dbw->endAtomic( __METHOD__ ); + + return Status::newFatal( "undeleterevdel" ); + } + } + + $newid = false; + $pageId = $article->getId(); + } + + foreach ( $result as $row ) { + // Check for key dupes due to needed archive integrity. + if ( $row->ar_rev_id && $allowedRevIdToArIdMap[$row->ar_rev_id] !== $row->ar_id ) { + continue; + } + // Insert one revision at a time...maintaining deletion status + // unless we are specifically removing all restrictions... + $revision = Revision::newFromArchiveRow( $row, + [ + 'page' => $pageId, + 'title' => $this->title, + 'deleted' => $unsuppress ? 0 : $row->ar_deleted + ] ); + + // This will also copy the revision to ip_changes if it was an IP edit. + $revision->insertOn( $dbw ); + + $restored++; + + Hooks::run( 'ArticleRevisionUndeleted', + [ &$this->title, $revision, $row->ar_page_id ] ); + $restoredPages[$row->ar_page_id] = true; + } + + // Now that it's safely stored, take it out of the archive + // Don't delete rows that we failed to restore + $toDeleteConds = $oldWhere; + $failedRevisionCount = count( $restoreFailedArIds ); + if ( $failedRevisionCount > 0 ) { + $toDeleteConds[] = 'ar_id NOT IN ( ' . $dbw->makeList( $restoreFailedArIds ) . ' )'; + } + + $dbw->delete( 'archive', + $toDeleteConds, + __METHOD__ ); + } + + $status = Status::newGood( $restored ); + + if ( $failedRevisionCount > 0 ) { + $status->warning( + wfMessage( 'undeleterevision-duplicate-revid', $failedRevisionCount ) ); + } + + // Was anything restored at all? + if ( $restored ) { + $created = (bool)$newid; + // Attach the latest revision to the page... + $wasnew = $article->updateIfNewerOn( $dbw, $revision ); + if ( $created || $wasnew ) { + // Update site stats, link tables, etc + $article->doEditUpdates( + $revision, + User::newFromName( $revision->getUserText( Revision::RAW ), false ), + [ + 'created' => $created, + 'oldcountable' => $oldcountable, + 'restored' => true + ] + ); + } + + Hooks::run( 'ArticleUndelete', + [ &$this->title, $created, $comment, $oldPageId, $restoredPages ] ); + if ( $this->title->getNamespace() == NS_FILE ) { + DeferredUpdates::addUpdate( + new HTMLCacheUpdate( $this->title, 'imagelinks', 'file-restore' ) + ); + } + } + + $dbw->endAtomic( __METHOD__ ); + + return $status; + } + + /** + * @return Status + */ + public function getFileStatus() { + return $this->fileStatus; + } + + /** + * @return Status + */ + public function getRevisionStatus() { + return $this->revisionStatus; + } +} diff --git a/www/wiki/includes/page/WikiCategoryPage.php b/www/wiki/includes/page/WikiCategoryPage.php new file mode 100644 index 00000000..6c932029 --- /dev/null +++ b/www/wiki/includes/page/WikiCategoryPage.php @@ -0,0 +1,64 @@ +<?php +/** + * Special handling for category pages. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +/** + * Special handling for category pages + */ +class WikiCategoryPage extends WikiPage { + + /** + * Don't return a 404 for categories in use. + * In use defined as: either the actual page exists + * or the category currently has members. + * + * @return bool + */ + public function hasViewableContent() { + if ( parent::hasViewableContent() ) { + return true; + } else { + $cat = Category::newFromTitle( $this->mTitle ); + // If any of these are not 0, then has members + if ( $cat->getPageCount() + || $cat->getSubcatCount() + || $cat->getFileCount() + ) { + return true; + } + } + return false; + } + + /** + * Checks if a category is hidden. + * + * @since 1.27 + * + * @return bool + */ + public function isHidden() { + $pageId = $this->getTitle()->getArticleID(); + $pageProps = PageProps::getInstance()->getProperties( $this->getTitle(), 'hiddencat' ); + + return isset( $pageProps[$pageId] ) ? true : false; + } +} diff --git a/www/wiki/includes/page/WikiFilePage.php b/www/wiki/includes/page/WikiFilePage.php new file mode 100644 index 00000000..4c2ebdc2 --- /dev/null +++ b/www/wiki/includes/page/WikiFilePage.php @@ -0,0 +1,259 @@ +<?php +/** + * Special handling for file pages. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +use Wikimedia\Rdbms\FakeResultWrapper; + +/** + * Special handling for file pages + * + * @ingroup Media + */ +class WikiFilePage extends WikiPage { + /** @var File */ + protected $mFile = false; + /** @var LocalRepo */ + protected $mRepo = null; + /** @var bool */ + protected $mFileLoaded = false; + /** @var array */ + protected $mDupes = null; + + public function __construct( $title ) { + parent::__construct( $title ); + $this->mDupes = null; + $this->mRepo = null; + } + + /** + * @param File $file + */ + public function setFile( $file ) { + $this->mFile = $file; + $this->mFileLoaded = true; + } + + /** + * @return bool + */ + protected function loadFile() { + if ( $this->mFileLoaded ) { + return true; + } + $this->mFileLoaded = true; + + $this->mFile = wfFindFile( $this->mTitle ); + if ( !$this->mFile ) { + $this->mFile = wfLocalFile( $this->mTitle ); // always a File + } + $this->mRepo = $this->mFile->getRepo(); + return true; + } + + /** + * @return mixed|null|Title + */ + public function getRedirectTarget() { + $this->loadFile(); + if ( $this->mFile->isLocal() ) { + return parent::getRedirectTarget(); + } + // Foreign image page + $from = $this->mFile->getRedirected(); + $to = $this->mFile->getName(); + if ( $from == $to ) { + return null; + } + $this->mRedirectTarget = Title::makeTitle( NS_FILE, $to ); + return $this->mRedirectTarget; + } + + /** + * @return bool|mixed|Title + */ + public function followRedirect() { + $this->loadFile(); + if ( $this->mFile->isLocal() ) { + return parent::followRedirect(); + } + $from = $this->mFile->getRedirected(); + $to = $this->mFile->getName(); + if ( $from == $to ) { + return false; + } + return Title::makeTitle( NS_FILE, $to ); + } + + /** + * @return bool + */ + public function isRedirect() { + $this->loadFile(); + if ( $this->mFile->isLocal() ) { + return parent::isRedirect(); + } + + return (bool)$this->mFile->getRedirected(); + } + + /** + * @return bool + */ + public function isLocal() { + $this->loadFile(); + return $this->mFile->isLocal(); + } + + /** + * @return bool|File + */ + public function getFile() { + $this->loadFile(); + return $this->mFile; + } + + /** + * @return array|null + */ + public function getDuplicates() { + $this->loadFile(); + if ( !is_null( $this->mDupes ) ) { + return $this->mDupes; + } + $hash = $this->mFile->getSha1(); + if ( !( $hash ) ) { + $this->mDupes = []; + return $this->mDupes; + } + $dupes = RepoGroup::singleton()->findBySha1( $hash ); + // Remove duplicates with self and non matching file sizes + $self = $this->mFile->getRepoName() . ':' . $this->mFile->getName(); + $size = $this->mFile->getSize(); + + /** + * @var $file File + */ + foreach ( $dupes as $index => $file ) { + $key = $file->getRepoName() . ':' . $file->getName(); + if ( $key == $self ) { + unset( $dupes[$index] ); + } + if ( $file->getSize() != $size ) { + unset( $dupes[$index] ); + } + } + $this->mDupes = $dupes; + return $this->mDupes; + } + + /** + * Override handling of action=purge + * @return bool + */ + public function doPurge() { + $this->loadFile(); + + if ( $this->mFile->exists() ) { + wfDebug( 'ImagePage::doPurge purging ' . $this->mFile->getName() . "\n" ); + DeferredUpdates::addUpdate( + new HTMLCacheUpdate( $this->mTitle, 'imagelinks', 'file-purge' ) + ); + } else { + wfDebug( 'ImagePage::doPurge no image for ' + . $this->mFile->getName() . "; limiting purge to cache only\n" ); + } + + // even if the file supposedly doesn't exist, force any cached information + // to be updated (in case the cached information is wrong) + + // Purge current version and its thumbnails + $this->mFile->purgeCache( [ 'forThumbRefresh' => true ] ); + + // Purge the old versions and their thumbnails + foreach ( $this->mFile->getHistory() as $oldFile ) { + $oldFile->purgeCache( [ 'forThumbRefresh' => true ] ); + } + + if ( $this->mRepo ) { + // Purge redirect cache + $this->mRepo->invalidateImageRedirect( $this->mTitle ); + } + + return parent::doPurge(); + } + + /** + * Get the categories this file is a member of on the wiki where it was uploaded. + * For local files, this is the same as getCategories(). + * For foreign API files (InstantCommons), this is not supported currently. + * Results will include hidden categories. + * + * @return TitleArray|Title[] + * @since 1.23 + */ + public function getForeignCategories() { + $this->loadFile(); + $title = $this->mTitle; + $file = $this->mFile; + + if ( !$file instanceof LocalFile ) { + wfDebug( __CLASS__ . '::' . __METHOD__ . " is not supported for this file\n" ); + return TitleArray::newFromResult( new FakeResultWrapper( [] ) ); + } + + /** @var LocalRepo $repo */ + $repo = $file->getRepo(); + $dbr = $repo->getReplicaDB(); + + $res = $dbr->select( + [ 'page', 'categorylinks' ], + [ + 'page_title' => 'cl_to', + 'page_namespace' => NS_CATEGORY, + ], + [ + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey(), + ], + __METHOD__, + [], + [ 'categorylinks' => [ 'INNER JOIN', 'page_id = cl_from' ] ] + ); + + return TitleArray::newFromResult( $res ); + } + + /** + * @since 1.28 + * @return string + */ + public function getWikiDisplayName() { + return $this->getFile()->getRepo()->getDisplayName(); + } + + /** + * @since 1.28 + * @return string + */ + public function getSourceURL() { + return $this->getFile()->getDescriptionUrl(); + } +} diff --git a/www/wiki/includes/page/WikiPage.php b/www/wiki/includes/page/WikiPage.php new file mode 100644 index 00000000..820df58d --- /dev/null +++ b/www/wiki/includes/page/WikiPage.php @@ -0,0 +1,3768 @@ +<?php +/** + * Base representation for a MediaWiki page. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +use MediaWiki\Edit\PreparedEdit; +use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; +use Wikimedia\Assert\Assert; +use Wikimedia\Rdbms\FakeResultWrapper; +use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Rdbms\DBUnexpectedError; + +/** + * Class representing a MediaWiki article and history. + * + * Some fields are public only for backwards-compatibility. Use accessors. + * In the past, this class was part of Article.php and everything was public. + */ +class WikiPage implements Page, IDBAccessObject { + // Constants for $mDataLoadedFrom and related + + /** + * @var Title + */ + public $mTitle = null; + + /**@{{ + * @protected + */ + public $mDataLoaded = false; // !< Boolean + public $mIsRedirect = false; // !< Boolean + public $mLatest = false; // !< Integer (false means "not loaded") + /**@}}*/ + + /** @var PreparedEdit Map of cache fields (text, parser output, ect) for a proposed/new edit */ + public $mPreparedEdit = false; + + /** + * @var int + */ + protected $mId = null; + + /** + * @var int One of the READ_* constants + */ + protected $mDataLoadedFrom = self::READ_NONE; + + /** + * @var Title + */ + protected $mRedirectTarget = null; + + /** + * @var Revision + */ + protected $mLastRevision = null; + + /** + * @var string Timestamp of the current revision or empty string if not loaded + */ + protected $mTimestamp = ''; + + /** + * @var string + */ + protected $mTouched = '19700101000000'; + + /** + * @var string + */ + protected $mLinksUpdated = '19700101000000'; + + /** + * Constructor and clear the article + * @param Title $title Reference to a Title object. + */ + public function __construct( Title $title ) { + $this->mTitle = $title; + } + + /** + * Makes sure that the mTitle object is cloned + * to the newly cloned WikiPage. + */ + public function __clone() { + $this->mTitle = clone $this->mTitle; + } + + /** + * Create a WikiPage object of the appropriate class for the given title. + * + * @param Title $title + * + * @throws MWException + * @return WikiPage|WikiCategoryPage|WikiFilePage + */ + public static function factory( Title $title ) { + $ns = $title->getNamespace(); + + if ( $ns == NS_MEDIA ) { + throw new MWException( "NS_MEDIA is a virtual namespace; use NS_FILE." ); + } elseif ( $ns < 0 ) { + throw new MWException( "Invalid or virtual namespace $ns given." ); + } + + $page = null; + if ( !Hooks::run( 'WikiPageFactory', [ $title, &$page ] ) ) { + return $page; + } + + switch ( $ns ) { + case NS_FILE: + $page = new WikiFilePage( $title ); + break; + case NS_CATEGORY: + $page = new WikiCategoryPage( $title ); + break; + default: + $page = new WikiPage( $title ); + } + + return $page; + } + + /** + * Constructor from a page id + * + * @param int $id Article ID to load + * @param string|int $from One of the following values: + * - "fromdb" or WikiPage::READ_NORMAL to select from a replica DB + * - "fromdbmaster" or WikiPage::READ_LATEST to select from the master database + * + * @return WikiPage|null + */ + public static function newFromID( $id, $from = 'fromdb' ) { + // page ids are never 0 or negative, see T63166 + if ( $id < 1 ) { + return null; + } + + $from = self::convertSelectType( $from ); + $db = wfGetDB( $from === self::READ_LATEST ? DB_MASTER : DB_REPLICA ); + $pageQuery = self::getQueryInfo(); + $row = $db->selectRow( + $pageQuery['tables'], $pageQuery['fields'], [ 'page_id' => $id ], __METHOD__, + [], $pageQuery['joins'] + ); + if ( !$row ) { + return null; + } + return self::newFromRow( $row, $from ); + } + + /** + * Constructor from a database row + * + * @since 1.20 + * @param object $row Database row containing at least fields returned by selectFields(). + * @param string|int $from Source of $data: + * - "fromdb" or WikiPage::READ_NORMAL: from a replica DB + * - "fromdbmaster" or WikiPage::READ_LATEST: from the master DB + * - "forupdate" or WikiPage::READ_LOCKING: from the master DB using SELECT FOR UPDATE + * @return WikiPage + */ + public static function newFromRow( $row, $from = 'fromdb' ) { + $page = self::factory( Title::newFromRow( $row ) ); + $page->loadFromRow( $row, $from ); + return $page; + } + + /** + * Convert 'fromdb', 'fromdbmaster' and 'forupdate' to READ_* constants. + * + * @param object|string|int $type + * @return mixed + */ + private static function convertSelectType( $type ) { + switch ( $type ) { + case 'fromdb': + return self::READ_NORMAL; + case 'fromdbmaster': + return self::READ_LATEST; + case 'forupdate': + return self::READ_LOCKING; + default: + // It may already be an integer or whatever else + return $type; + } + } + + /** + * @todo Move this UI stuff somewhere else + * + * @see ContentHandler::getActionOverrides + * @return array + */ + public function getActionOverrides() { + return $this->getContentHandler()->getActionOverrides(); + } + + /** + * Returns the ContentHandler instance to be used to deal with the content of this WikiPage. + * + * Shorthand for ContentHandler::getForModelID( $this->getContentModel() ); + * + * @return ContentHandler + * + * @since 1.21 + */ + public function getContentHandler() { + return ContentHandler::getForModelID( $this->getContentModel() ); + } + + /** + * Get the title object of the article + * @return Title Title object of this page + */ + public function getTitle() { + return $this->mTitle; + } + + /** + * Clear the object + * @return void + */ + public function clear() { + $this->mDataLoaded = false; + $this->mDataLoadedFrom = self::READ_NONE; + + $this->clearCacheFields(); + } + + /** + * Clear the object cache fields + * @return void + */ + protected function clearCacheFields() { + $this->mId = null; + $this->mRedirectTarget = null; // Title object if set + $this->mLastRevision = null; // Latest revision + $this->mTouched = '19700101000000'; + $this->mLinksUpdated = '19700101000000'; + $this->mTimestamp = ''; + $this->mIsRedirect = false; + $this->mLatest = false; + // T59026: do not clear mPreparedEdit since prepareTextForEdit() already checks + // the requested rev ID and content against the cached one for equality. For most + // content types, the output should not change during the lifetime of this cache. + // Clearing it can cause extra parses on edit for no reason. + } + + /** + * Clear the mPreparedEdit cache field, as may be needed by mutable content types + * @return void + * @since 1.23 + */ + public function clearPreparedEdit() { + $this->mPreparedEdit = false; + } + + /** + * Return the list of revision fields that should be selected to create + * a new page. + * + * @deprecated since 1.31, use self::getQueryInfo() instead. + * @return array + */ + public static function selectFields() { + global $wgContentHandlerUseDB, $wgPageLanguageUseDB; + + wfDeprecated( __METHOD__, '1.31' ); + + $fields = [ + 'page_id', + 'page_namespace', + 'page_title', + 'page_restrictions', + 'page_is_redirect', + 'page_is_new', + 'page_random', + 'page_touched', + 'page_links_updated', + 'page_latest', + 'page_len', + ]; + + if ( $wgContentHandlerUseDB ) { + $fields[] = 'page_content_model'; + } + + if ( $wgPageLanguageUseDB ) { + $fields[] = 'page_lang'; + } + + return $fields; + } + + /** + * Return the tables, fields, and join conditions to be selected to create + * a new page object. + * @since 1.31 + * @return array With three keys: + * - tables: (string[]) to include in the `$table` to `IDatabase->select()` + * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` + * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` + */ + public static function getQueryInfo() { + global $wgContentHandlerUseDB, $wgPageLanguageUseDB; + + $ret = [ + 'tables' => [ 'page' ], + 'fields' => [ + 'page_id', + 'page_namespace', + 'page_title', + 'page_restrictions', + 'page_is_redirect', + 'page_is_new', + 'page_random', + 'page_touched', + 'page_links_updated', + 'page_latest', + 'page_len', + ], + 'joins' => [], + ]; + + if ( $wgContentHandlerUseDB ) { + $ret['fields'][] = 'page_content_model'; + } + + if ( $wgPageLanguageUseDB ) { + $ret['fields'][] = 'page_lang'; + } + + return $ret; + } + + /** + * Fetch a page record with the given conditions + * @param IDatabase $dbr + * @param array $conditions + * @param array $options + * @return object|bool Database result resource, or false on failure + */ + protected function pageData( $dbr, $conditions, $options = [] ) { + $pageQuery = self::getQueryInfo(); + + // Avoid PHP 7.1 warning of passing $this by reference + $wikiPage = $this; + + Hooks::run( 'ArticlePageDataBefore', [ + &$wikiPage, &$pageQuery['fields'], &$pageQuery['tables'], &$pageQuery['joins'] + ] ); + + $row = $dbr->selectRow( + $pageQuery['tables'], + $pageQuery['fields'], + $conditions, + __METHOD__, + $options, + $pageQuery['joins'] + ); + + Hooks::run( 'ArticlePageDataAfter', [ &$wikiPage, &$row ] ); + + return $row; + } + + /** + * Fetch a page record matching the Title object's namespace and title + * using a sanitized title string + * + * @param IDatabase $dbr + * @param Title $title + * @param array $options + * @return object|bool Database result resource, or false on failure + */ + public function pageDataFromTitle( $dbr, $title, $options = [] ) { + return $this->pageData( $dbr, [ + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey() ], $options ); + } + + /** + * Fetch a page record matching the requested ID + * + * @param IDatabase $dbr + * @param int $id + * @param array $options + * @return object|bool Database result resource, or false on failure + */ + public function pageDataFromId( $dbr, $id, $options = [] ) { + return $this->pageData( $dbr, [ 'page_id' => $id ], $options ); + } + + /** + * Load the object from a given source by title + * + * @param object|string|int $from One of the following: + * - A DB query result object. + * - "fromdb" or WikiPage::READ_NORMAL to get from a replica DB. + * - "fromdbmaster" or WikiPage::READ_LATEST to get from the master DB. + * - "forupdate" or WikiPage::READ_LOCKING to get from the master DB + * using SELECT FOR UPDATE. + * + * @return void + */ + public function loadPageData( $from = 'fromdb' ) { + $from = self::convertSelectType( $from ); + if ( is_int( $from ) && $from <= $this->mDataLoadedFrom ) { + // We already have the data from the correct location, no need to load it twice. + return; + } + + if ( is_int( $from ) ) { + list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from ); + $loadBalancer = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $db = $loadBalancer->getConnection( $index ); + $data = $this->pageDataFromTitle( $db, $this->mTitle, $opts ); + + if ( !$data + && $index == DB_REPLICA + && $loadBalancer->getServerCount() > 1 + && $loadBalancer->hasOrMadeRecentMasterChanges() + ) { + $from = self::READ_LATEST; + list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from ); + $db = $loadBalancer->getConnection( $index ); + $data = $this->pageDataFromTitle( $db, $this->mTitle, $opts ); + } + } else { + // No idea from where the caller got this data, assume replica DB. + $data = $from; + $from = self::READ_NORMAL; + } + + $this->loadFromRow( $data, $from ); + } + + /** + * Load the object from a database row + * + * @since 1.20 + * @param object|bool $data DB row containing fields returned by selectFields() or false + * @param string|int $from One of the following: + * - "fromdb" or WikiPage::READ_NORMAL if the data comes from a replica DB + * - "fromdbmaster" or WikiPage::READ_LATEST if the data comes from the master DB + * - "forupdate" or WikiPage::READ_LOCKING if the data comes from + * the master DB using SELECT FOR UPDATE + */ + public function loadFromRow( $data, $from ) { + $lc = LinkCache::singleton(); + $lc->clearLink( $this->mTitle ); + + if ( $data ) { + $lc->addGoodLinkObjFromRow( $this->mTitle, $data ); + + $this->mTitle->loadFromRow( $data ); + + // Old-fashioned restrictions + $this->mTitle->loadRestrictions( $data->page_restrictions ); + + $this->mId = intval( $data->page_id ); + $this->mTouched = wfTimestamp( TS_MW, $data->page_touched ); + $this->mLinksUpdated = wfTimestampOrNull( TS_MW, $data->page_links_updated ); + $this->mIsRedirect = intval( $data->page_is_redirect ); + $this->mLatest = intval( $data->page_latest ); + // T39225: $latest may no longer match the cached latest Revision object. + // Double-check the ID of any cached latest Revision object for consistency. + if ( $this->mLastRevision && $this->mLastRevision->getId() != $this->mLatest ) { + $this->mLastRevision = null; + $this->mTimestamp = ''; + } + } else { + $lc->addBadLinkObj( $this->mTitle ); + + $this->mTitle->loadFromRow( false ); + + $this->clearCacheFields(); + + $this->mId = 0; + } + + $this->mDataLoaded = true; + $this->mDataLoadedFrom = self::convertSelectType( $from ); + } + + /** + * @return int Page ID + */ + public function getId() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return $this->mId; + } + + /** + * @return bool Whether or not the page exists in the database + */ + public function exists() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return $this->mId > 0; + } + + /** + * Check if this page is something we're going to be showing + * some sort of sensible content for. If we return false, page + * views (plain action=view) will return an HTTP 404 response, + * so spiders and robots can know they're following a bad link. + * + * @return bool + */ + public function hasViewableContent() { + return $this->mTitle->isKnown(); + } + + /** + * Tests if the article content represents a redirect + * + * @return bool + */ + public function isRedirect() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + + return (bool)$this->mIsRedirect; + } + + /** + * Returns the page's content model id (see the CONTENT_MODEL_XXX constants). + * + * Will use the revisions actual content model if the page exists, + * and the page's default if the page doesn't exist yet. + * + * @return string + * + * @since 1.21 + */ + public function getContentModel() { + if ( $this->exists() ) { + $cache = ObjectCache::getMainWANInstance(); + + return $cache->getWithSetCallback( + $cache->makeKey( 'page-content-model', $this->getLatest() ), + $cache::TTL_MONTH, + function () { + $rev = $this->getRevision(); + if ( $rev ) { + // Look at the revision's actual content model + return $rev->getContentModel(); + } else { + $title = $this->mTitle->getPrefixedDBkey(); + wfWarn( "Page $title exists but has no (visible) revisions!" ); + return $this->mTitle->getContentModel(); + } + } + ); + } + + // use the default model for this page + return $this->mTitle->getContentModel(); + } + + /** + * Loads page_touched and returns a value indicating if it should be used + * @return bool True if this page exists and is not a redirect + */ + public function checkTouched() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return ( $this->mId && !$this->mIsRedirect ); + } + + /** + * Get the page_touched field + * @return string Containing GMT timestamp + */ + public function getTouched() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return $this->mTouched; + } + + /** + * Get the page_links_updated field + * @return string|null Containing GMT timestamp + */ + public function getLinksTimestamp() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return $this->mLinksUpdated; + } + + /** + * Get the page_latest field + * @return int The rev_id of current revision + */ + public function getLatest() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return (int)$this->mLatest; + } + + /** + * Get the Revision object of the oldest revision + * @return Revision|null + */ + public function getOldestRevision() { + // Try using the replica DB first, then try the master + $rev = $this->mTitle->getFirstRevision(); + if ( !$rev ) { + $rev = $this->mTitle->getFirstRevision( Title::GAID_FOR_UPDATE ); + } + return $rev; + } + + /** + * Loads everything except the text + * This isn't necessary for all uses, so it's only done if needed. + */ + protected function loadLastEdit() { + if ( $this->mLastRevision !== null ) { + return; // already loaded + } + + $latest = $this->getLatest(); + if ( !$latest ) { + return; // page doesn't exist or is missing page_latest info + } + + if ( $this->mDataLoadedFrom == self::READ_LOCKING ) { + // T39225: if session S1 loads the page row FOR UPDATE, the result always + // includes the latest changes committed. This is true even within REPEATABLE-READ + // transactions, where S1 normally only sees changes committed before the first S1 + // SELECT. Thus we need S1 to also gets the revision row FOR UPDATE; otherwise, it + // may not find it since a page row UPDATE and revision row INSERT by S2 may have + // happened after the first S1 SELECT. + // https://dev.mysql.com/doc/refman/5.0/en/set-transaction.html#isolevel_repeatable-read + $flags = Revision::READ_LOCKING; + $revision = Revision::newFromPageId( $this->getId(), $latest, $flags ); + } elseif ( $this->mDataLoadedFrom == self::READ_LATEST ) { + // Bug T93976: if page_latest was loaded from the master, fetch the + // revision from there as well, as it may not exist yet on a replica DB. + // Also, this keeps the queries in the same REPEATABLE-READ snapshot. + $flags = Revision::READ_LATEST; + $revision = Revision::newFromPageId( $this->getId(), $latest, $flags ); + } else { + $dbr = wfGetDB( DB_REPLICA ); + $revision = Revision::newKnownCurrent( $dbr, $this->getTitle(), $latest ); + } + + if ( $revision ) { // sanity + $this->setLastEdit( $revision ); + } + } + + /** + * Set the latest revision + * @param Revision $revision + */ + protected function setLastEdit( Revision $revision ) { + $this->mLastRevision = $revision; + $this->mTimestamp = $revision->getTimestamp(); + } + + /** + * Get the latest revision + * @return Revision|null + */ + public function getRevision() { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision; + } + return null; + } + + /** + * Get the content of the current revision. No side-effects... + * + * @param int $audience One of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to $wgUser + * Revision::RAW get the text regardless of permissions + * @param User $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return Content|null The content of the current revision + * + * @since 1.21 + */ + public function getContent( $audience = Revision::FOR_PUBLIC, User $user = null ) { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->getContent( $audience, $user ); + } + return null; + } + + /** + * @return string MW timestamp of last article revision + */ + public function getTimestamp() { + // Check if the field has been filled by WikiPage::setTimestamp() + if ( !$this->mTimestamp ) { + $this->loadLastEdit(); + } + + return wfTimestamp( TS_MW, $this->mTimestamp ); + } + + /** + * Set the page timestamp (use only to avoid DB queries) + * @param string $ts MW timestamp of last article revision + * @return void + */ + public function setTimestamp( $ts ) { + $this->mTimestamp = wfTimestamp( TS_MW, $ts ); + } + + /** + * @param int $audience One of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to the given user + * Revision::RAW get the text regardless of permissions + * @param User $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return int User ID for the user that made the last article revision + */ + public function getUser( $audience = Revision::FOR_PUBLIC, User $user = null ) { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->getUser( $audience, $user ); + } else { + return -1; + } + } + + /** + * Get the User object of the user who created the page + * @param int $audience One of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to the given user + * Revision::RAW get the text regardless of permissions + * @param User $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return User|null + */ + public function getCreator( $audience = Revision::FOR_PUBLIC, User $user = null ) { + $revision = $this->getOldestRevision(); + if ( $revision ) { + $userName = $revision->getUserText( $audience, $user ); + return User::newFromName( $userName, false ); + } else { + return null; + } + } + + /** + * @param int $audience One of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to the given user + * Revision::RAW get the text regardless of permissions + * @param User $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return string Username of the user that made the last article revision + */ + public function getUserText( $audience = Revision::FOR_PUBLIC, User $user = null ) { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->getUserText( $audience, $user ); + } else { + return ''; + } + } + + /** + * @param int $audience One of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to the given user + * Revision::RAW get the text regardless of permissions + * @param User $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return string Comment stored for the last article revision + */ + public function getComment( $audience = Revision::FOR_PUBLIC, User $user = null ) { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->getComment( $audience, $user ); + } else { + return ''; + } + } + + /** + * Returns true if last revision was marked as "minor edit" + * + * @return bool Minor edit indicator for the last article revision. + */ + public function getMinorEdit() { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->isMinor(); + } else { + return false; + } + } + + /** + * Determine whether a page would be suitable for being counted as an + * article in the site_stats table based on the title & its content + * + * @param PreparedEdit|bool $editInfo (false): object returned by prepareTextForEdit(), + * if false, the current database state will be used + * @return bool + */ + public function isCountable( $editInfo = false ) { + global $wgArticleCountMethod; + + if ( !$this->mTitle->isContentPage() ) { + return false; + } + + if ( $editInfo ) { + $content = $editInfo->pstContent; + } else { + $content = $this->getContent(); + } + + if ( !$content || $content->isRedirect() ) { + return false; + } + + $hasLinks = null; + + if ( $wgArticleCountMethod === 'link' ) { + // nasty special case to avoid re-parsing to detect links + + if ( $editInfo ) { + // ParserOutput::getLinks() is a 2D array of page links, so + // to be really correct we would need to recurse in the array + // but the main array should only have items in it if there are + // links. + $hasLinks = (bool)count( $editInfo->output->getLinks() ); + } else { + $hasLinks = (bool)wfGetDB( DB_REPLICA )->selectField( 'pagelinks', 1, + [ 'pl_from' => $this->getId() ], __METHOD__ ); + } + } + + return $content->isCountable( $hasLinks ); + } + + /** + * If this page is a redirect, get its target + * + * The target will be fetched from the redirect table if possible. + * If this page doesn't have an entry there, call insertRedirect() + * @return Title|null Title object, or null if this page is not a redirect + */ + public function getRedirectTarget() { + if ( !$this->mTitle->isRedirect() ) { + return null; + } + + if ( $this->mRedirectTarget !== null ) { + return $this->mRedirectTarget; + } + + // Query the redirect table + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( 'redirect', + [ 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ], + [ 'rd_from' => $this->getId() ], + __METHOD__ + ); + + // rd_fragment and rd_interwiki were added later, populate them if empty + if ( $row && !is_null( $row->rd_fragment ) && !is_null( $row->rd_interwiki ) ) { + $this->mRedirectTarget = Title::makeTitle( + $row->rd_namespace, $row->rd_title, + $row->rd_fragment, $row->rd_interwiki + ); + return $this->mRedirectTarget; + } + + // This page doesn't have an entry in the redirect table + $this->mRedirectTarget = $this->insertRedirect(); + return $this->mRedirectTarget; + } + + /** + * Insert an entry for this page into the redirect table if the content is a redirect + * + * The database update will be deferred via DeferredUpdates + * + * Don't call this function directly unless you know what you're doing. + * @return Title|null Title object or null if not a redirect + */ + public function insertRedirect() { + $content = $this->getContent(); + $retval = $content ? $content->getUltimateRedirectTarget() : null; + if ( !$retval ) { + return null; + } + + // Update the DB post-send if the page has not cached since now + $latest = $this->getLatest(); + DeferredUpdates::addCallableUpdate( + function () use ( $retval, $latest ) { + $this->insertRedirectEntry( $retval, $latest ); + }, + DeferredUpdates::POSTSEND, + wfGetDB( DB_MASTER ) + ); + + return $retval; + } + + /** + * Insert or update the redirect table entry for this page to indicate it redirects to $rt + * @param Title $rt Redirect target + * @param int|null $oldLatest Prior page_latest for check and set + */ + public function insertRedirectEntry( Title $rt, $oldLatest = null ) { + $dbw = wfGetDB( DB_MASTER ); + $dbw->startAtomic( __METHOD__ ); + + if ( !$oldLatest || $oldLatest == $this->lockAndGetLatest() ) { + $dbw->upsert( + 'redirect', + [ + 'rd_from' => $this->getId(), + 'rd_namespace' => $rt->getNamespace(), + 'rd_title' => $rt->getDBkey(), + 'rd_fragment' => $rt->getFragment(), + 'rd_interwiki' => $rt->getInterwiki(), + ], + [ 'rd_from' ], + [ + 'rd_namespace' => $rt->getNamespace(), + 'rd_title' => $rt->getDBkey(), + 'rd_fragment' => $rt->getFragment(), + 'rd_interwiki' => $rt->getInterwiki(), + ], + __METHOD__ + ); + } + + $dbw->endAtomic( __METHOD__ ); + } + + /** + * Get the Title object or URL this page redirects to + * + * @return bool|Title|string False, Title of in-wiki target, or string with URL + */ + public function followRedirect() { + return $this->getRedirectURL( $this->getRedirectTarget() ); + } + + /** + * Get the Title object or URL to use for a redirect. We use Title + * objects for same-wiki, non-special redirects and URLs for everything + * else. + * @param Title $rt Redirect target + * @return bool|Title|string False, Title object of local target, or string with URL + */ + public function getRedirectURL( $rt ) { + if ( !$rt ) { + return false; + } + + if ( $rt->isExternal() ) { + if ( $rt->isLocal() ) { + // Offsite wikis need an HTTP redirect. + // This can be hard to reverse and may produce loops, + // so they may be disabled in the site configuration. + $source = $this->mTitle->getFullURL( 'redirect=no' ); + return $rt->getFullURL( [ 'rdfrom' => $source ] ); + } else { + // External pages without "local" bit set are not valid + // redirect targets + return false; + } + } + + if ( $rt->isSpecialPage() ) { + // Gotta handle redirects to special pages differently: + // Fill the HTTP response "Location" header and ignore the rest of the page we're on. + // Some pages are not valid targets. + if ( $rt->isValidRedirectTarget() ) { + return $rt->getFullURL(); + } else { + return false; + } + } + + return $rt; + } + + /** + * Get a list of users who have edited this article, not including the user who made + * the most recent revision, which you can get from $article->getUser() if you want it + * @return UserArrayFromResult + */ + public function getContributors() { + // @todo FIXME: This is expensive; cache this info somewhere. + + $dbr = wfGetDB( DB_REPLICA ); + + $actorMigration = ActorMigration::newMigration(); + $actorQuery = $actorMigration->getJoin( 'rev_user' ); + + $tables = array_merge( [ 'revision' ], $actorQuery['tables'], [ 'user' ] ); + + $fields = [ + 'user_id' => $actorQuery['fields']['rev_user'], + 'user_name' => $actorQuery['fields']['rev_user_text'], + 'actor_id' => $actorQuery['fields']['rev_actor'], + 'user_real_name' => 'MIN(user_real_name)', + 'timestamp' => 'MAX(rev_timestamp)', + ]; + + $conds = [ 'rev_page' => $this->getId() ]; + + // The user who made the top revision gets credited as "this page was last edited by + // John, based on contributions by Tom, Dick and Harry", so don't include them twice. + $user = $this->getUser() + ? User::newFromId( $this->getUser() ) + : User::newFromName( $this->getUserText(), false ); + $conds[] = 'NOT(' . $actorMigration->getWhere( $dbr, 'rev_user', $user )['conds'] . ')'; + + // Username hidden? + $conds[] = "{$dbr->bitAnd( 'rev_deleted', Revision::DELETED_USER )} = 0"; + + $jconds = [ + 'user' => [ 'LEFT JOIN', $actorQuery['fields']['rev_user'] . ' = user_id' ], + ] + $actorQuery['joins']; + + $options = [ + 'GROUP BY' => [ $fields['user_id'], $fields['user_name'] ], + 'ORDER BY' => 'timestamp DESC', + ]; + + $res = $dbr->select( $tables, $fields, $conds, __METHOD__, $options, $jconds ); + return new UserArrayFromResult( $res ); + } + + /** + * Should the parser cache be used? + * + * @param ParserOptions $parserOptions ParserOptions to check + * @param int $oldId + * @return bool + */ + public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) { + return $parserOptions->getStubThreshold() == 0 + && $this->exists() + && ( $oldId === null || $oldId === 0 || $oldId === $this->getLatest() ) + && $this->getContentHandler()->isParserCacheSupported(); + } + + /** + * Get a ParserOutput for the given ParserOptions and revision ID. + * + * The parser cache will be used if possible. Cache misses that result + * in parser runs are debounced with PoolCounter. + * + * @since 1.19 + * @param ParserOptions $parserOptions ParserOptions to use for the parse operation + * @param null|int $oldid Revision ID to get the text from, passing null or 0 will + * get the current revision (default value) + * @param bool $forceParse Force reindexing, regardless of cache settings + * @return bool|ParserOutput ParserOutput or false if the revision was not found + */ + public function getParserOutput( + ParserOptions $parserOptions, $oldid = null, $forceParse = false + ) { + $useParserCache = + ( !$forceParse ) && $this->shouldCheckParserCache( $parserOptions, $oldid ); + + if ( $useParserCache && !$parserOptions->isSafeToCache() ) { + throw new InvalidArgumentException( + 'The supplied ParserOptions are not safe to cache. Fix the options or set $forceParse = true.' + ); + } + + wfDebug( __METHOD__ . + ': using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" ); + if ( $parserOptions->getStubThreshold() ) { + wfIncrStats( 'pcache.miss.stub' ); + } + + if ( $useParserCache ) { + $parserOutput = MediaWikiServices::getInstance()->getParserCache() + ->get( $this, $parserOptions ); + if ( $parserOutput !== false ) { + return $parserOutput; + } + } + + if ( $oldid === null || $oldid === 0 ) { + $oldid = $this->getLatest(); + } + + $pool = new PoolWorkArticleView( $this, $parserOptions, $oldid, $useParserCache ); + $pool->execute(); + + return $pool->getParserOutput(); + } + + /** + * Do standard deferred updates after page view (existing or missing page) + * @param User $user The relevant user + * @param int $oldid Revision id being viewed; if not given or 0, latest revision is assumed + */ + public function doViewUpdates( User $user, $oldid = 0 ) { + if ( wfReadOnly() ) { + return; + } + + // Update newtalk / watchlist notification status; + // Avoid outage if the master is not reachable by using a deferred updated + DeferredUpdates::addCallableUpdate( + function () use ( $user, $oldid ) { + Hooks::run( 'PageViewUpdates', [ $this, $user ] ); + + $user->clearNotification( $this->mTitle, $oldid ); + }, + DeferredUpdates::PRESEND + ); + } + + /** + * Perform the actions of a page purging + * @return bool + * @note In 1.28 (and only 1.28), this took a $flags parameter that + * controlled how much purging was done. + */ + public function doPurge() { + // Avoid PHP 7.1 warning of passing $this by reference + $wikiPage = $this; + + if ( !Hooks::run( 'ArticlePurge', [ &$wikiPage ] ) ) { + return false; + } + + $this->mTitle->invalidateCache(); + + // Clear file cache + HTMLFileCache::clearFileCache( $this->getTitle() ); + // Send purge after above page_touched update was committed + DeferredUpdates::addUpdate( + new CdnCacheUpdate( $this->mTitle->getCdnUrls() ), + DeferredUpdates::PRESEND + ); + + if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) { + $messageCache = MessageCache::singleton(); + $messageCache->updateMessageOverride( $this->mTitle, $this->getContent() ); + } + + return true; + } + + /** + * Insert a new empty page record for this article. + * This *must* be followed up by creating a revision + * and running $this->updateRevisionOn( ... ); + * or else the record will be left in a funky state. + * Best if all done inside a transaction. + * + * @param IDatabase $dbw + * @param int|null $pageId Custom page ID that will be used for the insert statement + * + * @return bool|int The newly created page_id key; false if the row was not + * inserted, e.g. because the title already existed or because the specified + * page ID is already in use. + */ + public function insertOn( $dbw, $pageId = null ) { + $pageIdForInsert = $pageId ? [ 'page_id' => $pageId ] : []; + $dbw->insert( + 'page', + [ + 'page_namespace' => $this->mTitle->getNamespace(), + 'page_title' => $this->mTitle->getDBkey(), + 'page_restrictions' => '', + 'page_is_redirect' => 0, // Will set this shortly... + 'page_is_new' => 1, + 'page_random' => wfRandom(), + 'page_touched' => $dbw->timestamp(), + 'page_latest' => 0, // Fill this in shortly... + 'page_len' => 0, // Fill this in shortly... + ] + $pageIdForInsert, + __METHOD__, + 'IGNORE' + ); + + if ( $dbw->affectedRows() > 0 ) { + $newid = $pageId ? (int)$pageId : $dbw->insertId(); + $this->mId = $newid; + $this->mTitle->resetArticleID( $newid ); + + return $newid; + } else { + return false; // nothing changed + } + } + + /** + * Update the page record to point to a newly saved revision. + * + * @param IDatabase $dbw + * @param Revision $revision For ID number, and text used to set + * length and redirect status fields + * @param int $lastRevision If given, will not overwrite the page field + * when different from the currently set value. + * Giving 0 indicates the new page flag should be set on. + * @param bool $lastRevIsRedirect If given, will optimize adding and + * removing rows in redirect table. + * @return bool Success; false if the page row was missing or page_latest changed + */ + public function updateRevisionOn( $dbw, $revision, $lastRevision = null, + $lastRevIsRedirect = null + ) { + global $wgContentHandlerUseDB; + + // Assertion to try to catch T92046 + if ( (int)$revision->getId() === 0 ) { + throw new InvalidArgumentException( + __METHOD__ . ': Revision has ID ' . var_export( $revision->getId(), 1 ) + ); + } + + $content = $revision->getContent(); + $len = $content ? $content->getSize() : 0; + $rt = $content ? $content->getUltimateRedirectTarget() : null; + + $conditions = [ 'page_id' => $this->getId() ]; + + if ( !is_null( $lastRevision ) ) { + // An extra check against threads stepping on each other + $conditions['page_latest'] = $lastRevision; + } + + $revId = $revision->getId(); + Assert::parameter( $revId > 0, '$revision->getId()', 'must be > 0' ); + + $row = [ /* SET */ + 'page_latest' => $revId, + 'page_touched' => $dbw->timestamp( $revision->getTimestamp() ), + 'page_is_new' => ( $lastRevision === 0 ) ? 1 : 0, + 'page_is_redirect' => $rt !== null ? 1 : 0, + 'page_len' => $len, + ]; + + if ( $wgContentHandlerUseDB ) { + $row['page_content_model'] = $revision->getContentModel(); + } + + $dbw->update( 'page', + $row, + $conditions, + __METHOD__ ); + + $result = $dbw->affectedRows() > 0; + if ( $result ) { + $this->updateRedirectOn( $dbw, $rt, $lastRevIsRedirect ); + $this->setLastEdit( $revision ); + $this->mLatest = $revision->getId(); + $this->mIsRedirect = (bool)$rt; + // Update the LinkCache. + LinkCache::singleton()->addGoodLinkObj( + $this->getId(), + $this->mTitle, + $len, + $this->mIsRedirect, + $this->mLatest, + $revision->getContentModel() + ); + } + + return $result; + } + + /** + * Add row to the redirect table if this is a redirect, remove otherwise. + * + * @param IDatabase $dbw + * @param Title|null $redirectTitle Title object pointing to the redirect target, + * or NULL if this is not a redirect + * @param null|bool $lastRevIsRedirect If given, will optimize adding and + * removing rows in redirect table. + * @return bool True on success, false on failure + * @private + */ + public function updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect = null ) { + // Always update redirects (target link might have changed) + // Update/Insert if we don't know if the last revision was a redirect or not + // Delete if changing from redirect to non-redirect + $isRedirect = !is_null( $redirectTitle ); + + if ( !$isRedirect && $lastRevIsRedirect === false ) { + return true; + } + + if ( $isRedirect ) { + $this->insertRedirectEntry( $redirectTitle ); + } else { + // This is not a redirect, remove row from redirect table + $where = [ 'rd_from' => $this->getId() ]; + $dbw->delete( 'redirect', $where, __METHOD__ ); + } + + if ( $this->getTitle()->getNamespace() == NS_FILE ) { + RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $this->getTitle() ); + } + + return ( $dbw->affectedRows() != 0 ); + } + + /** + * If the given revision is newer than the currently set page_latest, + * update the page record. Otherwise, do nothing. + * + * @deprecated since 1.24, use updateRevisionOn instead + * + * @param IDatabase $dbw + * @param Revision $revision + * @return bool + */ + public function updateIfNewerOn( $dbw, $revision ) { + $row = $dbw->selectRow( + [ 'revision', 'page' ], + [ 'rev_id', 'rev_timestamp', 'page_is_redirect' ], + [ + 'page_id' => $this->getId(), + 'page_latest=rev_id' ], + __METHOD__ ); + + if ( $row ) { + if ( wfTimestamp( TS_MW, $row->rev_timestamp ) >= $revision->getTimestamp() ) { + return false; + } + $prev = $row->rev_id; + $lastRevIsRedirect = (bool)$row->page_is_redirect; + } else { + // No or missing previous revision; mark the page as new + $prev = 0; + $lastRevIsRedirect = null; + } + + $ret = $this->updateRevisionOn( $dbw, $revision, $prev, $lastRevIsRedirect ); + + return $ret; + } + + /** + * Get the content that needs to be saved in order to undo all revisions + * between $undo and $undoafter. Revisions must belong to the same page, + * must exist and must not be deleted + * @param Revision $undo + * @param Revision $undoafter Must be an earlier revision than $undo + * @return Content|bool Content on success, false on failure + * @since 1.21 + * Before we had the Content object, this was done in getUndoText + */ + public function getUndoContent( Revision $undo, Revision $undoafter = null ) { + $handler = $undo->getContentHandler(); + return $handler->getUndoContent( $this->getRevision(), $undo, $undoafter ); + } + + /** + * Returns true if this page's content model supports sections. + * + * @return bool + * + * @todo The skin should check this and not offer section functionality if + * sections are not supported. + * @todo The EditPage should check this and not offer section functionality + * if sections are not supported. + */ + public function supportsSections() { + return $this->getContentHandler()->supportsSections(); + } + + /** + * @param string|int|null|bool $sectionId Section identifier as a number or string + * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page + * or 'new' for a new section. + * @param Content $sectionContent New content of the section. + * @param string $sectionTitle New section's subject, only if $section is "new". + * @param string $edittime Revision timestamp or null to use the current revision. + * + * @throws MWException + * @return Content|null New complete article content, or null if error. + * + * @since 1.21 + * @deprecated since 1.24, use replaceSectionAtRev instead + */ + public function replaceSectionContent( + $sectionId, Content $sectionContent, $sectionTitle = '', $edittime = null + ) { + $baseRevId = null; + if ( $edittime && $sectionId !== 'new' ) { + $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $dbr = $lb->getConnection( DB_REPLICA ); + $rev = Revision::loadFromTimestamp( $dbr, $this->mTitle, $edittime ); + // Try the master if this thread may have just added it. + // This could be abstracted into a Revision method, but we don't want + // to encourage loading of revisions by timestamp. + if ( !$rev + && $lb->getServerCount() > 1 + && $lb->hasOrMadeRecentMasterChanges() + ) { + $dbw = $lb->getConnection( DB_MASTER ); + $rev = Revision::loadFromTimestamp( $dbw, $this->mTitle, $edittime ); + } + if ( $rev ) { + $baseRevId = $rev->getId(); + } + } + + return $this->replaceSectionAtRev( $sectionId, $sectionContent, $sectionTitle, $baseRevId ); + } + + /** + * @param string|int|null|bool $sectionId Section identifier as a number or string + * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page + * or 'new' for a new section. + * @param Content $sectionContent New content of the section. + * @param string $sectionTitle New section's subject, only if $section is "new". + * @param int|null $baseRevId + * + * @throws MWException + * @return Content|null New complete article content, or null if error. + * + * @since 1.24 + */ + public function replaceSectionAtRev( $sectionId, Content $sectionContent, + $sectionTitle = '', $baseRevId = null + ) { + if ( strval( $sectionId ) === '' ) { + // Whole-page edit; let the whole text through + $newContent = $sectionContent; + } else { + if ( !$this->supportsSections() ) { + throw new MWException( "sections not supported for content model " . + $this->getContentHandler()->getModelID() ); + } + + // T32711: always use current version when adding a new section + if ( is_null( $baseRevId ) || $sectionId === 'new' ) { + $oldContent = $this->getContent(); + } else { + $rev = Revision::newFromId( $baseRevId ); + if ( !$rev ) { + wfDebug( __METHOD__ . " asked for bogus section (page: " . + $this->getId() . "; section: $sectionId)\n" ); + return null; + } + + $oldContent = $rev->getContent(); + } + + if ( !$oldContent ) { + wfDebug( __METHOD__ . ": no page text\n" ); + return null; + } + + $newContent = $oldContent->replaceSection( $sectionId, $sectionContent, $sectionTitle ); + } + + return $newContent; + } + + /** + * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed. + * @param int $flags + * @return int Updated $flags + */ + public function checkFlags( $flags ) { + if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) { + if ( $this->exists() ) { + $flags |= EDIT_UPDATE; + } else { + $flags |= EDIT_NEW; + } + } + + return $flags; + } + + /** + * Change an existing article or create a new article. Updates RC and all necessary caches, + * optionally via the deferred update array. + * + * @param Content $content New content + * @param string $summary Edit summary + * @param int $flags Bitfield: + * EDIT_NEW + * Article is known or assumed to be non-existent, create a new one + * EDIT_UPDATE + * Article is known or assumed to be pre-existing, update it + * EDIT_MINOR + * Mark this edit minor, if the user is allowed to do so + * EDIT_SUPPRESS_RC + * Do not log the change in recentchanges + * EDIT_FORCE_BOT + * Mark the edit a "bot" edit regardless of user rights + * EDIT_AUTOSUMMARY + * Fill in blank summaries with generated text where possible + * EDIT_INTERNAL + * Signal that the page retrieve/save cycle happened entirely in this request. + * + * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the + * article will be detected. If EDIT_UPDATE is specified and the article + * doesn't exist, the function will return an edit-gone-missing error. If + * EDIT_NEW is specified and the article does exist, an edit-already-exists + * error will be returned. These two conditions are also possible with + * auto-detection due to MediaWiki's performance-optimised locking strategy. + * + * @param bool|int $baseRevId The revision ID this edit was based off, if any. + * This is not the parent revision ID, rather the revision ID for older + * content used as the source for a rollback, for example. + * @param User $user The user doing the edit + * @param string $serialFormat Format for storing the content in the + * database. + * @param array|null $tags Change tags to apply to this edit + * Callers are responsible for permission checks + * (with ChangeTags::canAddTagsAccompanyingChange) + * @param Int $undidRevId Id of revision that was undone or 0 + * + * @throws MWException + * @return Status Possible errors: + * edit-hook-aborted: The ArticleSave hook aborted the edit but didn't + * set the fatal flag of $status. + * edit-gone-missing: In update mode, but the article didn't exist. + * edit-conflict: In update mode, the article changed unexpectedly. + * edit-no-change: Warning that the text was the same as before. + * edit-already-exists: In creation mode, but the article already exists. + * + * Extensions may define additional errors. + * + * $return->value will contain an associative array with members as follows: + * new: Boolean indicating if the function attempted to create a new article. + * revision: The revision object for the inserted revision, or null. + * + * @since 1.21 + * @throws MWException + */ + public function doEditContent( + Content $content, $summary, $flags = 0, $baseRevId = false, + User $user = null, $serialFormat = null, $tags = [], $undidRevId = 0 + ) { + global $wgUser, $wgUseAutomaticEditSummaries; + + // Old default parameter for $tags was null + if ( $tags === null ) { + $tags = []; + } + + // Low-level sanity check + if ( $this->mTitle->getText() === '' ) { + throw new MWException( 'Something is trying to edit an article with an empty title' ); + } + // Make sure the given content type is allowed for this page + if ( !$content->getContentHandler()->canBeUsedOn( $this->mTitle ) ) { + return Status::newFatal( 'content-not-allowed-here', + ContentHandler::getLocalizedName( $content->getModel() ), + $this->mTitle->getPrefixedText() + ); + } + + // Load the data from the master database if needed. + // The caller may already loaded it from the master or even loaded it using + // SELECT FOR UPDATE, so do not override that using clear(). + $this->loadPageData( 'fromdbmaster' ); + + $user = $user ?: $wgUser; + $flags = $this->checkFlags( $flags ); + + // Avoid PHP 7.1 warning of passing $this by reference + $wikiPage = $this; + + // Trigger pre-save hook (using provided edit summary) + $hookStatus = Status::newGood( [] ); + $hook_args = [ &$wikiPage, &$user, &$content, &$summary, + $flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ]; + // Check if the hook rejected the attempted save + if ( !Hooks::run( 'PageContentSave', $hook_args ) ) { + if ( $hookStatus->isOK() ) { + // Hook returned false but didn't call fatal(); use generic message + $hookStatus->fatal( 'edit-hook-aborted' ); + } + + return $hookStatus; + } + + $old_revision = $this->getRevision(); // current revision + $old_content = $this->getContent( Revision::RAW ); // current revision's content + + $handler = $content->getContentHandler(); + $tag = $handler->getChangeTag( $old_content, $content, $flags ); + // If there is no applicable tag, null is returned, so we need to check + if ( $tag ) { + $tags[] = $tag; + } + + // Check for undo tag + if ( $undidRevId !== 0 && in_array( 'mw-undo', ChangeTags::getSoftwareTags() ) ) { + $tags[] = 'mw-undo'; + } + + // Provide autosummaries if summary is not provided and autosummaries are enabled + if ( $wgUseAutomaticEditSummaries && ( $flags & EDIT_AUTOSUMMARY ) && $summary == '' ) { + $summary = $handler->getAutosummary( $old_content, $content, $flags ); + } + + // Avoid statsd noise and wasted cycles check the edit stash (T136678) + if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) { + $useCache = false; + } else { + $useCache = true; + } + + // Get the pre-save transform content and final parser output + $editInfo = $this->prepareContentForEdit( $content, null, $user, $serialFormat, $useCache ); + $pstContent = $editInfo->pstContent; // Content object + $meta = [ + 'bot' => ( $flags & EDIT_FORCE_BOT ), + 'minor' => ( $flags & EDIT_MINOR ) && $user->isAllowed( 'minoredit' ), + 'serialized' => $pstContent->serialize( $serialFormat ), + 'serialFormat' => $serialFormat, + 'baseRevId' => $baseRevId, + 'oldRevision' => $old_revision, + 'oldContent' => $old_content, + 'oldId' => $this->getLatest(), + 'oldIsRedirect' => $this->isRedirect(), + 'oldCountable' => $this->isCountable(), + 'tags' => ( $tags !== null ) ? (array)$tags : [], + 'undidRevId' => $undidRevId + ]; + + // Actually create the revision and create/update the page + if ( $flags & EDIT_UPDATE ) { + $status = $this->doModify( $pstContent, $flags, $user, $summary, $meta ); + } else { + $status = $this->doCreate( $pstContent, $flags, $user, $summary, $meta ); + } + + // Promote user to any groups they meet the criteria for + DeferredUpdates::addCallableUpdate( function () use ( $user ) { + $user->addAutopromoteOnceGroups( 'onEdit' ); + $user->addAutopromoteOnceGroups( 'onView' ); // b/c + } ); + + return $status; + } + + /** + * @param Content $content Pre-save transform content + * @param int $flags + * @param User $user + * @param string $summary + * @param array $meta + * @return Status + * @throws DBUnexpectedError + * @throws Exception + * @throws FatalError + * @throws MWException + */ + private function doModify( + Content $content, $flags, User $user, $summary, array $meta + ) { + global $wgUseRCPatrol; + + // Update article, but only if changed. + $status = Status::newGood( [ 'new' => false, 'revision' => null ] ); + + // Convenience variables + $now = wfTimestampNow(); + $oldid = $meta['oldId']; + /** @var Content|null $oldContent */ + $oldContent = $meta['oldContent']; + $newsize = $content->getSize(); + + if ( !$oldid ) { + // Article gone missing + $status->fatal( 'edit-gone-missing' ); + + return $status; + } elseif ( !$oldContent ) { + // Sanity check for T39225 + throw new MWException( "Could not find text for current revision {$oldid}." ); + } + + $changed = !$content->equals( $oldContent ); + + $dbw = wfGetDB( DB_MASTER ); + + if ( $changed ) { + // @TODO: pass content object?! + $revision = new Revision( [ + 'page' => $this->getId(), + 'title' => $this->mTitle, // for determining the default content model + 'comment' => $summary, + 'minor_edit' => $meta['minor'], + 'text' => $meta['serialized'], + 'len' => $newsize, + 'parent_id' => $oldid, + 'user' => $user->getId(), + 'user_text' => $user->getName(), + 'timestamp' => $now, + 'content_model' => $content->getModel(), + 'content_format' => $meta['serialFormat'], + ] ); + + $prepStatus = $content->prepareSave( $this, $flags, $oldid, $user ); + $status->merge( $prepStatus ); + if ( !$status->isOK() ) { + return $status; + } + + $dbw->startAtomic( __METHOD__ ); + // Get the latest page_latest value while locking it. + // Do a CAS style check to see if it's the same as when this method + // started. If it changed then bail out before touching the DB. + $latestNow = $this->lockAndGetLatest(); + if ( $latestNow != $oldid ) { + $dbw->endAtomic( __METHOD__ ); + // Page updated or deleted in the mean time + $status->fatal( 'edit-conflict' ); + + return $status; + } + + // At this point we are now comitted to returning an OK + // status unless some DB query error or other exception comes up. + // This way callers don't have to call rollback() if $status is bad + // unless they actually try to catch exceptions (which is rare). + + // Save the revision text + $revisionId = $revision->insertOn( $dbw ); + // Update page_latest and friends to reflect the new revision + if ( !$this->updateRevisionOn( $dbw, $revision, null, $meta['oldIsRedirect'] ) ) { + throw new MWException( "Failed to update page row to use new revision." ); + } + + $tags = $meta['tags']; + Hooks::run( 'NewRevisionFromEditComplete', + [ $this, $revision, $meta['baseRevId'], $user, &$tags ] ); + + // Update recentchanges + if ( !( $flags & EDIT_SUPPRESS_RC ) ) { + // Mark as patrolled if the user can do so + $autopatrolled = $wgUseRCPatrol && !count( + $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) ); + // Add RC row to the DB + RecentChange::notifyEdit( + $now, + $this->mTitle, + $revision->isMinor(), + $user, + $summary, + $oldid, + $this->getTimestamp(), + $meta['bot'], + '', + $oldContent ? $oldContent->getSize() : 0, + $newsize, + $revisionId, + $autopatrolled ? RecentChange::PRC_AUTOPATROLLED : + RecentChange::PRC_UNPATROLLED, + $tags + ); + } + + $user->incEditCount(); + + $dbw->endAtomic( __METHOD__ ); + $this->mTimestamp = $now; + } else { + // T34948: revision ID must be set to page {{REVISIONID}} and + // related variables correctly. Likewise for {{REVISIONUSER}} (T135261). + // Since we don't insert a new revision into the database, the least + // error-prone way is to reuse given old revision. + $revision = $meta['oldRevision']; + } + + if ( $changed ) { + // Return the new revision to the caller + $status->value['revision'] = $revision; + } else { + $status->warning( 'edit-no-change' ); + // Update page_touched as updateRevisionOn() was not called. + // Other cache updates are managed in onArticleEdit() via doEditUpdates(). + $this->mTitle->invalidateCache( $now ); + } + + // Do secondary updates once the main changes have been committed... + DeferredUpdates::addUpdate( + new AtomicSectionUpdate( + $dbw, + __METHOD__, + function () use ( + $revision, &$user, $content, $summary, &$flags, + $changed, $meta, &$status + ) { + // Update links tables, site stats, etc. + $this->doEditUpdates( + $revision, + $user, + [ + 'changed' => $changed, + 'oldcountable' => $meta['oldCountable'], + 'oldrevision' => $meta['oldRevision'] + ] + ); + // Avoid PHP 7.1 warning of passing $this by reference + $wikiPage = $this; + // Trigger post-save hook + $params = [ &$wikiPage, &$user, $content, $summary, $flags & EDIT_MINOR, + null, null, &$flags, $revision, &$status, $meta['baseRevId'], + $meta['undidRevId'] ]; + Hooks::run( 'PageContentSaveComplete', $params ); + } + ), + DeferredUpdates::PRESEND + ); + + return $status; + } + + /** + * @param Content $content Pre-save transform content + * @param int $flags + * @param User $user + * @param string $summary + * @param array $meta + * @return Status + * @throws DBUnexpectedError + * @throws Exception + * @throws FatalError + * @throws MWException + */ + private function doCreate( + Content $content, $flags, User $user, $summary, array $meta + ) { + global $wgUseRCPatrol, $wgUseNPPatrol; + + $status = Status::newGood( [ 'new' => true, 'revision' => null ] ); + + $now = wfTimestampNow(); + $newsize = $content->getSize(); + $prepStatus = $content->prepareSave( $this, $flags, $meta['oldId'], $user ); + $status->merge( $prepStatus ); + if ( !$status->isOK() ) { + return $status; + } + + $dbw = wfGetDB( DB_MASTER ); + $dbw->startAtomic( __METHOD__ ); + + // Add the page record unless one already exists for the title + $newid = $this->insertOn( $dbw ); + if ( $newid === false ) { + $dbw->endAtomic( __METHOD__ ); // nothing inserted + $status->fatal( 'edit-already-exists' ); + + return $status; // nothing done + } + + // At this point we are now comitted to returning an OK + // status unless some DB query error or other exception comes up. + // This way callers don't have to call rollback() if $status is bad + // unless they actually try to catch exceptions (which is rare). + + // @TODO: pass content object?! + $revision = new Revision( [ + 'page' => $newid, + 'title' => $this->mTitle, // for determining the default content model + 'comment' => $summary, + 'minor_edit' => $meta['minor'], + 'text' => $meta['serialized'], + 'len' => $newsize, + 'user' => $user->getId(), + 'user_text' => $user->getName(), + 'timestamp' => $now, + 'content_model' => $content->getModel(), + 'content_format' => $meta['serialFormat'], + ] ); + + // Save the revision text... + $revisionId = $revision->insertOn( $dbw ); + // Update the page record with revision data + if ( !$this->updateRevisionOn( $dbw, $revision, 0 ) ) { + throw new MWException( "Failed to update page row to use new revision." ); + } + + Hooks::run( 'NewRevisionFromEditComplete', [ $this, $revision, false, $user ] ); + + // Update recentchanges + if ( !( $flags & EDIT_SUPPRESS_RC ) ) { + // Mark as patrolled if the user can do so + $patrolled = ( $wgUseRCPatrol || $wgUseNPPatrol ) && + !count( $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) ); + // Add RC row to the DB + RecentChange::notifyNew( + $now, + $this->mTitle, + $revision->isMinor(), + $user, + $summary, + $meta['bot'], + '', + $newsize, + $revisionId, + $patrolled, + $meta['tags'] + ); + } + + $user->incEditCount(); + + $dbw->endAtomic( __METHOD__ ); + $this->mTimestamp = $now; + + // Return the new revision to the caller + $status->value['revision'] = $revision; + + // Do secondary updates once the main changes have been committed... + DeferredUpdates::addUpdate( + new AtomicSectionUpdate( + $dbw, + __METHOD__, + function () use ( + $revision, &$user, $content, $summary, &$flags, $meta, &$status + ) { + // Update links, etc. + $this->doEditUpdates( $revision, $user, [ 'created' => true ] ); + // Avoid PHP 7.1 warning of passing $this by reference + $wikiPage = $this; + // Trigger post-create hook + $params = [ &$wikiPage, &$user, $content, $summary, + $flags & EDIT_MINOR, null, null, &$flags, $revision ]; + Hooks::run( 'PageContentInsertComplete', $params ); + // Trigger post-save hook + $params = array_merge( $params, [ &$status, $meta['baseRevId'], 0 ] ); + Hooks::run( 'PageContentSaveComplete', $params ); + } + ), + DeferredUpdates::PRESEND + ); + + return $status; + } + + /** + * Get parser options suitable for rendering the primary article wikitext + * + * @see ContentHandler::makeParserOptions + * + * @param IContextSource|User|string $context One of the following: + * - IContextSource: Use the User and the Language of the provided + * context + * - User: Use the provided User object and $wgLang for the language, + * so use an IContextSource object if possible. + * - 'canonical': Canonical options (anonymous user with default + * preferences and content language). + * @return ParserOptions + */ + public function makeParserOptions( $context ) { + $options = $this->getContentHandler()->makeParserOptions( $context ); + + if ( $this->getTitle()->isConversionTable() ) { + // @todo ConversionTable should become a separate content model, so + // we don't need special cases like this one. + $options->disableContentConversion(); + } + + return $options; + } + + /** + * Prepare content which is about to be saved. + * + * Prior to 1.30, this returned a stdClass object with the same class + * members. + * + * @param Content $content + * @param Revision|int|null $revision Revision object. For backwards compatibility, a + * revision ID is also accepted, but this is deprecated. + * @param User|null $user + * @param string|null $serialFormat + * @param bool $useCache Check shared prepared edit cache + * + * @return PreparedEdit + * + * @since 1.21 + */ + public function prepareContentForEdit( + Content $content, $revision = null, User $user = null, + $serialFormat = null, $useCache = true + ) { + global $wgContLang, $wgUser, $wgAjaxEditStash; + + if ( is_object( $revision ) ) { + $revid = $revision->getId(); + } else { + $revid = $revision; + // This code path is deprecated, and nothing is known to + // use it, so performance here shouldn't be a worry. + if ( $revid !== null ) { + wfDeprecated( __METHOD__ . ' with $revision = revision ID', '1.25' ); + $revision = Revision::newFromId( $revid, Revision::READ_LATEST ); + } else { + $revision = null; + } + } + + $user = is_null( $user ) ? $wgUser : $user; + // XXX: check $user->getId() here??? + + // Use a sane default for $serialFormat, see T59026 + if ( $serialFormat === null ) { + $serialFormat = $content->getContentHandler()->getDefaultFormat(); + } + + if ( $this->mPreparedEdit + && isset( $this->mPreparedEdit->newContent ) + && $this->mPreparedEdit->newContent->equals( $content ) + && $this->mPreparedEdit->revid == $revid + && $this->mPreparedEdit->format == $serialFormat + // XXX: also check $user here? + ) { + // Already prepared + return $this->mPreparedEdit; + } + + // The edit may have already been prepared via api.php?action=stashedit + $cachedEdit = $useCache && $wgAjaxEditStash + ? ApiStashEdit::checkCache( $this->getTitle(), $content, $user ) + : false; + + $popts = ParserOptions::newFromUserAndLang( $user, $wgContLang ); + Hooks::run( 'ArticlePrepareTextForEdit', [ $this, $popts ] ); + + $edit = new PreparedEdit(); + if ( $cachedEdit ) { + $edit->timestamp = $cachedEdit->timestamp; + } else { + $edit->timestamp = wfTimestampNow(); + } + // @note: $cachedEdit is safely not used if the rev ID was referenced in the text + $edit->revid = $revid; + + if ( $cachedEdit ) { + $edit->pstContent = $cachedEdit->pstContent; + } else { + $edit->pstContent = $content + ? $content->preSaveTransform( $this->mTitle, $user, $popts ) + : null; + } + + $edit->format = $serialFormat; + $edit->popts = $this->makeParserOptions( 'canonical' ); + if ( $cachedEdit ) { + $edit->output = $cachedEdit->output; + } else { + if ( $revision ) { + // We get here if vary-revision is set. This means that this page references + // itself (such as via self-transclusion). In this case, we need to make sure + // that any such self-references refer to the newly-saved revision, and not + // to the previous one, which could otherwise happen due to replica DB lag. + $oldCallback = $edit->popts->getCurrentRevisionCallback(); + $edit->popts->setCurrentRevisionCallback( + function ( Title $title, $parser = false ) use ( $revision, &$oldCallback ) { + if ( $title->equals( $revision->getTitle() ) ) { + return $revision; + } else { + return call_user_func( $oldCallback, $title, $parser ); + } + } + ); + } else { + // Try to avoid a second parse if {{REVISIONID}} is used + $dbIndex = ( $this->mDataLoadedFrom & self::READ_LATEST ) === self::READ_LATEST + ? DB_MASTER // use the best possible guess + : DB_REPLICA; // T154554 + + $edit->popts->setSpeculativeRevIdCallback( function () use ( $dbIndex ) { + return 1 + (int)wfGetDB( $dbIndex )->selectField( + 'revision', + 'MAX(rev_id)', + [], + __METHOD__ + ); + } ); + } + $edit->output = $edit->pstContent + ? $edit->pstContent->getParserOutput( $this->mTitle, $revid, $edit->popts ) + : null; + } + + $edit->newContent = $content; + $edit->oldContent = $this->getContent( Revision::RAW ); + + if ( $edit->output ) { + $edit->output->setCacheTime( wfTimestampNow() ); + } + + // Process cache the result + $this->mPreparedEdit = $edit; + + return $edit; + } + + /** + * Do standard deferred updates after page edit. + * Update links tables, site stats, search index and message cache. + * Purges pages that include this page if the text was changed here. + * Every 100th edit, prune the recent changes table. + * + * @param Revision $revision + * @param User $user User object that did the revision + * @param array $options Array of options, following indexes are used: + * - changed: bool, whether the revision changed the content (default true) + * - created: bool, whether the revision created the page (default false) + * - moved: bool, whether the page was moved (default false) + * - restored: bool, whether the page was undeleted (default false) + * - oldrevision: Revision object for the pre-update revision (default null) + * - oldcountable: bool, null, or string 'no-change' (default null): + * - bool: whether the page was counted as an article before that + * revision, only used in changed is true and created is false + * - null: if created is false, don't update the article count; if created + * is true, do update the article count + * - 'no-change': don't update the article count, ever + */ + public function doEditUpdates( Revision $revision, User $user, array $options = [] ) { + global $wgRCWatchCategoryMembership; + + $options += [ + 'changed' => true, + 'created' => false, + 'moved' => false, + 'restored' => false, + 'oldrevision' => null, + 'oldcountable' => null + ]; + $content = $revision->getContent(); + + $logger = LoggerFactory::getInstance( 'SaveParse' ); + + // See if the parser output before $revision was inserted is still valid + $editInfo = false; + if ( !$this->mPreparedEdit ) { + $logger->debug( __METHOD__ . ": No prepared edit...\n" ); + } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision' ) ) { + $logger->info( __METHOD__ . ": Prepared edit has vary-revision...\n" ); + } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision-id' ) + && $this->mPreparedEdit->output->getSpeculativeRevIdUsed() !== $revision->getId() + ) { + $logger->info( __METHOD__ . ": Prepared edit has vary-revision-id with wrong ID...\n" ); + } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-user' ) && !$options['changed'] ) { + $logger->info( __METHOD__ . ": Prepared edit has vary-user and is null...\n" ); + } else { + wfDebug( __METHOD__ . ": Using prepared edit...\n" ); + $editInfo = $this->mPreparedEdit; + } + + if ( !$editInfo ) { + // Parse the text again if needed. Be careful not to do pre-save transform twice: + // $text is usually already pre-save transformed once. Avoid using the edit stash + // as any prepared content from there or in doEditContent() was already rejected. + $editInfo = $this->prepareContentForEdit( $content, $revision, $user, null, false ); + } + + // Save it to the parser cache. + // Make sure the cache time matches page_touched to avoid double parsing. + MediaWikiServices::getInstance()->getParserCache()->save( + $editInfo->output, $this, $editInfo->popts, + $revision->getTimestamp(), $editInfo->revid + ); + + // Update the links tables and other secondary data + if ( $content ) { + $recursive = $options['changed']; // T52785 + $updates = $content->getSecondaryDataUpdates( + $this->getTitle(), null, $recursive, $editInfo->output + ); + foreach ( $updates as $update ) { + $update->setCause( 'edit-page', $user->getName() ); + if ( $update instanceof LinksUpdate ) { + $update->setRevision( $revision ); + $update->setTriggeringUser( $user ); + } + DeferredUpdates::addUpdate( $update ); + } + if ( $wgRCWatchCategoryMembership + && $this->getContentHandler()->supportsCategories() === true + && ( $options['changed'] || $options['created'] ) + && !$options['restored'] + ) { + // Note: jobs are pushed after deferred updates, so the job should be able to see + // the recent change entry (also done via deferred updates) and carry over any + // bot/deletion/IP flags, ect. + JobQueueGroup::singleton()->lazyPush( new CategoryMembershipChangeJob( + $this->getTitle(), + [ + 'pageId' => $this->getId(), + 'revTimestamp' => $revision->getTimestamp() + ] + ) ); + } + } + + // Avoid PHP 7.1 warning of passing $this by reference + $wikiPage = $this; + + Hooks::run( 'ArticleEditUpdates', [ &$wikiPage, &$editInfo, $options['changed'] ] ); + + if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) { + // Flush old entries from the `recentchanges` table + if ( mt_rand( 0, 9 ) == 0 ) { + JobQueueGroup::singleton()->lazyPush( RecentChangesUpdateJob::newPurgeJob() ); + } + } + + if ( !$this->exists() ) { + return; + } + + $id = $this->getId(); + $title = $this->mTitle->getPrefixedDBkey(); + $shortTitle = $this->mTitle->getDBkey(); + + if ( $options['oldcountable'] === 'no-change' || + ( !$options['changed'] && !$options['moved'] ) + ) { + $good = 0; + } elseif ( $options['created'] ) { + $good = (int)$this->isCountable( $editInfo ); + } elseif ( $options['oldcountable'] !== null ) { + $good = (int)$this->isCountable( $editInfo ) - (int)$options['oldcountable']; + } else { + $good = 0; + } + $edits = $options['changed'] ? 1 : 0; + $pages = $options['created'] ? 1 : 0; + + DeferredUpdates::addUpdate( SiteStatsUpdate::factory( + [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ] + ) ); + DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $content ) ); + + // If this is another user's talk page, update newtalk. + // Don't do this if $options['changed'] = false (null-edits) nor if + // it's a minor edit and the user doesn't want notifications for those. + if ( $options['changed'] + && $this->mTitle->getNamespace() == NS_USER_TALK + && $shortTitle != $user->getTitleKey() + && !( $revision->isMinor() && $user->isAllowed( 'nominornewtalk' ) ) + ) { + $recipient = User::newFromName( $shortTitle, false ); + if ( !$recipient ) { + wfDebug( __METHOD__ . ": invalid username\n" ); + } else { + // Avoid PHP 7.1 warning of passing $this by reference + $wikiPage = $this; + + // Allow extensions to prevent user notification + // when a new message is added to their talk page + if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$wikiPage, $recipient ] ) ) { + if ( User::isIP( $shortTitle ) ) { + // An anonymous user + $recipient->setNewtalk( true, $revision ); + } elseif ( $recipient->isLoggedIn() ) { + $recipient->setNewtalk( true, $revision ); + } else { + wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" ); + } + } + } + } + + if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) { + MessageCache::singleton()->updateMessageOverride( $this->mTitle, $content ); + } + + if ( $options['created'] ) { + self::onArticleCreate( $this->mTitle ); + } elseif ( $options['changed'] ) { // T52785 + self::onArticleEdit( $this->mTitle, $revision ); + } + + ResourceLoaderWikiModule::invalidateModuleCache( + $this->mTitle, $options['oldrevision'], $revision, wfWikiID() + ); + } + + /** + * Update the article's restriction field, and leave a log entry. + * This works for protection both existing and non-existing pages. + * + * @param array $limit Set of restriction keys + * @param array $expiry Per restriction type expiration + * @param int &$cascade Set to false if cascading protection isn't allowed. + * @param string $reason + * @param User $user The user updating the restrictions + * @param string|string[] $tags Change tags to add to the pages and protection log entries + * ($user should be able to add the specified tags before this is called) + * @return Status Status object; if action is taken, $status->value is the log_id of the + * protection log entry. + */ + public function doUpdateRestrictions( array $limit, array $expiry, + &$cascade, $reason, User $user, $tags = null + ) { + global $wgCascadingRestrictionLevels; + + if ( wfReadOnly() ) { + return Status::newFatal( wfMessage( 'readonlytext', wfReadOnlyReason() ) ); + } + + $this->loadPageData( 'fromdbmaster' ); + $restrictionTypes = $this->mTitle->getRestrictionTypes(); + $id = $this->getId(); + + if ( !$cascade ) { + $cascade = false; + } + + // Take this opportunity to purge out expired restrictions + Title::purgeExpiredRestrictions(); + + // @todo FIXME: Same limitations as described in ProtectionForm.php (line 37); + // we expect a single selection, but the schema allows otherwise. + $isProtected = false; + $protect = false; + $changed = false; + + $dbw = wfGetDB( DB_MASTER ); + + foreach ( $restrictionTypes as $action ) { + if ( !isset( $expiry[$action] ) || $expiry[$action] === $dbw->getInfinity() ) { + $expiry[$action] = 'infinity'; + } + if ( !isset( $limit[$action] ) ) { + $limit[$action] = ''; + } elseif ( $limit[$action] != '' ) { + $protect = true; + } + + // Get current restrictions on $action + $current = implode( '', $this->mTitle->getRestrictions( $action ) ); + if ( $current != '' ) { + $isProtected = true; + } + + if ( $limit[$action] != $current ) { + $changed = true; + } elseif ( $limit[$action] != '' ) { + // Only check expiry change if the action is actually being + // protected, since expiry does nothing on an not-protected + // action. + if ( $this->mTitle->getRestrictionExpiry( $action ) != $expiry[$action] ) { + $changed = true; + } + } + } + + if ( !$changed && $protect && $this->mTitle->areRestrictionsCascading() != $cascade ) { + $changed = true; + } + + // If nothing has changed, do nothing + if ( !$changed ) { + return Status::newGood(); + } + + if ( !$protect ) { // No protection at all means unprotection + $revCommentMsg = 'unprotectedarticle-comment'; + $logAction = 'unprotect'; + } elseif ( $isProtected ) { + $revCommentMsg = 'modifiedarticleprotection-comment'; + $logAction = 'modify'; + } else { + $revCommentMsg = 'protectedarticle-comment'; + $logAction = 'protect'; + } + + $logRelationsValues = []; + $logRelationsField = null; + $logParamsDetails = []; + + // Null revision (used for change tag insertion) + $nullRevision = null; + + if ( $id ) { // Protection of existing page + // Avoid PHP 7.1 warning of passing $this by reference + $wikiPage = $this; + + if ( !Hooks::run( 'ArticleProtect', [ &$wikiPage, &$user, $limit, $reason ] ) ) { + return Status::newGood(); + } + + // Only certain restrictions can cascade... + $editrestriction = isset( $limit['edit'] ) + ? [ $limit['edit'] ] + : $this->mTitle->getRestrictions( 'edit' ); + foreach ( array_keys( $editrestriction, 'sysop' ) as $key ) { + $editrestriction[$key] = 'editprotected'; // backwards compatibility + } + foreach ( array_keys( $editrestriction, 'autoconfirmed' ) as $key ) { + $editrestriction[$key] = 'editsemiprotected'; // backwards compatibility + } + + $cascadingRestrictionLevels = $wgCascadingRestrictionLevels; + foreach ( array_keys( $cascadingRestrictionLevels, 'sysop' ) as $key ) { + $cascadingRestrictionLevels[$key] = 'editprotected'; // backwards compatibility + } + foreach ( array_keys( $cascadingRestrictionLevels, 'autoconfirmed' ) as $key ) { + $cascadingRestrictionLevels[$key] = 'editsemiprotected'; // backwards compatibility + } + + // The schema allows multiple restrictions + if ( !array_intersect( $editrestriction, $cascadingRestrictionLevels ) ) { + $cascade = false; + } + + // insert null revision to identify the page protection change as edit summary + $latest = $this->getLatest(); + $nullRevision = $this->insertProtectNullRevision( + $revCommentMsg, + $limit, + $expiry, + $cascade, + $reason, + $user + ); + + if ( $nullRevision === null ) { + return Status::newFatal( 'no-null-revision', $this->mTitle->getPrefixedText() ); + } + + $logRelationsField = 'pr_id'; + + // Update restrictions table + foreach ( $limit as $action => $restrictions ) { + $dbw->delete( + 'page_restrictions', + [ + 'pr_page' => $id, + 'pr_type' => $action + ], + __METHOD__ + ); + if ( $restrictions != '' ) { + $cascadeValue = ( $cascade && $action == 'edit' ) ? 1 : 0; + $dbw->insert( + 'page_restrictions', + [ + 'pr_page' => $id, + 'pr_type' => $action, + 'pr_level' => $restrictions, + 'pr_cascade' => $cascadeValue, + 'pr_expiry' => $dbw->encodeExpiry( $expiry[$action] ) + ], + __METHOD__ + ); + $logRelationsValues[] = $dbw->insertId(); + $logParamsDetails[] = [ + 'type' => $action, + 'level' => $restrictions, + 'expiry' => $expiry[$action], + 'cascade' => (bool)$cascadeValue, + ]; + } + } + + // Clear out legacy restriction fields + $dbw->update( + 'page', + [ 'page_restrictions' => '' ], + [ 'page_id' => $id ], + __METHOD__ + ); + + // Avoid PHP 7.1 warning of passing $this by reference + $wikiPage = $this; + + Hooks::run( 'NewRevisionFromEditComplete', + [ $this, $nullRevision, $latest, $user ] ); + Hooks::run( 'ArticleProtectComplete', [ &$wikiPage, &$user, $limit, $reason ] ); + } else { // Protection of non-existing page (also known as "title protection") + // Cascade protection is meaningless in this case + $cascade = false; + + if ( $limit['create'] != '' ) { + $commentFields = CommentStore::getStore()->insert( $dbw, 'pt_reason', $reason ); + $dbw->replace( 'protected_titles', + [ [ 'pt_namespace', 'pt_title' ] ], + [ + 'pt_namespace' => $this->mTitle->getNamespace(), + 'pt_title' => $this->mTitle->getDBkey(), + 'pt_create_perm' => $limit['create'], + 'pt_timestamp' => $dbw->timestamp(), + 'pt_expiry' => $dbw->encodeExpiry( $expiry['create'] ), + 'pt_user' => $user->getId(), + ] + $commentFields, __METHOD__ + ); + $logParamsDetails[] = [ + 'type' => 'create', + 'level' => $limit['create'], + 'expiry' => $expiry['create'], + ]; + } else { + $dbw->delete( 'protected_titles', + [ + 'pt_namespace' => $this->mTitle->getNamespace(), + 'pt_title' => $this->mTitle->getDBkey() + ], __METHOD__ + ); + } + } + + $this->mTitle->flushRestrictions(); + InfoAction::invalidateCache( $this->mTitle ); + + if ( $logAction == 'unprotect' ) { + $params = []; + } else { + $protectDescriptionLog = $this->protectDescriptionLog( $limit, $expiry ); + $params = [ + '4::description' => $protectDescriptionLog, // parameter for IRC + '5:bool:cascade' => $cascade, + 'details' => $logParamsDetails, // parameter for localize and api + ]; + } + + // Update the protection log + $logEntry = new ManualLogEntry( 'protect', $logAction ); + $logEntry->setTarget( $this->mTitle ); + $logEntry->setComment( $reason ); + $logEntry->setPerformer( $user ); + $logEntry->setParameters( $params ); + if ( !is_null( $nullRevision ) ) { + $logEntry->setAssociatedRevId( $nullRevision->getId() ); + } + $logEntry->setTags( $tags ); + if ( $logRelationsField !== null && count( $logRelationsValues ) ) { + $logEntry->setRelations( [ $logRelationsField => $logRelationsValues ] ); + } + $logId = $logEntry->insert(); + $logEntry->publish( $logId ); + + return Status::newGood( $logId ); + } + + /** + * Insert a new null revision for this page. + * + * @param string $revCommentMsg Comment message key for the revision + * @param array $limit Set of restriction keys + * @param array $expiry Per restriction type expiration + * @param int $cascade Set to false if cascading protection isn't allowed. + * @param string $reason + * @param User|null $user + * @return Revision|null Null on error + */ + public function insertProtectNullRevision( $revCommentMsg, array $limit, + array $expiry, $cascade, $reason, $user = null + ) { + $dbw = wfGetDB( DB_MASTER ); + + // Prepare a null revision to be added to the history + $editComment = wfMessage( + $revCommentMsg, + $this->mTitle->getPrefixedText(), + $user ? $user->getName() : '' + )->inContentLanguage()->text(); + if ( $reason ) { + $editComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason; + } + $protectDescription = $this->protectDescription( $limit, $expiry ); + if ( $protectDescription ) { + $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text(); + $editComment .= wfMessage( 'parentheses' )->params( $protectDescription ) + ->inContentLanguage()->text(); + } + if ( $cascade ) { + $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text(); + $editComment .= wfMessage( 'brackets' )->params( + wfMessage( 'protect-summary-cascade' )->inContentLanguage()->text() + )->inContentLanguage()->text(); + } + + $nullRev = Revision::newNullRevision( $dbw, $this->getId(), $editComment, true, $user ); + if ( $nullRev ) { + $nullRev->insertOn( $dbw ); + + // Update page record and touch page + $oldLatest = $nullRev->getParentId(); + $this->updateRevisionOn( $dbw, $nullRev, $oldLatest ); + } + + return $nullRev; + } + + /** + * @param string $expiry 14-char timestamp or "infinity", or false if the input was invalid + * @return string + */ + protected function formatExpiry( $expiry ) { + global $wgContLang; + + if ( $expiry != 'infinity' ) { + return wfMessage( + 'protect-expiring', + $wgContLang->timeanddate( $expiry, false, false ), + $wgContLang->date( $expiry, false, false ), + $wgContLang->time( $expiry, false, false ) + )->inContentLanguage()->text(); + } else { + return wfMessage( 'protect-expiry-indefinite' ) + ->inContentLanguage()->text(); + } + } + + /** + * Builds the description to serve as comment for the edit. + * + * @param array $limit Set of restriction keys + * @param array $expiry Per restriction type expiration + * @return string + */ + public function protectDescription( array $limit, array $expiry ) { + $protectDescription = ''; + + foreach ( array_filter( $limit ) as $action => $restrictions ) { + # $action is one of $wgRestrictionTypes = [ 'create', 'edit', 'move', 'upload' ]. + # All possible message keys are listed here for easier grepping: + # * restriction-create + # * restriction-edit + # * restriction-move + # * restriction-upload + $actionText = wfMessage( 'restriction-' . $action )->inContentLanguage()->text(); + # $restrictions is one of $wgRestrictionLevels = [ '', 'autoconfirmed', 'sysop' ], + # with '' filtered out. All possible message keys are listed below: + # * protect-level-autoconfirmed + # * protect-level-sysop + $restrictionsText = wfMessage( 'protect-level-' . $restrictions ) + ->inContentLanguage()->text(); + + $expiryText = $this->formatExpiry( $expiry[$action] ); + + if ( $protectDescription !== '' ) { + $protectDescription .= wfMessage( 'word-separator' )->inContentLanguage()->text(); + } + $protectDescription .= wfMessage( 'protect-summary-desc' ) + ->params( $actionText, $restrictionsText, $expiryText ) + ->inContentLanguage()->text(); + } + + return $protectDescription; + } + + /** + * Builds the description to serve as comment for the log entry. + * + * Some bots may parse IRC lines, which are generated from log entries which contain plain + * protect description text. Keep them in old format to avoid breaking compatibility. + * TODO: Fix protection log to store structured description and format it on-the-fly. + * + * @param array $limit Set of restriction keys + * @param array $expiry Per restriction type expiration + * @return string + */ + public function protectDescriptionLog( array $limit, array $expiry ) { + global $wgContLang; + + $protectDescriptionLog = ''; + + foreach ( array_filter( $limit ) as $action => $restrictions ) { + $expiryText = $this->formatExpiry( $expiry[$action] ); + $protectDescriptionLog .= $wgContLang->getDirMark() . + "[$action=$restrictions] ($expiryText)"; + } + + return trim( $protectDescriptionLog ); + } + + /** + * Take an array of page restrictions and flatten it to a string + * suitable for insertion into the page_restrictions field. + * + * @param string[] $limit + * + * @throws MWException + * @return string + */ + protected static function flattenRestrictions( $limit ) { + if ( !is_array( $limit ) ) { + throw new MWException( __METHOD__ . ' given non-array restriction set' ); + } + + $bits = []; + ksort( $limit ); + + foreach ( array_filter( $limit ) as $action => $restrictions ) { + $bits[] = "$action=$restrictions"; + } + + return implode( ':', $bits ); + } + + /** + * Same as doDeleteArticleReal(), but returns a simple boolean. This is kept around for + * backwards compatibility, if you care about error reporting you should use + * doDeleteArticleReal() instead. + * + * Deletes the article with database consistency, writes logs, purges caches + * + * @param string $reason Delete reason for deletion log + * @param bool $suppress Suppress all revisions and log the deletion in + * the suppression log instead of the deletion log + * @param int $u1 Unused + * @param bool $u2 Unused + * @param array|string &$error Array of errors to append to + * @param User $user The deleting user + * @return bool True if successful + */ + public function doDeleteArticle( + $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null + ) { + $status = $this->doDeleteArticleReal( $reason, $suppress, $u1, $u2, $error, $user ); + return $status->isGood(); + } + + /** + * Back-end article deletion + * Deletes the article with database consistency, writes logs, purges caches + * + * @since 1.19 + * + * @param string $reason Delete reason for deletion log + * @param bool $suppress Suppress all revisions and log the deletion in + * the suppression log instead of the deletion log + * @param int $u1 Unused + * @param bool $u2 Unused + * @param array|string &$error Array of errors to append to + * @param User $deleter The deleting user + * @param array $tags Tags to apply to the deletion action + * @param string $logsubtype + * @return Status Status object; if successful, $status->value is the log_id of the + * deletion log entry. If the page couldn't be deleted because it wasn't + * found, $status is a non-fatal 'cannotdelete' error + */ + public function doDeleteArticleReal( + $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $deleter = null, + $tags = [], $logsubtype = 'delete' + ) { + global $wgUser, $wgContentHandlerUseDB, $wgCommentTableSchemaMigrationStage, + $wgActorTableSchemaMigrationStage; + + wfDebug( __METHOD__ . "\n" ); + + $status = Status::newGood(); + + if ( $this->mTitle->getDBkey() === '' ) { + $status->error( 'cannotdelete', + wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) ); + return $status; + } + + // Avoid PHP 7.1 warning of passing $this by reference + $wikiPage = $this; + + $deleter = is_null( $deleter ) ? $wgUser : $deleter; + if ( !Hooks::run( 'ArticleDelete', + [ &$wikiPage, &$deleter, &$reason, &$error, &$status, $suppress ] + ) ) { + if ( $status->isOK() ) { + // Hook aborted but didn't set a fatal status + $status->fatal( 'delete-hook-aborted' ); + } + return $status; + } + + $dbw = wfGetDB( DB_MASTER ); + $dbw->startAtomic( __METHOD__ ); + + $this->loadPageData( self::READ_LATEST ); + $id = $this->getId(); + // T98706: lock the page from various other updates but avoid using + // WikiPage::READ_LOCKING as that will carry over the FOR UPDATE to + // the revisions queries (which also JOIN on user). Only lock the page + // row and CAS check on page_latest to see if the trx snapshot matches. + $lockedLatest = $this->lockAndGetLatest(); + if ( $id == 0 || $this->getLatest() != $lockedLatest ) { + $dbw->endAtomic( __METHOD__ ); + // Page not there or trx snapshot is stale + $status->error( 'cannotdelete', + wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) ); + return $status; + } + + // Given the lock above, we can be confident in the title and page ID values + $namespace = $this->getTitle()->getNamespace(); + $dbKey = $this->getTitle()->getDBkey(); + + // At this point we are now comitted to returning an OK + // status unless some DB query error or other exception comes up. + // This way callers don't have to call rollback() if $status is bad + // unless they actually try to catch exceptions (which is rare). + + // we need to remember the old content so we can use it to generate all deletion updates. + $revision = $this->getRevision(); + try { + $content = $this->getContent( Revision::RAW ); + } catch ( Exception $ex ) { + wfLogWarning( __METHOD__ . ': failed to load content during deletion! ' + . $ex->getMessage() ); + + $content = null; + } + + $commentStore = CommentStore::getStore(); + $actorMigration = ActorMigration::newMigration(); + + $revQuery = Revision::getQueryInfo(); + $bitfield = false; + + // Bitfields to further suppress the content + if ( $suppress ) { + $bitfield = Revision::SUPPRESSED_ALL; + $revQuery['fields'] = array_diff( $revQuery['fields'], [ 'rev_deleted' ] ); + } + + // For now, shunt the revision data into the archive table. + // Text is *not* removed from the text table; bulk storage + // is left intact to avoid breaking block-compression or + // immutable storage schemes. + // In the future, we may keep revisions and mark them with + // the rev_deleted field, which is reserved for this purpose. + + // Lock rows in `revision` and its temp tables, but not any others. + // Note array_intersect() preserves keys from the first arg, and we're + // assuming $revQuery has `revision` primary and isn't using subtables + // for anything we care about. + $res = $dbw->select( + array_intersect( + $revQuery['tables'], + [ 'revision', 'revision_comment_temp', 'revision_actor_temp' ] + ), + '1', + [ 'rev_page' => $id ], + __METHOD__, + 'FOR UPDATE', + $revQuery['joins'] + ); + foreach ( $res as $row ) { + // Fetch all rows in case the DB needs that to properly lock them. + } + + // Get all of the page revisions + $res = $dbw->select( + $revQuery['tables'], + $revQuery['fields'], + [ 'rev_page' => $id ], + __METHOD__, + [], + $revQuery['joins'] + ); + + // Build their equivalent archive rows + $rowsInsert = []; + $revids = []; + + /** @var int[] Revision IDs of edits that were made by IPs */ + $ipRevIds = []; + + foreach ( $res as $row ) { + $comment = $commentStore->getComment( 'rev_comment', $row ); + $user = User::newFromAnyId( $row->rev_user, $row->rev_user_text, $row->rev_actor ); + $rowInsert = [ + 'ar_namespace' => $namespace, + 'ar_title' => $dbKey, + 'ar_timestamp' => $row->rev_timestamp, + 'ar_minor_edit' => $row->rev_minor_edit, + 'ar_rev_id' => $row->rev_id, + 'ar_parent_id' => $row->rev_parent_id, + 'ar_text_id' => $row->rev_text_id, + 'ar_len' => $row->rev_len, + 'ar_page_id' => $id, + 'ar_deleted' => $suppress ? $bitfield : $row->rev_deleted, + 'ar_sha1' => $row->rev_sha1, + ] + $commentStore->insert( $dbw, 'ar_comment', $comment ) + + $actorMigration->getInsertValues( $dbw, 'ar_user', $user ); + if ( $wgContentHandlerUseDB ) { + $rowInsert['ar_content_model'] = $row->rev_content_model; + $rowInsert['ar_content_format'] = $row->rev_content_format; + } + $rowsInsert[] = $rowInsert; + $revids[] = $row->rev_id; + + // Keep track of IP edits, so that the corresponding rows can + // be deleted in the ip_changes table. + if ( (int)$row->rev_user === 0 && IP::isValid( $row->rev_user_text ) ) { + $ipRevIds[] = $row->rev_id; + } + } + // Copy them into the archive table + $dbw->insert( 'archive', $rowsInsert, __METHOD__ ); + // Save this so we can pass it to the ArticleDeleteComplete hook. + $archivedRevisionCount = $dbw->affectedRows(); + + // Clone the title and wikiPage, so we have the information we need when + // we log and run the ArticleDeleteComplete hook. + $logTitle = clone $this->mTitle; + $wikiPageBeforeDelete = clone $this; + + // Now that it's safely backed up, delete it + $dbw->delete( 'page', [ 'page_id' => $id ], __METHOD__ ); + $dbw->delete( 'revision', [ 'rev_page' => $id ], __METHOD__ ); + if ( $wgCommentTableSchemaMigrationStage > MIGRATION_OLD ) { + $dbw->delete( 'revision_comment_temp', [ 'revcomment_rev' => $revids ], __METHOD__ ); + } + if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) { + $dbw->delete( 'revision_actor_temp', [ 'revactor_rev' => $revids ], __METHOD__ ); + } + + // Also delete records from ip_changes as applicable. + if ( count( $ipRevIds ) > 0 ) { + $dbw->delete( 'ip_changes', [ 'ipc_rev_id' => $ipRevIds ], __METHOD__ ); + } + + // Log the deletion, if the page was suppressed, put it in the suppression log instead + $logtype = $suppress ? 'suppress' : 'delete'; + + $logEntry = new ManualLogEntry( $logtype, $logsubtype ); + $logEntry->setPerformer( $deleter ); + $logEntry->setTarget( $logTitle ); + $logEntry->setComment( $reason ); + $logEntry->setTags( $tags ); + $logid = $logEntry->insert(); + + $dbw->onTransactionPreCommitOrIdle( + function () use ( $dbw, $logEntry, $logid ) { + // T58776: avoid deadlocks (especially from FileDeleteForm) + $logEntry->publish( $logid ); + }, + __METHOD__ + ); + + $dbw->endAtomic( __METHOD__ ); + + $this->doDeleteUpdates( $id, $content, $revision, $deleter ); + + Hooks::run( 'ArticleDeleteComplete', [ + &$wikiPageBeforeDelete, + &$deleter, + $reason, + $id, + $content, + $logEntry, + $archivedRevisionCount + ] ); + $status->value = $logid; + + // Show log excerpt on 404 pages rather than just a link + $cache = MediaWikiServices::getInstance()->getMainObjectStash(); + $key = $cache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) ); + $cache->set( $key, 1, $cache::TTL_DAY ); + + return $status; + } + + /** + * Lock the page row for this title+id and return page_latest (or 0) + * + * @return int Returns 0 if no row was found with this title+id + * @since 1.27 + */ + public function lockAndGetLatest() { + return (int)wfGetDB( DB_MASTER )->selectField( + 'page', + 'page_latest', + [ + 'page_id' => $this->getId(), + // Typically page_id is enough, but some code might try to do + // updates assuming the title is the same, so verify that + 'page_namespace' => $this->getTitle()->getNamespace(), + 'page_title' => $this->getTitle()->getDBkey() + ], + __METHOD__, + [ 'FOR UPDATE' ] + ); + } + + /** + * Do some database updates after deletion + * + * @param int $id The page_id value of the page being deleted + * @param Content|null $content Optional page content to be used when determining + * the required updates. This may be needed because $this->getContent() + * may already return null when the page proper was deleted. + * @param Revision|null $revision The latest page revision + * @param User|null $user The user that caused the deletion + */ + public function doDeleteUpdates( + $id, Content $content = null, Revision $revision = null, User $user = null + ) { + try { + $countable = $this->isCountable(); + } catch ( Exception $ex ) { + // fallback for deleting broken pages for which we cannot load the content for + // some reason. Note that doDeleteArticleReal() already logged this problem. + $countable = false; + } + + // Update site status + DeferredUpdates::addUpdate( SiteStatsUpdate::factory( + [ 'edits' => 1, 'articles' => -$countable, 'pages' => -1 ] + ) ); + + // Delete pagelinks, update secondary indexes, etc + $updates = $this->getDeletionUpdates( $content ); + foreach ( $updates as $update ) { + DeferredUpdates::addUpdate( $update ); + } + + $causeAgent = $user ? $user->getName() : 'unknown'; + // Reparse any pages transcluding this page + LinksUpdate::queueRecursiveJobsForTable( + $this->mTitle, 'templatelinks', 'delete-page', $causeAgent ); + // Reparse any pages including this image + if ( $this->mTitle->getNamespace() == NS_FILE ) { + LinksUpdate::queueRecursiveJobsForTable( + $this->mTitle, 'imagelinks', 'delete-page', $causeAgent ); + } + + // Clear caches + self::onArticleDelete( $this->mTitle ); + ResourceLoaderWikiModule::invalidateModuleCache( + $this->mTitle, $revision, null, wfWikiID() + ); + + // Reset this object and the Title object + $this->loadFromRow( false, self::READ_LATEST ); + + // Search engine + DeferredUpdates::addUpdate( new SearchUpdate( $id, $this->mTitle ) ); + } + + /** + * Roll back the most recent consecutive set of edits to a page + * from the same user; fails if there are no eligible edits to + * roll back to, e.g. user is the sole contributor. This function + * performs permissions checks on $user, then calls commitRollback() + * to do the dirty work + * + * @todo Separate the business/permission stuff out from backend code + * @todo Remove $token parameter. Already verified by RollbackAction and ApiRollback. + * + * @param string $fromP Name of the user whose edits to rollback. + * @param string $summary Custom summary. Set to default summary if empty. + * @param string $token Rollback token. + * @param bool $bot If true, mark all reverted edits as bot. + * + * @param array &$resultDetails Array contains result-specific array of additional values + * 'alreadyrolled' : 'current' (rev) + * success : 'summary' (str), 'current' (rev), 'target' (rev) + * + * @param User $user The user performing the rollback + * @param array|null $tags Change tags to apply to the rollback + * Callers are responsible for permission checks + * (with ChangeTags::canAddTagsAccompanyingChange) + * + * @return array Array of errors, each error formatted as + * array(messagekey, param1, param2, ...). + * On success, the array is empty. This array can also be passed to + * OutputPage::showPermissionsErrorPage(). + */ + public function doRollback( + $fromP, $summary, $token, $bot, &$resultDetails, User $user, $tags = null + ) { + $resultDetails = null; + + // Check permissions + $editErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $user ); + $rollbackErrors = $this->mTitle->getUserPermissionsErrors( 'rollback', $user ); + $errors = array_merge( $editErrors, wfArrayDiff2( $rollbackErrors, $editErrors ) ); + + if ( !$user->matchEditToken( $token, 'rollback' ) ) { + $errors[] = [ 'sessionfailure' ]; + } + + if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) { + $errors[] = [ 'actionthrottledtext' ]; + } + + // If there were errors, bail out now + if ( !empty( $errors ) ) { + return $errors; + } + + return $this->commitRollback( $fromP, $summary, $bot, $resultDetails, $user, $tags ); + } + + /** + * Backend implementation of doRollback(), please refer there for parameter + * and return value documentation + * + * NOTE: This function does NOT check ANY permissions, it just commits the + * rollback to the DB. Therefore, you should only call this function direct- + * ly if you want to use custom permissions checks. If you don't, use + * doRollback() instead. + * @param string $fromP Name of the user whose edits to rollback. + * @param string $summary Custom summary. Set to default summary if empty. + * @param bool $bot If true, mark all reverted edits as bot. + * + * @param array &$resultDetails Contains result-specific array of additional values + * @param User $guser The user performing the rollback + * @param array|null $tags Change tags to apply to the rollback + * Callers are responsible for permission checks + * (with ChangeTags::canAddTagsAccompanyingChange) + * + * @return array + */ + public function commitRollback( $fromP, $summary, $bot, + &$resultDetails, User $guser, $tags = null + ) { + global $wgUseRCPatrol, $wgContLang; + + $dbw = wfGetDB( DB_MASTER ); + + if ( wfReadOnly() ) { + return [ [ 'readonlytext' ] ]; + } + + // Get the last editor + $current = $this->getRevision(); + if ( is_null( $current ) ) { + // Something wrong... no page? + return [ [ 'notanarticle' ] ]; + } + + $from = str_replace( '_', ' ', $fromP ); + // User name given should match up with the top revision. + // If the user was deleted then $from should be empty. + if ( $from != $current->getUserText() ) { + $resultDetails = [ 'current' => $current ]; + return [ [ 'alreadyrolled', + htmlspecialchars( $this->mTitle->getPrefixedText() ), + htmlspecialchars( $fromP ), + htmlspecialchars( $current->getUserText() ) + ] ]; + } + + // Get the last edit not by this person... + // Note: these may not be public values + $userId = intval( $current->getUser( Revision::RAW ) ); + $userName = $current->getUserText( Revision::RAW ); + if ( $userId ) { + $user = User::newFromId( $userId ); + $user->setName( $userName ); + } else { + $user = User::newFromName( $current->getUserText( Revision::RAW ), false ); + } + + $actorWhere = ActorMigration::newMigration()->getWhere( $dbw, 'rev_user', $user ); + + $s = $dbw->selectRow( + [ 'revision' ] + $actorWhere['tables'], + [ 'rev_id', 'rev_timestamp', 'rev_deleted' ], + [ + 'rev_page' => $current->getPage(), + 'NOT(' . $actorWhere['conds'] . ')', + ], + __METHOD__, + [ + 'USE INDEX' => [ 'revision' => 'page_timestamp' ], + 'ORDER BY' => 'rev_timestamp DESC' + ], + $actorWhere['joins'] + ); + if ( $s === false ) { + // No one else ever edited this page + return [ [ 'cantrollback' ] ]; + } elseif ( $s->rev_deleted & Revision::DELETED_TEXT + || $s->rev_deleted & Revision::DELETED_USER + ) { + // Only admins can see this text + return [ [ 'notvisiblerev' ] ]; + } + + // Generate the edit summary if necessary + $target = Revision::newFromId( $s->rev_id, Revision::READ_LATEST ); + if ( empty( $summary ) ) { + if ( $from == '' ) { // no public user name + $summary = wfMessage( 'revertpage-nouser' ); + } else { + $summary = wfMessage( 'revertpage' ); + } + } + + // Allow the custom summary to use the same args as the default message + $args = [ + $target->getUserText(), $from, $s->rev_id, + $wgContLang->timeanddate( wfTimestamp( TS_MW, $s->rev_timestamp ) ), + $current->getId(), $wgContLang->timeanddate( $current->getTimestamp() ) + ]; + if ( $summary instanceof Message ) { + $summary = $summary->params( $args )->inContentLanguage()->text(); + } else { + $summary = wfMsgReplaceArgs( $summary, $args ); + } + + // Trim spaces on user supplied text + $summary = trim( $summary ); + + // Save + $flags = EDIT_UPDATE | EDIT_INTERNAL; + + if ( $guser->isAllowed( 'minoredit' ) ) { + $flags |= EDIT_MINOR; + } + + if ( $bot && ( $guser->isAllowedAny( 'markbotedits', 'bot' ) ) ) { + $flags |= EDIT_FORCE_BOT; + } + + $targetContent = $target->getContent(); + $changingContentModel = $targetContent->getModel() !== $current->getContentModel(); + + if ( in_array( 'mw-rollback', ChangeTags::getSoftwareTags() ) ) { + $tags[] = 'mw-rollback'; + } + + // Actually store the edit + $status = $this->doEditContent( + $targetContent, + $summary, + $flags, + $target->getId(), + $guser, + null, + $tags + ); + + // Set patrolling and bot flag on the edits, which gets rollbacked. + // This is done even on edit failure to have patrolling in that case (T64157). + $set = []; + if ( $bot && $guser->isAllowed( 'markbotedits' ) ) { + // Mark all reverted edits as bot + $set['rc_bot'] = 1; + } + + if ( $wgUseRCPatrol ) { + // Mark all reverted edits as patrolled + $set['rc_patrolled'] = RecentChange::PRC_PATROLLED; + } + + if ( count( $set ) ) { + $actorWhere = ActorMigration::newMigration()->getWhere( $dbw, 'rc_user', $user, false ); + $dbw->update( 'recentchanges', $set, + [ /* WHERE */ + 'rc_cur_id' => $current->getPage(), + 'rc_timestamp > ' . $dbw->addQuotes( $s->rev_timestamp ), + $actorWhere['conds'], // No tables/joins are needed for rc_user + ], + __METHOD__ + ); + } + + if ( !$status->isOK() ) { + return $status->getErrorsArray(); + } + + // raise error, when the edit is an edit without a new version + $statusRev = isset( $status->value['revision'] ) + ? $status->value['revision'] + : null; + if ( !( $statusRev instanceof Revision ) ) { + $resultDetails = [ 'current' => $current ]; + return [ [ 'alreadyrolled', + htmlspecialchars( $this->mTitle->getPrefixedText() ), + htmlspecialchars( $fromP ), + htmlspecialchars( $current->getUserText() ) + ] ]; + } + + if ( $changingContentModel ) { + // If the content model changed during the rollback, + // make sure it gets logged to Special:Log/contentmodel + $log = new ManualLogEntry( 'contentmodel', 'change' ); + $log->setPerformer( $guser ); + $log->setTarget( $this->mTitle ); + $log->setComment( $summary ); + $log->setParameters( [ + '4::oldmodel' => $current->getContentModel(), + '5::newmodel' => $targetContent->getModel(), + ] ); + + $logId = $log->insert( $dbw ); + $log->publish( $logId ); + } + + $revId = $statusRev->getId(); + + Hooks::run( 'ArticleRollbackComplete', [ $this, $guser, $target, $current ] ); + + $resultDetails = [ + 'summary' => $summary, + 'current' => $current, + 'target' => $target, + 'newid' => $revId, + 'tags' => $tags + ]; + + return []; + } + + /** + * The onArticle*() functions are supposed to be a kind of hooks + * which should be called whenever any of the specified actions + * are done. + * + * This is a good place to put code to clear caches, for instance. + * + * This is called on page move and undelete, as well as edit + * + * @param Title $title + */ + public static function onArticleCreate( Title $title ) { + // Update existence markers on article/talk tabs... + $other = $title->getOtherPage(); + + $other->purgeSquid(); + + $title->touchLinks(); + $title->purgeSquid(); + $title->deleteTitleProtection(); + + MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title ); + + // Invalidate caches of articles which include this page + DeferredUpdates::addUpdate( + new HTMLCacheUpdate( $title, 'templatelinks', 'page-create' ) + ); + + if ( $title->getNamespace() == NS_CATEGORY ) { + // Load the Category object, which will schedule a job to create + // the category table row if necessary. Checking a replica DB is ok + // here, in the worst case it'll run an unnecessary recount job on + // a category that probably doesn't have many members. + Category::newFromTitle( $title )->getID(); + } + } + + /** + * Clears caches when article is deleted + * + * @param Title $title + */ + public static function onArticleDelete( Title $title ) { + // Update existence markers on article/talk tabs... + // Clear Backlink cache first so that purge jobs use more up-to-date backlink information + BacklinkCache::get( $title )->clear(); + $other = $title->getOtherPage(); + + $other->purgeSquid(); + + $title->touchLinks(); + $title->purgeSquid(); + + MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title ); + + // File cache + HTMLFileCache::clearFileCache( $title ); + InfoAction::invalidateCache( $title ); + + // Messages + if ( $title->getNamespace() == NS_MEDIAWIKI ) { + MessageCache::singleton()->updateMessageOverride( $title, null ); + } + + // Images + if ( $title->getNamespace() == NS_FILE ) { + DeferredUpdates::addUpdate( + new HTMLCacheUpdate( $title, 'imagelinks', 'page-delete' ) + ); + } + + // User talk pages + if ( $title->getNamespace() == NS_USER_TALK ) { + $user = User::newFromName( $title->getText(), false ); + if ( $user ) { + $user->setNewtalk( false ); + } + } + + // Image redirects + RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $title ); + } + + /** + * Purge caches on page update etc + * + * @param Title $title + * @param Revision|null $revision Revision that was just saved, may be null + */ + public static function onArticleEdit( Title $title, Revision $revision = null ) { + // Invalidate caches of articles which include this page + DeferredUpdates::addUpdate( + new HTMLCacheUpdate( $title, 'templatelinks', 'page-edit' ) + ); + + // Invalidate the caches of all pages which redirect here + DeferredUpdates::addUpdate( + new HTMLCacheUpdate( $title, 'redirect', 'page-edit' ) + ); + + MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title ); + + // Purge CDN for this page only + $title->purgeSquid(); + // Clear file cache for this page only + HTMLFileCache::clearFileCache( $title ); + + $revid = $revision ? $revision->getId() : null; + DeferredUpdates::addCallableUpdate( function () use ( $title, $revid ) { + InfoAction::invalidateCache( $title, $revid ); + } ); + } + + /**#@-*/ + + /** + * Returns a list of categories this page is a member of. + * Results will include hidden categories + * + * @return TitleArray + */ + public function getCategories() { + $id = $this->getId(); + if ( $id == 0 ) { + return TitleArray::newFromResult( new FakeResultWrapper( [] ) ); + } + + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( 'categorylinks', + [ 'cl_to AS page_title, ' . NS_CATEGORY . ' AS page_namespace' ], + // Have to do that since Database::fieldNamesWithAlias treats numeric indexes + // as not being aliases, and NS_CATEGORY is numeric + [ 'cl_from' => $id ], + __METHOD__ ); + + return TitleArray::newFromResult( $res ); + } + + /** + * Returns a list of hidden categories this page is a member of. + * Uses the page_props and categorylinks tables. + * + * @return array Array of Title objects + */ + public function getHiddenCategories() { + $result = []; + $id = $this->getId(); + + if ( $id == 0 ) { + return []; + } + + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( [ 'categorylinks', 'page_props', 'page' ], + [ 'cl_to' ], + [ 'cl_from' => $id, 'pp_page=page_id', 'pp_propname' => 'hiddencat', + 'page_namespace' => NS_CATEGORY, 'page_title=cl_to' ], + __METHOD__ ); + + if ( $res !== false ) { + foreach ( $res as $row ) { + $result[] = Title::makeTitle( NS_CATEGORY, $row->cl_to ); + } + } + + return $result; + } + + /** + * Auto-generates a deletion reason + * + * @param bool &$hasHistory Whether the page has a history + * @return string|bool String containing deletion reason or empty string, or boolean false + * if no revision occurred + */ + public function getAutoDeleteReason( &$hasHistory ) { + return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle(), $hasHistory ); + } + + /** + * Update all the appropriate counts in the category table, given that + * we've added the categories $added and deleted the categories $deleted. + * + * This should only be called from deferred updates or jobs to avoid contention. + * + * @param array $added The names of categories that were added + * @param array $deleted The names of categories that were deleted + * @param int $id Page ID (this should be the original deleted page ID) + */ + public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) { + $id = $id ?: $this->getId(); + $ns = $this->getTitle()->getNamespace(); + + $addFields = [ 'cat_pages = cat_pages + 1' ]; + $removeFields = [ 'cat_pages = cat_pages - 1' ]; + if ( $ns == NS_CATEGORY ) { + $addFields[] = 'cat_subcats = cat_subcats + 1'; + $removeFields[] = 'cat_subcats = cat_subcats - 1'; + } elseif ( $ns == NS_FILE ) { + $addFields[] = 'cat_files = cat_files + 1'; + $removeFields[] = 'cat_files = cat_files - 1'; + } + + $dbw = wfGetDB( DB_MASTER ); + + if ( count( $added ) ) { + $existingAdded = $dbw->selectFieldValues( + 'category', + 'cat_title', + [ 'cat_title' => $added ], + __METHOD__ + ); + + // For category rows that already exist, do a plain + // UPDATE instead of INSERT...ON DUPLICATE KEY UPDATE + // to avoid creating gaps in the cat_id sequence. + if ( count( $existingAdded ) ) { + $dbw->update( + 'category', + $addFields, + [ 'cat_title' => $existingAdded ], + __METHOD__ + ); + } + + $missingAdded = array_diff( $added, $existingAdded ); + if ( count( $missingAdded ) ) { + $insertRows = []; + foreach ( $missingAdded as $cat ) { + $insertRows[] = [ + 'cat_title' => $cat, + 'cat_pages' => 1, + 'cat_subcats' => ( $ns == NS_CATEGORY ) ? 1 : 0, + 'cat_files' => ( $ns == NS_FILE ) ? 1 : 0, + ]; + } + $dbw->upsert( + 'category', + $insertRows, + [ 'cat_title' ], + $addFields, + __METHOD__ + ); + } + } + + if ( count( $deleted ) ) { + $dbw->update( + 'category', + $removeFields, + [ 'cat_title' => $deleted ], + __METHOD__ + ); + } + + foreach ( $added as $catName ) { + $cat = Category::newFromName( $catName ); + Hooks::run( 'CategoryAfterPageAdded', [ $cat, $this ] ); + } + + foreach ( $deleted as $catName ) { + $cat = Category::newFromName( $catName ); + Hooks::run( 'CategoryAfterPageRemoved', [ $cat, $this, $id ] ); + } + + // Refresh counts on categories that should be empty now, to + // trigger possible deletion. Check master for the most + // up-to-date cat_pages. + if ( count( $deleted ) ) { + $rows = $dbw->select( + 'category', + [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ], + [ 'cat_title' => $deleted, 'cat_pages <= 0' ], + __METHOD__ + ); + foreach ( $rows as $row ) { + $cat = Category::newFromRow( $row ); + // T166757: do the update after this DB commit + DeferredUpdates::addCallableUpdate( function () use ( $cat ) { + $cat->refreshCounts(); + } ); + } + } + } + + /** + * Opportunistically enqueue link update jobs given fresh parser output if useful + * + * @param ParserOutput $parserOutput Current version page output + * @since 1.25 + */ + public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) { + if ( wfReadOnly() ) { + return; + } + + if ( !Hooks::run( 'OpportunisticLinksUpdate', + [ $this, $this->mTitle, $parserOutput ] + ) ) { + return; + } + + $config = RequestContext::getMain()->getConfig(); + + $params = [ + 'isOpportunistic' => true, + 'rootJobTimestamp' => $parserOutput->getCacheTime() + ]; + + if ( $this->mTitle->areRestrictionsCascading() ) { + // If the page is cascade protecting, the links should really be up-to-date + JobQueueGroup::singleton()->lazyPush( + RefreshLinksJob::newPrioritized( $this->mTitle, $params ) + ); + } elseif ( !$config->get( 'MiserMode' ) && $parserOutput->hasDynamicContent() ) { + // Assume the output contains "dynamic" time/random based magic words. + // Only update pages that expired due to dynamic content and NOT due to edits + // to referenced templates/files. When the cache expires due to dynamic content, + // page_touched is unchanged. We want to avoid triggering redundant jobs due to + // views of pages that were just purged via HTMLCacheUpdateJob. In that case, the + // template/file edit already triggered recursive RefreshLinksJob jobs. + if ( $this->getLinksTimestamp() > $this->getTouched() ) { + // If a page is uncacheable, do not keep spamming a job for it. + // Although it would be de-duplicated, it would still waste I/O. + $cache = ObjectCache::getLocalClusterInstance(); + $key = $cache->makeKey( 'dynamic-linksupdate', 'last', $this->getId() ); + $ttl = max( $parserOutput->getCacheExpiry(), 3600 ); + if ( $cache->add( $key, time(), $ttl ) ) { + JobQueueGroup::singleton()->lazyPush( + RefreshLinksJob::newDynamic( $this->mTitle, $params ) + ); + } + } + } + } + + /** + * Returns a list of updates to be performed when this page is deleted. The + * updates should remove any information about this page from secondary data + * stores such as links tables. + * + * @param Content|null $content Optional Content object for determining the + * necessary updates. + * @return DeferrableUpdate[] + */ + public function getDeletionUpdates( Content $content = null ) { + if ( !$content ) { + // load content object, which may be used to determine the necessary updates. + // XXX: the content may not be needed to determine the updates. + try { + $content = $this->getContent( Revision::RAW ); + } catch ( Exception $ex ) { + // If we can't load the content, something is wrong. Perhaps that's why + // the user is trying to delete the page, so let's not fail in that case. + // Note that doDeleteArticleReal() will already have logged an issue with + // loading the content. + } + } + + if ( !$content ) { + $updates = []; + } else { + $updates = $content->getDeletionUpdates( $this ); + } + + Hooks::run( 'WikiPageDeletionUpdates', [ $this, $content, &$updates ] ); + return $updates; + } + + /** + * Whether this content displayed on this page + * comes from the local database + * + * @since 1.28 + * @return bool + */ + public function isLocal() { + return true; + } + + /** + * The display name for the site this content + * come from. If a subclass overrides isLocal(), + * this could return something other than the + * current site name + * + * @since 1.28 + * @return string + */ + public function getWikiDisplayName() { + global $wgSitename; + return $wgSitename; + } + + /** + * Get the source URL for the content on this page, + * typically the canonical URL, but may be a remote + * link if the content comes from another site + * + * @since 1.28 + * @return string + */ + public function getSourceURL() { + return $this->getTitle()->getCanonicalURL(); + } + + /** + * @param WANObjectCache $cache + * @return string[] + * @since 1.28 + */ + public function getMutableCacheKeys( WANObjectCache $cache ) { + $linkCache = MediaWikiServices::getInstance()->getLinkCache(); + + return $linkCache->getMutableCacheKeys( $cache, $this->getTitle()->getTitleValue() ); + } +} |