Passed
Push — master ( 587f2b...51d07d )
by MusikAnimal
09:36 queued 03:02
created

UserRights::unsetAutoRemoval()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 4.5923

Importance

Changes 0
Metric Value
cc 4
eloc 5
nc 4
nop 2
dl 0
loc 7
ccs 4
cts 6
cp 0.6667
crap 4.5923
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains only the UserRights class.
4
 */
5
6
declare(strict_types = 1);
7
8
namespace AppBundle\Model;
9
10
use AppBundle\Helper\I18nHelper;
11
use DateInterval;
12
use Exception;
13
14
/**
15
 * An UserRights provides methods around parsing changes to a user's rights.
16
 */
17
class UserRights extends Model
18
{
19
    /** @var I18nHelper For i18n and l10n. */
20
    protected $i18n;
21
22
    /** @var string[] Rights changes, keyed by timestamp then 'added' and 'removed'. */
23
    protected $rightsChanges;
24
25
    /** @var string[] Localized names of the rights. */
26
    protected $rightsNames;
27
28
    /** @var string[] Global rights changes (log), keyed by timestamp then 'added' and 'removed'. */
29
    protected $globalRightsChanges;
30
31
    /** @var array The current and former rights of the user. */
32
    protected $rightsStates = [];
33
34
    /**
35
     * Get user rights changes of the given user.
36
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
37
     */
38 1
    public function getRightsChanges(): array
39
    {
40 1
        if (isset($this->rightsChanges)) {
41 1
            return $this->rightsChanges;
42
        }
43
44 1
        $logData = $this->getRepository()
45 1
            ->getRightsChanges($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getRightsChanges() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

45
            ->/** @scrutinizer ignore-call */ getRightsChanges($this->project, $this->user);
Loading history...
46
47 1
        $this->rightsChanges = $this->processRightsChanges($logData);
48
49 1
        $acDate = $this->getAutoconfirmedTimestamp();
50 1
        if (false !== $acDate) {
51
            $this->rightsChanges[$acDate] = [
52
                'logId' => null,
53
                'performer' => null,
54
                'comment' => null,
55
                'added' => ['autoconfirmed'],
56
                'removed' => [],
57
                'grantType' => strtotime($acDate) > time() ? 'pending' : 'automatic',
58
                'type' => 'local',
59
            ];
60
            krsort($this->rightsChanges);
61
        }
62
63 1
        return $this->rightsChanges;
64
    }
65
66
    /**
67
     * Checks the user rights log to see whether the user is an admin or used to be one.
68
     * @return string|false One of false (never an admin), 'current' or 'former'.
69
     */
70 1
    public function getAdminStatus()
71
    {
72 1
        $rightsStates = $this->getRightsStates();
73
74 1
        if (in_array('sysop', $rightsStates['local']['current'])) {
75 1
            return 'current';
76
        } elseif (in_array('sysop', $rightsStates['local']['former'])) {
77
            return 'former';
78
        } else {
79
            return false;
80
        }
81
    }
82
83
    /**
84
     * Get a list of the current and former rights of the user.
85
     * @return array With keys 'local' and 'global', each with keys 'current' and 'former'.
86
     */
87 1
    public function getRightsStates(): array
88
    {
89 1
        if (count($this->rightsStates) > 0) {
90 1
            return $this->rightsStates;
91
        }
92
93 1
        foreach (['local', 'global'] as $type) {
94 1
            [$currentRights, $rightsChanges] = $this->getCurrentRightsAndChanges($type);
95
96 1
            $former = [];
97
98
            // We'll keep track of added rights, which we'll later compare with the
99
            // current rights to ensure the list of former rights is complete.
100
            // This is because sometimes rights were removed but there mysteriously
101
            // is no log entry of it.
102 1
            $added = [];
103
104 1
            foreach ($rightsChanges as $change) {
105 1
                $former = array_diff(
106 1
                    array_merge($former, $change['removed']),
107 1
                    $change['added']
108
                );
109
110 1
                $added = array_unique(array_merge($added, $change['added']));
111
            }
112
113
            // Also tag on rights that were previously added but mysteriously
114
            // don't have a log entry for when they were removed.
115 1
            $former = array_merge(
116 1
                array_diff($added, $currentRights),
117 1
                $former
118
            );
119
120
            // Remove the current rights for good measure. Autoconfirmed is a special case -- it can never be former,
121
            // but will end up in $former from the above code.
122 1
            $former = array_diff(array_unique($former), $currentRights, ['autoconfirmed']);
123
124 1
            $this->rightsStates[$type] = [
125 1
                'current' => $currentRights,
126 1
                'former' => $former,
127
            ];
128
        }
129
130 1
        return $this->rightsStates;
131
    }
132
133
    /**
134
     * Get a list of the current rights (of given type) and the log.
135
     * @param string $type 'local' or 'global'
136
     * @return array [string[] current rights, array rights changes].
137
     */
138 1
    private function getCurrentRightsAndChanges(string $type): array
139
    {
140
        // Current rights are not fetched from the log because really old
141
        // log entries contained little or no metadata, and the rights
142
        // changes may be undetectable.
143 1
        if ('local' === $type) {
144 1
            $currentRights = $this->user->getUserRights($this->project);
145 1
            $rightsChanges = $this->getRightsChanges();
146
147 1
            $acDate = $this->getAutoconfirmedTimestamp();
148 1
            if (false !== $acDate && strtotime($acDate) <= time()) {
149 1
                $currentRights[] = 'autoconfirmed';
150
            }
151
        } else {
152 1
            $currentRights = $this->user->getGlobalUserRights($this->project);
153 1
            $rightsChanges = $this->getGlobalRightsChanges();
154
        }
155
156 1
        return [$currentRights, $rightsChanges];
157
    }
158
159
    /**
160
     * Get a list of the current and former global rights of the user.
161
     * @return array With keys 'current' and 'former'.
162
     */
163
    public function getGlobalRightsStates(): array
164
    {
165
        return $this->getRightsStates()['global'];
166
    }
167
168
    /**
169
     * Get global user rights changes of the given user.
170
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
171
     */
172 1
    public function getGlobalRightsChanges(): array
173
    {
174 1
        if (isset($this->globalRightsChanges)) {
175 1
            return $this->globalRightsChanges;
176
        }
177
178 1
        $logData = $this->getRepository()
179 1
            ->getGlobalRightsChanges($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getGlobalRightsChanges() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

179
            ->/** @scrutinizer ignore-call */ getGlobalRightsChanges($this->project, $this->user);
Loading history...
180
181 1
        $this->globalRightsChanges = $this->processRightsChanges($logData);
182
183 1
        return $this->globalRightsChanges;
184
    }
185
186
    /**
187
     * Get the localized names for the user groups, fetched from on-wiki system messages.
188
     * @return string[] Localized names keyed by database value.
189
     */
190
    public function getRightsNames(): array
191
    {
192
        if (isset($this->rightsNames)) {
193
            return $this->rightsNames;
194
        }
195
196
        $this->rightsNames = $this->getRepository()
197
            ->getRightsNames($this->project, $this->i18n->getLang());
0 ignored issues
show
Bug introduced by
The method getRightsNames() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

197
            ->/** @scrutinizer ignore-call */ getRightsNames($this->project, $this->i18n->getLang());
Loading history...
198
199
        return $this->rightsNames;
200
    }
201
202
    /**
203
     * Get the localized translation for the given user right.
204
     * @param string $name The name of the right, such as 'sysop'.
205
     * @return string
206
     */
207
    public function getRightsName(string $name): string
208
    {
209
        return $this->getRightsNames()[$name] ?? $name;
210
    }
211
212
    /**
213
     * Process the given rights changes, sorting an putting in a human-readable format.
214
     * @param array $logData As fetched with EditCounterRepository::getRightsChanges.
215
     * @return array
216
     */
217 1
    private function processRightsChanges(array $logData): array
218
    {
219 1
        $rightsChanges = [];
220
221 1
        foreach ($logData as $row) {
222
            // Can happen if the log entry has been deleted.
223 1
            if (!isset($row['log_params']) || null === $row['log_params']) {
224
                continue;
225
            }
226
227 1
            $unserialized = @unserialize($row['log_params']);
228
229 1
            if (false !== $unserialized) {
230 1
                $old = $unserialized['4::oldgroups'];
231 1
                $new = $unserialized['5::newgroups'];
232 1
                $added = array_diff($new, $old);
233 1
                $removed = array_diff($old, $new);
234 1
                $oldMetadata = $unserialized['oldmetadata'] ?? null;
235 1
                $newMetadata = $unserialized['newmetadata'] ?? null;
236
237
                // Check for changes only to expiry. If such exists, treat it as added. Various issets are safeguards.
238 1
                if (empty($added) && empty($removed) && isset($oldMetadata) && isset($newMetadata)) {
239
                    foreach ($old as $index => $right) {
240
                        $oldExpiry = $oldMetadata[$index]['expiry'] ?? null;
241
                        $newExpiry = $newMetadata[$index]['expiry'] ?? null;
242
243
                        // Check if an expiry was added, removed, or modified.
244
                        if ((null !== $oldExpiry && null === $newExpiry) ||
245
                            (null === $oldExpiry && null !== $newExpiry) ||
246
                            (null !== $oldExpiry && null !== $newExpiry)
247
                        ) {
248
                            $added[$index] = $right;
249
250
                            // Remove the last auto-removal(s), which must exist.
251
                            foreach (array_reverse($rightsChanges, true) as $timestamp => $change) {
252
                                if (in_array($right, $change['removed']) && !in_array($right, $change['added']) &&
253
                                    'automatic' === $change['grantType']
254
                                ) {
255
                                    unset($rightsChanges[$timestamp]);
256
                                }
257
                            }
258
                        }
259
                    }
260
                }
261
262
                // If a right was removed, remove any previously pending auto-removals.
263 1
                if (count($removed) > 0) {
264 1
                    $this->unsetAutoRemoval($rightsChanges, $removed);
265
                }
266
267 1
                $this->setAutoRemovals($rightsChanges, $row, $unserialized, $added);
268
            } else {
269
                // This is the old school format the most likely contains
270
                // the list of rights additions as a comma-separated list.
271
                try {
272 1
                    [$old, $new] = explode("\n", $row['log_params']);
273 1
                    $old = array_filter(array_map('trim', explode(',', $old)));
274 1
                    $new = array_filter(array_map('trim', explode(',', (string)$new)));
275 1
                    $added = array_diff($new, $old);
276 1
                    $removed = array_diff($old, $new);
277
                } catch (Exception $e) {
278
                    // Really, really old school format that may be missing metadata
279
                    // altogether. Here we'll just leave $added and $removed empty.
280
                    $added = [];
281
                    $removed = [];
282
                }
283
            }
284
285
            // Remove '(none)'.
286 1
            if (in_array('(none)', $added)) {
287
                array_splice($added, array_search('(none)', $added), 1);
0 ignored issues
show
Bug introduced by
It seems like array_search('(none)', $added) can also be of type false and string; however, parameter $offset of array_splice() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

287
                array_splice($added, /** @scrutinizer ignore-type */ array_search('(none)', $added), 1);
Loading history...
288
            }
289 1
            if (in_array('(none)', $removed)) {
290
                array_splice($removed, array_search('(none)', $removed), 1);
291
            }
292
293 1
            $rightsChanges[$row['log_timestamp']] = [
294 1
                'logId' => $row['log_id'],
295 1
                'performer' => 'autopromote' === $row['log_action'] ? null : $row['log_user_text'],
296 1
                'comment' => $row['log_comment'],
297 1
                'added' => array_values($added),
298 1
                'removed' => array_values($removed),
299 1
                'grantType' => 'autopromote' === $row['log_action'] ? 'automatic' : 'manual',
300 1
                'type' => $row['type'],
301
            ];
302
        }
303
304 1
        krsort($rightsChanges);
305
306 1
        return $rightsChanges;
307
    }
308
309
    /**
310
     * Check the given log entry for rights changes that are set to automatically expire,
311
     * and add entries to $rightsChanges accordingly.
312
     * @param array $rightsChanges
313
     * @param array $row Log entry row from database.
314
     * @param array $params Unserialized log params.
315
     * @param string[] $added List of added user rights.
316
     */
317 1
    private function setAutoRemovals(array &$rightsChanges, array $row, array $params, array $added): void
318
    {
319 1
        foreach ($added as $index => $entry) {
320
            // Skip if no expiry was set.
321 1
            if (!isset($params['newmetadata'][$index]) ||
322 1
                !array_key_exists('expiry', $params['newmetadata'][$index]) ||
323 1
                empty($params['newmetadata'][$index]['expiry'])
324
            ) {
325 1
                continue;
326
            }
327
328 1
            $expiry = $params['newmetadata'][$index]['expiry'];
329
330 1
            if (isset($rightsChanges[$expiry]) && !in_array($entry, $rightsChanges[$expiry]['removed'])) {
331
                // Temporary right expired.
332 1
                $rightsChanges[$expiry]['removed'][] = $entry;
333
            } else {
334
                // Temporary right was added.
335 1
                $rightsChanges[$expiry] = [
336 1
                    'logId' => $row['log_id'],
337 1
                    'performer' => $row['log_user_text'],
338
                    'comment' => null,
339
                    'added' => [],
340 1
                    'removed' => [$entry],
341 1
                    'grantType' => strtotime($expiry) > time() ? 'pending' : 'automatic',
342 1
                    'type' => $row['type'],
343
                ];
344
            }
345
        }
346
347
        // Resort because the auto-removal timestamp could be before other rights changes.
348 1
        ksort($rightsChanges);
349 1
    }
350
351 1
    private function unsetAutoRemoval(array &$rightsChanges, array $removed): void
352
    {
353 1
        foreach ($rightsChanges as $timestamp => $change) {
354 1
            if ('pending' === $change['grantType']) {
355
                $rightsChanges[$timestamp]['removed'] = array_diff($rightsChanges[$timestamp]['removed'], $removed);
356
                if (empty($rightsChanges[$timestamp]['removed'])) {
357 1
                    unset($rightsChanges[$timestamp]);
358
                }
359
            }
360
        }
361 1
    }
362
363
    /**
364
     * Get the timestamp of when the user became autoconfirmed.
365
     * @return string|false YmdHis format, or false if date is in the future or if AC status could not be determined.
366
     */
367 1
    private function getAutoconfirmedTimestamp()
368
    {
369 1
        static $acTimestamp = null;
370 1
        if (null !== $acTimestamp) {
371
            return $acTimestamp;
372
        }
373
374 1
        $thresholds = $this->getRepository()->getAutoconfirmedAgeAndCount($this->project);
0 ignored issues
show
Bug introduced by
The method getAutoconfirmedAgeAndCount() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

374
        $thresholds = $this->getRepository()->/** @scrutinizer ignore-call */ getAutoconfirmedAgeAndCount($this->project);
Loading history...
375
376
        // Happens for non-WMF installations, or if there is no autoconfirmed status.
377 1
        if (null === $thresholds) {
378 1
            return false;
379
        }
380
381
        $registrationDate = $this->user->getRegistrationDate($this->project);
382
383
        // Sometimes for old accounts the registration date is null, in which case
384
        // we won't attempt to find out when they were autoconfirmed.
385
        if (!is_a($registrationDate, 'DateTime')) {
386
            return false;
387
        }
388
389
        $regDateImmutable = new \DateTimeImmutable(
390
            $registrationDate->format('YmdHis')
391
        );
392
393
        $acDate = $regDateImmutable->add(DateInterval::createFromDateString(
394
            $thresholds['wgAutoConfirmAge'].' seconds'
395
        ))->format('YmdHis');
396
397
        // First check if they already had 10 edits made as of $acDate
398
        $editsByAcDate = $this->getRepository()->getNumEditsByTimestamp(
0 ignored issues
show
Bug introduced by
The method getNumEditsByTimestamp() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

398
        $editsByAcDate = $this->getRepository()->/** @scrutinizer ignore-call */ getNumEditsByTimestamp(
Loading history...
399
            $this->project,
400
            $this->user,
401
            $acDate
402
        );
403
404
        // If more than wgAutoConfirmCount, then $acDate is when they became autoconfirmed.
405
        if ($editsByAcDate >= $thresholds['wgAutoConfirmCount']) {
406
            return $acDate;
407
        }
408
409
        // Now check when the nth edit was made, where n is wgAutoConfirmCount.
410
        // This will be false if they still haven't made 10 edits.
411
        $acTimestamp = $this->getRepository()->getNthEditTimestamp(
0 ignored issues
show
Bug introduced by
The method getNthEditTimestamp() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

411
        $acTimestamp = $this->getRepository()->/** @scrutinizer ignore-call */ getNthEditTimestamp(
Loading history...
412
            $this->project,
413
            $this->user,
414
            $registrationDate->format('YmdHis'),
415
            $thresholds['wgAutoConfirmCount']
416
        );
417
418
        return $acTimestamp;
419
    }
420
}
421