Viewing File: /home/omtekel/www/wp-content/upgrade/backup/Variables.tar

VariablesManagerTest.php000066600000021175151334772630011345 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Unit;

use LogicException;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Extension\AbuseFilter\Parser\AFPData;
use MediaWiki\Extension\AbuseFilter\Variables\LazyLoadedVariable;
use MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer;
use MediaWiki\Extension\AbuseFilter\Variables\UnsetVariableException;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWikiUnitTestCase;

/**
 * @group Test
 * @group AbuseFilter
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Variables\VariablesManager
 */
class VariablesManagerTest extends MediaWikiUnitTestCase {
	/**
	 * @param LazyVariableComputer|null $lazyComputer
	 * @return VariablesManager
	 */
	private function getManager( LazyVariableComputer $lazyComputer = null ): VariablesManager {
		return new VariablesManager(
			new KeywordsManager( $this->createMock( AbuseFilterHookRunner::class ) ),
			$lazyComputer ?? $this->createMock( LazyVariableComputer::class )
		);
	}

	/**
	 * @covers ::translateDeprecatedVars
	 */
	public function testTranslateDeprecatedVars() {
		$varsMap = [
			'timestamp' => new AFPData( AFPData::DSTRING, '123' ),
			'added_lines' => new AFPData( AFPData::DSTRING, 'foobar' ),
			'article_text' => new AFPData( AFPData::DSTRING, 'FOO' ),
			'article_articleid' => new AFPData( AFPData::DINT, 42 )
		];
		$translatedVarsMap = [
			'timestamp' => $varsMap['timestamp'],
			'added_lines' => $varsMap['added_lines'],
			'page_title' => $varsMap['article_text'],
			'page_id' => $varsMap['article_articleid']
		];
		$holder = VariableHolder::newFromArray( $varsMap );
		$manager = $this->getManager();
		$manager->translateDeprecatedVars( $holder );
		$this->assertEquals( $translatedVarsMap, $holder->getVars() );
	}

	/**
	 * @param VariableHolder $holder
	 * @param array|bool $compute
	 * @param bool $includeUser
	 * @param array $expected
	 * @dataProvider provideDumpAllVars
	 *
	 * @covers ::dumpAllVars
	 */
	public function testDumpAllVars(
		VariableHolder $holder,
		$compute,
		bool $includeUser,
		array $expected
	) {
		$computer = $this->createMock( LazyVariableComputer::class );
		$computer->method( 'compute' )->willReturnCallback(
			static function ( LazyLoadedVariable $var ) {
				switch ( $var->getMethod() ) {
					case 'preftitle':
						return new AFPData( AFPData::DSTRING, 'title' );
					case 'lines':
						return new AFPData( AFPData::DSTRING, 'lines' );
					default:
						throw new LogicException( 'Unrecognized value!' );
				}
			}
		);

		$manager = $this->getManager( $computer );

		$this->assertEquals( $expected, $manager->dumpAllVars( $holder, $compute, $includeUser ) );
	}

	public static function provideDumpAllVars() {
		$titleVal = 'title';
		$preftitle = new LazyLoadedVariable( 'preftitle', [] );

		$linesVal = 'lines';
		$lines = new LazyLoadedVariable( 'lines', [] );

		$pairs = [
			'page_title' => 'foo',
			'page_prefixedtitle' => $preftitle,
			'added_lines' => $lines,
			'user_name' => 'bar',
			'custom-var' => 'foo'
		];
		$vars = VariableHolder::newFromArray( $pairs );

		$nonLazy = array_fill_keys( [ 'page_title', 'user_name', 'custom-var' ], 1 );
		$nonLazyExpect = array_intersect_key( $pairs, $nonLazy );
		yield 'lazy-loaded vars are excluded if not computed' => [
			clone $vars,
			[],
			true,
			$nonLazyExpect
		];

		$nonUserExpect = array_diff_key( $nonLazyExpect, [ 'custom-var' => 1 ] );
		yield 'user-set vars are excluded' => [ clone $vars, [], false, $nonUserExpect ];

		$allExpect = $pairs;
		$allExpect['page_prefixedtitle'] = $titleVal;
		$allExpect['added_lines'] = $linesVal;
		yield 'all vars computed' => [ clone $vars, true, true, $allExpect ];

		$titleOnlyComputed = array_merge( $nonLazyExpect, [ 'page_prefixedtitle' => $titleVal ] );
		yield 'Only a specific var computed' => [
			clone $vars,
			[ 'page_prefixedtitle' ],
			true,
			$titleOnlyComputed
		];
	}

	/**
	 * @covers ::computeDBVars
	 */
	public function testComputeDBVars() {
		$nonDBMet = [ 'unknown', 'certainly-not-db' ];
		$dbMet = [ 'page-age', 'user-age', 'load-recent-authors' ];
		$methods = array_merge( $nonDBMet, $dbMet );
		$objs = [];
		foreach ( $methods as $method ) {
			$cur = new LazyLoadedVariable( $method, [] );
			$objs[$method] = $cur;
		}

		$vars = VariableHolder::newFromArray( $objs );
		$computer = $this->createMock( LazyVariableComputer::class );
		$computer->method( 'compute' )->willReturnCallback(
			static function ( LazyLoadedVariable $var ) {
				return new AFPData( AFPData::DSTRING, $var->getMethod() );
			}
		);
		$varManager = $this->getManager( $computer );
		$varManager->computeDBVars( $vars );

		$expAFCV = array_intersect_key( $vars->getVars(), array_fill_keys( $nonDBMet, 1 ) );
		$this->assertContainsOnlyInstancesOf(
			LazyLoadedVariable::class,
			$expAFCV,
			"non-DB methods shouldn't have been computed"
		);

		$expComputed = array_intersect_key( $vars->getVars(), array_fill_keys( $dbMet, 1 ) );
		$this->assertContainsOnlyInstancesOf(
			AFPData::class,
			$expComputed,
			'DB methods should have been computed'
		);
	}

	/**
	 * @param VariablesManager $manager
	 * @param VariableHolder $holder
	 * @param string $name
	 * @param int $flags
	 * @param AFPData|string $expected String if expecting an exception
	 * @covers ::getVar
	 *
	 * @dataProvider provideGetVar
	 */
	public function testGetVar(
		VariablesManager $manager,
		VariableHolder $holder,
		string $name,
		int $flags,
		$expected
	) {
		if ( is_string( $expected ) ) {
			$this->expectException( $expected );
			$manager->getVar( $holder, $name, $flags );
		} else {
			$this->assertEquals( $expected, $manager->getVar( $holder, $name, $flags ) );
		}
	}

	/**
	 * @todo make static
	 * @return Generator|array
	 */
	public function provideGetVar() {
		$vars = new VariableHolder();

		$name = 'foo';
		$expected = new AFPData( AFPData::DSTRING, 'foobarbaz' );
		$computer = $this->createMock( LazyVariableComputer::class );
		$computer->method( 'compute' )->willReturn( $expected );
		$afcv = new LazyLoadedVariable( '', [] );
		$vars->setVar( $name, $afcv );
		yield 'set, lazy-loaded' => [ $this->getManager( $computer ), $vars, $name, 0, $expected ];

		$alreadySetName = 'first-var';
		$firstValue = new AFPData( AFPData::DSTRING, 'expected value' );
		$setVars = VariableHolder::newFromArray( [ 'first-var' => $firstValue ] );
		$computer = $this->createMock( LazyVariableComputer::class );
		$computer->method( 'compute' )->willReturnCallback(
			static function ( $var, $vars, $cb ) use ( $alreadySetName ) {
				return $cb( $alreadySetName );
			}
		);
		$name = 'second-var';
		$manager = $this->getManager( $computer );
		$lazyVar = new LazyLoadedVariable( '', [] );
		$setVars->setVar( $name, $lazyVar );
		yield 'set, lazy-loaded with callback' => [ $manager, $setVars, $name, 0, $firstValue ];

		$name = 'afpd';
		$afpd = new AFPData( AFPData::DINT, 42 );
		$vars->setVar( $name, $afpd );
		yield 'set, AFPData' => [ $this->getManager(), $vars, $name, 0, $afpd ];

		$name = 'not-set';
		$expected = new AFPData( AFPData::DUNDEFINED );
		yield 'unset, lax' => [ $this->getManager(), $vars, $name, VariablesManager::GET_LAX, $expected ];
		yield 'unset, strict' => [
			$this->getManager(),
			$vars,
			$name,
			VariablesManager::GET_STRICT,
			UnsetVariableException::class
		];
		yield 'unset, bc' => [
			$this->getManager(),
			$vars,
			$name,
			VariablesManager::GET_BC,
			new AFPData( AFPData::DNULL )
		];
	}

	/**
	 * @covers ::exportAllVars
	 */
	public function testExportAllVars() {
		$pairs = [
			'foo' => 42,
			'bar' => [ 'bar', 'baz' ],
			'var' => false,
			'boo' => null
		];
		$vars = VariableHolder::newFromArray( $pairs );
		$manager = $this->getManager();

		$this->assertSame( $pairs, $manager->exportAllVars( $vars ) );
	}

	/**
	 * @covers ::exportNonLazyVars
	 */
	public function testExportNonLazyVars() {
		$afcv = $this->createMock( LazyLoadedVariable::class );
		$pairs = [
			'lazy1' => $afcv,
			'lazy2' => $afcv,
			'native1' => 42,
			'native2' => 'foo',
			'native3' => null,
			'afpd' => new AFPData( AFPData::DSTRING, 'hey' ),
		];
		$vars = VariableHolder::newFromArray( $pairs );
		$manager = $this->getManager();

		$nonLazy = [
			'native1' => '42',
			'native2' => 'foo',
			'native3' => '',
			'afpd' => 'hey'
		];

		$this->assertSame( $nonLazy, $manager->exportNonLazyVars( $vars ) );
	}

