wikimedia/mediawiki-core

View on GitHub
includes/actions/DeleteAction.php

Summary

Maintainability
D
2 days
Test Coverage
<?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
 *
 * @file
 * @ingroup Actions
 */

use MediaWiki\Cache\BacklinkCacheFactory;
use MediaWiki\CommentStore\CommentStore;
use MediaWiki\Context\IContextSource;
use MediaWiki\Html\Html;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Message\Message;
use MediaWiki\Page\DeletePage;
use MediaWiki\Page\DeletePageFactory;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Status\Status;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\TitleFactory;
use MediaWiki\Title\TitleFormatter;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\Watchlist\WatchlistManager;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\ReadOnlyMode;
use Wikimedia\RequestTimeout\TimeoutException;

/**
 * Handle page deletion
 *
 * @ingroup Actions
 */
class DeleteAction extends FormAction {

    /**
     * Constants used to localize form fields
     */
    protected const MSG_REASON_DROPDOWN = 'reason-dropdown';
    protected const MSG_REASON_DROPDOWN_SUPPRESS = 'reason-dropdown-suppress';
    protected const MSG_REASON_DROPDOWN_OTHER = 'reason-dropdown-other';
    protected const MSG_COMMENT = 'comment';
    protected const MSG_REASON_OTHER = 'reason-other';
    protected const MSG_SUBMIT = 'submit';
    protected const MSG_LEGEND = 'legend';
    protected const MSG_EDIT_REASONS = 'edit-reasons';
    protected const MSG_EDIT_REASONS_SUPPRESS = 'edit-reasons-suppress';

    protected WatchlistManager $watchlistManager;
    protected LinkRenderer $linkRenderer;
    private BacklinkCacheFactory $backlinkCacheFactory;
    protected ReadOnlyMode $readOnlyMode;
    protected UserOptionsLookup $userOptionsLookup;
    private DeletePageFactory $deletePageFactory;
    private int $deleteRevisionsLimit;
    private NamespaceInfo $namespaceInfo;
    private TitleFormatter $titleFormatter;
    private TitleFactory $titleFactory;

    private IConnectionProvider $dbProvider;

    /**
     * @inheritDoc
     */
    public function __construct( Article $article, IContextSource $context ) {
        parent::__construct( $article, $context );
        $services = MediaWikiServices::getInstance();
        $this->watchlistManager = $services->getWatchlistManager();
        $this->linkRenderer = $services->getLinkRenderer();
        $this->backlinkCacheFactory = $services->getBacklinkCacheFactory();
        $this->readOnlyMode = $services->getReadOnlyMode();
        $this->userOptionsLookup = $services->getUserOptionsLookup();
        $this->deletePageFactory = $services->getDeletePageFactory();
        $this->deleteRevisionsLimit = $services->getMainConfig()->get( MainConfigNames::DeleteRevisionsLimit );
        $this->namespaceInfo = $services->getNamespaceInfo();
        $this->titleFormatter = $services->getTitleFormatter();
        $this->titleFactory = $services->getTitleFactory();
        $this->dbProvider = $services->getConnectionProvider();
    }

    public function getName() {
        return 'delete';
    }

    public function onSubmit( $data ) {
        return false;
    }

    public function onSuccess() {
        return false;
    }

    protected function usesOOUI() {
        return true;
    }

    protected function getPageTitle() {
        $title = $this->getTitle();
        return $this->msg( 'delete-confirm' )->plaintextParams( $title->getPrefixedText() );
    }

    public function getRestriction() {
        return 'delete';
    }

    protected function alterForm( HTMLForm $form ) {
        $title = $this->getTitle();
        $form
            ->setAction( $this->getFormAction() )
            ->setWrapperLegendMsg( $this->getFormMsg( self::MSG_LEGEND ) )
            ->setWrapperAttributes( [ 'id' => 'mw-delete-table' ] )
            ->suppressDefaultSubmit()
            ->setId( 'deleteconfirm' )
            ->setTokenSalt( [ 'delete', $title->getPrefixedText() ] );
    }

    public function show() {
        $this->setHeaders();
        $this->useTransactionalTimeLimit();
        $this->addHelpLink( 'Help:Sysop deleting and undeleting' );

        // This will throw exceptions if there's a problem
        $this->checkCanExecute( $this->getUser() );

        $this->tempDelete();
    }

