From def1219fdaa35d7f799619617a81152e32b311c8 Mon Sep 17 00:00:00 2001 From: Chlod Alejandro Date: Sun, 17 Nov 2024 17:53:02 +0800 Subject: [PATCH] Add more details to post-delete page This adds the following to the post-delete page: * Number of pages queued for deletion (or deleted immediately) * Username, talk page link, and Special:Block link for the target (if the target is a user) * Number of pages not queued for deletion (i.e. skipped) * List of pages not queued for deletion This adds relevant tests, and also modifies the "uploadTestFile" utility function, which now randomizes file name to ensure that files uploaded by users regardless of title are also deleted. Bug: T364223 Bug: T364225 Change-Id: I60cf1a1c54cdaa3ae4570b4d2bef7e7cf39b47e5 --- i18n/en.json | 4 + i18n/qqq.json | 4 + includes/SpecialNuke.php | 80 ++++++++-- tests/phpunit/integration/SpecialNukeTest.php | 139 ++++++++++++++++-- 4 files changed, 203 insertions(+), 24 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 7129be7d..a693432b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -23,9 +23,13 @@ "nuke-userorip": "Username, IP address or blank:", "nuke-maxpages": "Number of pages to retrieve (maximum 500):", "nuke-editby": "Created by [[Special:Contributions/$1|{{GENDER:$1|$1}}]].", + "nuke-delete-summary": "Queued '''{{PLURAL:$1|one page|$1 pages}}''' for deletion.", + "nuke-delete-summary-user": "Queued '''{{PLURAL:$1|one page|$1 pages}}''' {{GENDER:$2|created}} by [[{{ns:user}}:$2|$2]] ([[{{ns:user_talk}}:$2|{{int:talkpagelinktext}}]] {{int:pipe-separator}} [[{{ns:special}}:Contributions/$2|{{int:contribslink}}]] {{int:pipe-separator}} [[{{ns:special}}:Block/$2|{{int:blocklink}}]]) for deletion.", "nuke-deleted": "Page '''$1''' has been deleted.", "nuke-deletion-queued": "Page '''$1''' has been queued for deletion.", "nuke-not-deleted": "Page [[:$1]] '''could not''' be deleted.", + "nuke-skipped-summary": "'''{{PLURAL:$1|One page''' was|$1 pages''' were}} not selected for deletion.", + "nuke-skipped": "[[:$1]] ([[:$2|talk]] {{int:pipe-separator}} [[{{ns:special}}:PageHistory/$1|history]])", "nuke-delete-more": "[[Special:Nuke|Delete more pages]]", "nuke-pattern": "SQL LIKE pattern (e.g. %) for the page name:", "nuke-nopages-global": "There are no page titles matching your search.", diff --git a/i18n/qqq.json b/i18n/qqq.json index 6eb2d054..d9d66059 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -37,9 +37,13 @@ "nuke-userorip": "Used as label for \"target\" input box.", "nuke-maxpages": "Used as label for \"nuke limit\" input box.", "nuke-editby": "This message is followed by {{msg-mw|Comma-separator}} and {{msg-mw|Nuke-viewchanges}}.\n\nParameters:\n* $1 - a username", + "nuke-delete-summary": "Used at the top of the Nuke (mass deletion) result page. Shown when a username was not provided.\n\nParameters:\n* $1 - the number of pages queued for deletion\n\nSee also:\n* {{msg-mw|nuke-delete-summary-user}}", + "nuke-delete-summary-user": "Used at the top of the Nuke (mass deletion) result page. Shown when a username was provided.\n\nParameters:\n* $1 - the number of pages queued for deletion\n* $2 - the username of the user targeted for deletion\n\nSee also:\n* {{msg-mw|nuke-delete-summary}}", "nuke-deleted": "Used as success result of deletion. Parameters:\n* $1 - page title\nSee also:\n* {{msg-mw|Nuke-not-deleted}}", "nuke-deletion-queued": "Used when the page is queued for deletion. Parameters:\n* $1 - page title\nSee also:\n* {{msg-mw|Nuke-deleted}}", "nuke-not-deleted": "Used as failure result of deletion. Parameters:\n* $1 - page title\nSee also:\n* {{msg-mw|Nuke-deleted}}", + "nuke-skipped-summary": "Used at the middle (or bottom) of the Nuke (mass deletion) result page. Shown only when some pages were not selected when mass deleting.\n\nParameters:\n* $1 - the number of pages queued for deletion\n\nSee also: \n*{{msg-mw|nuke-skipped}}", + "nuke-skipped": "Used to show links to pages that were skipped, for further action by administrators.\n\nParameters:\n* $1 - the title of the skipped page\n* $2 - the title of the talk page for the skipped page\n\nSee also: \n*{{msg-mw|nuke-skipped}}", "nuke-delete-more": "Used at the bottom of the Nuke (mass deletion) result page.", "nuke-pattern": "{{doc-important|Do not translate LIKE, it is an SQL operator.}}\nUsed as label for \"nuke pattern\" input box.", "nuke-nopages-global": "Used if there are no pages to delete and the username is empty.\n\nSee also:\n* {{msg-mw|Nuke-nopages}}", diff --git a/includes/SpecialNuke.php b/includes/SpecialNuke.php index 7cee8c59..977310a4 100644 --- a/includes/SpecialNuke.php +++ b/includes/SpecialNuke.php @@ -136,12 +136,16 @@ class SpecialNuke extends SpecialPage { && $currentUser->matchEditToken( $req->getVal( 'wpEditToken' ) ) ) { if ( $req->getRawVal( 'action' ) === 'delete' ) { - $pages = $req->getArray( 'pages' ); + $pages = $req->getArray( 'pages' ) ?? []; + $originalPageList + = explode( '|', $req->getText( 'originalPageList' ) ); - if ( $pages ) { - $this->doDelete( $pages, $reason ); - return; + if ( count( $originalPageList ) === 1 && !$originalPageList[0] ) { + // No page list was provided. + $originalPageList = []; } + + $this->doDelete( $pages, $originalPageList, $reason, $target ); } elseif ( $req->getRawVal( 'action' ) === 'submit' ) { // if the target is an ip addresss and temp account lookup is available, // list pages created by the ip user or by temp accounts associated with the ip address @@ -362,6 +366,7 @@ class SpecialNuke extends SpecialPage { 'name' => 'nukelist' ] ) . Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) . + Html::hidden( 'target', $username ) . $dropdown . $reasonField . // Select: All, None, Invert @@ -369,6 +374,8 @@ class SpecialNuke extends SpecialPage { '\n" . + Html::hidden( 'originalPageList', implode( '|', $titles ) ) . Html::submitButton( $this->msg( 'nuke-submit-delete' )->text() ) . '' ); @@ -590,18 +599,47 @@ class SpecialNuke extends SpecialPage { * Does the actual deletion of the pages. * * @param array $pages The pages to delete + * @param array $originalPageList The original list of pages shown to the user * @param string $reason + * @param string $target * @throws PermissionsError */ - protected function doDelete( array $pages, $reason ): void { + protected function doDelete( + array $pages, array $originalPageList, string $reason, string $target + ): void { $res = []; $jobs = []; + $skippedRes = []; $user = $this->getUser(); + $queuedCount = 0; + + // Get a list of all pages involved and what to do with them. + // Pages in $pages will always be deleted, even if they are not present in + // $originalPageList. + $willDeleteList = []; + foreach ( $pages as $page ) { + $willDeleteList[$page] = true; + } + foreach ( $originalPageList as $originalPage ) { + if ( !isset( $willDeleteList[$originalPage] ) ) { + $willDeleteList[$originalPage] = false; + } + } $localRepo = $this->repoGroup->getLocalRepo(); - foreach ( $pages as $page ) { + foreach ( $willDeleteList as $page => $willDelete ) { $title = Title::newFromText( $page ); + if ( !$willDelete ) { + // If this page was skipped, add it to the list of skipped pages and move on. + $skippedRes[] = $this->msg( + 'nuke-skipped', + wfEscapeWikiText( $title->getPrefixedText() ), + wfEscapeWikiText( $title->getTalkPageIfDefined() ) + )->parse(); + continue; + } + $deletionResult = false; if ( !$this->getNukeHookRunner()->onNukeDeletePage( $title, $reason, $deletionResult ) ) { $res[] = $this->msg( @@ -649,11 +687,15 @@ class SpecialNuke extends SpecialPage { 'nuke-deletion-queued', wfEscapeWikiText( $title->getPrefixedText() ) )->parse(); + $queuedCount++; } else { $res[] = $this->msg( $status->isOK() ? 'nuke-deleted' : 'nuke-not-deleted', wfEscapeWikiText( $title->getPrefixedText() ) )->parse(); + if ( $status->isOK() ) { + $queuedCount++; + } } } @@ -661,11 +703,27 @@ class SpecialNuke extends SpecialPage { $this->jobQueueGroup->push( $jobs ); } - $this->getOutput()->addHTML( - "\n" - ); + // Show the main summary, regardless of whether we deleted pages or not. + if ( $target ) { + $this->getOutput()->addWikiMsg( 'nuke-delete-summary-user', $queuedCount, $target ); + } else { + $this->getOutput()->addWikiMsg( 'nuke-delete-summary', $queuedCount ); + } + if ( $queuedCount ) { + $this->getOutput()->addHTML( + "\n" + ); + } + if ( count( $skippedRes ) ) { + $this->getOutput()->addWikiMsg( 'nuke-skipped-summary', count( $skippedRes ) ); + $this->getOutput()->addHTML( + "\n" + ); + } $this->getOutput()->addWikiMsg( 'nuke-delete-more' ); } diff --git a/tests/phpunit/integration/SpecialNukeTest.php b/tests/phpunit/integration/SpecialNukeTest.php index 638cf999..5bf5c342 100644 --- a/tests/phpunit/integration/SpecialNukeTest.php +++ b/tests/phpunit/integration/SpecialNukeTest.php @@ -9,6 +9,7 @@ use MediaWiki\Permissions\UltimateAuthority; use MediaWiki\Request\FauxRequest; use MediaWiki\Tests\User\TempUser\TempUserTestTrait; use MediaWiki\Title\Title; +use MediaWiki\User\User; use PermissionsError; use SpecialPageTestBase; use UploadFromFile; @@ -653,7 +654,7 @@ class SpecialNukeTest extends SpecialPageTestBase { } public function testListFiles() { - $this->uploadTestFile(); + $testFileName = $this->uploadTestFile()['title']->getPrefixedText(); $admin = $this->getTestSysop()->getUser(); $request = new FauxRequest( [ @@ -664,10 +665,8 @@ class SpecialNukeTest extends SpecialPageTestBase { [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); - $expectedTitle = Title::makeTitle( NS_FILE, 'Example.png' ); - // The title should be in the list - $this->assertStringContainsString( $expectedTitle->getPrefixedText(), $html ); + $this->assertStringContainsString( $testFileName, $html ); // There should also be an image preview $this->assertStringContainsString( "executeSpecialPage( '', $request, 'qqx', $performer ); + $deleteCount = count( $pages ); + $this->assertStringContainsString( "(nuke-delete-summary: $deleteCount)", $html ); $this->assertStringContainsString( '(nuke-deletion-queued: Page123)', $html ); $this->assertStringContainsString( '(nuke-deletion-queued: Paging456)', $html ); @@ -753,6 +754,106 @@ class SpecialNukeTest extends SpecialPageTestBase { $this->assertStringContainsString( $fauxReason, $deleteLogHtml ); } + public function testDeleteTarget() { + $pages = []; + + $testUser = $this->getTestUser( "user" )->getUser(); + $testUserName = $testUser->getName(); + + $pages[] = $this->uploadTestFile( $testUser )[ 'title' ]; + $pages[] = $this->insertPage( 'Page123', 'Test', NS_MAIN, $testUser )[ 'title' ]; + $pages[] = $this->insertPage( 'Paging456', 'Test', NS_MAIN, $testUser )[ 'title' ]; + + $admin = $this->getTestSysop()->getUser(); + + $fauxReason = "Reason " . wfRandomString(); + $request = new FauxRequest( [ + 'action' => 'delete', + 'wpDeleteReasonList' => 'Vandalism', + 'wpReason' => $fauxReason, + 'target' => $testUserName, + 'pages' => $pages, + 'wpFormIdentifier' => 'nukelist', + 'wpEditToken' => $admin->getEditToken(), + ], true ); + $performer = new UltimateAuthority( $admin ); + + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); + + $deleteCount = count( $pages ); + $this->assertStringContainsString( + "(nuke-delete-summary-user: $deleteCount, $testUserName)", + $html + ); + } + + public function testDeleteTargetAnon() { + $pages = []; + + $testUser = $this->getTestUser( "user" )->getUser(); + $testUserName = $testUser->getName(); + + $pages[] = $this->uploadTestFile( $testUser )[ 'title' ]; + $pages[] = $this->insertPage( 'Page123', 'Test', NS_MAIN, $testUser )[ 'title' ]; + $pages[] = $this->insertPage( 'Paging456', 'Test', NS_MAIN, $testUser )[ 'title' ]; + + $admin = $this->getTestSysop()->getUser(); + + $fauxReason = "Reason " . wfRandomString(); + $request = new FauxRequest( [ + 'action' => 'delete', + 'wpDeleteReasonList' => 'Vandalism', + 'wpReason' => $fauxReason, + 'pages' => $pages, + 'wpFormIdentifier' => 'nukelist', + 'wpEditToken' => $admin->getEditToken(), + ], true ); + $performer = new UltimateAuthority( $admin ); + + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); + + $deleteCount = count( $pages ); + $this->assertStringContainsString( + "(nuke-delete-summary: $deleteCount)", + $html + ); + } + + public function testDeleteSkipped() { + $pages = []; + $pages[] = $this->insertPage( 'Page123', 'Test', NS_MAIN )[ 'title' ]; + $pages[] = $this->insertPage( 'Paging 456', 'Test', NS_MAIN )[ 'title' ]; + $skippedPage = $this->insertPage( 'Page 789', 'Test', NS_MAIN )['title']; + + $admin = $this->getTestSysop()->getUser(); + + $request = new FauxRequest( [ + 'action' => 'delete', + 'wpDeleteReasonList' => 'Vandalism', + 'originalPageList' => implode( '|', $pages ), + 'pages' => [ $skippedPage->getPrefixedDBkey() ], + 'wpFormIdentifier' => 'nukelist', + 'wpEditToken' => $admin->getEditToken(), + ], true ); + $performer = new UltimateAuthority( $admin ); + + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); + + $skippedCount = count( $pages ); + $this->assertStringContainsString( '(nuke-delete-summary: 1)', $html ); + $this->assertStringContainsString( "(nuke-skipped-summary: $skippedCount)", $html ); + + // Ensure all delete jobs are run + $this->runJobs(); + + // Make sure that those pages are not in the deletion log. + $deleteLogHtml = $this->getDeleteLogHtml(); + foreach ( $pages as $title ) { + $this->assertStringNotContainsString( $title->getPrefixedText(), $deleteLogHtml ); + } + $this->assertStringContainsString( $skippedPage->getPrefixedText(), $deleteLogHtml ); + } + public function testDeleteDropdownReason() { $pages = []; $pages[] = $this->insertPage( 'Page123', 'Test', NS_MAIN )[ 'title' ]; @@ -807,14 +908,14 @@ class SpecialNukeTest extends SpecialPageTestBase { } public function testDeleteFiles() { - $this->uploadTestFile(); + $testFileName = $this->uploadTestFile()['title']->getPrefixedText(); $admin = $this->getTestSysop()->getUser(); $request = new FauxRequest( [ 'action' => 'delete', 'wpDeleteReasonList' => 'Reason', 'wpReason' => 'Reason', - 'pages' => [ 'File:Example.png' ], + 'pages' => [ $testFileName ], 'wpFormIdentifier' => 'nukelist', 'wpEditToken' => $admin->getEditToken(), ], true ); @@ -822,11 +923,9 @@ class SpecialNukeTest extends SpecialPageTestBase { [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); - $expectedTitle = Title::makeTitle( NS_FILE, 'Example.png' ); - // Files are deleted instantly $this->assertStringContainsString( "nuke-deleted", $html ); - $this->assertStringContainsString( $expectedTitle->getPrefixedText(), $html ); + $this->assertStringContainsString( $testFileName, $html ); } public function testDeleteHook() { @@ -879,26 +978,40 @@ class SpecialNukeTest extends SpecialPageTestBase { $this->assertCount( 0, $searchResults2 ); } - private function uploadTestFile() { + /** + * Upload a test file. + * + * @param ?User|null $user + * @return array Title object and page id + */ + private function uploadTestFile( ?User $user = null ): array { $exampleFilePath = realpath( __DIR__ . "/../../assets/Example.png" ); $tempFilePath = $this->getNewTempFile(); copy( $exampleFilePath, $tempFilePath ); + $title = Title::makeTitle( NS_FILE, "Example " . rand() . ".png" ); $request = new FauxRequest( [], true ); $request->setUpload( 'wpUploadFile', [ - 'name' => 'Example.png', + 'name' => $title->getText(), 'type' => 'image/png', 'tmp_name' => $tempFilePath, 'size' => filesize( $tempFilePath ), 'error' => UPLOAD_ERR_OK ] ); $upload = UploadFromFile::createFromRequest( $request ); - $upload->performUpload( + $uploadStatus = $upload->performUpload( "test", false, false, - $this->getTestUser( "user" )->getUser() + $user ?? $this->getTestUser( "user" )->getUser() ); + $this->assertTrue( $uploadStatus->isOK() ); + $this->getServiceContainer()->getJobRunner()->run( [] ); + + return [ + 'title' => $title, + 'id' => $title->getId() + ]; } private function getDeleteLogHtml(): string {