	/**
	 * @covers ::__construct
	 */
	public function testConstruct() {
		$this->assertInstanceOf(
			VariablesManager::class,
			new VariablesManager(
				$this->createMock( KeywordsManager::class ),
				$this->createMock( LazyVariableComputer::class )
			)
		);
	}
}
VariablesBlobStoreTest.php000066600000014337151334772630011670 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Unit\Variables;

use FormatJson;
use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Extension\AbuseFilter\Parser\AFPData;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Storage\BlobAccessException;
use MediaWiki\Storage\BlobStore;
use MediaWiki\Storage\BlobStoreFactory;
use MediaWikiUnitTestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Test
 * @group AbuseFilter
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore
 * @covers ::__construct
 */
class VariablesBlobStoreTest extends MediaWikiUnitTestCase {

	private function getStore(
		BlobStoreFactory $blobStoreFactory = null,
		BlobStore $blobStore = null
	): VariablesBlobStore {
		$manager = $this->createMock( VariablesManager::class );
		$manager->method( 'dumpAllVars' )->willReturnCallback( static function ( VariableHolder $holder ) {
			$ret = [];
			foreach ( $holder->getVars() as $name => $var ) {
				if ( $var instanceof AFPData ) {
					$ret[$name] = $var->toNative();
				}
			}
			return $ret;
		} );
		$manager->method( 'translateDeprecatedVars' )->willReturnCallback( static function ( VariableHolder $holder ) {
			$depVars = TestingAccessWrapper::constant( KeywordsManager::class, 'DEPRECATED_VARS' );
			foreach ( $holder->getVars() as $name => $value ) {
				if ( array_key_exists( $name, $depVars ) ) {
					$holder->setVar( $depVars[$name], $value );
					$holder->removeVar( $name );
				}
			}
		} );
		return new VariablesBlobStore(
			$manager,
			$blobStoreFactory ?? $this->createMock( BlobStoreFactory::class ),
			$blobStore ?? $this->createMock( BlobStore::class ),
			null
		);
	}

	/**
	 * @covers ::storeVarDump
	 */
	public function testStoreVarDump() {
		$expectID = 123456;
		$blobStore = $this->createMock( BlobStore::class );
		$blobStore->expects( $this->once() )->method( 'storeBlob' )->willReturn( $expectID );
		$blobStoreFactory = $this->createMock( BlobStoreFactory::class );
		$blobStoreFactory->method( 'newBlobStore' )->willReturn( $blobStore );
		$varBlobStore = $this->getStore( $blobStoreFactory );
		$this->assertSame( $expectID, $varBlobStore->storeVarDump( new VariableHolder() ) );
	}

	/**
	 * @covers ::loadVarDump
	 */
	public function testLoadVarDump() {
		$vars = [ 'foo-variable' => 42 ];
		$blob = FormatJson::encode( $vars );
		$blobStore = $this->createMock( BlobStore::class );
		$blobStore->expects( $this->once() )->method( 'getBlob' )->willReturn( $blob );
		$varBlobStore = $this->getStore( null, $blobStore );
		$loadedVars = $varBlobStore->loadVarDump( 'foo' )->getVars();
		$this->assertArrayHasKey( 'foo-variable', $loadedVars );
		$this->assertSame( 42, $loadedVars['foo-variable']->toNative() );
	}

	/**
	 * @covers ::loadVarDump
	 */
	public function testLoadVarDump_fail() {
		$blobStore = $this->createMock( BlobStore::class );
		$blobStore->expects( $this->once() )->method( 'getBlob' )->willThrowException( new BlobAccessException );
		$varBlobStore = $this->getStore( null, $blobStore );
		$this->assertCount( 0, $varBlobStore->loadVarDump( 'foo' )->getVars() );
	}

	private function getBlobStore(): BlobStore {
		return new class implements BlobStore {
			private $blobs;

			private function getKey( string $data ) {
				return md5( $data );
			}

			public function getBlob( $blobAddress, $queryFlags = 0 ) {
				return $this->blobs[$blobAddress];
			}

			public function getBlobBatch( $blobAddresses, $queryFlags = 0 ) {
			}

			public function storeBlob( $data, $hints = [] ) {
				$key = $this->getKey( $data );
				$this->blobs[$key] = $data;
				return $key;
			}

			public function isReadOnly() {
			}
		};
	}

	/**
	 * @covers ::storeVarDump
	 * @covers ::loadVarDump
	 * @dataProvider provideVariables
	 */
	public function testRoundTrip( array $toStore, array $expected = null ) {
		$blobStore = $this->getBlobStore();
		$blobStoreFactory = $this->createMock( BlobStoreFactory::class );
		$blobStoreFactory->method( 'newBlobStore' )->willReturn( $blobStore );
		$varBlobStore = $this->getStore( $blobStoreFactory, $blobStore );

		$storeID = $varBlobStore->storeVarDump( VariableHolder::newFromArray( $toStore ) );
		$this->assertIsString( $storeID );
		$loadedVars = $varBlobStore->loadVarDump( $storeID )->getVars();
		$nativeLoadedVars = array_map( static function ( AFPData $el ) {
			return $el->toNative();
		}, $loadedVars );
		$this->assertSame( $expected ?? $toStore, $nativeLoadedVars );
	}

	/**
	 * Data provider for testVarDump
	 *
	 * @return array
	 */
	public static function provideVariables() {
		return [
			'Only basic variables' => [
				[
					'action' => 'edit',
					'old_wikitext' => 'Old text',
					'new_wikitext' => 'New text'
				]
			],
			'Normal case' => [
				[
					'action' => 'edit',
					'old_wikitext' => 'Old text',
					'new_wikitext' => 'New text',
					'user_editcount' => 15,
					'added_lines' => [ 'Foo', '', 'Bar' ]
				]
			],
			'Deprecated variables' => [
				[
					'action' => 'edit',
					'old_wikitext' => 'Old text',
					'new_wikitext' => 'New text',
					'article_articleid' => 11745,
					'article_first_contributor' => 'Good guy'
				],
				[
					'action' => 'edit',
					'old_wikitext' => 'Old text',
					'new_wikitext' => 'New text',
					'page_id' => 11745,
					'page_first_contributor' => 'Good guy'
				]
			],
			'Move action' => [
				[
					'action' => 'move',
					'old_wikitext' => 'Old text',
					'new_wikitext' => 'New text',
					'all_links' => [ 'https://en.wikipedia.org' ],
					'moved_to_id' => 156,
					'moved_to_prefixedtitle' => 'MediaWiki:Foobar.js',
					'new_content_model' => CONTENT_MODEL_JAVASCRIPT
				]
			],
			'Delete action' => [
				[
					'old_wikitext' => 'Old text',
					'new_wikitext' => 'New text',
					'timestamp' => 1546000295,
					'action' => 'delete',
					'page_namespace' => 114
				]
			],
			'Disabled vars' => [
				[
					'action' => 'edit',
					'old_wikitext' => 'Old text',
					'new_wikitext' => 'New text',
					'old_html' => 'Foo <small>bar</small> <s>lol</s>.',
					'old_text' => 'Foobar'
				]
			],
			'Account creation' => [
				[
					'action' => 'createaccount',
					'accountname' => 'XXX'
				]
			]
		];
	}
}
LazyLoadedVariableTest.php000066600000001307151334772630011633 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Unit;

use MediaWiki\Extension\AbuseFilter\Variables\LazyLoadedVariable;
use MediaWikiUnitTestCase;

/**
 * @group Test
 * @group AbuseFilter
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Variables\LazyLoadedVariable
 */
class LazyLoadedVariableTest extends MediaWikiUnitTestCase {
	/**
	 * @covers ::__construct
	 * @covers ::getMethod
	 * @covers ::getParameters
	 */
	public function testGetters() {
		$method = 'magic';
		$params = [ 'foo', true, 1, null ];
		$obj = new LazyLoadedVariable( $method, $params );
		$this->assertSame( $method, $obj->getMethod(), 'method' );
		$this->assertSame( $params, $obj->getParameters(), 'params' );
	}
}
LazyVariableComputerTest.php000066600000025334151334772630012247 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Unit;

use Generator;
use Language;
use LogicException;
use MediaWiki\Block\SystemBlock;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\Parser\AFPData;
use MediaWiki\Extension\AbuseFilter\TextExtractor;
use MediaWiki\Extension\AbuseFilter\Variables\LazyLoadedVariable;
use MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Permissions\RestrictionStore;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Title\Title;
use MediaWiki\User\UserEditTracker;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserIdentityValue;
use MediaWikiUnitTestCase;
use ParserFactory;
use Psr\Log\NullLogger;
use UnexpectedValueException;
use User;
use WANObjectCache;
use Wikimedia\Rdbms\LBFactory;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer
 * @covers ::__construct
 */
class LazyVariableComputerTest extends MediaWikiUnitTestCase {

	private function getComputer(
		array $services = [],
		array $hookHandlers = [],
		string $wikiID = ''
	): LazyVariableComputer {
		return new LazyVariableComputer(
			$this->createMock( TextExtractor::class ),
			new AbuseFilterHookRunner( $this->createHookContainer( $hookHandlers ) ),
			new NullLogger(),
			$this->createMock( LBFactory::class ),
			$this->createMock( WANObjectCache::class ),
			$services['RevisionLookup'] ?? $this->createMock( RevisionLookup::class ),
			$this->createMock( RevisionStore::class ),
			$services['ContentLanguage'] ?? $this->createMock( Language::class ),
			$this->createMock( ParserFactory::class ),
			$services['UserEditTracker'] ?? $this->createMock( UserEditTracker::class ),
			$services['UserGroupManager'] ?? $this->createMock( UserGroupManager::class ),
			$services['PermissionManager'] ?? $this->createMock( PermissionManager::class ),
			$services['RestrictionStore'] ?? $this->createMock( RestrictionStore::class ),
			$wikiID
		);
	}

