Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
CheckUserLookupUtils
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
7 / 7
26
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isValidIPOrRange
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getIPTargetExpr
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getIpHexColumn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getIndexName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getManualLogEntryFromRow
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 getRevisionRecordFromRow
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\CheckUser\Services;
4
5use LogPage;
6use ManualLogEntry;
7use MediaWiki\CheckUser\CheckUserQueryInterface;
8use MediaWiki\Config\ServiceOptions;
9use MediaWiki\Revision\ArchivedRevisionLookup;
10use MediaWiki\Revision\RevisionLookup;
11use MediaWiki\Revision\RevisionRecord;
12use MediaWiki\Title\Title;
13use MediaWiki\User\UserIdentity;
14use Psr\Log\LoggerInterface;
15use RuntimeException;
16use stdClass;
17use Wikimedia\IPUtils;
18use Wikimedia\Rdbms\IConnectionProvider;
19use Wikimedia\Rdbms\IExpression;
20use Wikimedia\Rdbms\IReadableDatabase;
21
22class CheckUserLookupUtils {
23
24    public const CONSTRUCTOR_OPTIONS = [ 'CheckUserCIDRLimit' ];
25
26    private ServiceOptions $options;
27    private IReadableDatabase $dbr;
28    private RevisionLookup $revisionLookup;
29    private ArchivedRevisionLookup $archivedRevisionLookup;
30    private LoggerInterface $logger;
31
32    public function __construct(
33        ServiceOptions $options,
34        IConnectionProvider $dbProvider,
35        RevisionLookup $revisionLookup,
36        ArchivedRevisionLookup $archivedRevisionLookup,
37        LoggerInterface $logger
38    ) {
39        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
40        $this->options = $options;
41        $this->dbr = $dbProvider->getReplicaDatabase();
42        $this->revisionLookup = $revisionLookup;
43        $this->archivedRevisionLookup = $archivedRevisionLookup;
44        $this->logger = $logger;
45    }
46
47    /**
48     * Returns whether the given target IP address or IP address range is valid. This applies the range limits
49     * imposed by $wgCheckUserCIDRLimit.
50     *
51     * @param string $target an IP address or CIDR range
52     * @return bool
53     */
54    public function isValidIPOrRange( string $target ): bool {
55        $CIDRLimit = $this->options->get( 'CheckUserCIDRLimit' );
56        if ( IPUtils::isValidRange( $target ) ) {
57            [ $ip, $range ] = explode( '/', $target, 2 );
58            return !(
59                ( IPUtils::isIPv4( $ip ) && $range < $CIDRLimit['IPv4'] ) ||
60                ( IPUtils::isIPv6( $ip ) && $range < $CIDRLimit['IPv6'] )
61            );
62        }
63
64        return IPUtils::isValid( $target );
65    }
66
67    /**
68     * Get the WHERE conditions as an IExpression object which can be used to filter results for provided an
69     * IP address / range and optionally for the XFF IP.
70     *
71     * @param string $target an IP address or CIDR range
72     * @param bool $xfor True if searching on XFF IPs by IP address / range
73     * @param string $table The table which will be used in the query these WHERE conditions
74     * are used (array of valid options in self::RESULT_TABLES).
75     * @return IExpression|null IExpression for valid conditions, null if invalid
76     */
77    public function getIPTargetExpr( string $target, bool $xfor, string $table ): ?IExpression {
78        $columnName = $this->getIpHexColumn( $xfor, $table );
79
80        if ( !$this->isValidIPOrRange( $target ) ) {
81            // Return null if the target is not a valid IP address or range
82            return null;
83        }
84
85        if ( IPUtils::isValidRange( $target ) ) {
86            // If the target is a range, then the conditions should include all rows where the IP hex
87            // is between the start and end (inclusive).
88            [ $start, $end ] = IPUtils::parseRange( $target );
89            return $this->dbr->expr( $columnName, '>=', $start )->and( $columnName, '<=', $end );
90        } else {
91            // If the target is a single IP, then the ip hex column should be equal to the hex of the target IP.
92            return $this->dbr->expr( $columnName, '=', IPUtils::toHex( $target ) );
93        }
94    }
95
96    /**
97     * Gets the column name for the IP hex column based
98     * on the value for $xfor and a given $table.
99     *
100     * @param bool $xfor Whether the IPs being searched through are XFF IPs.
101     * @param string $table The table selecting results from (array of valid
102     * options in CheckUserQueryInterface::RESULT_TABLES).
103     * @return string
104     */
105    private function getIpHexColumn( bool $xfor, string $table ): string {
106        $type = $xfor ? 'xff' : 'ip';
107        return CheckUserQueryInterface::RESULT_TABLE_TO_PREFIX[$table] . $type . '_hex';
108    }
109
110    /**
111     * Gets the name for the index for a given table.
112     *
113     * note: When SCHEMA_COMPAT_READ_NEW is set, the query will not use an index
114     * on the values of `cuc_only_for_read_old`.
115     * That shouldn't result in a significant performance drop, and this is a
116     * temporary situation until the temporary column is removed after the
117     * migration is complete.
118     *
119     * @param bool|null $xfor Whether the IPs being searched through are XFF IPs. Null if the target is a username.
120     * @param string $table The table this index should apply to (list of valid options
121     *   in CheckUserQueryInterface::RESULT_TABLES).
122     * @return string
123     */
124    public function getIndexName( ?bool $xfor, string $table ): string {
125        // So that a code search can find existing usages:
126        // cuc_actor_ip_time, cule_actor_ip_time, cupe_actor_ip_time, cuc_xff_hex_time, cuc_ip_hex_time,
127        // cule_xff_hex_time, cule_ip_hex_time, cupe_xff_hex_time, cupe_ip_hex_time
128        if ( $xfor === null ) {
129            return CheckUserQueryInterface::RESULT_TABLE_TO_PREFIX[$table] . 'actor_ip_time';
130        } else {
131            $type = $xfor ? 'xff' : 'ip';
132            return CheckUserQueryInterface::RESULT_TABLE_TO_PREFIX[$table] . $type . '_hex_time';
133        }
134    }
135
136    /**
137     * Get a ManualLogEntry instance for the given row from either cu_log_event or
138     * cu_private_event table. The column names are expected to not include the table prefix and the row should include
139     * the following columns for this method to work:
140     * - log_type
141     * - log_action
142     * - log_params
143     * - log_deleted
144     * - title (or page/page_id)
145     * - namespace (not needed if title is not defined but page/page_id is)
146     * - timestamp
147     *
148     * Do not call this method for rows from cu_changes.
149     *
150     * @param stdClass $row The database row
151     * @param UserIdentity $user The user who is the performer for this row.
152     * @return ManualLogEntry Which can be used via the LogFormatter to generate action text.
153     */
154    public function getManualLogEntryFromRow( stdClass $row, UserIdentity $user ): ManualLogEntry {
155        $logEntry = new ManualLogEntry( $row->log_type, $row->log_action );
156        if ( $row->log_params !== null ) {
157            // Suppress E_NOTICE from PHP's unserialize if the log parameters are legacy parameters.
158            // This is similar to DatabaseLogEntry::getParameters.
159            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
160            $parsedLogParams = @ManualLogEntry::extractParams( $row->log_params );
161            if ( $parsedLogParams === false ) {
162                // Use the LogPage::extractParams method to extract the log parameters as they are probably
163                // legacy parameters.
164                $parsedLogParams = LogPage::extractParams( $row->log_params );
165                $logEntry->setLegacy( true );
166            }
167            $logEntry->setParameters( $parsedLogParams );
168        }
169        $logEntry->setPerformer( $user );
170        if ( isset( $row->title ) && $row->title ) {
171            $logEntry->setTarget( Title::makeTitle( $row->namespace, $row->title ) );
172        } elseif (
173            // page_id is the column name for Special:CheckUser. page is the column name for the CheckUser API.
174            ( isset( $row->page ) && $row->page ) ||
175            ( isset( $row->page_id ) && $row->page_id )
176        ) {
177            $logEntry->setTarget( Title::newFromID( $row->page ) );
178        }
179        $logEntry->setTimestamp( $row->timestamp );
180        $logEntry->setDeleted( $row->log_deleted );
181        return $logEntry;
182    }
183
184    /**
185     * Get a RevisionRecord instance for the given row from cu_changes table. This should not be called for rows from
186     * the cu_log_event or cu_private_event tables.
187     *
188     * @param stdClass $row
189     * @return ?RevisionRecord null if no revision is found, otherwise the RevisionRecord.
190     */
191    public function getRevisionRecordFromRow( stdClass $row ): ?RevisionRecord {
192        $revRecord = $this->revisionLookup->getRevisionById( $row->this_oldid );
193        if ( !$revRecord ) {
194            $revRecord = $this->archivedRevisionLookup->getArchivedRevisionRecord( null, $row->this_oldid );
195        }
196        if ( $revRecord === null ) {
197            // This shouldn't happen, CheckUser points to a revision that isn't in revision nor archive table?
198            $this->logger->warning(
199                "Couldn't fetch revision with this_oldid as {old_id}",
200                [ 'old_id' => $row->this_oldid, 'exception' => new RuntimeException ]
201            );
202        }
203        return $revRecord;
204    }
205}