From 41d31c3076d48c3b580df6de829198e46aa57d05 Mon Sep 17 00:00:00 2001 From: Chlod Alejandro Date: Tue, 12 Nov 2024 09:56:47 +0800 Subject: [PATCH] Use revision table instead of recentchanges This switches Nuke over to using the `revision` table instead of `recentchanges`, allowing a wider timespan of pages to be deleted. As a DoS prevention measure, this adds the `$wgNukeMaxAge` config option (defaulted to the value of `$wgRCMaxAge`) and a max execution time for the SELECT (set to `$wgMaxExecutionTimeForExpensiveQueries`). This also adds a relevant test. Partially based off of I6c2b7e6b695d58a7dcba93ccaeba9ed35d81cf80. Bug: T379147 Co-Authored-by: Kgraessle Change-Id: I5d68d2663751783bcc773799e951f74866ceb722 --- extension.json | 6 ++ includes/SpecialNuke.php | 38 ++++++---- tests/phpunit/integration/SpecialNukeTest.php | 71 +++++++++++++++++++ 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/extension.json b/extension.json index 7bdae61b..ef76be69 100644 --- a/extension.json +++ b/extension.json @@ -29,6 +29,12 @@ "ListDefinedTags": "MediaWiki\\Extension\\Nuke\\Hooks::onRegisterTags", "ChangeTagsListActive": "MediaWiki\\Extension\\Nuke\\Hooks::onRegisterTags" }, + "config": { + "NukeMaxAge": { + "value": 0, + "description": "The maximum age of a new page creation or file upload before it becomes ineligible for mass deletion. Defaults to the value of $wgRCMaxAge." + } + }, "ResourceModules": { "ext.nuke.confirm": { "scripts": [ diff --git a/includes/SpecialNuke.php b/includes/SpecialNuke.php index 1590491e..51caac1c 100644 --- a/includes/SpecialNuke.php +++ b/includes/SpecialNuke.php @@ -12,6 +12,7 @@ use MediaWiki\Html\Html; use MediaWiki\Html\ListToggle; use MediaWiki\HTMLForm\HTMLForm; use MediaWiki\Language\Language; +use MediaWiki\MainConfigNames; use MediaWiki\Page\File\FileDeleteForm; use MediaWiki\Permissions\PermissionManager; use MediaWiki\Request\WebRequest; @@ -437,21 +438,32 @@ class SpecialNuke extends SpecialPage { */ protected function getNewPages( $username, $limit, $namespace = null, $tempnames = [] ): array { $dbr = $this->dbProvider->getReplicaDatabase(); + + $maxAge = $this->getConfig()->get( "NukeMaxAge" ); + // If no Nuke-specific max age was set, this should match the value of `$wgRCMaxAge`. + if ( !$maxAge ) { + $maxAge = $this->getConfig()->get( MainConfigNames::RCMaxAge ); + } + $queryBuilder = $dbr->newSelectQueryBuilder() ->select( [ 'page_title', 'page_namespace' ] ) - ->from( 'recentchanges' ) - ->join( 'actor', null, 'actor_id=rc_actor' ) - ->join( 'page', null, 'page_id=rc_cur_id' ) - ->where( - $dbr->expr( 'rc_source', '=', 'mw.new' )->orExpr( - $dbr->expr( 'rc_log_type', '=', 'upload' ) - ->and( 'rc_log_action', '=', 'upload' ) - ) - ) - ->orderBy( 'rc_timestamp', SelectQueryBuilder::SORT_DESC ) - ->limit( $limit ); + ->from( 'revision' ) + ->join( 'actor', null, 'actor_id=rev_actor' ) + ->join( 'page', null, 'page_id=rev_page' ) + ->where( [ + $dbr->expr( 'rev_parent_id', '=', 0 ), + $dbr->expr( 'rev_timestamp', '>', $dbr->timestamp( + time() - $maxAge + ) ) + ] ) + ->orderBy( 'rev_timestamp', SelectQueryBuilder::SORT_DESC ) + ->distinct() + ->limit( $limit ) + ->setMaxExecutionTime( + $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries ) + ); - $queryBuilder->field( 'actor_name', 'rc_user_text' ); + $queryBuilder->field( 'actor_name' ); $actornames = array_filter( [ $username, ...$tempnames ] ); if ( $actornames ) { $queryBuilder->andWhere( [ 'actor_name' => $actornames ] ); @@ -556,7 +568,7 @@ class SpecialNuke extends SpecialPage { foreach ( $result as $row ) { $pages[] = [ Title::makeTitle( $row->page_namespace, $row->page_title ), - $row->rc_user_text + $row->actor_name ]; } diff --git a/tests/phpunit/integration/SpecialNukeTest.php b/tests/phpunit/integration/SpecialNukeTest.php index ebba7edd..05936cf1 100644 --- a/tests/phpunit/integration/SpecialNukeTest.php +++ b/tests/phpunit/integration/SpecialNukeTest.php @@ -560,6 +560,77 @@ class SpecialNukeTest extends SpecialPageTestBase { $this->assertEquals( 2, substr_count( $html, '
  • ' ) ); } + public function testListMaxAge() { + // 1 day + $maxAge = 86400; + $this->overrideConfigValues( [ + 'NukeMaxAge' => $maxAge, + 'RCMaxAge' => $maxAge + ] ); + + // Will never show up. If it does, the max age isn't being applied at all. + $page1Status = $this->editPage( 'Page1', 'test' ); + // Will show up conditionally (see below). + $page2Status = $this->editPage( 'Page2', 'test' ); + // Will always show up. + $this->editPage( 'Page3', 'test' ); + + // Prepare database connection and update query builder + $dbw = $this->getServiceContainer()->getConnectionProvider()->getPrimaryDatabase(); + $uqb = $dbw->newUpdateQueryBuilder() + ->update( 'revision' ) + ->caller( __METHOD__ ); + + // Manually change the rev_timestamp of Page1's creation in the database. + ( clone $uqb ) + ->set( [ 'rev_timestamp' => $dbw->timestamp( time() - ( $maxAge * 3 ) ) ] ) + ->where( [ + 'rev_id' => $page1Status->getNewRevision()->getId() + ] )->execute(); + // Manually change the rev_timestamp of Page2's creation in the database. + ( clone $uqb ) + ->set( [ 'rev_timestamp' => $dbw->timestamp( time() - $maxAge - 60 ) ] ) + ->where( [ + 'rev_id' => $page2Status->getNewRevision()->getId() + ] )->execute(); + + $admin = $this->getTestSysop()->getUser(); + $request = new FauxRequest( [ + 'action' => 'submit', + 'pattern' => 'Page%', + 'limit' => 2 + ], true ); + $performer = new UltimateAuthority( $admin ); + + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); + $this->assertStringNotContainsString( 'Page1', $html ); + $this->assertStringNotContainsString( 'Page2', $html ); + $this->assertStringContainsString( 'Page3', $html ); + + // Now check if resetting $wgNukeMaxAge will show Page2. + // This should follow $wgRCMaxAge, which will include the page. + $this->overrideConfigValues( [ + 'RCMaxAge' => $maxAge * 2, + 'NukeMaxAge' => 0 + ] ); + + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); + $this->assertStringNotContainsString( 'Page1', $html ); + $this->assertStringContainsString( 'Page2', $html ); + $this->assertStringContainsString( 'Page3', $html ); + + // Now check if expanding just $wgNukeMaxAge will show Page2. + $this->overrideConfigValues( [ + 'RCMaxAge' => $maxAge, + 'NukeMaxAge' => $maxAge * 2 + ] ); + + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); + $this->assertStringNotContainsString( 'Page1', $html ); + $this->assertStringContainsString( 'Page2', $html ); + $this->assertStringContainsString( 'Page3', $html ); + } + public function testExecutePattern() { // Test that matching wildcards works, and that escaping wildcards works as documented // at https://www.mediawiki.org/wiki/Help:Extension:Nuke