	private function getForbidComputeCB(): callable {
		return static function () {
			throw new LogicException( 'Not expected to be called' );
		};
	}

	/**
	 * @covers ::compute
	 */
	public function testWikiNameVar() {
		$fakeID = 'some-wiki-ID';
		$var = new LazyLoadedVariable( 'get-wiki-name', [] );
		$computer = $this->getComputer( [], [], $fakeID );
		$this->assertSame(
			$fakeID,
			$computer->compute( $var, new VariableHolder(), $this->getForbidComputeCB() )->toNative()
		);
	}

	/**
	 * @covers ::compute
	 */
	public function testWikiLanguageVar() {
		$fakeCode = 'foobar';
		$fakeLang = $this->createMock( Language::class );
		$fakeLang->method( 'getCode' )->willReturn( $fakeCode );
		$computer = $this->getComputer( [ 'ContentLanguage' => $fakeLang ] );
		$var = new LazyLoadedVariable( 'get-wiki-language', [] );
		$this->assertSame(
			$fakeCode,
			$computer->compute( $var, new VariableHolder(), $this->getForbidComputeCB() )->toNative()
		);
	}

	/**
	 * @covers ::compute
	 */
	public function testCompute_invalidName() {
		$computer = $this->getComputer();
		$this->expectException( UnexpectedValueException::class );
		$computer->compute(
			new LazyLoadedVariable( 'method-does-not-exist', [] ),
			new VariableHolder(),
			$this->getForbidComputeCB()
		);
	}

	/**
	 * @covers ::compute
	 */
	public function testInterceptVariableHook() {
		$expected = new AFPData( AFPData::DSTRING, 'foobar' );
		$handler = static function ( $method, $vars, $params, &$result ) use ( $expected ) {
			$result = $expected;
			return false;
		};
		$computer = $this->getComputer( [], [ 'AbuseFilter-interceptVariable' => $handler ] );
		$actual = $computer->compute(
			new LazyLoadedVariable( 'get-wiki-name', [] ),
			new VariableHolder(),
			$this->getForbidComputeCB()
		);
		$this->assertSame( $expected, $actual );
	}

	/**
	 * @covers ::compute
	 */
	public function testComputeVariableHook() {
		$expected = new AFPData( AFPData::DSTRING, 'foobar' );
		$handler = static function ( $method, $vars, $params, &$result ) use ( $expected ) {
			$result = $expected;
			return false;
		};
		$computer = $this->getComputer( [], [ 'AbuseFilter-computeVariable' => $handler ] );
		$actual = $computer->compute(
			new LazyLoadedVariable( 'method-does-not-exist', [] ),
			new VariableHolder(),
			$this->getForbidComputeCB()
		);
		$this->assertSame( $expected, $actual );
	}

	/**
	 * @param LazyLoadedVariable $var
	 * @param mixed $expected
	 * @param array $services
	 * @covers ::compute
	 * @dataProvider provideUserRelatedVars
	 */
	public function testUserRelatedVars(
		LazyLoadedVariable $var,
		$expected,
		array $services = []
	) {
		$computer = $this->getComputer( $services );
		$this->assertSame(
			$expected,
			$computer->compute( $var, new VariableHolder(), $this->getForbidComputeCB() )->toNative()
		);
	}

	public function provideUserRelatedVars(): Generator {
		$user = $this->createMock( User::class );
		$getUserVar = static function ( $user, $method ): LazyLoadedVariable {
			return new LazyLoadedVariable(
				$method,
				[ 'user' => $user, 'user-identity' => $user ]
			);
		};

		$editCount = 7;

		$userEditTracker = $this->createMock( UserEditTracker::class );

		$userEditTracker->method( 'getUserEditCount' )->with( $user )->willReturn( $editCount );
		$var = $getUserVar( $user, 'user-editcount' );
		yield 'user_editcount' => [ $var, $editCount, [ 'UserEditTracker' => $userEditTracker ] ];

		$emailConfirm = '20000101000000';
		$user->method( 'getEmailAuthenticationTimestamp' )->willReturn( $emailConfirm );
		$var = $getUserVar( $user, 'user-emailconfirm' );
		yield 'user_emailconfirm' => [ $var, $emailConfirm ];

		$groups = [ '*', 'group1', 'group2' ];
		$userGroupManager = $this->createMock( UserGroupManager::class );
		$userGroupManager->method( 'getUserEffectiveGroups' )->with( $user )->willReturn( $groups );
		$var = $getUserVar( $user, 'user-groups' );
		yield 'user_groups' => [ $var, $groups, [ 'UserGroupManager' => $userGroupManager ] ];

		$rights = [ 'abusefilter-foo', 'abusefilter-bar' ];
		$permissionManager = $this->createMock( PermissionManager::class );
		$permissionManager->method( 'getUserPermissions' )->with( $user )->willReturn( $rights );
		$var = $getUserVar( $user, 'user-rights' );
		yield 'user_rights' => [ $var, $rights, [ 'PermissionManager' => $permissionManager ] ];

		$block = new SystemBlock( [] );
		$user->method( 'getBlock' )->willReturn( $block );
		$var = $getUserVar( $user, 'user-block' );
		yield 'user_blocked' => [ $var, (bool)$block ];

		$fakeTime = 1514700000;

		$anonUser = $this->createMock( User::class );
		$anonymousAge = 0;
		$var = new LazyLoadedVariable(
			'user-age',
			[ 'user' => $anonUser, 'asof' => $fakeTime ]
		);
		yield 'user_age, anonymous' => [ $var, $anonymousAge ];

		$user->method( 'isRegistered' )->willReturn( true );

		$missingRegistrationUser = clone $user;
		$var = new LazyLoadedVariable(
			'user-age',
			[ 'user' => $missingRegistrationUser, 'asof' => $fakeTime ]
		);
		$expected = (int)wfTimestamp( TS_UNIX, $fakeTime ) - (int)wfTimestamp( TS_UNIX, '20080115000000' );
		yield 'user_age, registered but not available' => [ $var, $expected ];

		$age = 163;
		$user->method( 'getRegistration' )->willReturn( $fakeTime - $age );
		$var = new LazyLoadedVariable(
			'user-age',
			[ 'user' => $user, 'asof' => $fakeTime ]
		);
		yield 'user_age, registered' => [ $var, $age ];
	}

	/**
	 * @param LazyLoadedVariable $var
	 * @param mixed $expected
	 * @param array $services
	 * @covers ::compute
	 * @dataProvider provideTitleRelatedVars
	 */
	public function testTitleRelatedVars(
		LazyLoadedVariable $var,
		$expected,
		array $services = []
	) {
		$computer = $this->getComputer( $services );
		$this->assertSame(
			$expected,
			$computer->compute( $var, new VariableHolder(), $this->getForbidComputeCB() )->toNative()
		);
	}

	public function provideTitleRelatedVars(): Generator {
		$restrictions = [ 'create', 'edit', 'move', 'upload' ];
		foreach ( $restrictions as $restriction ) {
			$appliedRestrictions = [ 'sysop' ];
			$restrictedTitle = $this->createMock( Title::class );
			$restrictionStore = $this->createMock( RestrictionStore::class );
			$restrictionStore->expects( $this->once() )
				->method( 'getRestrictions' )
				->with( $restrictedTitle, $restriction )
				->willReturn( $appliedRestrictions );
			$var = new LazyLoadedVariable(
				'get-page-restrictions',
				[ 'title' => $restrictedTitle, 'action' => $restriction ]
			);
			yield "*_restrictions_{$restriction}, restricted" => [
				$var, $appliedRestrictions, [ 'RestrictionStore' => $restrictionStore ]
			];

			$unrestrictedTitle = $this->createMock( Title::class );
			$restrictionStore = $this->createMock( RestrictionStore::class );
			$restrictionStore->expects( $this->once() )
				->method( 'getRestrictions' )
				->with( $unrestrictedTitle, $restriction )
				->willReturn( [] );
			$var = new LazyLoadedVariable(
				'get-page-restrictions',
				[ 'title' => $unrestrictedTitle, 'action' => $restriction ]
			);
			yield "*_restrictions_{$restriction}, unrestricted" => [
				$var, [], [ 'RestrictionStore' => $restrictionStore ]
			];
		}

		$fakeTime = 1514700000;

		$age = 163;
		$title = $this->createMock( Title::class );
		$revision = $this->createMock( RevisionRecord::class );
		$revision->method( 'getTimestamp' )->willReturn( $fakeTime - $age );
		$revLookup = $this->createMock( RevisionLookup::class );
		$revLookup->method( 'getFirstRevision' )->with( $title )->willReturn( $revision );
		$var = new LazyLoadedVariable(
			'page-age',
			[ 'title' => $title, 'asof' => $fakeTime ]
		);
		yield "*_age" => [ $var, $age, [ 'RevisionLookup' => $revLookup ] ];

		$title = $this->createMock( Title::class );
		$firstRev = $this->createMock( RevisionRecord::class );
		$firstUserName = 'First author';
		$firstUser = new UserIdentityValue( 1, $firstUserName );
		$firstRev->expects( $this->once() )->method( 'getUser' )->willReturn( $firstUser );
		$revLookup = $this->createMock( RevisionLookup::class );
		$revLookup->method( 'getFirstRevision' )->with( $title )->willReturn( $firstRev );
		$var = new LazyLoadedVariable(
			'load-first-author',
			[ 'title' => $title ]
		);
		yield '*_first_contributor, with rev' => [
			$var, $firstUserName, [ 'RevisionLookup' => $revLookup ]
		];

		$title = $this->createMock( Title::class );
		$revLookup = $this->createMock( RevisionLookup::class );
		$revLookup->method( 'getFirstRevision' )->with( $title )->willReturn( null );
		$var = new LazyLoadedVariable(
			'load-first-author',
			[ 'title' => $title ]
		);
		yield '*_first_contributor, no rev' => [ $var, '', [ 'RevisionLookup' => $revLookup ] ];

		// TODO _recent_contributors is tested in LazyVariableComputerDBTest
	}
}
VariableHolderTest.php000066600000013566151334772630011032 0ustar00<?php
/**
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 *
 * @license GPL-2.0-or-later
 */

