Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.16% covered (warning)
85.16%
109 / 128
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseLogger
85.16% covered (warning)
85.16%
109 / 128
71.43% covered (warning)
71.43%
5 / 7
36.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 addLogEntries
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
6
 buildLogTemplate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 newLocalLogEntryFromData
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 insertLocalLogEntries
72.00% covered (warning)
72.00%
18 / 25
0.00% covered (danger)
0.00%
0 / 1
12.20
 insertGlobalLogEntries
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 publishEntry
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter;
4
5use DeferredUpdates;
6use ExtensionRegistry;
7use InvalidArgumentException;
8use ManualLogEntry;
9use MediaWiki\CheckUser\Hooks;
10use MediaWiki\Config\ServiceOptions;
11use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
12use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
13use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
14use MediaWiki\Title\Title;
15use MediaWiki\User\UserIdentityValue;
16use User;
17use Wikimedia\Rdbms\IDatabase;
18use Wikimedia\Rdbms\LBFactory;
19
20class AbuseLogger {
21    public const CONSTRUCTOR_OPTIONS = [
22        'AbuseFilterLogIP',
23        'AbuseFilterNotifications',
24        'AbuseFilterNotificationsPrivate',
25    ];
26
27    /** @var Title */
28    private $title;
29    /** @var User */
30    private $user;
31    /** @var VariableHolder */
32    private $vars;
33    /** @var string */
34    private $action;
35
36    /** @var CentralDBManager */
37    private $centralDBManager;
38    /** @var FilterLookup */
39    private $filterLookup;
40    /** @var VariablesBlobStore */
41    private $varBlobStore;
42    /** @var VariablesManager */
43    private $varManager;
44    /** @var EditRevUpdater */
45    private $editRevUpdater;
46    /** @var LBFactory */
47    private $lbFactory;
48    /** @var ServiceOptions */
49    private $options;
50    /** @var string */
51    private $wikiID;
52    /** @var string */
53    private $requestIP;
54
55    /**
56     * @param CentralDBManager $centralDBManager
57     * @param FilterLookup $filterLookup
58     * @param VariablesBlobStore $varBlobStore
59     * @param VariablesManager $varManager
60     * @param EditRevUpdater $editRevUpdater
61     * @param LBFactory $lbFactory
62     * @param ServiceOptions $options
63     * @param string $wikiID
64     * @param string $requestIP
65     * @param Title $title
66     * @param User $user
67     * @param VariableHolder $vars
68     */
69    public function __construct(
70        CentralDBManager $centralDBManager,
71        FilterLookup $filterLookup,
72        VariablesBlobStore $varBlobStore,
73        VariablesManager $varManager,
74        EditRevUpdater $editRevUpdater,
75        LBFactory $lbFactory,
76        ServiceOptions $options,
77        string $wikiID,
78        string $requestIP,
79        Title $title,
80        User $user,
81        VariableHolder $vars
82    ) {
83        if ( !$vars->varIsSet( 'action' ) ) {
84            throw new InvalidArgumentException( "The 'action' variable is not set." );
85        }
86        $this->centralDBManager = $centralDBManager;
87        $this->filterLookup = $filterLookup;
88        $this->varBlobStore = $varBlobStore;
89        $this->varManager = $varManager;
90        $this->editRevUpdater = $editRevUpdater;
91        $this->lbFactory = $lbFactory;
92        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
93        $this->options = $options;
94        $this->wikiID = $wikiID;
95        $this->requestIP = $requestIP;
96        $this->title = $title;
97        $this->user = $user;
98        $this->vars = $vars;
99        $this->action = $vars->getComputedVariable( 'action' )->toString();
100    }
101
102    /**
103     * Create and publish log entries for taken actions
104     *
105     * @param array[] $actionsTaken
106     * @return array Shape is [ 'local' => int[], 'global' => int[] ], IDs of logged filters
107     * @phan-return array{local:int[],global:int[]}
108     */
109    public function addLogEntries( array $actionsTaken ): array {
110        $dbw = $this->lbFactory->getPrimaryDatabase();
111        $logTemplate = $this->buildLogTemplate();
112        $centralLogTemplate = [
113            'afl_wiki' => $this->wikiID,
114        ];
115
116        $logRows = [];
117        $centralLogRows = [];
118        $loggedLocalFilters = [];
119        $loggedGlobalFilters = [];
120
121        foreach ( $actionsTaken as $filter => $actions ) {
122            list( $filterID, $global ) = GlobalNameUtils::splitGlobalName( $filter );
123            $thisLog = $logTemplate;
124            $thisLog['afl_filter_id'] = $filterID;
125            $thisLog['afl_global'] = (int)$global;
126            $thisLog['afl_actions'] = implode( ',', $actions );
127
128            // Don't log if we were only throttling.
129            // TODO This check should be removed or rewritten using Consequence objects
130            if ( $thisLog['afl_actions'] !== 'throttle' ) {
131                $logRows[] = $thisLog;
132                // Global logging
133                if ( $global ) {
134                    $centralLog = $thisLog + $centralLogTemplate;
135                    $centralLog['afl_filter_id'] = $filterID;
136                    $centralLog['afl_global'] = 0;
137                    $centralLog['afl_title'] = $this->title->getPrefixedText();
138                    $centralLog['afl_namespace'] = 0;
139
140                    $centralLogRows[] = $centralLog;
141                    $loggedGlobalFilters[] = $filterID;
142                } else {
143                    $loggedLocalFilters[] = $filterID;
144                }
145            }
146        }
147
148        if ( !count( $logRows ) ) {
149            return [ 'local' => [], 'global' => [] ];
150        }
151
152        $localLogIDs = $this->insertLocalLogEntries( $logRows, $dbw );
153
154        $globalLogIDs = [];
155        if ( count( $loggedGlobalFilters ) ) {
156            $fdb = $this->centralDBManager->getConnection( DB_PRIMARY );
157            $globalLogIDs = $this->insertGlobalLogEntries( $centralLogRows, $fdb );
158        }
159
160        $this->editRevUpdater->setLogIdsForTarget(
161            $this->title,
162            [ 'local' => $localLogIDs, 'global' => $globalLogIDs ]
163        );
164
165        return [ 'local' => $loggedLocalFilters, 'global' => $loggedGlobalFilters ];
166    }
167
168    /**
169     * Creates a template to use for logging taken actions
170     *
171     * @return array
172     */
173    private function buildLogTemplate(): array {
174        // If $this->user isn't safe to load (e.g. a failure during
175        // AbortAutoAccount), create a dummy anonymous user instead.
176        $user = $this->user->isSafeToLoad() ? $this->user : new User;
177        // Create a template
178        $logTemplate = [
179            'afl_user' => $user->getId(),
180            'afl_user_text' => $user->getName(),
181            'afl_timestamp' => $this->lbFactory->getReplicaDatabase()->timestamp(),
182            'afl_namespace' => $this->title->getNamespace(),
183            'afl_title' => $this->title->getDBkey(),
184            'afl_action' => $this->action,
185            'afl_ip' => $this->options->get( 'AbuseFilterLogIP' ) ? $this->requestIP : ''
186        ];
187        // Hack to avoid revealing IPs of people creating accounts
188        if ( ( $this->action === 'createaccount' || $this->action === 'autocreateaccount' ) && !$user->getId() ) {
189            $logTemplate['afl_user_text'] = $this->vars->getComputedVariable( 'accountname' )->toString();
190        }
191        return $logTemplate;
192    }
193
194    /**
195     * @param array $data
196     * @return ManualLogEntry
197     */
198    private function newLocalLogEntryFromData( array $data ): ManualLogEntry {
199        // Give grep a chance to find the usages:
200        // logentry-abusefilter-hit
201        $entry = new ManualLogEntry( 'abusefilter', 'hit' );
202        $user = new UserIdentityValue( $data['afl_user'], $data['afl_user_text'] );
203        $entry->setPerformer( $user );
204        $entry->setTarget( $this->title );
205        $filterName = GlobalNameUtils::buildGlobalName(
206            $data['afl_filter_id'],
207            $data['afl_global'] === 1
208        );
209        // Additional info
210        $entry->setParameters( [
211            'action' => $data['afl_action'],
212            'filter' => $filterName,
213            'actions' => $data['afl_actions'],
214            'log' => $data['afl_id'],
215        ] );
216        return $entry;
217    }
218
219    /**
220     * @param array[] $logRows
221     * @param IDatabase $dbw
222     * @return int[]
223     */
224    private function insertLocalLogEntries( array $logRows, IDatabase $dbw ): array {
225        $varDump = $this->varBlobStore->storeVarDump( $this->vars );
226
227        $loggedIDs = [];
228        foreach ( $logRows as $data ) {
229            $data['afl_var_dump'] = $varDump;
230            $dbw->insert( 'abuse_filter_log', $data, __METHOD__ );
231            $loggedIDs[] = $data['afl_id'] = $dbw->insertId();
232
233            // Send data to CheckUser if installed and we
234            // aren't already sending a notification to recentchanges
235            if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' )
236                && strpos( $this->options->get( 'AbuseFilterNotifications' ), 'rc' ) === false
237            ) {
238                global $wgCheckUserLogAdditionalRights;
239                $wgCheckUserLogAdditionalRights[] = 'abusefilter-view';
240                $entry = $this->newLocalLogEntryFromData( $data );
241                $user = $entry->getPerformerIdentity();
242                // Invert the hack from ::buildLogTemplate because CheckUser attempts
243                // to assign an actor id to the non-existing user
244                if (
245                    ( $this->action === 'createaccount' || $this->action === 'autocreateaccount' )
246                    && !$user->getId()
247                ) {
248                    $entry->setPerformer( new UserIdentityValue( 0, $this->requestIP ) );
249                }
250                $rc = $entry->getRecentChange();
251                Hooks::updateCheckUserData( $rc );
252            }
253
254            if ( $this->options->get( 'AbuseFilterNotifications' ) !== false ) {
255                $filterID = $data['afl_filter_id'];
256                $global = $data['afl_global'];
257                if (
258                    !$this->options->get( 'AbuseFilterNotificationsPrivate' ) &&
259                    $this->filterLookup->getFilter( $filterID, $global )->isHidden()
260                ) {
261                    continue;
262                }
263                $entry = $this->newLocalLogEntryFromData( $data );
264                $this->publishEntry( $dbw, $entry );
265            }
266        }
267        return $loggedIDs;
268    }
269
270    /**
271     * @param array[] $centralLogRows
272     * @param IDatabase $fdb
273     * @return int[]
274     */
275    private function insertGlobalLogEntries( array $centralLogRows, IDatabase $fdb ): array {
276        $this->varManager->computeDBVars( $this->vars );
277        $globalVarDump = $this->varBlobStore->storeVarDump( $this->vars, true );
278        foreach ( $centralLogRows as $index => $data ) {
279            $centralLogRows[$index]['afl_var_dump'] = $globalVarDump;
280        }
281
282        $loggedIDs = [];
283        foreach ( $centralLogRows as $row ) {
284            $fdb->insert( 'abuse_filter_log', $row, __METHOD__ );
285            $loggedIDs[] = $fdb->insertId();
286        }
287        return $loggedIDs;
288    }
289
290    /**
291     * Like ManualLogEntry::publish, but doesn't require an ID (which we don't have) and skips the
292     * tagging part
293     *
294     * @param IDatabase $dbw To cancel the callback if the log insertion fails
295     * @param ManualLogEntry $entry
296     */
297    private function publishEntry( IDatabase $dbw, ManualLogEntry $entry ): void {
298        DeferredUpdates::addCallableUpdate(
299            function () use ( $entry ) {
300                $rc = $entry->getRecentChange();
301                $to = $this->options->get( 'AbuseFilterNotifications' );
302
303                if ( $to === 'rc' || $to === 'rcandudp' ) {
304                    $rc->save( $rc::SEND_NONE );
305                }
306                if ( $to === 'udp' || $to === 'rcandudp' ) {
307                    $rc->notifyRCFeeds();
308                }
309            },
310            DeferredUpdates::POSTSEND,
311            $dbw
312        );
313    }
314
315}