Test Setup Failed
Pull Request — main (#426)
by MusikAnimal
17:10 queued 11:44
created

UserRights::getRightsStates()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 44
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 19
nc 4
nop 0
dl 0
loc 44
rs 9.6333
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 App\Model;
9
10
use App\Helper\I18nHelper;
11
use DateInterval;
12
use DateTimeImmutable;
13
use Exception;
14
15
/**
16
 * An UserRights provides methods around parsing changes to a user's rights.
17
 */
18
class UserRights extends Model
19
{
20
    /** @var I18nHelper For i18n and l10n. */
21
    protected $i18n;
22
23
    /** @var string[] Rights changes, keyed by timestamp then 'added' and 'removed'. */
24
    protected $rightsChanges;
25
26
    /** @var string[] Localized names of the rights. */
27
    protected $rightsNames;
28
29
    /** @var string[] Global rights changes (log), keyed by timestamp then 'added' and 'removed'. */
30
    protected $globalRightsChanges;
31
32
    /** @var array The current and former rights of the user. */
33
    protected $rightsStates = [];
34
35
    /**
36
     * Get user rights changes of the given user.
37
     * @param int|null $limit
38
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
39
     */
40
    public function getRightsChanges(?int $limit = null): array
41
    {
42
        if (!isset($this->rightsChanges)) {
43
            $logData = $this->getRepository()
44
                ->getRightsChanges($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getRightsChanges() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\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

44
                ->/** @scrutinizer ignore-call */ getRightsChanges($this->project, $this->user);
Loading history...
45
46
            $this->rightsChanges = $this->processRightsChanges($logData);
47
48
            $acDate = $this->getAutoconfirmedTimestamp();
49
            if (false !== $acDate) {
50
                $this->rightsChanges[$acDate] = [
51
                    'logId' => null,
52
                    'performer' => null,
53
                    'comment' => null,
54
                    'added' => ['autoconfirmed'],
55
                    'removed' => [],
56
                    'grantType' => strtotime($acDate) > time() ? 'pending' : 'automatic',
57
                    'type' => 'local',
58
                ];
59
                krsort($this->rightsChanges);
60
            }
61
        }
62
63
        return array_slice($this->rightsChanges, 0, $limit, true);
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
    public function getAdminStatus()
71
    {
72
        $rightsStates = $this->getRightsStates();
73
74
        if (in_array('sysop', $rightsStates['local']['current'])) {
75
            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
    public function getRightsStates(): array
88
    {
89
        if (count($this->rightsStates) > 0) {
90
            return $this->rightsStates;
91
        }
92
93
        foreach (['local', 'global'] as $type) {
94
            [$currentRights, $rightsChanges] = $this->getCurrentRightsAndChanges($type);
95
96
            $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
            $added = [];
103
104
            foreach ($rightsChanges as $change) {
105
                $former = array_diff(
106
                    array_merge($former, $change['removed']),
107
                    $change['added']
108
                );
109
110
                $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
            $former = array_merge(
116
                array_diff($added, $currentRights),
117
                $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
            $former = array_diff(array_unique($former), $currentRights, ['autoconfirmed']);
123
124
            $this->rightsStates[$type] = [
125
                'current' => $currentRights,
126
                'former' => $former,
127
            ];
128
        }
129
130
        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
    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
        if ('local' === $type) {
144
            $currentRights = $this->user->getUserRights($this->project);
145
            $rightsChanges = $this->getRightsChanges();
146
147
            $acDate = $this->getAutoconfirmedTimestamp();
148
            if (false !== $acDate && strtotime($acDate) <= time()) {
149
                $currentRights[] = 'autoconfirmed';
150
            }
151
        } else {
152
            $currentRights = $this->user->getGlobalUserRights($this->project);
153
            $rightsChanges = $this->getGlobalRightsChanges();
154
        }
155
156
        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
     * @param int|null $limit
171
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
172
     */
173
    public function getGlobalRightsChanges(?int $limit = null): array
174
    {
175
        if (!isset($this->globalRightsChanges)) {
176
            $logData = $this->getRepository()
177
                ->getGlobalRightsChanges($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getGlobalRightsChanges() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\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

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

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

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

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

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

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