diff --git a/.phan/config.php b/.phan/config.php index 5788007f..f426db9f 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -32,4 +32,12 @@ $cfg['exclude_analysis_directory_list'] = array_merge( ] ); +// Don't stub CheckUser dependency if present +if ( file_exists( '../../extensions/CheckUser/src/Services/CheckUserTemporaryAccountsByIPLookup.php' ) ) { + $cfg[ 'exclude_file_list' ] = array_merge( + $cfg[ 'exclude_file_list' ], + [ '.phan/stubs/CheckUserTemporaryAccountsByIPLookup.php' ] + ); +} + return $cfg; diff --git a/.phan/stubs/CheckUserTemporaryAccountsByIPLookup.php b/.phan/stubs/CheckUserTemporaryAccountsByIPLookup.php new file mode 100644 index 00000000..6ff26598 --- /dev/null +++ b/.phan/stubs/CheckUserTemporaryAccountsByIPLookup.php @@ -0,0 +1,30 @@ + static function ( + MediaWikiServices $services + ) { + // Allow IP lookups if temp user is known and CheckUser is present + if ( !ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) ) { + return null; + } + $tempUserIsKnown = $services->getTempUserConfig()->isKnown(); + if ( !$tempUserIsKnown ) { + return null; + } + return $services->get( 'CheckUserTemporaryAccountsByIPLookup' ); + } +]; diff --git a/includes/SpecialNuke.php b/includes/SpecialNuke.php index 13adf482..ccb952ec 100644 --- a/includes/SpecialNuke.php +++ b/includes/SpecialNuke.php @@ -3,7 +3,9 @@ namespace MediaWiki\Extension\Nuke; use DeletePageJob; +use ErrorPageError; use JobQueueGroup; +use MediaWiki\CheckUser\Services\CheckUserTemporaryAccountsByIPLookup; use MediaWiki\CommentStore\CommentStore; use MediaWiki\Extension\Nuke\Hooks\NukeHookRunner; use MediaWiki\Html\Html; @@ -16,6 +18,8 @@ use MediaWiki\Request\WebRequest; use MediaWiki\SpecialPage\SpecialPage; use MediaWiki\Title\NamespaceInfo; use MediaWiki\Title\Title; +use MediaWiki\User\Options\UserOptionsLookup; +use MediaWiki\User\User; use MediaWiki\User\UserFactory; use MediaWiki\User\UserNamePrefixSearch; use MediaWiki\User\UserNameUtils; @@ -26,6 +30,7 @@ use OOUI\TextInputWidget; use PermissionsError; use RepoGroup; use UserBlockedError; +use Wikimedia\IPUtils; use Wikimedia\Rdbms\IConnectionProvider; use Wikimedia\Rdbms\IExpression; use Wikimedia\Rdbms\LikeMatch; @@ -42,21 +47,29 @@ class SpecialNuke extends SpecialPage { private PermissionManager $permissionManager; private RepoGroup $repoGroup; private UserFactory $userFactory; + private UserOptionsLookup $userOptionsLookup; private UserNamePrefixSearch $userNamePrefixSearch; private UserNameUtils $userNameUtils; private NamespaceInfo $namespaceInfo; private Language $contentLanguage; + /** @var CheckUserTemporaryAccountsByIPLookup|null */ + private $checkUserTemporaryAccountsByIPLookup = null; + /** + * @inheritDoc + */ public function __construct( JobQueueGroup $jobQueueGroup, IConnectionProvider $dbProvider, PermissionManager $permissionManager, RepoGroup $repoGroup, UserFactory $userFactory, + UserOptionsLookup $userOptionsLookup, UserNamePrefixSearch $userNamePrefixSearch, UserNameUtils $userNameUtils, NamespaceInfo $namespaceInfo, - Language $contentLanguage + Language $contentLanguage, + $checkUserTemporaryAccountsByIPLookup = null ) { parent::__construct( 'Nuke', 'nuke' ); $this->jobQueueGroup = $jobQueueGroup; @@ -64,10 +77,12 @@ class SpecialNuke extends SpecialPage { $this->permissionManager = $permissionManager; $this->repoGroup = $repoGroup; $this->userFactory = $userFactory; + $this->userOptionsLookup = $userOptionsLookup; $this->userNamePrefixSearch = $userNamePrefixSearch; $this->userNameUtils = $userNameUtils; $this->namespaceInfo = $namespaceInfo; $this->contentLanguage = $contentLanguage; + $this->checkUserTemporaryAccountsByIPLookup = $checkUserTemporaryAccountsByIPLookup; } /** @@ -127,7 +142,20 @@ class SpecialNuke extends SpecialPage { return; } } elseif ( $req->getRawVal( 'action' ) === 'submit' ) { - $this->listForm( $target, $reason, $limit, $namespace ); + // 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 + if ( + $this->checkUserTemporaryAccountsByIPLookup && + IPUtils::isValid( $target ) + ) { + $this->assertUserCanAccessTemporaryAccounts( $currentUser ); + $tempnames = $this->getTempAccountData( $target ); + $reason = $this->getDeleteReason( $this->getRequest(), $target, true ); + $this->listForm( $target, $reason, $limit, $namespace, $tempnames ); + } else { + // otherwise just list pages normally + $this->listForm( $target, $reason, $limit, $namespace ); + } } else { $this->promptForm(); } @@ -138,6 +166,62 @@ class SpecialNuke extends SpecialPage { } } + /** + * Does the user have the appropriate permissions and have they enabled in preferences? + * Adapted from MediaWiki\CheckUser\Api\Rest\Handler\AbstractTemporaryAccountHandler::checkPermissions + * + * @param User $currentUser + * + * @throws PermissionsError if the user does not have the 'checkuser-temporary-account' right + * @throws ErrorPageError if the user has not enabled the 'checkuser-temporary-account-enabled' preference + */ + private function assertUserCanAccessTemporaryAccounts( User $currentUser ) { + if ( + !$currentUser->isAllowed( 'checkuser-temporary-account-no-preference' ) + ) { + if ( + !$currentUser->isAllowed( 'checkuser-temporary-account' ) + ) { + throw new PermissionsError( 'checkuser-temporary-account' ); + } + if ( + !$this->userOptionsLookup->getOption( + $currentUser, + 'checkuser-temporary-account-enable' + ) + ) { + throw new ErrorPageError( + $this->msg( 'checkuser-ip-contributions-permission-error-title' ), + $this->msg( 'checkuser-ip-contributions-permission-error-description' ) + ); + } + } + } + + /** + * Given an IP address, return a list of temporary accounts that are known to have edited from the IP. + * + * Calls to this method result in a log entry being generated for the logged-in user account making the request. + * @param string $ip The IP address used for looking up temporary account names. + * The address will be normalized in the IP lookup service. + * @return string[] A list of temporary account usernames associated with the IP address + */ + private function getTempAccountData( string $ip ): array { + // Requires CheckUserTemporaryAccountsByIPLookup service + if ( !$this->checkUserTemporaryAccountsByIPLookup ) { + return []; + } + $status = $this->checkUserTemporaryAccountsByIPLookup->get( + $ip, + $this->getAuthority(), + true + ); + if ( $status->isGood() ) { + return $status->getValue(); + } + return []; + } + /** * Prompt for a username or IP address. * @@ -146,7 +230,11 @@ class SpecialNuke extends SpecialPage { protected function promptForm( string $userName = '' ): void { $out = $this->getOutput(); - $out->addWikiMsg( 'nuke-tools' ); + if ( $this->checkUserTemporaryAccountsByIPLookup ) { + $out->addWikiMsg( 'nuke-tools-tempaccount' ); + } else { + $out->addWikiMsg( 'nuke-tools' ); + } $formDescriptor = [ 'nuke-target' => [ @@ -199,11 +287,12 @@ class SpecialNuke extends SpecialPage { * @param string $reason * @param int $limit * @param int|null $namespace + * @param string[] $tempnames */ - protected function listForm( $username, $reason, $limit, $namespace = null ): void { + protected function listForm( $username, $reason, $limit, $namespace = null, $tempnames = [] ): void { $out = $this->getOutput(); - $pages = $this->getNewPages( $username, $limit, $namespace ); + $pages = $this->getNewPages( $username, $limit, $namespace, $tempnames ); if ( !$pages ) { if ( $username === '' ) { @@ -221,6 +310,8 @@ class SpecialNuke extends SpecialPage { if ( $username === '' ) { $out->addWikiMsg( 'nuke-list-multiple' ); + } elseif ( $tempnames ) { + $out->addWikiMsg( 'nuke-list-tempaccount', $username ); } else { $out->addWikiMsg( 'nuke-list', $username ); } @@ -340,10 +431,11 @@ class SpecialNuke extends SpecialPage { * @param string $username * @param int $limit * @param int|null $namespace + * @param string[] $tempnames * * @return array{0:Title,1:string|false}[] */ - protected function getNewPages( $username, $limit, $namespace = null ): array { + protected function getNewPages( $username, $limit, $namespace = null, $tempnames = [] ): array { $dbr = $this->dbProvider->getReplicaDatabase(); $queryBuilder = $dbr->newSelectQueryBuilder() ->select( [ 'page_title', 'page_namespace' ] ) @@ -362,7 +454,8 @@ class SpecialNuke extends SpecialPage { if ( $username === '' ) { $queryBuilder->field( 'actor_name', 'rc_user_text' ); } else { - $queryBuilder->andWhere( [ 'actor_name' => $username ] ); + $actornames = [ $username, ...$tempnames ]; + $queryBuilder->andWhere( [ 'actor_name' => $actornames ] ); } if ( $namespace !== null ) { @@ -595,10 +688,14 @@ class SpecialNuke extends SpecialPage { return 'pagetools'; } - private function getDeleteReason( WebRequest $request, string $target ): string { - $defaultReason = $target === '' - ? $this->msg( 'nuke-multiplepeople' )->inContentLanguage()->text() - : $this->msg( 'nuke-defaultreason', $target )->inContentLanguage()->text(); + private function getDeleteReason( WebRequest $request, string $target, bool $tempaccount = false ): string { + if ( $tempaccount ) { + $defaultReason = $this->msg( 'nuke-defaultreason-tempaccount' ); + } else { + $defaultReason = $target === '' + ? $this->msg( 'nuke-multiplepeople' )->inContentLanguage()->text() + : $this->msg( 'nuke-defaultreason', $target )->inContentLanguage()->text(); + } $dropdownSelection = $request->getText( 'wpDeleteReasonList', 'other' ); $reasonInput = $request->getText( 'wpReason', $defaultReason ); diff --git a/tests/phpunit/integration/ServiceWiringTest.php b/tests/phpunit/integration/ServiceWiringTest.php new file mode 100644 index 00000000..aa1bbc40 --- /dev/null +++ b/tests/phpunit/integration/ServiceWiringTest.php @@ -0,0 +1,30 @@ +get( $name ); + $this->addToAssertionCount( 1 ); + } + + public static function provideService() { + $wiring = require __DIR__ . '/../../../includes/ServiceWiring.php'; + foreach ( $wiring as $name => $_ ) { + yield $name => [ $name ]; + } + } +} diff --git a/tests/phpunit/integration/SpecialNukeTest.php b/tests/phpunit/integration/SpecialNukeTest.php index 3f1a6388..fcb2f8ca 100644 --- a/tests/phpunit/integration/SpecialNukeTest.php +++ b/tests/phpunit/integration/SpecialNukeTest.php @@ -2,9 +2,12 @@ namespace MediaWiki\Extension\Nuke\Test\Integration; +use ErrorPageError; +use MediaWiki\Context\RequestContext; use MediaWiki\Extension\Nuke\SpecialNuke; use MediaWiki\Permissions\UltimateAuthority; use MediaWiki\Request\FauxRequest; +use MediaWiki\Tests\User\TempUser\TempUserTestTrait; use MediaWiki\Title\Title; use PermissionsError; use SpecialPageTestBase; @@ -17,6 +20,8 @@ use UserBlockedError; */ class SpecialNukeTest extends SpecialPageTestBase { + use TempUserTestTrait; + protected function newSpecialPage(): SpecialNuke { $services = $this->getServiceContainer(); @@ -26,15 +31,17 @@ class SpecialNukeTest extends SpecialPageTestBase { $services->getPermissionManager(), $services->getRepoGroup(), $services->getUserFactory(), + $services->getUserOptionsLookup(), $services->getUserNamePrefixSearch(), $services->getUserNameUtils(), $services->getNamespaceInfo(), - $services->getContentLanguage() + $services->getContentLanguage(), + $services->getService( 'NukeIPLookup' ) ); } /** - * Ensure that the prompt doesn't allow a user blocked from deleting + * Ensure that the prompt prevents a user blocked from deleting * pages from accessing the form. * * @return void @@ -110,11 +117,92 @@ class SpecialNukeTest extends SpecialPageTestBase { public function testPrompt() { $admin = $this->getTestSysop()->getUser(); + $this->disableAutoCreateTempUser(); $performer = new UltimateAuthority( $admin ); [ $html ] = $this->executeSpecialPage( '', null, 'qqx', $performer ); $this->assertStringContainsString( '(nuke-summary)', $html ); + $this->assertStringContainsString( '(nuke-tools)', $html ); + } + + /** + * Ensure that the prompt prevents a nuke user without the checkuser-temporary-account permission + * from performing CheckUser IP lookups + * + * @return void + */ + public function testPromptCheckUserNoPermission() { + $this->expectException( PermissionsError::class ); + + $this->markTestSkippedIfExtensionNotLoaded( 'CheckUser' ); + $this->enableAutoCreateTempUser(); + $ip = '1.2.3.4'; + + $adminUser = $this->getTestSysop()->getUser(); + $permissionManager = $this->getServiceContainer()->getPermissionManager(); + $permissions = $permissionManager->getUserPermissions( $adminUser ); + $permissions = array_diff( $permissions, [ 'checkuser-temporary-account' ] ); + $permissionManager->overrideUserRightsForTesting( $adminUser, + $permissions + ); + $performer = new UltimateAuthority( $adminUser ); + $request = new FauxRequest( [ + 'target' => $ip, + 'action' => 'submit', + ], true ); + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $performer ); + } + + /** + * Ensure that the prompt prevents a nuke user who hasn't accepted the agreement + * from performing CheckUser IP lookups + * + * @return void + */ + public function testPromptCheckUserNoPreference() { + $this->expectException( ErrorPageError::class ); + $this->expectExceptionMessage( + 'To view temporary account contributions for an IP, please accept' . + ' the agreement in [[Special:Preferences|your preferences]].' + ); + $this->markTestSkippedIfExtensionNotLoaded( 'CheckUser' ); + $this->enableAutoCreateTempUser(); + $ip = '1.2.3.4'; + $this->overrideConfigValues( [ + 'GroupPermissions' => [ + 'testgroup' => [ + 'nuke' => true, + 'checkuser-temporary-account' => true + ] + ] + ] ); + + $adminUser = $this->getTestUser( [ 'testgroup' ] )->getUser(); + $request = new FauxRequest( [ + 'target' => $ip, + 'action' => 'submit', + ], true ); + $adminPerformer = new UltimateAuthority( $adminUser ); + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $adminPerformer ); + } + + /** + * Ensure that the prompt displays the correct messages when + * temp accounts and CheckUser are enabled + * + * @return void + */ + public function testPromptWithCheckUser() { + $this->markTestSkippedIfExtensionNotLoaded( 'CheckUser' ); + $admin = $this->getTestSysop()->getUser(); + $this->enableAutoCreateTempUser(); + $performer = new UltimateAuthority( $admin ); + + [ $html ] = $this->executeSpecialPage( '', null, 'qqx', $performer ); + + $this->assertStringContainsString( '(nuke-summary)', $html ); + $this->assertStringContainsString( '(nuke-tools-tempaccount)', $html ); } public function testPromptTarget() { @@ -136,6 +224,123 @@ class SpecialNukeTest extends SpecialPageTestBase { $this->assertStringContainsString( 'Target2', $html ); } + /** + * Ensure that the prompt works with anon IP searches when + * temp accounts are disabled + * + * @return void + */ + public function testPromptTargetAnonUser() { + $this->disableAutoCreateTempUser( [ 'known' => false ] ); + $ip = '127.0.0.1'; + $testUser = $this->getServiceContainer()->getUserFactory()->newAnonymous( $ip ); + $performer = new UltimateAuthority( $testUser ); + + $this->editPage( 'Target1', 'test', "", NS_MAIN, $performer ); + $this->editPage( 'Target2', 'test', "", NS_MAIN, $performer ); + + $adminUser = $this->getTestSysop()->getUser(); + $request = new FauxRequest( [ + 'target' => $testUser->getUser()->getName() + ] ); + $adminPerformer = new UltimateAuthority( $adminUser ); + + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $adminPerformer ); + + $this->assertStringContainsString( '(nuke-list:', $html ); + $this->assertStringContainsString( 'Target1', $html ); + $this->assertStringContainsString( 'Target2', $html ); + + $usernameCount = substr_count( $html, $ip ); + $this->assertStringContainsString( 5, $usernameCount ); + } + + /** + * Ensure that the prompt returns temp accounts from IP lookups when + * temp accounts and CheckUser are enabled + * + * @return void + */ + public function testPromptTargetCheckUser() { + $this->markTestSkippedIfExtensionNotLoaded( 'CheckUser' ); + $this->enableAutoCreateTempUser(); + $ip = '1.2.3.4'; + RequestContext::getMain()->getRequest()->setIP( $ip ); + $testUser = $this->getServiceContainer()->getTempUserCreator() + ->create( null, new FauxRequest() )->getUser(); + $this->editPage( 'Target1', 'test', "", NS_MAIN, $testUser ); + $this->editPage( 'Target2', 'test', "", NS_MAIN, $testUser ); + + $adminUser = $this->getTestSysop()->getUser(); + $permissionManager = $this->getServiceContainer()->getPermissionManager(); + $permissionManager->overrideUserRightsForTesting( $adminUser, + array_merge( + $permissionManager->getUserPermissions( $adminUser ), + [ 'checkuser-temporary-account-no-preference' ] + ) ); + $request = new FauxRequest( [ + 'target' => $ip, + 'action' => 'submit', + ], true ); + $adminPerformer = new UltimateAuthority( $adminUser ); + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $adminPerformer ); + + $usernameCount = substr_count( $html, $ip ); + $this->assertStringContainsString( 1, $usernameCount ); + + $this->assertStringContainsString( '(nuke-list-tempaccount:', $html ); + $this->assertStringContainsString( 'Target1', $html ); + $this->assertStringContainsString( 'Target2', $html ); + } + + /** + * Ensure that the prompt returns temp accounts and IP accounts from IP lookups when + * temp accounts and CheckUser are enabled and Anonymous IP accounts exist + * + * @return void + */ + public function testPromptTargetCheckUserMixed() { + $this->markTestSkippedIfExtensionNotLoaded( 'CheckUser' ); + $this->disableAutoCreateTempUser( [ 'known' => false ] ); + $ip = '1.2.3.4'; + $testUser = $this->getServiceContainer()->getUserFactory()->newAnonymous( $ip ); + $performer = new UltimateAuthority( $testUser ); + + // create a page as an anonymous IP user + $this->editPage( 'Target1', 'test', "", NS_MAIN, $performer ); + + $this->enableAutoCreateTempUser(); + RequestContext::getMain()->getRequest()->setIP( $ip ); + $testUser = $this->getServiceContainer()->getTempUserCreator() + ->create( null, new FauxRequest() )->getUser(); + $performer = new UltimateAuthority( $testUser ); + + // create a page as a temp user + $this->editPage( 'Target2', 'test', "", NS_MAIN, $performer ); + + $adminUser = $this->getTestSysop()->getUser(); + $permissionManager = $this->getServiceContainer()->getPermissionManager(); + $permissionManager->overrideUserRightsForTesting( $adminUser, + array_merge( + $permissionManager->getUserPermissions( $adminUser ), + [ 'checkuser-temporary-account-no-preference' ] + ) ); + $request = new FauxRequest( [ + 'target' => $ip, + 'action' => 'submit', + ], true ); + $adminPerformer = new UltimateAuthority( $adminUser ); + [ $html ] = $this->executeSpecialPage( '', $request, 'qqx', $adminPerformer ); + + $usernameCount = substr_count( $html, $ip ); + $this->assertStringContainsString( 1, $usernameCount ); + + // They should all show up together + $this->assertStringContainsString( '(nuke-list-tempaccount:', $html ); + $this->assertStringContainsString( 'Target1', $html ); + $this->assertStringContainsString( 'Target2', $html ); + } + public function testListNoPagesGlobal() { $admin = $this->getTestSysop()->getUser(); $request = new FauxRequest( [