namespace MediaWiki\Extension\AbuseFilter\Tests\Unit;

use MediaWiki\Extension\AbuseFilter\Parser\AFPData;
use MediaWiki\Extension\AbuseFilter\Variables\LazyLoadedVariable;
use MediaWiki\Extension\AbuseFilter\Variables\UnsetVariableException;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWikiUnitTestCase;

/**
 * @group Test
 * @group AbuseFilter
 * @group AbuseFilterParser
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Variables\VariableHolder
 */
class VariableHolderTest extends MediaWikiUnitTestCase {
	/**
	 * @covers ::newFromArray
	 */
	public function testNewFromArray() {
		$vars = [
			'foo' => 12,
			'bar' => [ 'x', 'y' ],
			'baz' => false
		];
		$actual = VariableHolder::newFromArray( $vars );
		$expected = new VariableHolder();
		foreach ( $vars as $var => $value ) {
			$expected->setVar( $var, $value );
		}

		$this->assertEquals( $expected, $actual );
	}

	/**
	 * @covers ::setVar
	 */
	public function testVarsAreLowercased() {
		$vars = new VariableHolder();
		$this->assertCount( 0, $vars->getVars(), 'precondition' );
		$vars->setVar( 'FOO', 42 );
		$this->assertCount( 1, $vars->getVars(), 'variable should be set' );
		$this->assertArrayHasKey( 'foo', $vars->getVars(), 'var should be lowercase' );
	}

	/**
	 * @param string $name
	 * @param mixed $val
	 * @param mixed $expected
	 *
	 * @dataProvider provideSetVar
	 *
	 * @covers ::setVar
	 */
	public function testSetVar( string $name, $val, $expected ) {
		$vars = new VariableHolder();
		$vars->setVar( $name, $val );
		$this->assertEquals( $expected, $vars->getVars()[$name] );
	}

	public static function provideSetVar() {
		yield 'native' => [ 'foo', 12, new AFPData( AFPData::DINT, 12 ) ];

		$afpdata = new AFPData( AFPData::DSTRING, 'foobar' );
		yield 'AFPData' => [ 'foo', $afpdata, $afpdata ];

		$lazyloadVar = new LazyLoadedVariable( 'foo', [] );
		yield 'lazy-loaded' => [ 'foo', $lazyloadVar, $lazyloadVar ];
	}

	/**
	 * @covers ::getVars
	 */
	public function testGetVars() {
		$vars = new VariableHolder();
		$this->assertSame( [], $vars->getVars(), 'precondition' );

		$vars->setVar( 'foo', [ true ] );
		$vars->setVar( 'bar', 'bar' );
		$exp = [
			'foo' => new AFPData( AFPData::DARRAY, [ new AFPData( AFPData::DBOOL, true ) ] ),
			'bar' => new AFPData( AFPData::DSTRING, 'bar' )
		];

		$this->assertEquals( $exp, $vars->getVars() );
	}

	/**
	 * @param VariableHolder $vars
	 * @param string $name
	 * @param AFPData|LazyLoadedVariable $expected
	 * @covers ::getVarThrow
	 *
	 * @dataProvider provideGetVarThrow
	 */
	public function testGetVarThrow( VariableHolder $vars, string $name, $expected ) {
		$this->assertEquals( $expected, $vars->getVarThrow( $name ) );
	}

	public static function provideGetVarThrow() {
		$vars = new VariableHolder();

		$name = 'foo';
		$afcv = new LazyLoadedVariable( 'method', [ 'param' ] );
		$vars->setVar( $name, $afcv );
		yield 'set, lazy-loaded' => [ $vars, $name, $afcv ];

		$name = 'afpd';
		$afpd = new AFPData( AFPData::DINT, 42 );
		$vars->setVar( $name, $afpd );
		yield 'set, AFPData' => [ $vars, $name, $afpd ];
	}

	/**
	 * @covers ::getVarThrow
	 */
	public function testGetVarThrow_unset() {
		$vars = new VariableHolder();
		$this->expectException( UnsetVariableException::class );
		$vars->getVarThrow( 'unset-variable' );
	}

	/**
	 * @param array $expected
	 * @param VariableHolder ...$holders
	 * @dataProvider provideHoldersForAddition
	 *
	 * @covers ::addHolders
	 */
	public function testAddHolders( array $expected, VariableHolder ...$holders ) {
		$actual = new VariableHolder();
		$actual->addHolders( ...$holders );

		$this->assertEquals( $expected, $actual->getVars() );
	}

	public static function provideHoldersForAddition() {
		$v1 = VariableHolder::newFromArray( [ 'a' => 1, 'b' => 2 ] );
		$v2 = VariableHolder::newFromArray( [ 'b' => 3, 'c' => 4 ] );
		$v3 = VariableHolder::newFromArray( [ 'c' => 5, 'd' => 6 ] );

		$expected = [
			'a' => new AFPData( AFPData::DINT, 1 ),
			'b' => new AFPData( AFPData::DINT, 3 ),
			'c' => new AFPData( AFPData::DINT, 5 ),
			'd' => new AFPData( AFPData::DINT, 6 )
		];

		return [ [ $expected, $v1, $v2, $v3 ] ];
	}

	/**
	 * @covers ::varIsSet
	 */
	public function testVarIsSet() {
		$vars = new VariableHolder();
		$vars->setVar( 'foo', null );
		$this->assertTrue( $vars->varIsSet( 'foo' ), 'Set variable should be set' );
		$this->assertFalse( $vars->varIsSet( 'foobarbaz' ), 'Unset variable should be unset' );
	}

	/**
	 * @covers ::setLazyLoadVar
	 */
	public function testLazyLoader() {
		$var = 'foobar';
		$method = 'compute-foo';
		$params = [ 'baz', 1 ];
		$exp = new LazyLoadedVariable( $method, $params );

		$vars = new VariableHolder();
		$vars->setLazyLoadVar( $var, $method, $params );
		$this->assertEquals( $exp, $vars->getVars()[$var] );
	}

	/**
	 * @covers ::removeVar
	 */
	public function testRemoveVar() {
		$vars = new VariableHolder();
		$varName = 'foo';
		$vars->setVar( $varName, 'foobar' );
		$this->assertInstanceOf( AFPData::class, $vars->getVarThrow( $varName ) );
		$vars->removeVar( $varName );
		$this->expectException( UnsetVariableException::class );
		$vars->getVarThrow( $varName );
	}
}
VariablesFormatterTest.php000066600000010742151334772630011734 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Tests\Unit;

use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesFormatter;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWikiUnitTestCase;
use MessageLocalizer;
use Wikimedia\TestingAccessWrapper;

/**
 * @coversDefaultClass \MediaWiki\Extension\AbuseFilter\Variables\VariablesFormatter
 */
class VariablesFormatterTest extends MediaWikiUnitTestCase {
	/**
	 * @covers ::__construct
	 */
	public function testConstruct() {
		$this->assertInstanceOf(
			VariablesFormatter::class,
			new VariablesFormatter(
				$this->createMock( KeywordsManager::class ),
				$this->createMock( VariablesManager::class ),
				$this->createMock( MessageLocalizer::class )
			)
		);
	}

	/**
	 * @covers ::setMessageLocalizer
	 */
	public function testSetMessageLocalizer() {
		$formatter = new VariablesFormatter(
			$this->createMock( KeywordsManager::class ),
			$this->createMock( VariablesManager::class ),
			$this->createMock( MessageLocalizer::class )
		);
		$ml = $this->createMock( MessageLocalizer::class );
		$formatter->setMessageLocalizer( $ml );
		/** @var VariablesFormatter $wrapper */
		$wrapper = TestingAccessWrapper::newFromObject( $formatter );
		$this->assertSame( $ml, $wrapper->messageLocalizer );
	}

	/**
	 * @param mixed $var
	 * @param string $expected
	 * @covers ::formatVar
	 * @dataProvider provideFormatVar
	 */
	public function testFormatVar( $var, string $expected ) {
		$this->assertSame( $expected, VariablesFormatter::formatVar( $var ) );
	}

	/**
	 * Provider for testFormatVar
	 * @return array
	 */
	public static function provideFormatVar() {
		return [
			'boolean' => [ true, 'true' ],
			'single-quote string' => [ 'foo', "'foo'" ],
			'string with quotes' => [ "ba'r'", "'ba'r''" ],
			'integer' => [ 42, '42' ],
			'float' => [ 0.1, '0.1' ],
			'null' => [ null, 'null' ],
			'simple list' => [ [ true, 1, 'foo' ], "[\n\t0 => true,\n\t1 => 1,\n\t2 => 'foo'\n]" ],
			'assoc array' => [ [ 'foo' => 1, 'bar' => 'bar' ], "[\n\t'foo' => 1,\n\t'bar' => 'bar'\n]" ],
			'nested array' => [
				[ 'a1' => 1, [ 'a2' => 2, [ 'a3' => 3, [ 'a4' => 4 ] ] ] ],
				"[\n\t'a1' => 1,\n\t0 => [\n\t\t'a2' => 2,\n\t\t0 => [\n\t\t\t'a3' => 3,\n\t\t\t0 => " .
				"[\n\t\t\t\t'a4' => 4\n\t\t\t]\n\t\t]\n\t]\n]"
			],
			'empty array' => [ [], '[]' ],
			'mixed array' => [
				[ 3 => true, 'foo' => false, 1, [ 1, 'foo' => 42 ] ],
				"[\n\t3 => true,\n\t'foo' => false,\n\t4 => 1,\n\t5 => [\n\t\t0 => 1,\n\t\t'foo' => 42\n\t]\n]"
			]
		];
	}

