Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.34% covered (success)
94.34%
50 / 53
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Logger
94.34% covered (success)
94.34%
50 / 53
75.00% covered (warning)
75.00%
6 / 8
13.03
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 logViewInfobox
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 logViewPopup
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 logAccessEnabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 logAccessDisabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 debouncedLog
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
3.00
 log
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 createManualLogEntry
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\IPInfo\Logging;
4
5use ManualLogEntry;
6use MediaWiki\Title\Title;
7use MediaWiki\User\ActorStore;
8use MediaWiki\User\UserIdentity;
9use Wikimedia\Assert\Assert;
10use Wikimedia\Assert\ParameterAssertionException;
11use Wikimedia\IPUtils;
12use Wikimedia\Rdbms\IDatabase;
13use Wikimedia\Rdbms\IExpression;
14use Wikimedia\Rdbms\LikeValue;
15
16/**
17 * Defines the API for the component responsible for logging the following interactions:
18 *
19 * 1. A user views information about an IP via the infobox
20 * 2. A user views information about an IP via the popup
21 * 3. A user enables IP Info (via Special:Preferences)
22 * 4. A user disables IP Info
23 *
24 * 1 and 2 are debounced. By default, if the same user views information about the same IP via the
25 * same treatment within 24 hours, then only one such action should be logged.
26 *
27 * All the above interactions will be logged to the `logging` table with a log type `ipinfo`.
28 */
29class Logger {
30    /**
31     * Represents a user (the performer) viewing information about an IP via the infobox.
32     *
33     * @var string
34     */
35    public const ACTION_VIEW_INFOBOX = 'view_infobox';
36
37    /**
38     * Represents a user (the performer) viewing information about an IP via the popup.
39     *
40     * @var string
41     */
42    public const ACTION_VIEW_POPUP = 'view_popup';
43
44    /**
45     * Represents a user enabling or disabling their own access to IPInfo
46     *
47     * @var string
48     */
49    public const ACTION_CHANGE_ACCESS = 'change_access';
50
51    /**
52     * @var string
53     */
54    public const ACTION_ACCESS_ENABLED = 'enable';
55
56    /**
57     * @var string
58     */
59    public const ACTION_ACCESS_DISABLED = 'disable';
60
61    /**
62     * @var string
63     */
64    public const LOG_TYPE = 'ipinfo';
65
66    private ActorStore $actorStore;
67
68    private IDatabase $dbw;
69
70    private int $delay;
71
72    /**
73     * @param ActorStore $actorStore
74     * @param IDatabase $dbw
75     * @param int $delay The number of seconds after which a duplicate log entry can be
76     *  created by `Logger::logViewInfobox` or `Logger::logViewPopup`
77     * @throws ParameterAssertionException if `$delay` is less than 1
78     */
79    public function __construct(
80        ActorStore $actorStore,
81        IDatabase $dbw,
82        int $delay
83    ) {
84        Assert::parameter( $delay > 0, 'delay', 'delay must be positive' );
85
86        $this->actorStore = $actorStore;
87        $this->dbw = $dbw;
88        $this->delay = $delay;
89    }
90
91    /**
92     * Logs the user (the performer) viewing information about an IP via the infobox.
93     *
94     * @param UserIdentity $performer
95     * @param string $ip
96     * @param int $timestamp
97     * @param string|null $level
98     */
99    public function logViewInfobox( UserIdentity $performer, string $ip, int $timestamp, ?string $level ): void {
100        if ( !$level ) {
101            return;
102        }
103        $params = [ '4::level' => $level ];
104        $this->debouncedLog( $performer, $ip, self::ACTION_VIEW_INFOBOX, $timestamp, $params );
105    }
106
107    /**
108     * Logs the user (the performer) viewing information about an IP via the popup.
109     *
110     * @param UserIdentity $performer
111     * @param string $ip
112     * @param int $timestamp
113     * @param string|null $level
114     */
115    public function logViewPopup( UserIdentity $performer, string $ip, int $timestamp, ?string $level ): void {
116        if ( !$level ) {
117            return;
118        }
119        $params = [ '4::level' => $level ];
120        $this->debouncedLog( $performer, $ip, self::ACTION_VIEW_POPUP, $timestamp, $params );
121    }
122
123    /**
124     * Log when the user enables their own access
125     *
126     * @param UserIdentity $performer
127     */
128    public function logAccessEnabled( UserIdentity $performer ): void {
129        $params = [
130            '4::changeType' => self::ACTION_ACCESS_ENABLED,
131        ];
132        $this->log( $performer, $performer->getName(), self::ACTION_CHANGE_ACCESS, $params );
133    }
134
135    /**
136     * Log when the user disables their own access
137     *
138     * @param UserIdentity $performer
139     */
140    public function logAccessDisabled( UserIdentity $performer ): void {
141        $params = [
142            '4::changeType' => self::ACTION_ACCESS_DISABLED,
143        ];
144        $this->log( $performer, $performer->getName(), self::ACTION_CHANGE_ACCESS, $params );
145    }
146
147    /**
148     * @param UserIdentity $performer
149     * @param string $ip
150     * @param string $action Either `Logger::ACTION_VIEW_INFOBOX` or
151     *  `Logger::ACTION_VIEW_POPUP`
152     * @param int $timestamp
153     * @param array $params
154     */
155    private function debouncedLog(
156        UserIdentity $performer,
157        string $ip,
158        string $action,
159        int $timestamp,
160        array $params
161    ): void {
162        $timestampMinusDelay = $timestamp - $this->delay;
163        $ip = IPUtils::sanitizeIP( $ip );
164        $actorId = $this->actorStore->findActorId( $performer, $this->dbw );
165        if ( !$actorId ) {
166            $this->log( $performer, $ip, $action, $params );
167            return;
168        }
169
170        $logLine = $this->dbw->newSelectQueryBuilder()
171            ->select( '*' )
172            ->from( 'logging' )
173            ->where( [
174                'log_type' => self::LOG_TYPE,
175                'log_action' => $action,
176                'log_actor' => $actorId,
177                'log_namespace' => NS_USER,
178                'log_title' => $ip,
179                $this->dbw->expr( 'log_timestamp', '>', $this->dbw->timestamp( $timestampMinusDelay ) ),
180                $this->dbw->expr( 'log_params', IExpression::LIKE, new LikeValue(
181                    $this->dbw->anyString(),
182                    $params['4::level'],
183                    $this->dbw->anyString()
184                ) ),
185            ] )
186            ->fetchRow();
187
188        if ( !$logLine ) {
189            $this->log( $performer, $ip, $action, $params, $timestamp );
190        }
191    }
192
193    /**
194     * @param UserIdentity $performer
195     * @param string $ip
196     * @param string $action
197     * @param array $params
198     * @param int|null $timestamp
199     */
200    private function log(
201        UserIdentity $performer,
202        string $ip,
203        string $action,
204        array $params,
205        ?int $timestamp = null
206    ): void {
207        $logEntry = $this->createManualLogEntry( $action );
208        $logEntry->setPerformer( $performer );
209        $logEntry->setTarget( Title::makeTitle( NS_USER, $ip ) );
210        $logEntry->setParameters( $params );
211
212        if ( $timestamp ) {
213            $logEntry->setTimestamp( wfTimestamp( TS_MW, $timestamp ) );
214        }
215
216        $logEntry->insert( $this->dbw );
217    }
218
219    /**
220     * There is no `LogEntryFactory` (or `Logger::insert()` method) in MediaWiki Core to inject
221     * via the constructor, so use this method to isolate the creation of `LogEntry` objects during
222     * testing.
223     *
224     * @private
225     *
226     * @param string $subtype
227     * @return ManualLogEntry
228     */
229    protected function createManualLogEntry( string $subtype ): ManualLogEntry {
230        return new ManualLogEntry( self::LOG_TYPE, $subtype );
231    }
232}