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 <kgraessle@wikimedia.org>
Change-Id: I5d68d2663751783bcc773799e951f74866ceb722
This commit is contained in:
Chlod Alejandro 2024-11-12 09:56:47 +08:00
parent 10f5a9b1bc
commit 41d31c3076
No known key found for this signature in database
GPG key ID: A1E67C59037B0CC1
3 changed files with 102 additions and 13 deletions

View file

@ -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": [

View file

@ -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
];
}

View file

@ -560,6 +560,77 @@ class SpecialNukeTest extends SpecialPageTestBase {
$this->assertEquals( 2, substr_count( $html, '<li>' ) );
}
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