	/**
	 * @covers ::buildVarDumpTable
	 */
	public function testBuildVarDumpTable_empty() {
		$ml = $this->createMock( MessageLocalizer::class );
		$ml->method( 'msg' )->willReturnCallback( function ( $key ) {
			return $this->getMockMessage( $key );
		} );
		$formatter = new VariablesFormatter(
			$this->createMock( KeywordsManager::class ),
			$this->createMock( VariablesManager::class ),
			$ml
		);

		$actual = $formatter->buildVarDumpTable( new VariableHolder() );
		$this->assertStringContainsString( 'abusefilter-log-details-var', $actual, 'header' );
		$this->assertStringNotContainsString( 'mw-abuselog-var-value', $actual, 'no values' );
	}

	/**
	 * @covers ::buildVarDumpTable
	 */
	public function testBuildVarDumpTable() {
		$ml = $this->createMock( MessageLocalizer::class );
		$ml->method( 'msg' )->willReturnCallback( function ( $key ) {
			return $this->getMockMessage( $key );
		} );
		$kManager = $this->createMock( KeywordsManager::class );
		$varMessage = 'Dummy variable message';
		$varArray = [ 'foo' => true, 'bar' => 'foobar' ];
		$kManager->expects( $this->atLeastOnce() )
			->method( 'getMessageKeyForVar' )
			->willReturnCallback( static function ( $var ) use ( $varMessage ) {
				return $var === 'foo' ? $varMessage : null;
			} );
		$holder = VariableHolder::newFromArray( $varArray );
		$varManager = $this->createMock( VariablesManager::class );
		$varManager->method( 'exportAllVars' )->willReturn( $varArray );
		$formatter = new VariablesFormatter( $kManager, $varManager, $ml );

		$actual = $formatter->buildVarDumpTable( $holder );
		$this->assertStringContainsString( 'abusefilter-log-details-var', $actual, 'header' );
		$this->assertStringContainsString( 'mw-abuselog-var-value', $actual, 'values' );
		$this->assertStringContainsString( '<code', $actual, 'formatted var name' );
		$this->assertSame( 1, substr_count( $actual, $varMessage ), 'only one var with message' );
	}
}
LazyLoadedVariable.php000066600000001232151335016240010755 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

class LazyLoadedVariable {
	/**
	 * @var string The method used to compute the variable
	 */
	private $method;
	/**
	 * @var array Parameters to be used with the specified method
	 */
	private $parameters;

	/**
	 * @param string $method
	 * @param array $parameters
	 */
	public function __construct( string $method, array $parameters ) {
		$this->method = $method;
		$this->parameters = $parameters;
	}

	/**
	 * @return string
	 */
	public function getMethod(): string {
		return $this->method;
	}

	/**
	 * @return array
	 */
	public function getParameters(): array {
		return $this->parameters;
	}
}
UnsetVariableException.php000066600000000500151335016240011677 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use RuntimeException;

/**
 * @codeCoverageIgnore
 */
class UnsetVariableException extends RuntimeException {
	/**
	 * @param string $varName
	 */
	public function __construct( string $varName ) {
		parent::__construct( "Variable $varName is not set" );
	}
}
VariablesFormatter.php000066600000007275151335016240011070 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use Html;
use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MessageLocalizer;
use Xml;

/**
 * Pretty-prints the content of a VariableHolder for use e.g. in AbuseLog hit details
 */
class VariablesFormatter {
	public const SERVICE_NAME = 'AbuseFilterVariablesFormatter';

	/** @var KeywordsManager */
	private $keywordsManager;
	/** @var VariablesManager */
	private $varManager;
	/** @var MessageLocalizer */
	private $messageLocalizer;

	/**
	 * @param KeywordsManager $keywordsManager
	 * @param VariablesManager $variablesManager
	 * @param MessageLocalizer $messageLocalizer
	 */
	public function __construct(
		KeywordsManager $keywordsManager,
		VariablesManager $variablesManager,
		MessageLocalizer $messageLocalizer
	) {
		$this->keywordsManager = $keywordsManager;
		$this->varManager = $variablesManager;
		$this->messageLocalizer = $messageLocalizer;
	}

	/**
	 * @param MessageLocalizer $messageLocalizer
	 */
	public function setMessageLocalizer( MessageLocalizer $messageLocalizer ): void {
		$this->messageLocalizer = $messageLocalizer;
	}

	/**
	 * @param VariableHolder $varHolder
	 * @return string
	 */
	public function buildVarDumpTable( VariableHolder $varHolder ): string {
		$vars = $this->varManager->exportAllVars( $varHolder );

		$output = '';

		$output .=
			Xml::openElement( 'table', [ 'class' => 'mw-abuselog-details' ] ) .
			Xml::openElement( 'tbody' ) .
			"\n";

		$header =
			Xml::element( 'th', null, $this->messageLocalizer->msg( 'abusefilter-log-details-var' )->text() ) .
			Xml::element( 'th', null, $this->messageLocalizer->msg( 'abusefilter-log-details-val' )->text() );
		$output .= Xml::tags( 'tr', null, $header ) . "\n";

		if ( !count( $vars ) ) {
			$output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' );

			return $output;
		}

		// Now, build the body of the table.
		foreach ( $vars as $key => $value ) {
			$key = strtolower( $key );

			$varMsgKey = $this->keywordsManager->getMessageKeyForVar( $key );
			if ( $varMsgKey ) {
				$keyDisplay = $this->messageLocalizer->msg( $varMsgKey )->parse() . ' ' .
					Html::element(
						'code',
						[],
						// @phan-suppress-next-line SecurityCheck-XSS Keys are safe
						$this->messageLocalizer->msg( 'parentheses' )->rawParams( $key )->text()
					);
			} else {
				$keyDisplay = Html::element( 'code', [], $key );
			}

			$value = Html::element(
				'div',
				[ 'class' => 'mw-abuselog-var-value' ],
				self::formatVar( $value )
			);

			$trow =
				Xml::tags( 'td', [ 'class' => 'mw-abuselog-var' ], $keyDisplay ) .
				Xml::tags( 'td', [ 'class' => 'mw-abuselog-var-value' ], $value );
			$output .=
				Xml::tags( 'tr',
					[ 'class' => "mw-abuselog-details-$key mw-abuselog-value" ], $trow
				) . "\n";
		}

		$output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' );

		return $output;
	}

	/**
	 * @param mixed $var
	 * @param string $indent
	 * @return string
	 */
	public static function formatVar( $var, string $indent = '' ): string {
		if ( $var === [] ) {
			return '[]';
		} elseif ( is_array( $var ) ) {
			$ret = '[';
			$indent .= "\t";
			foreach ( $var as $key => $val ) {
				$ret .= "\n$indent" . self::formatVar( $key, $indent ) .
					' => ' . self::formatVar( $val, $indent ) . ',';
			}
			// Strip trailing commas
			return substr( $ret, 0, -1 ) . "\n" . substr( $indent, 0, -1 ) . ']';
		} elseif ( is_string( $var ) ) {
			// Don't escape the string (specifically backslashes) to avoid displaying wrong stuff
			return "'$var'";
		} elseif ( $var === null ) {
			return 'null';
		} elseif ( is_float( $var ) ) {
			// Don't let float precision produce weirdness
			return (string)$var;
		}
		return var_export( $var, true );
	}
}
VariablesManager.php000066600000015535151335016240010475 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use LogicException;
use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Extension\AbuseFilter\Parser\AFPData;

/**
 * Service that allows manipulating a VariableHolder
 */
class VariablesManager {
	public const SERVICE_NAME = 'AbuseFilterVariablesManager';
	/**
	 * Used in self::getVar() to determine what to do if the requested variable is missing. See
	 * the docs of that method for an explanation.
	 */
	public const GET_LAX = 0;
	public const GET_STRICT = 1;
	public const GET_BC = 2;

	/** @var KeywordsManager */
	private $keywordsManager;
	/** @var LazyVariableComputer */
	private $lazyComputer;

	/**
	 * @param KeywordsManager $keywordsManager
	 * @param LazyVariableComputer $lazyComputer
	 */
	public function __construct(
		KeywordsManager $keywordsManager,
		LazyVariableComputer $lazyComputer
	) {
		$this->keywordsManager = $keywordsManager;
		$this->lazyComputer = $lazyComputer;
	}

	/**
	 * Checks whether any deprecated variable is stored with the old name, and replaces it with
	 * the new name. This should normally only happen when a DB dump is retrieved from the DB.
	 *
	 * @param VariableHolder $holder
	 */
	public function translateDeprecatedVars( VariableHolder $holder ): void {
		$deprecatedVars = $this->keywordsManager->getDeprecatedVariables();
		foreach ( $holder->getVars() as $name => $value ) {
			if ( array_key_exists( $name, $deprecatedVars ) ) {
				$holder->setVar( $deprecatedVars[$name], $value );
				$holder->removeVar( $name );
			}
		}
	}

