Passed
Push — master ( 250650...eb8721 )
by MusikAnimal
10:40
created

UserRights::getRightsName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 0
cts 2
cp 0
crap 2
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(?int $limit = null): array
39
    {
40 1
        if (!isset($this->rightsChanges)) {
41 1
            $logData = $this->getRepository()
42 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

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

177
            ->/** @scrutinizer ignore-call */ getGlobalRightsChanges($this->project, $this->user);
Loading history...
178
179 1
        $this->globalRightsChanges = $this->processRightsChanges($logData);
180
181 1
        return $this->globalRightsChanges;
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 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

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 1
    private function processRightsChanges(array $logData): array
216
    {
217 1
        $rightsChanges = [];
218
219 1
        foreach ($logData as $row) {
220
            // Can happen if the log entry has been deleted.
221 1
            if (!isset($row['log_params']) || null === $row['log_params']) {
222
                continue;
223
            }
224
225 1
            $unserialized = @unserialize($row['log_params']);
226
227 1
            if (false !== $unserialized) {
228 1
                $old = $unserialized['4::oldgroups'];
229 1
                $new = $unserialized['5::newgroups'];
230 1
                $added = array_diff($new, $old);
231 1
                $removed = array_diff($old, $new);
232 1
                $oldMetadata = $unserialized['oldmetadata'] ?? null;
233 1
                $newMetadata = $unserialized['newmetadata'] ?? null;
234
235
                // Check for changes only to expiry. If such exists, treat it as added. Various issets are safeguards.
236 1
                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 1
                if (count($removed) > 0) {
262 1
                    $this->unsetAutoRemoval($rightsChanges, $removed);
263
                }
264
265 1
                $this->setAutoRemovals($rightsChanges, $row, $unserialized, $added);
266
            } else {
267
                // This is the old school format the most likely contains
268
                // the list of rights additions as a comma-separated list.
269
                try {
270 1
                    [$old, $new] = explode("\n", $row['log_params']);
271 1
                    $old = array_filter(array_map('trim', explode(',', $old)));
272 1
                    $new = array_filter(array_map('trim', explode(',', (string)$new)));
273 1
                    $added = array_diff($new, $old);
274 1
                    $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 1
            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 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

285
                array_splice($added, /** @scrutinizer ignore-type */ array_search('(none)', $added), 1);
Loading history...
286
            }
287 1
            if (in_array('(none)', $removed)) {
288
                array_splice($removed, array_search('(none)', $removed), 1);
289
            }
290
291 1
            $rightsChanges[$row['log_timestamp']] = [
292 1
                'logId' => $row['log_id'],
293 1
                'performer' => 'autopromote' === $row['log_action'] ? null : $row['performer'],
294 1
                'comment' => $row['log_comment'],
295 1
                'added' => array_values($added),
296 1
                'removed' => array_values($removed),
297 1
                'grantType' => 'autopromote' === $row['log_action'] ? 'automatic' : 'manual',
298 1
                'type' => $row['type'],
299
            ];
300
        }
301
302 1
        krsort($rightsChanges);
303
304 1
        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 1
    private function setAutoRemovals(array &$rightsChanges, array $row, array $params, array $added): void
316
    {
317 1
        foreach ($added as $index => $entry) {
318
            // Skip if no expiry was set.
319 1
            if (!isset($params['newmetadata'][$index]) ||
320 1
                !array_key_exists('expiry', $params['newmetadata'][$index]) ||
321 1
                empty($params['newmetadata'][$index]['expiry'])
322
            ) {
323 1
                continue;
324
            }
325
326 1
            $expiry = $params['newmetadata'][$index]['expiry'];
327
328 1
            if (isset($rightsChanges[$expiry]) && !in_array($entry, $rightsChanges[$expiry]['removed'])) {
329
                // Temporary right expired.
330 1
                $rightsChanges[$expiry]['removed'][] = $entry;
331
            } else {
332
                // Temporary right was added.
333 1
                $rightsChanges[$expiry] = [
334 1
                    'logId' => $row['log_id'],
335 1
                    'performer' => $row['performer'],
336
                    'comment' => null,
337
                    'added' => [],
338 1
                    'removed' => [$entry],
339 1
                    'grantType' => strtotime($expiry) > time() ? 'pending' : 'automatic',
340 1
                    'type' => $row['type'],
341
                ];
342
            }
343
        }
344
345
        // Resort because the auto-removal timestamp could be before other rights changes.
346 1
        ksort($rightsChanges);
347 1
    }
348
349 1
    private function unsetAutoRemoval(array &$rightsChanges, array $removed): void
350
    {
351 1
        foreach ($rightsChanges as $timestamp => $change) {
352 1
            if ('pending' === $change['grantType']) {
353
                $rightsChanges[$timestamp]['removed'] = array_diff($rightsChanges[$timestamp]['removed'], $removed);
354
                if (empty($rightsChanges[$timestamp]['removed'])) {
355 1
                    unset($rightsChanges[$timestamp]);
356
                }
357
            }
358
        }
359 1
    }
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 1
    private function getAutoconfirmedTimestamp()
366
    {
367 1
        static $acTimestamp = null;
368 1
        if (null !== $acTimestamp) {
369
            return $acTimestamp;
370
        }
371
372 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

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 1
        if (null === $thresholds) {
376 1
            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 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

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 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

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