Passed
Push — master ( a61c57...8dbe0a )
by MusikAnimal
10:33
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
     * @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