UserRights::getRightsName()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Model;
6
7
use App\Helper\I18nHelper;
8
use App\Repository\UserRightsRepository;
9
use DateInterval;
10
use DateTimeImmutable;
11
use Exception;
12
13
/**
14
 * An UserRights provides methods around parsing changes to a user's rights.
15
 */
16
class UserRights extends Model
17
{
18
    protected I18nHelper $i18n;
19
20
    /** @var string[] Rights changes, keyed by timestamp then 'added' and 'removed'. */
21
    protected array $rightsChanges;
22
23
    /** @var string[] Localized names of the rights. */
24
    protected array $rightsNames;
25
26
    /** @var string[] Global rights changes (log), keyed by timestamp then 'added' and 'removed'. */
27
    protected array $globalRightsChanges;
28
29
    /** @var array The current and former rights of the user. */
30
    protected array $rightsStates = [];
31
32
    /**
33
     * @param UserRightsRepository $repository
34
     * @param User $user
35
     */
36
    public function __construct(UserRightsRepository $repository, Project $project, User $user, I18nHelper $i18n)
37
    {
38
        $this->repository = $repository;
39
        $this->project = $project;
40
        $this->user = $user;
41
        $this->i18n = $i18n;
42
    }
43
44
    /**
45
     * Get user rights changes of the given user.
46
     * @param int|null $limit
47
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
48
     */
49
    public function getRightsChanges(?int $limit = null): array
50
    {
51
        if (!isset($this->rightsChanges)) {
52
            $logData = $this->repository->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

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

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

152
            /** @scrutinizer ignore-call */ 
153
            $currentRights = $this->user->getUserRights($this->project);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
153
            $rightsChanges = $this->getRightsChanges();
154
155
            $acDate = $this->getAutoconfirmedTimestamp();
156
            if (false !== $acDate && strtotime($acDate) <= time()) {
157
                $currentRights[] = 'autoconfirmed';
158
            }
159
        } else {
160
            $currentRights = $this->user->getGlobalUserRights($this->project);
161
            $rightsChanges = $this->getGlobalRightsChanges();
162
        }
163
164
        return [$currentRights, $rightsChanges];
165
    }
166
167
    /**
168
     * Get a list of the current and former global rights of the user.
169
     * @return array With keys 'current' and 'former'.
170
     */
171
    public function getGlobalRightsStates(): array
172
    {
173
        return $this->getRightsStates()['global'];
174
    }
175
176
    /**
177
     * Get global user rights changes of the given user.
178
     * @param int|null $limit
179
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
180
     */
181
    public function getGlobalRightsChanges(?int $limit = null): array
182
    {
183
        if (!isset($this->globalRightsChanges)) {
184
            $logData = $this->repository->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

184
            /** @scrutinizer ignore-call */ 
185
            $logData = $this->repository->getGlobalRightsChanges($this->project, $this->user);
Loading history...
185
            $this->globalRightsChanges = $this->processRightsChanges($logData);
186
        }
187
188
        return array_slice($this->globalRightsChanges, 0, $limit, true);
189
    }
190
191
    /**
192
     * Get the localized names for the user groups, fetched from on-wiki system messages.
193
     * @return string[] Localized names keyed by database value.
194
     */
195
    public function getRightsNames(): array
196
    {
197
        if (isset($this->rightsNames)) {
198
            return $this->rightsNames;
199
        }
200
201
        $this->rightsNames = $this->repository->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

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

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

378
        /** @scrutinizer ignore-call */ 
379
        $thresholds = $this->repository->getAutoconfirmedAgeAndCount($this->project);
Loading history...
379
380
        // Happens for non-WMF installations, or if there is no autoconfirmed status.
381
        if (null === $thresholds) {
382
            return false;
383
        }
384
385
        $registrationDate = $this->user->getRegistrationDate($this->project);
386
387
        // Sometimes for old accounts the registration date is null, in which case
388
        // we won't attempt to find out when they were autoconfirmed.
389
        if (!is_a($registrationDate, 'DateTime')) {
390
            return false;
391
        }
392
393
        $regDateImmutable = new DateTimeImmutable(
394
            $registrationDate->format('YmdHis')
395
        );
396
397
        $acDate = $regDateImmutable->add(DateInterval::createFromDateString(
398
            $thresholds['wgAutoConfirmAge'].' seconds'
399
        ))->format('YmdHis');
400
401
        // First check if they already had 10 edits made as of $acDate
402
        $editsByAcDate = $this->repository->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

402
        /** @scrutinizer ignore-call */ 
403
        $editsByAcDate = $this->repository->getNumEditsByTimestamp(
Loading history...
403
            $this->project,
404
            $this->user,
405
            $acDate
406
        );
407
408
        // If more than wgAutoConfirmCount, then $acDate is when they became autoconfirmed.
409
        if ($editsByAcDate >= $thresholds['wgAutoConfirmCount']) {
410
            return $acDate;
411
        }
412
413
        // Now check when the nth edit was made, where n is wgAutoConfirmCount.
414
        // This will be false if they still haven't made 10 edits.
415
        $acTimestamp = $this->repository->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

415
        /** @scrutinizer ignore-call */ 
416
        $acTimestamp = $this->repository->getNthEditTimestamp(
Loading history...
416
            $this->project,
417
            $this->user,
418
            $registrationDate->format('YmdHis'),
419
            $thresholds['wgAutoConfirmCount']
420
        );
421
422
        return $acTimestamp;
423
    }
424
}
425