Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
124 / 124
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
CheckUserInsert
100.00% covered (success)
100.00%
124 / 124
100.00% covered (success)
100.00%
6 / 6
25
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 insertIntoCuLogEventTable
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
5
 insertIntoCuPrivateEventTable
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
6
 insertIntoCuChangesTable
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
6
 getAgent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 acquireActorId
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3namespace MediaWiki\CheckUser\Services;
4
5use DatabaseLogEntry;
6use Language;
7use LogEntryBase;
8use MediaWiki\CheckUser\CheckUserQueryInterface;
9use MediaWiki\CheckUser\Hook\HookRunner;
10use MediaWiki\CommentStore\CommentStore;
11use MediaWiki\HookContainer\HookContainer;
12use MediaWiki\User\ActorStore;
13use MediaWiki\User\TempUser\TempUserConfig;
14use MediaWiki\User\UserIdentity;
15use RecentChange;
16use RequestContext;
17use WebRequest;
18use Wikimedia\IPUtils;
19use Wikimedia\Rdbms\IConnectionProvider;
20
21/**
22 * This service provides methods that can be used
23 * to insert data into the CheckUser result tables.
24 *
25 * Extensions other than CheckUser should not use
26 * the methods marked as internal.
27 */
28class CheckUserInsert {
29
30    private ActorStore $actorStore;
31    private CheckUserUtilityService $checkUserUtilityService;
32    private CommentStore $commentStore;
33    private HookRunner $hookRunner;
34    private IConnectionProvider $connectionProvider;
35    private Language $contentLanguage;
36    private TempUserConfig $tempUserConfig;
37
38    /**
39     * The maximum number of bytes that fit in CheckUser's text fields,
40     * specifically user agent, XFF strings and action text.
41     */
42    public const TEXT_FIELD_LENGTH = 255;
43
44    public function __construct(
45        ActorStore $actorStore,
46        CheckUserUtilityService $checkUserUtilityService,
47        CommentStore $commentStore,
48        HookContainer $hookContainer,
49        IConnectionProvider $connectionProvider,
50        Language $contentLanguage,
51        TempUserConfig $tempUserConfig
52    ) {
53        $this->actorStore = $actorStore;
54        $this->checkUserUtilityService = $checkUserUtilityService;
55        $this->commentStore = $commentStore;
56        $this->hookRunner = new HookRunner( $hookContainer );
57        $this->connectionProvider = $connectionProvider;
58        $this->contentLanguage = $contentLanguage;
59        $this->tempUserConfig = $tempUserConfig;
60    }
61
62    /**
63     * Inserts a row into cu_log_event based on provided log ID and performer.
64     *
65     * The $user parameter is used to fill the column values about the performer of the log action.
66     * The log ID is stored in the table and used to get information to show the CheckUser when
67     * running a check.
68     *
69     * @param DatabaseLogEntry $logEntry the log entry to add to cu_log_event
70     * @param string $method the method name that called this, used for the insertion into the DB.
71     * @param UserIdentity $user the user who made the request.
72     * @param ?RecentChange $rc If triggered by a RecentChange, then this is the associated
73     *  RecentChange object. Null if not triggered by a RecentChange.
74     * @return void
75     * @internal Only for use by the CheckUser extension
76     */
77    public function insertIntoCuLogEventTable(
78        DatabaseLogEntry $logEntry,
79        string $method,
80        UserIdentity $user,
81        ?RecentChange $rc = null
82    ): void {
83        $request = RequestContext::getMain()->getRequest();
84
85        $ip = $request->getIP();
86        $xff = $request->getHeader( 'X-Forwarded-For' );
87
88        $row = [
89            'cule_log_id' => $logEntry->getId()
90        ];
91
92        // Provide the ip, xff and row to code that hooks onto this so that they can modify the row before
93        //  it's inserted. The ip and xff are provided separately so that the caller doesn't have to set
94        //  the hex versions of the IP and XFF and can therefore leave that to this function.
95        $this->hookRunner->onCheckUserInsertLogEventRow( $ip, $xff, $row, $user, $logEntry->getId(), $rc );
96        [ $xff_ip, $isSquidOnly, $xff ] = $this->checkUserUtilityService->getClientIPfromXFF( $xff );
97
98        $dbw = $this->connectionProvider->getPrimaryDatabase();
99        $row = array_merge( [
100            'cule_actor'     => $this->acquireActorId( $user, CheckUserQueryInterface::LOG_EVENT_TABLE ),
101            'cule_timestamp' => $dbw->timestamp( $logEntry->getTimestamp() ),
102            'cule_ip'        => IPUtils::sanitizeIP( $ip ),
103            'cule_ip_hex'    => $ip ? IPUtils::toHex( $ip ) : null,
104            'cule_xff'       => !$isSquidOnly ? $xff : '',
105            'cule_xff_hex'   => ( $xff_ip && !$isSquidOnly ) ? IPUtils::toHex( $xff_ip ) : null,
106            'cule_agent'     => $this->getAgent( $request ),
107        ], $row );
108
109        // (T199323) Truncate text fields prior to database insertion
110        // Attempting to insert too long text will cause an error in MariaDB/MySQL strict mode
111        $row['cule_xff'] = $this->contentLanguage->truncateForDatabase( $row['cule_xff'], self::TEXT_FIELD_LENGTH );
112
113        $dbw->newInsertQueryBuilder()
114            ->insertInto( 'cu_log_event' )
115            ->row( $row )
116            ->caller( $method )
117            ->execute();
118    }
119
120    /**
121     * Inserts a row to cu_private_event based on a provided row and performer of the action.
122     *
123     * The $row has defaults applied, truncation performed and comment table insertion performed.
124     * The $user parameter is used to fill the default for the actor ID column.
125     *
126     * Provide cupe_comment_id if you have generated a comment table ID for this action, or provide
127     * cupe_comment if you want this method to deal with the comment table.
128     *
129     * @param array $row an array of cu_private_event table column names to their values. Changeable by a hook
130     *  and for any needed truncation.
131     * @param string $method the method name that called this, used for the insertion into the DB.
132     * @param UserIdentity $user the user associated with the event
133     * @param ?RecentChange $rc If triggered by a RecentChange, then this is the associated
134     *  RecentChange object. Null if not triggered by a RecentChange.
135     * @internal Only for use by the CheckUser extension
136     */
137    public function insertIntoCuPrivateEventTable(
138        array $row,
139        string $method,
140        UserIdentity $user,
141        ?RecentChange $rc = null
142    ): void {
143        $request = RequestContext::getMain()->getRequest();
144
145        $ip = $request->getIP();
146        $xff = $request->getHeader( 'X-Forwarded-For' );
147
148        // Provide the ip, xff and row to code that hooks onto this so that they can modify the row before
149        //  it's inserted. The ip and xff are provided separately so that the caller doesn't have to set
150        //  the hex versions of the IP and XFF and can therefore leave that to this function.
151        $this->hookRunner->onCheckUserInsertPrivateEventRow( $ip, $xff, $row, $user, $rc );
152        [ $xff_ip, $isSquidOnly, $xff ] = $this->checkUserUtilityService->getClientIPfromXFF( $xff );
153
154        $dbw = $this->connectionProvider->getPrimaryDatabase();
155        $row = array_merge(
156            [
157                'cupe_namespace'  => 0,
158                'cupe_title'      => '',
159                'cupe_log_type'   => 'checkuser-private-event',
160                'cupe_log_action' => '',
161                'cupe_params'     => LogEntryBase::makeParamBlob( [] ),
162                'cupe_page'       => 0,
163                'cupe_actor'      => $this->acquireActorId( $user, CheckUserQueryInterface::PRIVATE_LOG_EVENT_TABLE ),
164                'cupe_timestamp'  => $dbw->timestamp( wfTimestampNow() ),
165                'cupe_ip'         => IPUtils::sanitizeIP( $ip ),
166                'cupe_ip_hex'     => $ip ? IPUtils::toHex( $ip ) : null,
167                'cupe_xff'        => !$isSquidOnly ? $xff : '',
168                'cupe_xff_hex'    => ( $xff_ip && !$isSquidOnly ) ? IPUtils::toHex( $xff_ip ) : null,
169                'cupe_agent'      => $this->getAgent( $request ),
170            ],
171            $row
172        );
173
174        // (T199323) Truncate text fields prior to database insertion
175        // Attempting to insert too long text will cause an error in MariaDB/MySQL strict mode
176        $row['cupe_xff'] = $this->contentLanguage->truncateForDatabase( $row['cupe_xff'], self::TEXT_FIELD_LENGTH );
177
178        if ( !isset( $row['cupe_comment_id'] ) ) {
179            $row += $this->commentStore->insert(
180                $dbw,
181                'cupe_comment',
182                $row['cupe_comment'] ?? ''
183            );
184        }
185
186        // Remove any defined cupe_comment as this is not a valid column name.
187        unset( $row['cupe_comment'] );
188
189        $dbw->newInsertQueryBuilder()
190            ->insertInto( 'cu_private_event' )
191            ->row( $row )
192            ->caller( $method )
193            ->execute();
194    }
195
196    /**
197     * Inserts a row in cu_changes based on the provided $row.
198     *
199     * The $user parameter is used to generate the default value for cuc_actor.
200     *
201     * @param array $row an array of cu_change table column names to their values. Overridable by a hook
202     *  and for any necessary truncation.
203     * @param string $method the method name that called this, used for the insertion into the DB.
204     * @param UserIdentity $user the user who made the change
205     * @param ?RecentChange $rc If triggered by a RecentChange, then this is the associated
206     *  RecentChange object. Null if not triggered by a RecentChange.
207     * @internal Only for use by the CheckUser extension
208     */
209    public function insertIntoCuChangesTable(
210        array $row,
211        string $method,
212        UserIdentity $user,
213        ?RecentChange $rc = null
214    ): void {
215        $request = RequestContext::getMain()->getRequest();
216
217        $ip = $request->getIP();
218        $xff = $request->getHeader( 'X-Forwarded-For' );
219        // Provide the ip, xff and row to code that hooks onto this so that they can modify the row before
220        //  it's inserted. The ip and xff are provided separately so that the caller doesn't have to set
221        //  the hex versions of the IP and XFF and can therefore leave that to this function.
222        $this->hookRunner->onCheckUserInsertChangesRow( $ip, $xff, $row, $user, $rc );
223        [ $xff_ip, $isSquidOnly, $xff ] = $this->checkUserUtilityService->getClientIPfromXFF( $xff );
224
225        $dbw = $this->connectionProvider->getPrimaryDatabase();
226        $row = array_merge(
227            [
228                'cuc_page_id'    => 0,
229                'cuc_namespace'  => 0,
230                'cuc_minor'      => 0,
231                'cuc_title'      => '',
232                'cuc_actiontext' => '',
233                'cuc_comment'    => '',
234                'cuc_actor'      => $this->acquireActorId( $user, CheckUserQueryInterface::CHANGES_TABLE ),
235                'cuc_this_oldid' => 0,
236                'cuc_last_oldid' => 0,
237                'cuc_type'       => RC_LOG,
238                'cuc_timestamp'  => $dbw->timestamp( wfTimestampNow() ),
239                'cuc_ip'         => IPUtils::sanitizeIP( $ip ),
240                'cuc_ip_hex'     => $ip ? IPUtils::toHex( $ip ) : null,
241                'cuc_xff'        => !$isSquidOnly ? $xff : '',
242                'cuc_xff_hex'    => ( $xff_ip && !$isSquidOnly ) ? IPUtils::toHex( $xff_ip ) : null,
243                'cuc_agent'      => $this->getAgent( $request ),
244            ],
245            $row
246        );
247
248        // (T199323) Truncate text fields prior to database insertion
249        // Attempting to insert too long text will cause an error in MariaDB/MySQL strict mode
250        $row['cuc_actiontext'] = $this->contentLanguage->truncateForDatabase(
251            $row['cuc_actiontext'],
252            self::TEXT_FIELD_LENGTH
253        );
254        $row['cuc_xff'] = $this->contentLanguage->truncateForDatabase( $row['cuc_xff'], self::TEXT_FIELD_LENGTH );
255
256        if ( !isset( $row['cuc_comment_id'] ) ) {
257            $row += $this->commentStore->insert(
258                $dbw,
259                'cuc_comment',
260                $row['cuc_comment']
261            );
262        }
263        unset( $row['cuc_comment'] );
264
265        $dbw->newInsertQueryBuilder()
266            ->insertInto( 'cu_changes' )
267            ->row( $row )
268            ->caller( $method )
269            ->execute();
270    }
271
272    /**
273     * Get user agent for the given request.
274     *
275     * @param WebRequest $request
276     * @return string
277     */
278    private function getAgent( WebRequest $request ): string {
279        $agent = $request->getHeader( 'User-Agent' );
280        if ( $agent === false ) {
281            // no agent was present, store as an empty string (otherwise, it would
282            // end up stored as a zero due to boolean casting done by the DB layer).
283            return '';
284        }
285        return $this->contentLanguage->truncateForDatabase( $agent, self::TEXT_FIELD_LENGTH );
286    }
287
288    /**
289     * Generates an integer for insertion into cuc_actor, cule_actor, or cupe_actor.
290     *
291     * This integer will be an actor ID for the $user unless all the following are true:
292     * * The $user is an IP address
293     * * $wgAutoCreateTempUser['enabled'] is true
294     * * The $table is 'cu_private_event'
295     *
296     * In all of the above are true, this method will return null as when the first two are true, trying to create an
297     * actor ID will cause a CannotCreateActorException exception to be thrown.
298     *
299     * If the first two are true but the last is not, then the code will try to find an existing actor ID for the IP
300     * address (to allow imports) and if this fails then will throw a CannotCreateActorException.
301     *
302     * @param UserIdentity $user
303     * @param string $table The table that the actor ID will be inserted into.
304     * @return ?int The value to insert into the actor column (can be null if the table is cu_private_event).
305     */
306    private function acquireActorId( UserIdentity $user, string $table ): ?int {
307        $dbw = $this->connectionProvider->getPrimaryDatabase();
308        if ( IPUtils::isIPAddress( $user->getName() ) && $this->tempUserConfig->isEnabled() ) {
309            if ( $table === CheckUserQueryInterface::PRIVATE_LOG_EVENT_TABLE ) {
310                return null;
311            }
312            $actorId = $this->actorStore->findActorId( $user, $dbw );
313            if ( $actorId !== null ) {
314                return $actorId;
315            }
316        }
317        return $this->actorStore->acquireActorId( $user, $dbw );
318    }
319}