    protected function tempDelete() {
        $article = $this->getArticle();
        $title = $this->getTitle();
        $context = $this->getContext();
        $user = $context->getUser();
        $request = $context->getRequest();
        $outputPage = $context->getOutput();

        # Better double-check that it hasn't been deleted yet!
        $article->getPage()->loadPageData(
            $request->wasPosted() ? IDBAccessObject::READ_LATEST : IDBAccessObject::READ_NORMAL
        );
        if ( !$article->getPage()->exists() ) {
            $outputPage->setPageTitleMsg(
                $context->msg( 'cannotdelete-title' )->plaintextParams( $title->getPrefixedText() )
            );
            $outputPage->wrapWikiMsg( "<div class=\"error mw-error-cannotdelete\">\n$1\n</div>",
                [ 'cannotdelete', wfEscapeWikiText( $title->getPrefixedText() ) ]
            );
            $this->showLogEntries();

            return;
        }

        # If we are not processing the results of the deletion confirmation dialog, show the form
        $token = $request->getVal( 'wpEditToken' );
        if ( !$request->wasPosted() || !$user->matchEditToken( $token, [ 'delete', $title->getPrefixedText() ] ) ) {
            $this->tempConfirmDelete();
            return;
        }

        # Check to make sure the page has not been edited while the deletion was being confirmed
        if ( $article->getRevIdFetched() !== $request->getIntOrNull( 'wpConfirmationRevId' ) ) {
            $this->showEditedWarning();
            $this->tempConfirmDelete();
            return;
        }

        # Flag to hide all contents of the archived revisions
        $suppress = $request->getCheck( 'wpSuppress' ) &&
            $context->getAuthority()->isAllowed( 'suppressrevision' );

        $context = $this->getContext();
        $deletePage = $this->deletePageFactory->newDeletePage(
            $this->getWikiPage(),
            $context->getAuthority()
        );
        $shouldDeleteTalk = $request->getCheck( 'wpDeleteTalk' ) &&
            $deletePage->canProbablyDeleteAssociatedTalk()->isGood();
        $deletePage->setDeleteAssociatedTalk( $shouldDeleteTalk );
        $status = $deletePage
            ->setSuppress( $suppress )
            ->deleteIfAllowed( $this->getDeleteReason() );

        if ( $status->isOK() ) {
            $outputPage->setPageTitleMsg( $this->msg( 'actioncomplete' ) );
            $outputPage->setRobotPolicy( 'noindex,nofollow' );

            if ( !$status->isGood() ) {
                // If the page (and/or its talk) couldn't be found (e.g. because it was deleted in another request),
                // let the user know.
                $outputPage->addHTML(
                    Html::warningBox(
                        $outputPage->parseAsContent(
                            Status::wrap( $status )->getWikiText(
                                false,
                                false,
                                $context->getLanguage()
                            )
                        )
                    )
                );
            }

            $this->showSuccessMessages(
                $deletePage->getSuccessfulDeletionsIDs(),
                $deletePage->deletionsWereScheduled()
            );

            if ( !$status->isGood() ) {
                $this->showLogEntries();
            }
            $outputPage->returnToMain();
        } else {
            $outputPage->setPageTitleMsg(
                $this->msg( 'cannotdelete-title' )->plaintextParams( $this->getTitle()->getPrefixedText() )
            );

            $outputPage->wrapWikiTextAsInterface(
                'error mw-error-cannotdelete',
                Status::wrap( $status )->getWikiText( false, false, $context->getLanguage() )
            );
            $this->showLogEntries();
        }

        $this->watchlistManager->setWatch( $request->getCheck( 'wpWatch' ), $context->getAuthority(), $title );
    }

