Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / actions / DeleteAction.php
blob0a3bf54b167c65ffecf55b656fed71c21364ae40
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program; if not, write to the Free Software
15 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
17 * @file
18 * @ingroup Actions
21 use MediaWiki\Cache\BacklinkCacheFactory;
22 use MediaWiki\CommentStore\CommentStore;
23 use MediaWiki\Context\IContextSource;
24 use MediaWiki\Html\Html;
25 use MediaWiki\HTMLForm\HTMLForm;
26 use MediaWiki\Linker\LinkRenderer;
27 use MediaWiki\MainConfigNames;
28 use MediaWiki\MediaWikiServices;
29 use MediaWiki\Message\Message;
30 use MediaWiki\Page\DeletePage;
31 use MediaWiki\Page\DeletePageFactory;
32 use MediaWiki\Revision\RevisionRecord;
33 use MediaWiki\Session\CsrfTokenSet;
34 use MediaWiki\Title\NamespaceInfo;
35 use MediaWiki\Title\TitleFactory;
36 use MediaWiki\Title\TitleFormatter;
37 use MediaWiki\User\Options\UserOptionsLookup;
38 use MediaWiki\Watchlist\WatchlistManager;
39 use Wikimedia\Rdbms\IConnectionProvider;
40 use Wikimedia\Rdbms\IDBAccessObject;
41 use Wikimedia\Rdbms\ReadOnlyMode;
42 use Wikimedia\RequestTimeout\TimeoutException;
44 /**
45 * Handle page deletion
47 * @ingroup Actions
49 class DeleteAction extends FormAction {
51 /**
52 * Constants used to localize form fields
54 protected const MSG_REASON_DROPDOWN = 'reason-dropdown';
55 protected const MSG_REASON_DROPDOWN_SUPPRESS = 'reason-dropdown-suppress';
56 protected const MSG_REASON_DROPDOWN_OTHER = 'reason-dropdown-other';
57 protected const MSG_COMMENT = 'comment';
58 protected const MSG_REASON_OTHER = 'reason-other';
59 protected const MSG_SUBMIT = 'submit';
60 protected const MSG_LEGEND = 'legend';
61 protected const MSG_EDIT_REASONS = 'edit-reasons';
62 protected const MSG_EDIT_REASONS_SUPPRESS = 'edit-reasons-suppress';
64 protected WatchlistManager $watchlistManager;
65 protected LinkRenderer $linkRenderer;
66 private BacklinkCacheFactory $backlinkCacheFactory;
67 protected ReadOnlyMode $readOnlyMode;
68 protected UserOptionsLookup $userOptionsLookup;
69 private DeletePageFactory $deletePageFactory;
70 private int $deleteRevisionsLimit;
71 private NamespaceInfo $namespaceInfo;
72 private TitleFormatter $titleFormatter;
73 private TitleFactory $titleFactory;
75 private IConnectionProvider $dbProvider;
77 /**
78 * @inheritDoc
80 public function __construct( Article $article, IContextSource $context ) {
81 parent::__construct( $article, $context );
82 $services = MediaWikiServices::getInstance();
83 $this->watchlistManager = $services->getWatchlistManager();
84 $this->linkRenderer = $services->getLinkRenderer();
85 $this->backlinkCacheFactory = $services->getBacklinkCacheFactory();
86 $this->readOnlyMode = $services->getReadOnlyMode();
87 $this->userOptionsLookup = $services->getUserOptionsLookup();
88 $this->deletePageFactory = $services->getDeletePageFactory();
89 $this->deleteRevisionsLimit = $services->getMainConfig()->get( MainConfigNames::DeleteRevisionsLimit );
90 $this->namespaceInfo = $services->getNamespaceInfo();
91 $this->titleFormatter = $services->getTitleFormatter();
92 $this->titleFactory = $services->getTitleFactory();
93 $this->dbProvider = $services->getConnectionProvider();
96 public function getName() {
97 return 'delete';
100 public function onSubmit( $data ) {
101 return false;
104 public function onSuccess() {
105 return false;
108 protected function usesOOUI() {
109 return true;
112 protected function getPageTitle() {
113 $title = $this->getTitle();
114 return $this->msg( 'delete-confirm' )->plaintextParams( $title->getPrefixedText() );
117 public function getRestriction() {
118 return 'delete';
121 protected function alterForm( HTMLForm $form ) {
122 $title = $this->getTitle();
123 $form
124 ->setAction( $this->getFormAction() )
125 ->setWrapperLegendMsg( $this->getFormMsg( self::MSG_LEGEND ) )
126 ->setWrapperAttributes( [ 'id' => 'mw-delete-table' ] )
127 ->suppressDefaultSubmit()
128 ->setId( 'deleteconfirm' )
129 ->setTokenSalt( [ 'delete', $title->getPrefixedText() ] );
132 public function show() {
133 $this->setHeaders();
134 $this->useTransactionalTimeLimit();
135 $this->addHelpLink( 'Help:Sysop deleting and undeleting' );
137 // This will throw exceptions if there's a problem
138 $this->checkCanExecute( $this->getUser() );
140 $this->tempDelete();
143 protected function tempDelete() {
144 $article = $this->getArticle();
145 $title = $this->getTitle();
146 $context = $this->getContext();
147 $user = $context->getUser();
148 $request = $context->getRequest();
149 $outputPage = $context->getOutput();
151 # Better double-check that it hasn't been deleted yet!
152 $article->getPage()->loadPageData(
153 $request->wasPosted() ? IDBAccessObject::READ_LATEST : IDBAccessObject::READ_NORMAL
155 if ( !$article->getPage()->exists() ) {
156 $outputPage->setPageTitleMsg(
157 $context->msg( 'cannotdelete-title' )->plaintextParams( $title->getPrefixedText() )
159 $outputPage->addHTML( Html::errorBox(
160 $context->msg( 'cannotdelete', wfEscapeWikiText( $title->getPrefixedText() ) )->parse(),
162 'mw-error-cannotdelete'
163 ) );
164 $this->showLogEntries();
166 return;
169 $hasValidCsrfToken = $this->getContext()
170 ->getCsrfTokenSet()
171 ->matchTokenField(
172 CsrfTokenSet::DEFAULT_FIELD_NAME,
173 [ 'delete', $title->getPrefixedText() ]
176 # If we are not processing the results of the deletion confirmation dialog, show the form
177 if ( !$request->wasPosted() || !$hasValidCsrfToken ) {
178 $this->tempConfirmDelete();
179 return;
182 # Check to make sure the page has not been edited while the deletion was being confirmed
183 if ( $article->getRevIdFetched() !== $request->getIntOrNull( 'wpConfirmationRevId' ) ) {
184 $this->showEditedWarning();
185 $this->tempConfirmDelete();
186 return;
189 # Flag to hide all contents of the archived revisions
190 $suppress = $request->getCheck( 'wpSuppress' ) &&
191 $context->getAuthority()->isAllowed( 'suppressrevision' );
193 $context = $this->getContext();
194 $deletePage = $this->deletePageFactory->newDeletePage(
195 $this->getWikiPage(),
196 $context->getAuthority()
198 $shouldDeleteTalk = $request->getCheck( 'wpDeleteTalk' ) &&
199 $deletePage->canProbablyDeleteAssociatedTalk()->isGood();
200 $deletePage->setDeleteAssociatedTalk( $shouldDeleteTalk );
201 $status = $deletePage
202 ->setSuppress( $suppress )
203 ->deleteIfAllowed( $this->getDeleteReason() );
205 if ( $status->isOK() ) {
206 $outputPage->setPageTitleMsg( $this->msg( 'actioncomplete' ) );
207 $outputPage->setRobotPolicy( 'noindex,nofollow' );
209 if ( !$status->isGood() ) {
210 // If the page (and/or its talk) couldn't be found (e.g. because it was deleted in another request),
211 // let the user know.
212 foreach ( $status->getMessages() as $msg ) {
213 $outputPage->addHTML(
214 Html::warningBox( $context->msg( $msg )->parse() )
219 $this->showSuccessMessages(
220 $deletePage->getSuccessfulDeletionsIDs(),
221 $deletePage->deletionsWereScheduled()
224 if ( !$status->isGood() ) {
225 $this->showLogEntries();
227 $outputPage->returnToMain();
228 } else {
229 $outputPage->setPageTitleMsg(
230 $this->msg( 'cannotdelete-title' )->plaintextParams( $this->getTitle()->getPrefixedText() )
233 foreach ( $status->getMessages() as $msg ) {
234 $outputPage->addHTML( Html::errorBox(
235 $context->msg( $msg )->parse(),
237 'mw-error-cannotdelete'
238 ) );
241 $this->showLogEntries();
244 $this->watchlistManager->setWatch( $request->getCheck( 'wpWatch' ), $context->getAuthority(), $title );
248 * Display success messages
250 * @param array $deleted
251 * @param array $scheduled
252 * @return void
254 private function showSuccessMessages( array $deleted, array $scheduled ): void {
255 $outputPage = $this->getContext()->getOutput();
256 $loglink = '[[Special:Log/delete|' . $this->msg( 'deletionlog' )->text() . ']]';
257 $pageBaseDisplayTitle = wfEscapeWikiText( $this->getTitle()->getPrefixedText() );
258 $pageTalkDisplayTitle = wfEscapeWikiText( $this->titleFormatter->getPrefixedText(
259 $this->namespaceInfo->getTalkPage( $this->getTitle() )
260 ) );
262 $deletedTalk = $deleted[DeletePage::PAGE_TALK] ?? false;
263 $deletedBase = $deleted[DeletePage::PAGE_BASE];
264 $scheduledTalk = $scheduled[DeletePage::PAGE_TALK] ?? false;
265 $scheduledBase = $scheduled[DeletePage::PAGE_BASE];
267 if ( $deletedBase && $deletedTalk ) {
268 $outputPage->addWikiMsg( 'deleted-page-and-talkpage',
269 $pageBaseDisplayTitle,
270 $pageTalkDisplayTitle,
271 $loglink );
272 } elseif ( $deletedBase ) {
273 $outputPage->addWikiMsg( 'deletedtext', $pageBaseDisplayTitle, $loglink );
274 } elseif ( $deletedTalk ) {
275 $outputPage->addWikiMsg( 'deletedtext', $pageTalkDisplayTitle, $loglink );
278 // run hook if article was deleted
279 if ( $deletedBase ) {
280 $this->getHookRunner()->onArticleDeleteAfterSuccess( $this->getTitle(), $outputPage );
283 if ( $scheduledBase ) {
284 $outputPage->addWikiMsg( 'delete-scheduled', $pageBaseDisplayTitle );
287 if ( $scheduledTalk ) {
288 $outputPage->addWikiMsg( 'delete-scheduled', $pageTalkDisplayTitle );
292 protected function showEditedWarning(): void {
293 $this->getOutput()->addHTML(
294 Html::warningBox( $this->getContext()->msg( 'editedwhiledeleting' )->parse() )
298 private function showHistoryWarnings(): void {
299 $context = $this->getContext();
300 $title = $this->getTitle();
302 // The following can use the real revision count as this is only being shown for users
303 // that can delete this page.
304 // This, as a side-effect, also makes sure that the following query isn't being run for
305 // pages with a larger history, unless the user has the 'bigdelete' right
306 // (and is about to delete this page).
307 $revisions = (int)$this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
308 ->select( 'COUNT(rev_page)' )
309 ->from( 'revision' )
310 ->where( [ 'rev_page' => $title->getArticleID() ] )
311 ->caller( __METHOD__ )
312 ->fetchField();
314 // @todo i18n issue/patchwork message
315 $context->getOutput()->addHTML(
316 '<strong class="mw-delete-warning-revisions">' .
317 $context->msg( 'historywarning' )->numParams( $revisions )->parse() .
318 $context->msg( 'word-separator' )->escaped() . $this->linkRenderer->makeKnownLink(
319 $title,
320 $context->msg( 'history' )->text(),
322 [ 'action' => 'history' ] ) .
323 '</strong>'
326 if ( $title->isBigDeletion() ) {
327 $context->getOutput()->addHTML( Html::errorBox(
328 $context->msg( 'delete-warning-toobig' )
329 ->numParams( $this->deleteRevisionsLimit )
330 ->parse()
331 ) );
335 protected function showFormWarnings(): void {
336 $this->showBacklinksWarning();
337 $this->showSubpagesWarnings();
340 private function showBacklinksWarning(): void {
341 $backlinkCache = $this->backlinkCacheFactory->getBacklinkCache( $this->getTitle() );
342 if ( $backlinkCache->hasLinks( 'pagelinks' ) || $backlinkCache->hasLinks( 'templatelinks' ) ) {
343 $this->getOutput()->addHTML(
344 Html::warningBox(
345 $this->msg( 'deleting-backlinks-warning' )->parse(),
346 'plainlinks'
352 protected function showSubpagesWarnings(): void {
353 $title = $this->getTitle();
354 $subpageCount = count( $title->getSubpages( 51 ) );
355 if ( $subpageCount ) {
356 $this->getOutput()->addHTML(
357 Html::warningBox(
358 $this->msg( 'deleting-subpages-warning' )->numParams( $subpageCount )->parse(),
359 'plainlinks'
364 if ( !$title->isTalkPage() ) {
365 $talkPageTitle = $this->titleFactory->newFromLinkTarget( $this->namespaceInfo->getTalkPage( $title ) );
366 $subpageCount = count( $talkPageTitle->getSubpages( 51 ) );
367 if ( $subpageCount ) {
368 $this->getOutput()->addHTML(
369 Html::warningBox(
370 $this->msg( 'deleting-talkpage-subpages-warning' )->numParams( $subpageCount )->parse(),
371 'plainlinks'
378 private function tempConfirmDelete(): void {
379 $this->prepareOutputForForm();
380 $context = $this->getContext();
381 $outputPage = $context->getOutput();
382 $article = $this->getArticle();
384 $reason = $this->getDefaultReason();
386 // oldid is set to the revision id of the page when the page was displayed.
387 // Check to make sure the page has not been edited between loading the page
388 // and clicking the delete link
389 $oldid = $context->getRequest()->getIntOrNull( 'oldid' );
390 if ( $oldid !== null && $oldid !== $article->getRevIdFetched() ) {
391 $this->showEditedWarning();
393 // If the page has a history, insert a warning
394 if ( $this->pageHasHistory() ) {
395 $this->showHistoryWarnings();
397 $this->showFormWarnings();
399 $outputPage->addWikiMsg( 'confirmdeletetext' );
401 // FIXME: Replace (or at least rename) this hook
402 $this->getHookRunner()->onArticleConfirmDelete( $this->getArticle(), $outputPage, $reason );
404 $form = $this->getForm();
405 if ( $form->show() ) {
406 $this->onSuccess();
409 $this->showEditReasonsLinks();
410 $this->showLogEntries();
413 protected function showEditReasonsLinks(): void {
414 if ( $this->getAuthority()->isAllowed( 'editinterface' ) ) {
415 $link = '';
416 if ( $this->isSuppressionAllowed() ) {
417 $link .= $this->linkRenderer->makeKnownLink(
418 $this->getFormMsg( self::MSG_REASON_DROPDOWN_SUPPRESS )->inContentLanguage()->getTitle(),
419 $this->getFormMsg( self::MSG_EDIT_REASONS_SUPPRESS )->text(),
421 [ 'action' => 'edit' ]
423 $link .= $this->msg( 'pipe-separator' )->escaped();
425 $link .= $this->linkRenderer->makeKnownLink(
426 $this->getFormMsg( self::MSG_REASON_DROPDOWN )->inContentLanguage()->getTitle(),
427 $this->getFormMsg( self::MSG_EDIT_REASONS )->text(),
429 [ 'action' => 'edit' ]
431 $this->getOutput()->addHTML( '<p class="mw-delete-editreasons">' . $link . '</p>' );
436 * @return bool
438 protected function isSuppressionAllowed(): bool {
439 return $this->getAuthority()->isAllowed( 'suppressrevision' );
443 * @return array
445 protected function getFormFields(): array {
446 $user = $this->getUser();
447 $title = $this->getTitle();
448 $article = $this->getArticle();
450 $fields = [];
452 $dropdownReason = $this->getFormMsg( self::MSG_REASON_DROPDOWN )->inContentLanguage()->text();
453 // Add additional specific reasons for suppress
454 if ( $this->isSuppressionAllowed() ) {
455 $dropdownReason .= "\n" . $this->getFormMsg( self::MSG_REASON_DROPDOWN_SUPPRESS )
456 ->inContentLanguage()->text();
459 $options = Html::listDropdownOptions(
460 $dropdownReason,
461 [ 'other' => $this->getFormMsg( self::MSG_REASON_DROPDOWN_OTHER )->text() ]
464 $fields['DeleteReasonList'] = [
465 'type' => 'select',
466 'id' => 'wpDeleteReasonList',
467 'tabindex' => 1,
468 'infusable' => true,
469 'options' => $options,
470 'label' => $this->getFormMsg( self::MSG_COMMENT )->text(),
473 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
474 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
475 // Unicode codepoints.
476 $fields['Reason'] = [
477 'type' => 'text',
478 'id' => 'wpReason',
479 'tabindex' => 2,
480 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
481 'infusable' => true,
482 'default' => $this->getDefaultReason(),
483 'autofocus' => true,
484 'label' => $this->getFormMsg( self::MSG_REASON_OTHER )->text(),
487 $delPage = $this->deletePageFactory->newDeletePage( $this->getWikiPage(), $this->getAuthority() );
488 if ( $delPage->canProbablyDeleteAssociatedTalk()->isGood() ) {
489 $fields['DeleteTalk'] = [
490 'type' => 'check',
491 'id' => 'wpDeleteTalk',
492 'tabindex' => 3,
493 'default' => false,
494 'label-message' => 'deletepage-deletetalk',
498 if ( $user->isRegistered() ) {
499 $checkWatch = $this->userOptionsLookup->getBoolOption( $user, 'watchdeletion' ) ||
500 $this->watchlistManager->isWatched( $user, $title );
501 $fields['Watch'] = [
502 'type' => 'check',
503 'id' => 'wpWatch',
504 'tabindex' => 4,
505 'default' => $checkWatch,
506 'label-message' => 'watchthis',
509 if ( $this->isSuppressionAllowed() ) {
510 $fields['Suppress'] = [
511 'type' => 'check',
512 'id' => 'wpSuppress',
513 'tabindex' => 5,
514 'default' => false,
515 'label-message' => 'revdelete-suppress',
519 $fields['ConfirmB'] = [
520 'type' => 'submit',
521 'id' => 'wpConfirmB',
522 'tabindex' => 6,
523 'buttonlabel' => $this->getFormMsg( self::MSG_SUBMIT )->text(),
524 'flags' => [ 'primary', 'destructive' ],
527 $fields['ConfirmationRevId'] = [
528 'type' => 'hidden',
529 'id' => 'wpConfirmationRevId',
530 'default' => $article->getRevIdFetched(),
533 return $fields;
537 * @return string
539 protected function getDeleteReason(): string {
540 $deleteReasonList = $this->getRequest()->getText( 'wpDeleteReasonList', 'other' );
541 $deleteReason = $this->getRequest()->getText( 'wpReason' );
543 if ( $deleteReasonList === 'other' ) {
544 return $deleteReason;
545 } elseif ( $deleteReason !== '' ) {
546 // Entry from drop down menu + additional comment
547 $colonseparator = $this->msg( 'colon-separator' )->inContentLanguage()->text();
548 return $deleteReasonList . $colonseparator . $deleteReason;
549 } else {
550 return $deleteReasonList;
555 * Show deletion log fragments pertaining to the current page
557 protected function showLogEntries(): void {
558 $deleteLogPage = new LogPage( 'delete' );
559 $outputPage = $this->getContext()->getOutput();
560 $outputPage->addHTML( Html::element( 'h2', [], $deleteLogPage->getName()->text() ) );
561 LogEventsList::showLogExtract( $outputPage, 'delete', $this->getTitle() );
564 protected function prepareOutputForForm(): void {
565 $outputPage = $this->getOutput();
566 $outputPage->addModules( 'mediawiki.misc-authed-ooui' );
567 $outputPage->addModuleStyles( [
568 'mediawiki.action.styles',
569 'mediawiki.codex.messagebox.styles',
570 ] );
571 $outputPage->enableOOUI();
575 * @return string[]
577 protected function getFormMessages(): array {
578 return [
579 self::MSG_REASON_DROPDOWN => 'deletereason-dropdown',
580 self::MSG_REASON_DROPDOWN_SUPPRESS => 'deletereason-dropdown-suppress',
581 self::MSG_REASON_DROPDOWN_OTHER => 'deletereasonotherlist',
582 self::MSG_COMMENT => 'deletecomment',
583 self::MSG_REASON_OTHER => 'deleteotherreason',
584 self::MSG_SUBMIT => 'deletepage-submit',
585 self::MSG_LEGEND => 'delete-legend',
586 self::MSG_EDIT_REASONS => 'delete-edit-reasonlist',
587 self::MSG_EDIT_REASONS_SUPPRESS => 'delete-edit-reasonlist-suppress',
592 * @param string $field One of the self::MSG_* constants
593 * @return Message
595 protected function getFormMsg( string $field ): Message {
596 $messages = $this->getFormMessages();
597 if ( !isset( $messages[$field] ) ) {
598 throw new InvalidArgumentException( "Invalid field $field" );
600 return $this->msg( $messages[$field] );
604 * @return string
606 protected function getFormAction(): string {
607 return $this->getTitle()->getLocalURL( 'action=delete' );
611 * Default reason to be used for the deletion form
613 * @return string
615 protected function getDefaultReason(): string {
616 $requestReason = $this->getRequest()->getText( 'wpReason' );
617 if ( $requestReason ) {
618 return $requestReason;
621 try {
622 return $this->getArticle()->getPage()->getAutoDeleteReason();
623 } catch ( TimeoutException $e ) {
624 throw $e;
625 } catch ( Exception $e ) {
626 # if a page is horribly broken, we still want to be able to
627 # delete it. So be lenient about errors here.
628 # For example, WMF logs show MWException thrown from
629 # ContentHandler::checkModelID().
630 MWExceptionHandler::logException( $e );
631 return '';
636 * Determines whether a page has a history of more than one revision.
637 * @fixme We should use WikiPage::isNew() here, but it doesn't work right for undeleted pages (T289008)
638 * @return bool
640 private function pageHasHistory(): bool {
641 $dbr = $this->dbProvider->getReplicaDatabase();
642 $res = $dbr->newSelectQueryBuilder()
643 ->select( '*' )
644 ->from( 'revision' )
645 ->where( [ 'rev_page' => $this->getTitle()->getArticleID() ] )
646 ->andWhere(
647 [ $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . ' = 0' ]
648 )->limit( 2 )
649 ->caller( __METHOD__ )
650 ->fetchRowCount();
652 return $res > 1;