Page MenuHomeWickedGov Phorge

UserAgentClientHintsManager.php
No OneTemporary

Size
14 KB
Referenced Files
None
Subscribers
None

UserAgentClientHintsManager.php

<?php
namespace MediaWiki\CheckUser\Services;
use LogicException;
use MediaWiki\CheckUser\ClientHints\ClientHintsData;
use MediaWiki\CheckUser\ClientHints\ClientHintsReferenceIds;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Logging\DatabaseLogEntry;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionLookup;
use Psr\Log\LoggerInterface;
use StatusValue;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IReadableDatabase;
use Wikimedia\Timestamp\ConvertibleTimestamp;
/**
* Service to insert and delete user-agent client hint values and their associations with rows in cu_changes,
* cu_log_event and cu_private_event.
*/
class UserAgentClientHintsManager {
public const CONSTRUCTOR_OPTIONS = [
'CUDMaxAge',
MainConfigNames::UpdateRowsPerQuery,
];
public const SUPPORTED_TYPES = [
'revision',
'privatelog',
];
/**
* TINYINT references for use in cu_useragent_clienthints_map.uachm_reference_type
*/
// Identifier for the cu_changes table
public const IDENTIFIER_CU_CHANGES = 0;
// Identifier for the cu_log_event table
public const IDENTIFIER_CU_LOG_EVENT = 1;
// Identifier for the cu_private_event table
public const IDENTIFIER_CU_PRIVATE_EVENT = 2;
public const IDENTIFIER_TO_TABLE_NAME_MAP = [
self::IDENTIFIER_CU_CHANGES => 'cu_changes',
self::IDENTIFIER_CU_LOG_EVENT => 'cu_log_event',
self::IDENTIFIER_CU_PRIVATE_EVENT => 'cu_private_event',
];
public const IDENTIFIER_TO_COLUMN_NAME_MAP = [
self::IDENTIFIER_CU_CHANGES => 'cuc_this_oldid',
self::IDENTIFIER_CU_LOG_EVENT => 'cule_log_id',
self::IDENTIFIER_CU_PRIVATE_EVENT => 'cupe_id',
];
private IDatabase $dbw;
private IReadableDatabase $dbr;
private RevisionLookup $revisionLookup;
private ServiceOptions $options;
private LoggerInterface $logger;
public function __construct(
IConnectionProvider $connectionProvider,
RevisionLookup $revisionLookup,
ServiceOptions $options,
LoggerInterface $logger
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $options;
$this->dbw = $connectionProvider->getPrimaryDatabase();
$this->dbr = $connectionProvider->getReplicaDatabase();
$this->revisionLookup = $revisionLookup;
$this->logger = $logger;
}
/**
* Given an array of client hint data, a reference ID, and an identifier type, record the data to the
* cu_useragent_clienthints and cu_useragent_clienthints_map tables.
*
* @param ClientHintsData $clientHintsData
* @param int $referenceId An ID to use in `uachm_reference_id` column in the
* cu_useragent_clienthints_map table
* @param string $type The type of event this data is associated with. Valid values are the values in
* {@link UserAgentClientHintsManager::SUPPORTED_TYPES}.
* @param bool $usePrimary If true, use the primary DB for SELECT queries.
* @return StatusValue
*/
public function insertClientHintValues(
ClientHintsData $clientHintsData, int $referenceId, string $type, bool $usePrimary = false
): StatusValue {
// Check if there are rows to insert to the map table.
$rows = $clientHintsData->toDatabaseRows();
if ( !count( $rows ) ) {
// Nothing to insert, so return early.
// Having nothing to insert isn't considered "bad", so return a new good
// For example, a browser could choose to provide no Client Hints data but
// still send an empty API request.
return StatusValue::newGood();
}
// Check for existing entry.
$existingRecord = $this->dbr->newSelectQueryBuilder()
->table( 'cu_useragent_clienthints_map' )
->where( [
'uachm_reference_type' => $this->getMapIdByType( $type ),
'uachm_reference_id' => $referenceId,
] )
->caller( __METHOD__ )
->fetchRowCount();
if ( $existingRecord ) {
return StatusValue::newFatal(
'checkuser-api-useragent-clienthints-mappings-exist',
[ $type, $referenceId ]
);
}
$clientHintsMappings = $this->selectClientHintMappings( $rows, $usePrimary, true );
if ( $clientHintsMappings === false ) {
$usePrimary = true;
$clientHintsMappings = $this->selectClientHintMappings( $rows, $usePrimary, false );
}
return $this->insertMappingRows( $clientHintsMappings, $referenceId, $type );
}
/**
* Given an identifier for the type of event (e.g. 'revision'), return the relevant TINYINT
* for the table that the database entry for cu_useragent_clienthints_map refers to
*
* @param string $type
* @return int One of self::IDENTIFIER_* constants
*/
private function getMapIdByType( string $type ): int {
return match ( $type ) {
'revision' => self::IDENTIFIER_CU_CHANGES,
'log' => self::IDENTIFIER_CU_LOG_EVENT,
'privatelog' => self::IDENTIFIER_CU_PRIVATE_EVENT,
default => throw new LogicException( "Invalid type $type" ),
};
}
/**
* Insert rows into the cu_useragent_clienthints_map table.
*
* This links a foreign ID (e.g. "revision 1234") with client hint data values stored in cu_useragent_clienthints.
*
* @param int[] $clientHintMapping
* @param int $foreignId
* @param string $type
* @return StatusValue
* @see insertClientHintValues, which invokes this method.
*/
private function insertMappingRows(
array $clientHintMapping, int $foreignId, string $type
): StatusValue {
// TINYINT reference to cu_changes, cu_log_event or cu_private_event.
$idType = $this->getMapIdByType( $type );
$mapRows = [];
foreach ( $clientHintMapping as $clientHintId ) {
$mapRows[] = [
'uachm_uach_id' => $clientHintId,
'uachm_reference_type' => $idType,
'uachm_reference_id' => $foreignId,
];
}
if ( count( $mapRows ) ) {
$this->dbw->newInsertQueryBuilder()
->insertInto( 'cu_useragent_clienthints_map' )
->ignore()
->rows( $mapRows )
->caller( __METHOD__ )
->execute();
}
return StatusValue::newGood();
}
/**
* Given reference IDs this method finds and deletes
* the mapping entries for these reference IDs.
*
* @param ClientHintsReferenceIds $clientHintsReferenceIds
* @return int The number of mapping rows deleted.
*/
public function deleteMappingRows( ClientHintsReferenceIds $clientHintsReferenceIds ): int {
// Keep a track of the number of mapping rows that are deleted.
$mappingRowsDeleted = 0;
foreach ( $clientHintsReferenceIds->getReferenceIds() as $mapId => $referenceIds ) {
if ( !count( $referenceIds ) ) {
continue;
}
// Delete the rows in cu_useragent_clienthints_map associated with these reference IDs
do {
// Fetch a batch of rows to delete from the DB (the primary key is all rows in the table,
// so we need to fetch all of them).
$batchToDelete = $this->dbw->newSelectQueryBuilder()
->select( [ 'uachm_uach_id', 'uachm_reference_type', 'uachm_reference_id' ] )
->from( 'cu_useragent_clienthints_map' )
->where( [
'uachm_reference_id' => $referenceIds,
'uachm_reference_type' => $mapId,
] )
->limit( $this->options->get( MainConfigNames::UpdateRowsPerQuery ) )
->caller( __METHOD__ )
->fetchResultSet();
if ( !$batchToDelete->count() ) {
break;
}
// Construct a list of WHERE conditions which would delete all the rows for this batch.
$batchDeleteConds = [];
foreach ( $batchToDelete as $row ) {
$batchDeleteConds[] = $this->dbw->andExpr( [
'uachm_uach_id' => $row->uachm_uach_id,
'uachm_reference_type' => $row->uachm_reference_type,
'uachm_reference_id' => $row->uachm_reference_id,
] );
}
// Perform the deletion for this batch
$this->dbw->newDeleteQueryBuilder()
->deleteFrom( 'cu_useragent_clienthints_map' )
->where( $this->dbw->orExpr( $batchDeleteConds ) )
->caller( __METHOD__ )
->execute();
$mappingRowsDeleted += $this->dbw->affectedRows();
} while ( $batchToDelete->count() );
}
if ( !$mappingRowsDeleted ) {
$this->logger->info( "No mapping rows deleted." );
} else {
$this->logger->debug(
"Deleted {mapping_rows_deleted} mapping rows.",
[ 'mapping_rows_deleted' => $mappingRowsDeleted ]
);
}
return $mappingRowsDeleted;
}
/**
* Checks 100 rows with the smallest uachm_reference_id
* for each uachm_reference_type value to see whether their
* associated entry referenced by the uachm_reference_id
* value has been already purged.
*
* If it reaches an entry that is not orphaned, the checks are
* stopped as items with a larger reference ID are unlikely to
* be orphaned.
*
* This catches rows that have been left without deletion
* due to unforeseen circumstances, as described in T350681.
*
* @return int The number of orphaned map rows deleted.
*/
public function deleteOrphanedMapRows(): int {
// Keep a track of the number of mapping rows that are deleted.
$mappingRowsDeleted = 0;
foreach ( self::IDENTIFIER_TO_TABLE_NAME_MAP as $mappingId => $table ) {
// Get 100 rows with the given mapping ID
$resultSet = $this->dbr->newSelectQueryBuilder()
->select( 'uachm_reference_id' )
->table( 'cu_useragent_clienthints_map' )
->where( [ 'uachm_reference_type' => $mappingId ] )
->orderBy( 'uachm_reference_id' )
->groupBy( 'uachm_reference_id' )
->limit( 100 )
->caller( __METHOD__ )
->fetchResultSet();
foreach ( $resultSet as $row ) {
// For each row, check if the ::isMapRowOrphaned method
// indicates that the row is orphaned.
$referenceId = $row->uachm_reference_id;
$mapRowIsOrphaned = $this->isMapRowOrphaned( $referenceId, $mappingId );
if ( $mapRowIsOrphaned ) {
// If the map row is orphaned, then perform the deletion
// and add the affected rows count to the return count.
$this->dbw->newDeleteQueryBuilder()
->deleteFrom( 'cu_useragent_clienthints_map' )
->where( [
'uachm_reference_id' => $referenceId,
'uachm_reference_type' => $mappingId,
] )
->caller( __METHOD__ )
->execute();
$mappingRowsDeleted += $this->dbw->affectedRows();
} else {
// If the map row is probably not orphaned, then just stop processing
// the rows in this table.
break;
}
}
}
if ( $mappingRowsDeleted ) {
$this->logger->info(
"Deleted {mapping_rows_deleted} orphaned mapping rows.",
[ 'mapping_rows_deleted' => $mappingRowsDeleted ]
);
}
return $mappingRowsDeleted;
}
/**
* Returns whether rows with the given $referenceId and $mappingId
* in cu_useragent_clienthints_map are likely orphaned.
*
* @param int $referenceId
* @param int $mappingId
* @return bool
*/
private function isMapRowOrphaned( int $referenceId, int $mappingId ): bool {
if ( !array_key_exists( $mappingId, self::IDENTIFIER_TO_TABLE_NAME_MAP ) ) {
throw new LogicException( "Unrecognised map ID '$mappingId'" );
}
if ( !in_array( $mappingId, [ self::IDENTIFIER_CU_LOG_EVENT, self::IDENTIFIER_CU_CHANGES ] ) ) {
// If the mapping ID is not for cu_changes or cu_log_event,
// query the table directly to check if the associated reference ID
// exists in the table.
return !$this->dbr->newSelectQueryBuilder()
->field( '1' )
->table( self::IDENTIFIER_TO_TABLE_NAME_MAP[$mappingId] )
->where( [ self::IDENTIFIER_TO_COLUMN_NAME_MAP[$mappingId] => $referenceId ] )
->caller( __METHOD__ )
->fetchField();
}
// If the mapping ID is for cu_changes or cu_log_event,
// then query the revision table or logging table respectively
// for the associated timestamp to determine if the map
// row should have already been deleted.
$associatedTimestamp = false;
if ( $mappingId === self::IDENTIFIER_CU_CHANGES ) {
// Get the timestamp from the revision lookup service
$revisionRecord = $this->revisionLookup->getRevisionById( $referenceId );
if ( $revisionRecord ) {
$associatedTimestamp = $revisionRecord->getTimestamp();
}
} elseif ( $mappingId === self::IDENTIFIER_CU_LOG_EVENT ) {
// Get the timestamp from using DatabaseLogEntry::newFromId
$logObject = DatabaseLogEntry::newFromId( $referenceId, $this->dbr );
if ( $logObject ) {
$associatedTimestamp = $logObject->getTimestamp();
}
}
// The map rows are considered orphaned if of the following any apply:
// * There is no timestamp for the revision or log event (should be generally impossible for this
// to be the case).
// * No such reference ID exists (i.e. no such revision ID or log ID)
// * The timestamp associated with the revision or log event is before the
// wgCUDMaxAge + 100 seconds ago to the current time.
//
// The 100 seconds are added to wgCUDMaxAge to prevent attempting to delete map rows
// that would have been normally deleted. This code is intended to catch map rows that
// were not deleted normally.
return !$associatedTimestamp ||
$associatedTimestamp < ConvertibleTimestamp::convert(
TS_MW,
ConvertibleTimestamp::time() - ( $this->options->get( 'CUDMaxAge' ) + 100 )
);
}
/**
* Helper method to find the id for client hint values.
* Missing values are inserted to generate new ids on the database.
* In that case the function needs to be recalled to select the new ids.
*
* E.g. if "architecture: arm" does not exists as a name/value pair, insert it.
*
* @param array[] $rows An array of arrays, where each array contains a key/value pair:
* uach_name => "some name",
* uach_value => "some value"
* @param bool $usePrimary If true, use the primary DB for SELECT queries.
* @param bool $insertMissingData If true, insert missing data
* @return int[]|false
*/
private function selectClientHintMappings( array $rows, bool $usePrimary, bool $insertMissingData ) {
$db = $usePrimary ? $this->dbw : $this->dbr;
$orExpr = [];
$rowsToInsert = [];
foreach ( $rows as $row ) {
$orExpr[] = $db->andExpr( $row );
$rowsToInsert[$row['uach_name'] . ':' . $row['uach_value']] = $row;
}
$dbRes = $db->newSelectQueryBuilder()
->select( [ 'uach_id', 'uach_name', 'uach_value' ] )
->from( 'cu_useragent_clienthints' )
->where( $db->orExpr( $orExpr ) )
->caller( __METHOD__ )
->fetchResultSet();
$clientHintMapping = [];
foreach ( $dbRes as $dbRow ) {
$clientHintMapping[] = (int)$dbRow->uach_id;
unset( $rowsToInsert[$dbRow->uach_name . ':' . $dbRow->uach_value] );
}
if ( count( $rowsToInsert ) ) {
if ( $insertMissingData ) {
$this->dbw->newInsertQueryBuilder()
->insertInto( 'cu_useragent_clienthints' )
->ignore()
->rows( array_values( $rowsToInsert ) )
->caller( __METHOD__ )
->execute();
// False indicates the data has to be re-read to find ids for new inserted data
return false;
}
foreach ( $rowsToInsert as $row ) {
$this->logger->warning(
"Lookup failed for cu_useragent_clienthints row with name {name} and value {value}.",
[ $row['uach_name'], $row['uach_value'] ]
);
}
}
return $clientHintMapping;
}
}

File Metadata

Mime Type
text/x-php
Expires
Wed, Aug 19, 15:04 (3 w, 5 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
73/0f/c8c42aed030a90ccd77f0aaacdb7
Default Alt Text
UserAgentClientHintsManager.php (14 KB)

Event Timeline