	/**
	 * Get a variable from the current object
	 *
	 * @param VariableHolder $holder
	 * @param string $varName The variable name
	 * @param int $mode One of the self::GET_* constants, determines how to behave when the variable is unset:
	 *  - GET_STRICT -> In the future, this will throw an exception. For now it returns a DUNDEFINED and logs a warning
	 *  - GET_LAX -> Return a DUNDEFINED AFPData
	 *  - GET_BC -> Return a DNULL AFPData (this should only be used for BC, see T230256)
	 * @return AFPData
	 */
	public function getVar(
		VariableHolder $holder,
		string $varName,
		$mode = self::GET_STRICT
	): AFPData {
		$varName = strtolower( $varName );
		if ( $holder->varIsSet( $varName ) ) {
			/** @var $variable LazyLoadedVariable|AFPData */
			$variable = $holder->getVarThrow( $varName );
			if ( $variable instanceof LazyLoadedVariable ) {
				$getVarCB = function ( string $varName ) use ( $holder ): AFPData {
					return $this->getVar( $holder, $varName );
				};
				$value = $this->lazyComputer->compute( $variable, $holder, $getVarCB );
				$holder->setVar( $varName, $value );
				return $value;
			} elseif ( $variable instanceof AFPData ) {
				return $variable;
			} else {
				// @codeCoverageIgnoreStart
				throw new \UnexpectedValueException(
					"Variable $varName has unexpected type " . gettype( $variable )
				);
				// @codeCoverageIgnoreEnd
			}
		}

		// The variable is not set.
		switch ( $mode ) {
			case self::GET_STRICT:
				throw new UnsetVariableException( $varName );
			case self::GET_LAX:
				return new AFPData( AFPData::DUNDEFINED );
			case self::GET_BC:
				// Old behaviour, which can sometimes lead to unexpected results (e.g.
				// `edit_delta < -5000` will match any non-edit action).
				return new AFPData( AFPData::DNULL );
			default:
				// @codeCoverageIgnoreStart
				throw new LogicException( "Mode '$mode' not recognized." );
				// @codeCoverageIgnoreEnd
		}
	}

	/**
	 * Dump all variables stored in the holder in their native types.
	 * If you want a not yet set variable to be included in the results you can
	 * either set $compute to an array with the name of the variable or set
	 * $compute to true to compute all not yet set variables.
	 *
	 * @param VariableHolder $holder
	 * @param array|bool $compute Variables we should compute if not yet set
	 * @param bool $includeUserVars Include user set variables
	 * @return array
	 */
	public function dumpAllVars(
		VariableHolder $holder,
		$compute = [],
		bool $includeUserVars = false
	): array {
		$coreVariables = [];

		if ( !$includeUserVars ) {
			// Compile a list of all variables set by the extension to be able
			// to filter user set ones by name
			$activeVariables = array_keys( $this->keywordsManager->getVarsMappings() );
			$deprecatedVariables = array_keys( $this->keywordsManager->getDeprecatedVariables() );
			$disabledVariables = array_keys( $this->keywordsManager->getDisabledVariables() );
			$coreVariables = array_merge( $activeVariables, $deprecatedVariables, $disabledVariables );
			$coreVariables = array_map( 'strtolower', $coreVariables );
		}

		$exported = [];
		foreach ( array_keys( $holder->getVars() ) as $varName ) {
			$computeThis = ( is_array( $compute ) && in_array( $varName, $compute ) ) || $compute === true;
			if (
				( $includeUserVars || in_array( strtolower( $varName ), $coreVariables ) ) &&
				// Only include variables set in the extension in case $includeUserVars is false
				( $computeThis || $holder->getVarThrow( $varName ) instanceof AFPData )
			) {
				$exported[$varName] = $this->getVar( $holder, $varName )->toNative();
			}
		}

		return $exported;
	}

	/**
	 * Compute all vars which need DB access. Useful for vars which are going to be saved
	 * cross-wiki or used for offline analysis.
	 *
	 * @param VariableHolder $holder
	 */
	public function computeDBVars( VariableHolder $holder ): void {
		static $dbTypes = [
			'links-from-database',
			'links-from-update',
			'links-from-wikitext-or-database',
			'load-recent-authors',
			'page-age',
			'get-page-restrictions',
			'user-editcount',
			'user-emailconfirm',
			'user-groups',
			'user-rights',
			'user-age',
			'user-block',
			'revision-text-by-id',
			'content-model-by-id',
		];

		/** @var LazyLoadedVariable[] $missingVars */
		$missingVars = array_filter( $holder->getVars(), static function ( $el ) {
			return ( $el instanceof LazyLoadedVariable );
		} );
		foreach ( $missingVars as $name => $var ) {
			if ( in_array( $var->getMethod(), $dbTypes ) ) {
				$holder->setVar( $name, $this->getVar( $holder, $name ) );
			}
		}
	}

	/**
	 * Export all variables stored in this object with their native (PHP) types.
	 *
	 * @param VariableHolder $holder
	 * @return array
	 */
	public function exportAllVars( VariableHolder $holder ): array {
		$exported = [];
		foreach ( array_keys( $holder->getVars() ) as $varName ) {
			$exported[ $varName ] = $this->getVar( $holder, $varName )->toNative();
		}

		return $exported;
	}

	/**
	 * Export all non-lazy variables stored in this object as string
	 *
	 * @param VariableHolder $holder
	 * @return string[]
	 */
	public function exportNonLazyVars( VariableHolder $holder ): array {
		$exported = [];
		foreach ( $holder->getVars() as $varName => $data ) {
			if ( !( $data instanceof LazyLoadedVariable ) ) {
				$exported[$varName] = $holder->getComputedVariable( $varName )->toString();
			}
		}

		return $exported;
	}
}
VariablesBlobStore.php000066600000004513151335016240011010 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use FormatJson;
use MediaWiki\Storage\BlobAccessException;
use MediaWiki\Storage\BlobStore;
use MediaWiki\Storage\BlobStoreFactory;

/**
 * This service is used to store and load var dumps to a BlobStore
 */
class VariablesBlobStore {
	public const SERVICE_NAME = 'AbuseFilterVariablesBlobStore';

	/** @var VariablesManager */
	private $varManager;

	/** @var BlobStoreFactory */
	private $blobStoreFactory;

	/** @var BlobStore */
	private $blobStore;

	/** @var string|null */
	private $centralDB;

	/**
	 * @param VariablesManager $varManager
	 * @param BlobStoreFactory $blobStoreFactory
	 * @param BlobStore $blobStore
	 * @param string|null $centralDB
	 */
	public function __construct(
		VariablesManager $varManager,
		BlobStoreFactory $blobStoreFactory,
		BlobStore $blobStore,
		?string $centralDB
	) {
		$this->varManager = $varManager;
		$this->blobStoreFactory = $blobStoreFactory;
		$this->blobStore = $blobStore;
		$this->centralDB = $centralDB;
	}

	/**
	 * Store a var dump to a BlobStore.
	 *
	 * @param VariableHolder $varsHolder
	 * @param bool $global
	 *
	 * @return string Address of the record
	 */
	public function storeVarDump( VariableHolder $varsHolder, $global = false ) {
		// Get all variables yet set and compute old and new wikitext if not yet done
		// as those are needed for the diff view on top of the abuse log pages
		$vars = $this->varManager->dumpAllVars( $varsHolder, [ 'old_wikitext', 'new_wikitext' ] );

		// Vars is an array with native PHP data types (non-objects) now
		$text = FormatJson::encode( $vars );

		$dbDomain = $global ? $this->centralDB : false;
		$blobStore = $this->blobStoreFactory->newBlobStore( $dbDomain );

		$hints = [
			BlobStore::DESIGNATION_HINT => 'AbuseFilter',
			BlobStore::MODEL_HINT => 'AbuseFilter',
		];
		return $blobStore->storeBlob( $text, $hints );
	}

	/**
	 * Retrieve a var dump from a BlobStore.
	 *
	 * @param string $address
	 *
	 * @return VariableHolder
	 */
	public function loadVarDump( string $address ): VariableHolder {
		try {
			$blob = $this->blobStore->getBlob( $address );
		} catch ( BlobAccessException $ex ) {
			return new VariableHolder;
		}

		$vars = FormatJson::decode( $blob, true );
		$obj = VariableHolder::newFromArray( $vars );
		$this->varManager->translateDeprecatedVars( $obj );
		return $obj;
	}
}
VariableHolder.php000066600000005557151335016240010160 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use MediaWiki\Extension\AbuseFilter\Parser\AFPData;

/**
 * Mutable value object that holds a list of variables
 */
class VariableHolder {
	/**
	 * @var (AFPData|LazyLoadedVariable)[]
	 */
	private $mVars = [];

	/**
	 * Utility function to translate an array with shape [ varname => value ] into a self instance
	 *
	 * @param array $vars
	 * @return VariableHolder
	 */
	public static function newFromArray( array $vars ): VariableHolder {
		$ret = new self();
		foreach ( $vars as $var => $value ) {
			$ret->setVar( $var, $value );
		}
		return $ret;
	}

	/**
	 * @param string $variable
	 * @param mixed $datum
	 */
	public function setVar( string $variable, $datum ): void {
		$variable = strtolower( $variable );
		if ( !( $datum instanceof AFPData || $datum instanceof LazyLoadedVariable ) ) {
			$datum = AFPData::newFromPHPVar( $datum );
		}

		$this->mVars[$variable] = $datum;
	}

	/**
	 * Get all variables stored in this object
	 *
	 * @return (AFPData|LazyLoadedVariable)[]
	 */
	public function getVars(): array {
		return $this->mVars;
	}

	/**
	 * @param string $variable
	 * @param string $method
	 * @param array $parameters
	 */
	public function setLazyLoadVar( string $variable, string $method, array $parameters ): void {
		$placeholder = new LazyLoadedVariable( $method, $parameters );
		$this->setVar( $variable, $placeholder );
	}

