Passed
Push — master ( a61c57...8dbe0a )
by MusikAnimal
10:33
created

UserRights::processRightsChanges()   F

Complexity

Conditions 26
Paths 162

Size

Total Lines 90
Code Lines 51

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 33
CRAP Score 52.5695

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 26
eloc 51
c 1
b 0
f 0
nc 162
nop 1
dl 0
loc 90
ccs 33
cts 50
cp 0.66
crap 52.5695
rs 3.65

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
     * @param int|null $limit
37
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
38
     */
39 1
    public function getRightsChanges(?int $limit = null): array
40
    {
41 1
        if (!isset($this->rightsChanges)) {
42 1
            $logData = $this->getRepository()
43 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

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

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

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

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

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

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

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