    /**
     * Display success messages
     *
     * @param array $deleted
     * @param array $scheduled
     * @return void
     */
    private function showSuccessMessages( array $deleted, array $scheduled ): void {
        $outputPage = $this->getContext()->getOutput();
        $loglink = '[[Special:Log/delete|' . $this->msg( 'deletionlog' )->text() . ']]';
        $pageBaseDisplayTitle = wfEscapeWikiText( $this->getTitle()->getPrefixedText() );
        $pageTalkDisplayTitle = wfEscapeWikiText( $this->titleFormatter->getPrefixedText(
            $this->namespaceInfo->getTalkPage( $this->getTitle() )
        ) );

        $deletedTalk = $deleted[DeletePage::PAGE_TALK] ?? false;
        $deletedBase = $deleted[DeletePage::PAGE_BASE];
        $scheduledTalk = $scheduled[DeletePage::PAGE_TALK] ?? false;
        $scheduledBase = $scheduled[DeletePage::PAGE_BASE];

        if ( $deletedBase && $deletedTalk ) {
            $outputPage->addWikiMsg( 'deleted-page-and-talkpage',
                $pageBaseDisplayTitle,
                $pageTalkDisplayTitle,
                $loglink );
        } elseif ( $deletedBase ) {
            $outputPage->addWikiMsg( 'deletedtext', $pageBaseDisplayTitle, $loglink );
        } elseif ( $deletedTalk ) {
            $outputPage->addWikiMsg( 'deletedtext', $pageTalkDisplayTitle, $loglink );
        }

        // run hook if article was deleted
        if ( $deletedBase ) {
            $this->getHookRunner()->onArticleDeleteAfterSuccess( $this->getTitle(), $outputPage );
        }

        if ( $scheduledBase ) {
            $outputPage->addWikiMsg( 'delete-scheduled', $pageBaseDisplayTitle );
        }

        if ( $scheduledTalk ) {
            $outputPage->addWikiMsg( 'delete-scheduled', $pageTalkDisplayTitle );
        }
    }

    protected function showEditedWarning(): void {
        $this->getOutput()->addHTML(
            Html::warningBox( $this->getContext()->msg( 'editedwhiledeleting' )->parse() )
        );
    }

    private function showHistoryWarnings(): void {
        $context = $this->getContext();
        $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).
        $revisions = (int)$this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
            ->select( 'COUNT(rev_page)' )
            ->from( 'revision' )
            ->where( [ 'rev_page' => $title->getArticleID() ] )
            ->caller( __METHOD__ )
            ->fetchField();

        // @todo i18n issue/patchwork message
        $context->getOutput()->addHTML(
            '<strong class="mw-delete-warning-revisions">' .
            $context->msg( 'historywarning' )->numParams( $revisions )->parse() .
            $context->msg( 'word-separator' )->escaped() . $this->linkRenderer->makeKnownLink(
                $title,
                $context->msg( 'history' )->text(),
                [],
                [ 'action' => 'history' ] ) .
            '</strong>'
        );