	/**
	 * Get a variable from the current object, or throw if not set
	 *
	 * @param string $varName The variable name
	 * @return AFPData|LazyLoadedVariable
	 */
	public function getVarThrow( string $varName ) {
		$varName = strtolower( $varName );
		if ( !$this->varIsSet( $varName ) ) {
			throw new UnsetVariableException( $varName );
		}
		return $this->mVars[$varName];
	}

	/**
	 * A stronger version of self::getVarThrow that also asserts that the variable was computed
	 * @param string $varName
	 * @return AFPData
	 * @codeCoverageIgnore
	 */
	public function getComputedVariable( string $varName ): AFPData {
		return $this->getVarThrow( $varName );
	}

	/**
	 * Merge any number of holders given as arguments into this holder.
	 *
	 * @param VariableHolder ...$holders
	 */
	public function addHolders( VariableHolder ...$holders ): void {
		foreach ( $holders as $addHolder ) {
			$this->mVars = array_merge( $this->mVars, $addHolder->mVars );
		}
	}

	/**
	 * @param string $var
	 * @return bool
	 */
	public function varIsSet( string $var ): bool {
		return array_key_exists( $var, $this->mVars );
	}

	/**
	 * @param string $varName
	 */
	public function removeVar( string $varName ): void {
		unset( $this->mVars[$varName] );
	}
}

// @deprecated Since 1.36. Kept for BC with the UpdateVarDumps script, see T331861. The alias can be removed
// once we no longer support updating from a MW version where that script may run.
class_alias( VariableHolder::class, 'AbuseFilterVariableHolder' );
LazyVariableComputer.php000066600000042022151335016240011365 0ustar00<?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use ContentHandler;
use Language;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\Parser\AFPData;
use MediaWiki\Extension\AbuseFilter\TextExtractor;
use MediaWiki\ExternalLinks\ExternalLinksLookup;
use MediaWiki\ExternalLinks\LinkFilter;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Permissions\RestrictionStore;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\PreparedUpdate;
use MediaWiki\Title\Title;
use MediaWiki\User\UserEditTracker;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserIdentity;
use ParserFactory;
use ParserOptions;
use Psr\Log\LoggerInterface;
use stdClass;
use StringUtils;
use TextContent;
use UnexpectedValueException;
use User;
use WANObjectCache;
use Wikimedia\Diff\Diff;
use Wikimedia\Diff\UnifiedDiffFormatter;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\LBFactory;
use WikiPage;

/**
 * Service used to compute lazy-loaded variable.
 * @internal
 */
class LazyVariableComputer {
	public const SERVICE_NAME = 'AbuseFilterLazyVariableComputer';

	/**
	 * @var float The amount of time to subtract from profiling
	 * @todo This is a hack
	 */
	public static $profilingExtraTime = 0;

	/** @var TextExtractor */
	private $textExtractor;

	/** @var AbuseFilterHookRunner */
	private $hookRunner;

	/** @var LoggerInterface */
	private $logger;

	/** @var LBFactory */
	private $lbFactory;

	/** @var WANObjectCache */
	private $wanCache;

	/** @var RevisionLookup */
	private $revisionLookup;

	/** @var RevisionStore */
	private $revisionStore;

	/** @var Language */
	private $contentLanguage;

	/** @var ParserFactory */
	private $parserFactory;

	/** @var UserEditTracker */
	private $userEditTracker;

	/** @var UserGroupManager */
	private $userGroupManager;

	/** @var PermissionManager */
	private $permissionManager;

	/** @var RestrictionStore */
	private $restrictionStore;

	/** @var string */
	private $wikiID;

	/**
	 * @param TextExtractor $textExtractor
	 * @param AbuseFilterHookRunner $hookRunner
	 * @param LoggerInterface $logger
	 * @param LBFactory $lbFactory
	 * @param WANObjectCache $wanCache
	 * @param RevisionLookup $revisionLookup
	 * @param RevisionStore $revisionStore
	 * @param Language $contentLanguage
	 * @param ParserFactory $parserFactory
	 * @param UserEditTracker $userEditTracker
	 * @param UserGroupManager $userGroupManager
	 * @param PermissionManager $permissionManager
	 * @param RestrictionStore $restrictionStore
	 * @param string $wikiID
	 */
	public function __construct(
		TextExtractor $textExtractor,
		AbuseFilterHookRunner $hookRunner,
		LoggerInterface $logger,
		LBFactory $lbFactory,
		WANObjectCache $wanCache,
		RevisionLookup $revisionLookup,
		RevisionStore $revisionStore,
		Language $contentLanguage,
		ParserFactory $parserFactory,
		UserEditTracker $userEditTracker,
		UserGroupManager $userGroupManager,
		PermissionManager $permissionManager,
		RestrictionStore $restrictionStore,
		string $wikiID
	) {
		$this->textExtractor = $textExtractor;
		$this->hookRunner = $hookRunner;
		$this->logger = $logger;
		$this->lbFactory = $lbFactory;
		$this->wanCache = $wanCache;
		$this->revisionLookup = $revisionLookup;
		$this->revisionStore = $revisionStore;
		$this->contentLanguage = $contentLanguage;
		$this->parserFactory = $parserFactory;
		$this->userEditTracker = $userEditTracker;
		$this->userGroupManager = $userGroupManager;
		$this->permissionManager = $permissionManager;
		$this->restrictionStore = $restrictionStore;
		$this->wikiID = $wikiID;
	}

	/**
	 * XXX: $getVarCB is a hack to hide the cyclic dependency with VariablesManager. See T261069 for possible
	 * solutions. This might also be merged into VariablesManager, but it would bring a ton of dependencies.
	 * @todo Should we remove $vars parameter (check hooks)?
	 *
	 * @param LazyLoadedVariable $var
	 * @param VariableHolder $vars
	 * @param callable $getVarCB
	 * @phan-param callable(string $name):AFPData $getVarCB
	 * @return AFPData
	 */
	public function compute( LazyLoadedVariable $var, VariableHolder $vars, callable $getVarCB ) {
		$parameters = $var->getParameters();
		$varMethod = $var->getMethod();
		$result = null;

		if ( !$this->hookRunner->onAbuseFilter_interceptVariable(
			$varMethod,
			$vars,
			$parameters,
			$result
		) ) {
			return $result instanceof AFPData
				? $result : AFPData::newFromPHPVar( $result );
		}

		switch ( $varMethod ) {
			case 'diff':
				$text1Var = $parameters['oldtext-var'];
				$text2Var = $parameters['newtext-var'];
				$text1 = $getVarCB( $text1Var )->toString();
				$text2 = $getVarCB( $text2Var )->toString();
				// T74329: if there's no text, don't return an array with the empty string
				$text1 = $text1 === '' ? [] : explode( "\n", $text1 );
				$text2 = $text2 === '' ? [] : explode( "\n", $text2 );
				$diffs = new Diff( $text1, $text2 );
				$format = new UnifiedDiffFormatter();
				$result = $format->format( $diffs );
				break;
			case 'diff-split':
				$diff = $getVarCB( $parameters['diff-var'] )->toString();
				$line_prefix = $parameters['line-prefix'];
				$diff_lines = explode( "\n", $diff );
				$result = [];
				foreach ( $diff_lines as $line ) {
					if ( ( $line[0] ?? '' ) === $line_prefix ) {
						$result[] = substr( $line, 1 );
					}
				}
				break;
			case 'links-from-wikitext':
				// This should ONLY be used when sharing a parse operation with the edit.

				/** @var WikiPage $article */
				$article = $parameters['article'];
				if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
					// Shared with the edit, don't count it in profiling
					$startTime = microtime( true );
					$textVar = $parameters['text-var'];

					$new_text = $getVarCB( $textVar )->toString();
					$content = ContentHandler::makeContent( $new_text, $article->getTitle() );
					$editInfo = $article->prepareContentForEdit(
						$content,
						null,
						$parameters['contextUserIdentity']
					);
					$result = LinkFilter::getIndexedUrlsNonReversed(
						array_keys( $editInfo->output->getExternalLinks() )
					);
					self::$profilingExtraTime += ( microtime( true ) - $startTime );
					break;
				}
			// Otherwise fall back to database
			case 'links-from-wikitext-or-database':
				// TODO: use Content object instead, if available!
				/** @var WikiPage $article */
				$article ??= $parameters['article'];

				// this inference is ugly, but the name isn't accessible from here
				// and we only want this for debugging
				$textVar = $parameters['text-var'];
				$varName = strpos( $textVar, 'old_' ) === 0 ? 'old_links' : 'all_links';
				if ( $parameters['forFilter'] ?? false ) {
					$this->logger->debug( "Loading $varName from DB" );
					$links = $this->getLinksFromDB( $article );
				} elseif ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
					$this->logger->debug( "Loading $varName from Parser" );

					$wikitext = $getVarCB( $textVar )->toString();
					$editInfo = $this->parseNonEditWikitext(
						$wikitext,
						$article,
						$parameters['contextUserIdentity']
					);
					$links = LinkFilter::getIndexedUrlsNonReversed(
						array_keys( $editInfo->output->getExternalLinks() )
					);
				} else {
					// TODO: Get links from Content object. But we don't have the content object.
					// And for non-text content, $wikitext is usually not going to be a valid
					// serialization, but rather some dummy text for filtering.
					$links = [];
				}

