Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
52 / 52 |
|
100.00% |
7 / 7 |
CRAP | |
100.00% |
1 / 1 |
CheckUserLookupUtils | |
100.00% |
52 / 52 |
|
100.00% |
7 / 7 |
26 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
isValidIPOrRange | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
getIPTargetExpr | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
getIpHexColumn | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getIndexName | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
getManualLogEntryFromRow | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
9 | |||
getRevisionRecordFromRow | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 |
1 | <?php |
2 | |
3 | namespace MediaWiki\CheckUser\Services; |
4 | |
5 | use LogPage; |
6 | use ManualLogEntry; |
7 | use MediaWiki\CheckUser\CheckUserQueryInterface; |
8 | use MediaWiki\Config\ServiceOptions; |
9 | use MediaWiki\Revision\ArchivedRevisionLookup; |
10 | use MediaWiki\Revision\RevisionLookup; |
11 | use MediaWiki\Revision\RevisionRecord; |
12 | use MediaWiki\Title\Title; |
13 | use MediaWiki\User\UserIdentity; |
14 | use Psr\Log\LoggerInterface; |
15 | use RuntimeException; |
16 | use stdClass; |
17 | use Wikimedia\IPUtils; |
18 | use Wikimedia\Rdbms\IConnectionProvider; |
19 | use Wikimedia\Rdbms\IExpression; |
20 | use Wikimedia\Rdbms\IReadableDatabase; |
21 | |
22 | class 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 | } |