        if ( $title->isBigDeletion() ) {
            $context->getOutput()->wrapWikiMsg( "<div class='error'>\n$1\n</div>\n",
                [
                    'delete-warning-toobig',
                    $context->getLanguage()->formatNum( $this->deleteRevisionsLimit )
                ]
            );
        }
    }

    protected function showFormWarnings(): void {
        $this->showBacklinksWarning();
        $this->showSubpagesWarnings();
    }

    private function showBacklinksWarning(): void {
        $backlinkCache = $this->backlinkCacheFactory->getBacklinkCache( $this->getTitle() );
        if ( $backlinkCache->hasLinks( 'pagelinks' ) || $backlinkCache->hasLinks( 'templatelinks' ) ) {
            $this->getOutput()->addHTML(
                Html::warningBox(
                    $this->msg( 'deleting-backlinks-warning' )->parse(),
                    'plainlinks'
                )
            );
        }
    }

    protected function showSubpagesWarnings(): void {
        $title = $this->getTitle();
        $subpageCount = count( $title->getSubpages( 51 ) );
        if ( $subpageCount ) {
            $this->getOutput()->addHTML(
                Html::warningBox(
                    $this->msg( 'deleting-subpages-warning' )->numParams( $subpageCount )->parse(),
                    'plainlinks'
                )
            );
        }

        if ( !$title->isTalkPage() ) {
            $talkPageTitle = $this->titleFactory->newFromLinkTarget( $this->namespaceInfo->getTalkPage( $title ) );
            $subpageCount = count( $talkPageTitle->getSubpages( 51 ) );
            if ( $subpageCount ) {
                $this->getOutput()->addHTML(
                    Html::warningBox(
                        $this->msg( 'deleting-talkpage-subpages-warning' )->numParams( $subpageCount )->parse(),
                        'plainlinks'
                    )
                );
            }
        }
    }

    private function tempConfirmDelete(): void {
        $this->prepareOutputForForm();
        $context = $this->getContext();
        $outputPage = $context->getOutput();
        $article = $this->getArticle();

        $reason = $this->getDefaultReason();

        // oldid is set to the revision id of the page when the page was displayed.
        // Check to make sure the page has not been edited between loading the page
        // and clicking the delete link
        $oldid = $context->getRequest()->getIntOrNull( 'oldid' );
        if ( $oldid !== null && $oldid !== $article->getRevIdFetched() ) {
            $this->showEditedWarning();
        }
        // If the page has a history, insert a warning
        if ( $this->pageHasHistory() ) {
            $this->showHistoryWarnings();
        }
        $this->showFormWarnings();

        $outputPage->addWikiMsg( 'confirmdeletetext' );

        // FIXME: Replace (or at least rename) this hook
        $this->getHookRunner()->onArticleConfirmDelete( $this->getArticle(), $outputPage, $reason );

        $form = $this->getForm();
        if ( $form->show() ) {
            $this->onSuccess();
        }

        $this->showEditReasonsLinks();
        $this->showLogEntries();
    }

    protected function showEditReasonsLinks(): void {
        if ( $this->getAuthority()->isAllowed( 'editinterface' ) ) {
            $link = '';
            if ( $this->isSuppressionAllowed() ) {
                $link .= $this->linkRenderer->makeKnownLink(
                    $this->getFormMsg( self::MSG_REASON_DROPDOWN_SUPPRESS )->inContentLanguage()->getTitle(),
                    $this->getFormMsg( self::MSG_EDIT_REASONS_SUPPRESS )->text(),
                    [],
                    [ 'action' => 'edit' ]
                );
                $link .= $this->msg( 'pipe-separator' )->escaped();
            }
            $link .= $this->linkRenderer->makeKnownLink(
                $this->getFormMsg( self::MSG_REASON_DROPDOWN )->inContentLanguage()->getTitle(),
                $this->getFormMsg( self::MSG_EDIT_REASONS )->text(),
                [],
                [ 'action' => 'edit' ]
            );
            $this->getOutput()->addHTML( '<p class="mw-delete-editreasons">' . $link . '</p>' );
        }
    }

    /**
     * @return bool
     */
    protected function isSuppressionAllowed(): bool {
        return $this->getAuthority()->isAllowed( 'suppressrevision' );
    }

    /**
     * @return array
     */
    protected function getFormFields(): array {
        $user = $this->getUser();
        $title = $this->getTitle();
        $article = $this->getArticle();

        $fields = [];

        $dropdownReason = $this->getFormMsg( self::MSG_REASON_DROPDOWN )->inContentLanguage()->text();
        // Add additional specific reasons for suppress
        if ( $this->isSuppressionAllowed() ) {
            $dropdownReason .= "\n" . $this->getFormMsg( self::MSG_REASON_DROPDOWN_SUPPRESS )
                    ->inContentLanguage()->text();
        }

        $options = Html::listDropdownOptions(
            $dropdownReason,
            [ 'other' => $this->getFormMsg( self::MSG_REASON_DROPDOWN_OTHER )->text() ]
        );

        $fields['DeleteReasonList'] = [
            'type' => 'select',
            'id' => 'wpDeleteReasonList',
            'tabindex' => 1,
            'infusable' => true,
            'options' => $options,
            'label' => $this->getFormMsg( self::MSG_COMMENT )->text(),
        ];

        // 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.
        $fields['Reason'] = [
            'type' => 'text',
            'id' => 'wpReason',
            'tabindex' => 2,
            'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
            'infusable' => true,
            'default' => $this->getDefaultReason(),
            'autofocus' => true,
            'label' => $this->getFormMsg( self::MSG_REASON_OTHER )->text(),
        ];

        $delPage = $this->deletePageFactory->newDeletePage( $this->getWikiPage(), $this->getAuthority() );
        if ( $delPage->canProbablyDeleteAssociatedTalk()->isGood() ) {
            $fields['DeleteTalk'] = [
                'type' => 'check',
                'id' => 'wpDeleteTalk',
                'tabindex' => 3,
                'default' => false,
                'label-message' => 'deletepage-deletetalk',
            ];
        }

        if ( $user->isRegistered() ) {
            $checkWatch = $this->userOptionsLookup->getBoolOption( $user, 'watchdeletion' ) ||
                $this->watchlistManager->isWatched( $user, $title );
            $fields['Watch'] = [
                'type' => 'check',
                'id' => 'wpWatch',
                'tabindex' => 4,
                'default' => $checkWatch,
                'label-message' => 'watchthis',
            ];
        }
        if ( $this->isSuppressionAllowed() ) {
            $fields['Suppress'] = [
                'type' => 'check',
                'id' => 'wpSuppress',
                'tabindex' => 5,
                'default' => false,
                'label-message' => 'revdelete-suppress',
            ];
        }

        $fields['ConfirmB'] = [
            'type' => 'submit',
            'id' => 'wpConfirmB',
            'tabindex' => 6,
            'buttonlabel' => $this->getFormMsg( self::MSG_SUBMIT )->text(),
            'flags' => [ 'primary', 'destructive' ],
        ];

        $fields['ConfirmationRevId'] = [
            'type' => 'hidden',
            'id' => 'wpConfirmationRevId',
            'default' => $article->getRevIdFetched(),
        ];

        return $fields;
    }

    /**
     * @return string
     */
    protected function getDeleteReason(): string {
        $deleteReasonList = $this->getRequest()->getText( 'wpDeleteReasonList', 'other' );
        $deleteReason = $this->getRequest()->getText( 'wpReason' );

        if ( $deleteReasonList === 'other' ) {
            return $deleteReason;
        } elseif ( $deleteReason !== '' ) {
            // Entry from drop down menu + additional comment
            $colonseparator = $this->msg( 'colon-separator' )->inContentLanguage()->text();
            return $deleteReasonList . $colonseparator . $deleteReason;
        } else {
            return $deleteReasonList;
        }
    }

    /**
     * Show deletion log fragments pertaining to the current page
     */
    protected function showLogEntries(): void {
        $deleteLogPage = new LogPage( 'delete' );
        $outputPage = $this->getContext()->getOutput();
        $outputPage->addHTML( Html::element( 'h2', [], $deleteLogPage->getName()->text() ) );
        LogEventsList::showLogExtract( $outputPage, 'delete', $this->getTitle() );
    }

    protected function prepareOutputForForm(): void {
        $outputPage = $this->getOutput();
        $outputPage->addModules( 'mediawiki.misc-authed-ooui' );
        $outputPage->addModuleStyles( 'mediawiki.action.styles' );
        $outputPage->enableOOUI();
    }

    /**
     * @return string[]
     */
    protected function getFormMessages(): array {
        return [
            self::MSG_REASON_DROPDOWN => 'deletereason-dropdown',
            self::MSG_REASON_DROPDOWN_SUPPRESS => 'deletereason-dropdown-suppress',
            self::MSG_REASON_DROPDOWN_OTHER => 'deletereasonotherlist',
            self::MSG_COMMENT => 'deletecomment',
            self::MSG_REASON_OTHER => 'deleteotherreason',
            self::MSG_SUBMIT => 'deletepage-submit',
            self::MSG_LEGEND => 'delete-legend',
            self::MSG_EDIT_REASONS => 'delete-edit-reasonlist',
            self::MSG_EDIT_REASONS_SUPPRESS => 'delete-edit-reasonlist-suppress',
        ];
    }

    /**
     * @param string $field One of the self::MSG_* constants
     * @return Message
     */
    protected function getFormMsg( string $field ): Message {
        $messages = $this->getFormMessages();
        if ( !isset( $messages[$field] ) ) {
            throw new InvalidArgumentException( "Invalid field $field" );
        }
        return $this->msg( $messages[$field] );
    }

    /**
     * @return string
     */
    protected function getFormAction(): string {
        return $this->getTitle()->getLocalURL( 'action=delete' );
    }

    /**
     * Default reason to be used for the deletion form
     *
     * @return string
     */
    protected function getDefaultReason(): string {
        $requestReason = $this->getRequest()->getText( 'wpReason' );
        if ( $requestReason ) {
            return $requestReason;
        }

        try {
            return $this->getArticle()->getPage()->getAutoDeleteReason();
        } catch ( TimeoutException $e ) {
            throw $e;
        } catch ( Exception $e ) {
            # if a page is horribly broken, we still want to be able to
            # delete it. So be lenient about errors here.
            # For example, WMF logs show MWException thrown from
            # ContentHandler::checkModelID().
            MWExceptionHandler::logException( $e );
            return '';
        }
    }

    /**
     * Determines whether a page has a history of more than one revision.
     * @fixme We should use WikiPage::isNew() here, but it doesn't work right for undeleted pages (T289008)
     * @return bool
     */
    private function pageHasHistory(): bool {
        $dbr = $this->dbProvider->getReplicaDatabase();
        $res = $dbr->newSelectQueryBuilder()
            ->select( '*' )
            ->from( 'revision' )
            ->where( [ 'rev_page' => $this->getTitle()->getArticleID() ] )
            ->andWhere(
                [ $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . ' = 0' ]
            )->limit( 2 )
            ->caller( __METHOD__ )
            ->fetchRowCount();

        return $res > 1;
    }
}