				$result = $links;
				break;
			case 'links-from-update':
				/** @var PreparedUpdate $update */
				$update = $parameters['update'];
				// Shared with the edit, don't count it in profiling
				$startTime = microtime( true );
				$result = LinkFilter::getIndexedUrlsNonReversed(
					array_keys( $update->getParserOutputForMetaData()->getExternalLinks() )
				);
				self::$profilingExtraTime += ( microtime( true ) - $startTime );
				break;
			case 'links-from-database':
				/** @var WikiPage $article */
				$article = $parameters['article'];
				$this->logger->debug( 'Loading old_links from DB' );
				$result = $this->getLinksFromDB( $article );
				break;
			case 'link-diff-added':
			case 'link-diff-removed':
				$oldLinkVar = $parameters['oldlink-var'];
				$newLinkVar = $parameters['newlink-var'];

				$oldLinks = $getVarCB( $oldLinkVar )->toNative();
				$newLinks = $getVarCB( $newLinkVar )->toNative();

				if ( $varMethod === 'link-diff-added' ) {
					$result = array_diff( $newLinks, $oldLinks );
				}
				if ( $varMethod === 'link-diff-removed' ) {
					$result = array_diff( $oldLinks, $newLinks );
				}
				break;
			case 'parse-wikitext':
				// Should ONLY be used when sharing a parse operation with the edit.
				// TODO: use Content object instead, if available!
				/* @var WikiPage $article */
				$article = $parameters['article'];
				if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
					// Shared with the edit, don't count it in profiling
					$startTime = microtime( true );
					$textVar = $parameters['wikitext-var'];

					$new_text = $getVarCB( $textVar )->toString();
					$content = ContentHandler::makeContent( $new_text, $article->getTitle() );
					$editInfo = $article->prepareContentForEdit(
						$content,
						null,
						$parameters['contextUserIdentity']
					);
					if ( isset( $parameters['pst'] ) && $parameters['pst'] ) {
						$result = $editInfo->pstContent->serialize( $editInfo->format );
					} else {
						// Note: as of core change r727361, the PP limit comments (which we don't want to be here)
						// are already excluded.
						$result = $editInfo->getOutput()->getText();
					}
					self::$profilingExtraTime += ( microtime( true ) - $startTime );
				} else {
					$result = '';
				}
				break;
			case 'html-from-update':
				/** @var PreparedUpdate $update */
				$update = $parameters['update'];
				// Shared with the edit, don't count it in profiling
				$startTime = microtime( true );
				$result = $update->getCanonicalParserOutput()->getText();
				self::$profilingExtraTime += ( microtime( true ) - $startTime );
				break;
			case 'strip-html':
				$htmlVar = $parameters['html-var'];
				$html = $getVarCB( $htmlVar )->toString();
				$stripped = StringUtils::delimiterReplace( '<', '>', '', $html );
				// We strip extra spaces to the right because the stripping above
				// could leave a lot of whitespace.
				// @fixme Find a better way to do this.
				$result = TextContent::normalizeLineEndings( $stripped );
				break;
			case 'load-recent-authors':
				$result = $this->getLastPageAuthors( $parameters['title'] );
				break;
			case 'load-first-author':
				$revision = $this->revisionLookup->getFirstRevision( $parameters['title'] );
				if ( $revision ) {
					// TODO T233241
					$user = $revision->getUser();
					$result = $user === null ? '' : $user->getName();
				} else {
					$result = '';
				}
				break;
			case 'get-page-restrictions':
				$action = $parameters['action'];
				/** @var Title $title */
				$title = $parameters['title'];
				$result = $this->restrictionStore->getRestrictions( $title, $action );
				break;
			case 'user-editcount':
				/** @var UserIdentity $userIdentity */
				$userIdentity = $parameters['user-identity'];
				$result = $this->userEditTracker->getUserEditCount( $userIdentity );
				break;
			case 'user-emailconfirm':
				/** @var User $user */
				$user = $parameters['user'];
				$result = $user->getEmailAuthenticationTimestamp();
				break;
			case 'user-groups':
				/** @var UserIdentity $userIdentity */
				$userIdentity = $parameters['user-identity'];
				$result = $this->userGroupManager->getUserEffectiveGroups( $userIdentity );
				break;
			case 'user-rights':
				/** @var UserIdentity $userIdentity */
				$userIdentity = $parameters['user-identity'];
				$result = $this->permissionManager->getUserPermissions( $userIdentity );
				break;
			case 'user-block':
				// @todo Support partial blocks?
				/** @var User $user */
				$user = $parameters['user'];
				$result = (bool)$user->getBlock();
				break;
			case 'user-age':
				/** @var User $user */
				$user = $parameters['user'];
				$asOf = $parameters['asof'];

				if ( !$user->isRegistered() ) {
					$result = 0;
				} else {
					// HACK: If there's no registration date, assume 2008-01-15, Wikipedia Day
					// in the year before the new user log was created. See T243469.
					$registration = $user->getRegistration() ?? "20080115000000";
					$result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $registration );
				}
				break;
			case 'page-age':
				/** @var Title $title */
				$title = $parameters['title'];

				$firstRev = $this->revisionLookup->getFirstRevision( $title );
				$firstRevisionTime = $firstRev ? $firstRev->getTimestamp() : null;
				if ( !$firstRevisionTime ) {
					$result = 0;
					break;
				}

				$asOf = $parameters['asof'];
				$result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $firstRevisionTime );
				break;
			case 'length':
				$s = $getVarCB( $parameters['length-var'] )->toString();
				$result = strlen( $s );
				break;
			case 'subtract-int':
				$v1 = $getVarCB( $parameters['val1-var'] )->toInt();
				$v2 = $getVarCB( $parameters['val2-var'] )->toInt();
				$result = $v1 - $v2;
				break;
			case 'content-model-by-id':
				$revRec = $this->revisionLookup->getRevisionById( $parameters['revid'] );
				$result = $this->getContentModelFromRevision( $revRec );
				break;
			case 'revision-text-by-id':
				$revRec = $this->revisionLookup->getRevisionById( $parameters['revid'] );
				$result = $this->textExtractor->revisionToString( $revRec, $parameters['contextUser'] );
				break;
			case 'get-wiki-name':
				$result = $this->wikiID;
				break;
			case 'get-wiki-language':
				$result = $this->contentLanguage->getCode();
				break;
			default:
				if ( $this->hookRunner->onAbuseFilter_computeVariable(
					$varMethod,
					$vars,
					$parameters,
					$result
				) ) {
					throw new UnexpectedValueException( 'Unknown variable compute type ' . $varMethod );
				}
		}

		return $result instanceof AFPData ? $result : AFPData::newFromPHPVar( $result );
	}

	/**
	 * @param WikiPage $article
	 * @return array
	 */
	private function getLinksFromDB( WikiPage $article ) {
		$id = $article->getId();
		if ( !$id ) {
			return [];
		}

		return ExternalLinksLookup::getExternalLinksForPage(
			$id,
			$this->lbFactory->getReplicaDatabase(),
			__METHOD__
		);
	}

	/**
	 * @todo Move to MW core (T272050)
	 * @param Title $title
	 * @return string[] Usernames of the last 10 (unique) authors from $title
	 */
	private function getLastPageAuthors( Title $title ) {
		if ( !$title->exists() ) {
			return [];
		}

		$fname = __METHOD__;

		return $this->wanCache->getWithSetCallback(
			$this->wanCache->makeKey( 'last-10-authors', 'revision', $title->getLatestRevID() ),
			WANObjectCache::TTL_MINUTE,
			function ( $oldValue, &$ttl, array &$setOpts ) use ( $title, $fname ) {
				$dbr = $this->lbFactory->getReplicaDatabase();

				$setOpts += Database::getCacheSetOptions( $dbr );
				// Get the last 100 edit authors with a trivial query (avoid T116557)
				$revQuery = $this->revisionStore->getQueryInfo();
				$revAuthors = $dbr->selectFieldValues(
					$revQuery['tables'],
					$revQuery['fields']['rev_user_text'],
					[
						'rev_page' => $title->getArticleID(),
						// TODO Should deleted names be counted in the 10 authors? If yes, this check should
						// be moved inside the foreach
						'rev_deleted' => 0
					],
					$fname,
					// Some pages have < 10 authors but many revisions (e.g. bot pages)
					[ 'ORDER BY' => 'rev_timestamp DESC, rev_id DESC',
						'LIMIT' => 100,
						// Force index per T116557
						'USE INDEX' => [ 'revision' => 'rev_page_timestamp' ],
					],
					$revQuery['joins']
				);
				// Get the last 10 distinct authors within this set of edits
				$users = [];
				foreach ( $revAuthors as $author ) {
					$users[$author] = 1;
					if ( count( $users ) >= 10 ) {
						break;
					}
				}

				return array_keys( $users );
			}
		);
	}

	/**
	 * @param ?RevisionRecord $revision
	 * @return string
	 */
	private function getContentModelFromRevision( ?RevisionRecord $revision ): string {
		// this is consistent with what is done on various places in RunVariableGenerator
		// and RCVariableGenerator
		if ( $revision !== null ) {
			$content = $revision->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
			return $content->getModel();
		}
		return '';
	}

	/**
	 * It's like WikiPage::prepareContentForEdit, but not for editing (old wikitext usually)
	 *
	 * @param string $wikitext
	 * @param WikiPage $article
	 * @param UserIdentity $userIdentity Context user
	 *
	 * @return stdClass
	 */
	private function parseNonEditWikitext( $wikitext, WikiPage $article, UserIdentity $userIdentity ) {
		static $cache = [];

		$cacheKey = md5( $wikitext ) . ':' . $article->getTitle()->getPrefixedText();

		if ( !isset( $cache[$cacheKey] ) ) {
			$options = ParserOptions::newFromUser( $userIdentity );
			$cache[$cacheKey] = (object)[
				'output' => $this->parserFactory->getInstance()->parse( $wikitext, $article->getTitle(), $options )
			];
		}

		return $cache[$cacheKey];
	}
}
Back to Directory File Manager