Passed
Pull Request — main (#492)
by MusikAnimal
11:28 queued 07:09
created

User::isTemp()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 6
rs 10
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Model;
6
7
use App\Repository\UserRepository;
8
use DateTime;
9
use Exception;
10
use UnexpectedValueException;
11
use Wikimedia\IPUtils;
12
13
/**
14
 * A User is a wiki user who has the same username across all projects in an XTools installation.
15
 */
16
class User extends Model
17
{
18
    /** @var int Maximum queryable range for IPv4. */
19
    public const MAX_IPV4_CIDR = 16;
20
21
    /** @var int Maximum queryable range for IPv6. */
22
    public const MAX_IPV6_CIDR = 32;
23
24
    /** @var string The user's username. */
25
    protected string $username;
26
27
    /** @var int[] Quick cache of edit counts, keyed by project domain. */
28
    protected array $editCounts = [];
29
30
    /** @var bool Whether the user is a temporary account. */
31
    protected bool $isTemp;
32
33
    /**
34
     * Create a new User given a username.
35
     * @param UserRepository $repository
36
     * @param string $username
37
     */
38
    public function __construct(UserRepository $repository, string $username)
39
    {
40
        $this->repository = $repository;
41
        if ('ipr-' === substr($username, 0, 4)) {
42
            $username = substr($username, 4);
43
        }
44
        $this->username = ucfirst(str_replace('_', ' ', trim($username)));
45
46
        // IPv6 address are stored as uppercase in the database.
47
        if ($this->isIP()) {
48
            $this->username = IPUtils::sanitizeIP($this->username);
49
        }
50
    }
51
52
    /**
53
     * Unique identifier for this User, to be used in cache keys. Use of md5 ensures the cache key does not contain
54
     * reserved characters. You could also use the ID, but that may require an unnecessary DB query.
55
     * @see Repository::getCacheKey()
56
     * @return string
57
     */
58
    public function getCacheKey(): string
59
    {
60
        return md5($this->username);
61
    }
62
63
    /**
64
     * Get the username.
65
     * @return string
66
     */
67
    public function getUsername(): string
68
    {
69
        return $this->username;
70
    }
71
72
    /**
73
     * Get a prettified username for IP addresses. For accounts, just the username is returned.
74
     * @return string
75
     */
76
    public function getPrettyUsername(): string
77
    {
78
        if (!$this->isIP()) {
79
            return $this->username;
80
        }
81
        return IPUtils::prettifyIP($this->username);
82
    }
83
84
    /**
85
     * Get the username identifier that should be used in routing. This only matters for IP ranges,
86
     * which get prefixed with 'ipr-' to ensure they don't conflict with other routing params (such as namespace ID).
87
     * Always use this method when linking to or generating internal routes, and use it nowhere else.
88
     * @return string
89
     */
90
    public function getUsernameIdent(): string
91
    {
92
        if ($this->isIpRange()) {
93
            return 'ipr-'.$this->username;
94
        }
95
        return $this->username;
96
    }
97
98
    /**
99
     * Is this an IP range?
100
     * @return bool
101
     */
102
    public function isIpRange(): bool
103
    {
104
        return IPUtils::isValidRange($this->username);
105
    }
106
107
    /**
108
     * Is this an IPv6 address or range?
109
     * @return bool
110
     */
111
    public function isIPv6(): bool
112
    {
113
        return IPUtils::isIPv6($this->username);
114
    }
115
116
    /**
117
     * Get the common characters between the start and end address of an IPv4 range.
118
     * This is used when running a LIKE query against actor names.
119
     * @return string[]|null
120
     */
121
    public function getIpSubstringFromCidr(): ?string
122
    {
123
        if (!$this->isIpRange()) {
124
            return null;
125
        }
126
127
        if ($this->isIPv6()) {
128
            // Adapted from https://stackoverflow.com/a/10086404/604142 (CC BY-SA 3.0)
129
            [$firstAddrStr, $prefixLen] = explode('/', $this->username);
130
            $firstAddrBin = inet_pton($firstAddrStr);
131
            $firstAddrUnpacked = unpack('H*', $firstAddrBin);
132
            $firstAddrHex = reset($firstAddrUnpacked);
133
            $range[0] = inet_ntop($firstAddrBin);
0 ignored issues
show
Comprehensibility Best Practice introduced by
$range was never initialized. Although not strictly required by PHP, it is generally a good practice to add $range = array(); before regardless.
Loading history...
134
            $flexBits = 128 - $prefixLen;
135
            $lastAddrHex = $firstAddrHex;
136
137
            $pos = 31;
138
            while ($flexBits > 0) {
139
                $orig = substr($lastAddrHex, $pos, 1);
0 ignored issues
show
Bug introduced by
It seems like $lastAddrHex can also be of type array; however, parameter $string of substr() does only seem to accept string, 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

139
                $orig = substr(/** @scrutinizer ignore-type */ $lastAddrHex, $pos, 1);
Loading history...
140
                $origVal = hexdec($orig);
141
                $newVal = $origVal | (pow(2, min(4, $flexBits)) - 1);
142
                $new = dechex($newVal);
143
                $lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1);
144
                $flexBits -= 4;
145
                $pos -= 1;
146
            }
147
148
            $lastAddrBin = pack('H*', $lastAddrHex);
149
            $range[1] = inet_ntop($lastAddrBin);
150
        } else {
151
            $cidr = explode('/', $this->username);
152
            $range[0] = long2ip(ip2long($cidr[0]) & -1 << (32 - (int)$cidr[1]));
153
            $range[1] = long2ip(ip2long($range[0]) + pow(2, (32 - (int)$cidr[1])) - 1);
154
        }
155
156
        // Find the leftmost common characters between the two addresses.
157
        $common = '';
158
        $startSplit = str_split(strtoupper($range[0]));
159
        $endSplit = str_split(strtoupper($range[1]));
160
        foreach ($startSplit as $index => $char) {
161
            if ($endSplit[$index] === $char) {
162
                $common .= $char;
163
            } else {
164
                break;
165
            }
166
        }
167
168
        return $common;
169
    }
170
171
    /**
172
     * Is this IP range outside the queryable limits?
173
     * @return bool
174
     */
175
    public function isQueryableRange(): bool
176
    {
177
        if (!$this->isIpRange()) {
178
            return true;
179
        }
180
181
        [, $bits] = IPUtils::parseCIDR($this->username);
182
        $limit = $this->isIPv6() ? self::MAX_IPV6_CIDR : self::MAX_IPV4_CIDR;
183
        return (int)$bits >= $limit;
184
    }
185
186
    /**
187
     * Get the user's ID on the given project.
188
     * @param Project $project
189
     * @return int|null
190
     */
191
    public function getId(Project $project): ?int
192
    {
193
        $ret = $this->repository->getIdAndRegistration(
0 ignored issues
show
Bug introduced by
The method getIdAndRegistration() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRepository. ( Ignorable by Annotation )

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

193
        /** @scrutinizer ignore-call */ 
194
        $ret = $this->repository->getIdAndRegistration(
Loading history...
194
            $project->getDatabaseName(),
195
            $this->getUsername()
196
        );
197
198
        return $ret ? (int)$ret['userId'] : null;
199
    }
200
201
    /**
202
     * Get the user's actor ID on the given project.
203
     * @param Project $project
204
     * @return int
205
     */
206
    public function getActorId(Project $project): int
207
    {
208
        return (int)$this->repository->getActorId(
0 ignored issues
show
Bug introduced by
The method getActorId() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRepository. ( Ignorable by Annotation )

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

208
        return (int)$this->repository->/** @scrutinizer ignore-call */ getActorId(
Loading history...
209
            $project->getDatabaseName(),
210
            $this->getUsername()
211
        );
212
    }
213
214
    /**
215
     * Get the user's registration date on the given project.
216
     * @param Project $project
217
     * @return DateTime|null null if no registration date was found.
218
     */
219
    public function getRegistrationDate(Project $project): ?DateTime
220
    {
221
        $ret = $this->repository->getIdAndRegistration(
222
            $project->getDatabaseName(),
223
            $this->getUsername()
224
        );
225
226
        return null !== $ret['regDate']
227
            ? DateTime::createFromFormat('YmdHis', $ret['regDate'])
228
            : null;
229
    }
230
231
    /**
232
     * Get a user's local user rights on the given Project.
233
     * @param Project $project
234
     * @return string[]
235
     */
236
    public function getUserRights(Project $project): array
237
    {
238
        return $this->repository->getUserRights($project, $this);
0 ignored issues
show
Bug introduced by
The method getUserRights() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRepository. ( Ignorable by Annotation )

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

238
        return $this->repository->/** @scrutinizer ignore-call */ getUserRights($project, $this);
Loading history...
239
    }
240
241
    /**
242
     * Get a list of this user's global rights.
243
     * @param Project|null $project A project to query; if not provided, the default will be used.
244
     * @return string[]
245
     */
246
    public function getGlobalUserRights(?Project $project = null): array
247
    {
248
        return $this->repository->getGlobalUserRights($this->getUsername(), $project);
0 ignored issues
show
Bug introduced by
The method getGlobalUserRights() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRepository. ( Ignorable by Annotation )

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

248
        return $this->repository->/** @scrutinizer ignore-call */ getGlobalUserRights($this->getUsername(), $project);
Loading history...
249
    }
250
251
    /**
252
     * Get the user's (system) edit count.
253
     * @param Project $project
254
     * @return int
255
     */
256
    public function getEditCount(Project $project): int
257
    {
258
        $domain = $project->getDomain();
259
        if (isset($this->editCounts[$domain])) {
260
            return $this->editCounts[$domain];
261
        }
262
263
        $this->editCounts[$domain] = (int)$this->repository->getEditCount(
0 ignored issues
show
Bug introduced by
The method getEditCount() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRepository. ( Ignorable by Annotation )

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

263
        $this->editCounts[$domain] = (int)$this->repository->/** @scrutinizer ignore-call */ getEditCount(
Loading history...
264
            $project->getDatabaseName(),
265
            $this->getUsername()
266
        );
267
268
        return $this->editCounts[$domain];
269
    }
270
271
    /**
272
     * Number of edits which if exceeded, will require the user to log in.
273
     * @return int
274
     */
275
    public function numEditsRequiringLogin(): int
276
    {
277
        return $this->repository->numEditsRequiringLogin();
0 ignored issues
show
Bug introduced by
The method numEditsRequiringLogin() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRepository. ( Ignorable by Annotation )

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

277
        return $this->repository->/** @scrutinizer ignore-call */ numEditsRequiringLogin();
Loading history...
278
    }
279
280
    /**
281
     * Maximum number of edits to process, based on configuration.
282
     * @return int
283
     */
284
    public function maxEdits(): int
285
    {
286
        return $this->repository->maxEdits();
0 ignored issues
show
Bug introduced by
The method maxEdits() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRepository. ( Ignorable by Annotation )

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

286
        return $this->repository->/** @scrutinizer ignore-call */ maxEdits();
Loading history...
287
    }
288
289
    /**
290
     * Does this user exist on the given project?
291
     * @param Project $project
292
     * @return bool
293
     */
294
    public function existsOnProject(Project $project): bool
295
    {
296
        return $this->getId($project) > 0;
297
    }
298
299
    /**
300
     * Does this user exist globally?
301
     * @return bool
302
     */
303
    public function existsGlobally(): bool
304
    {
305
        return $this->repository->existsGlobally($this);
0 ignored issues
show
Bug introduced by
The method existsGlobally() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRepository. ( Ignorable by Annotation )

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

305
        return $this->repository->/** @scrutinizer ignore-call */ existsGlobally($this);
Loading history...
306
    }
307
308
    /**
309
     * Is this user an Administrator on the given project?
310
     * @param Project $project The project.
311
     * @return bool
312
     */
313
    public function isAdmin(Project $project): bool
314
    {
315
        return false !== array_search('sysop', $this->getUserRights($project));
316
    }
317
318
    /**
319
     * Is this user an IP user? (Not a named or temporary account)
320
     * @return bool
321
     */
322
    public function isIP(): bool
323
    {
324
        return IPUtils::isIPAddress($this->username);
325
    }
326
327
    /**
328
     * Is this user a temporary account?
329
     * @param Project $project
330
     * @return bool
331
     */
332
    public function isTemp(Project $project): bool
333
    {
334
        if (!isset($this->isTemp)) {
335
            $this->isTemp = self::isTempUsername($project, $this->getUsername());
336
        }
337
        return $this->isTemp;
338
    }
339
340
    /**
341
     * Does the given username match that of temporary accounts?
342
     * Based on https://w.wiki/BZQY from MediaWiki core (GPL-2.0-or-later)
343
     * @param Project $project
344
     * @return bool
345
     */
346
    public static function isTempUsername(Project $project, string $username): bool
347
    {
348
        if (!$project->hasTempAccounts()) {
349
            return false;
350
        }
351
        foreach ($project->getTempAccountPatterns() as $pattern) {
352
            $varPos = strpos($pattern, '$1');
353
            if (false === $varPos) {
354
                throw new UnexpectedValueException('Invalid temp account pattern: ' . $pattern);
355
            }
356
            $prefix = substr($pattern, 0, $varPos);
357
            $suffix = substr($pattern, $varPos + 2);
358
            $match = true;
359
            if ('' !== $prefix) {
360
                $match = str_starts_with($username, $prefix);
361
            }
362
            if ($match && '' !== $suffix) {
363
                $match = str_ends_with($username, $suffix)
364
                    && strlen($username) >= strlen($prefix) + strlen($suffix);
365
            }
366
            if ($match) {
367
                return true;
368
            }
369
        }
370
        return false;
371
    }
372
373
    /**
374
     * Is this user an anonymous user (IP or temporary account)?
375
     * @param Project $project
376
     * @return bool
377
     */
378
    public function isAnon(Project $project): bool
379
    {
380
        return $this->isIP() || $this->isTemp($project);
381
    }
382
383
    /**
384
     * Get the expiry of the current block on the user
385
     * @param Project $project The project.
386
     * @return DateTime|bool Expiry as DateTime, true if indefinite, or false if they are not blocked.
387
     */
388
    public function getBlockExpiry(Project $project)
389
    {
390
        $expiry = $this->repository->getBlockExpiry(
0 ignored issues
show
Bug introduced by
The method getBlockExpiry() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRepository. ( Ignorable by Annotation )

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

390
        /** @scrutinizer ignore-call */ 
391
        $expiry = $this->repository->getBlockExpiry(
Loading history...
391
            $project->getDatabaseName(),
392
            $this->getUsername()
393
        );
394
395
        if ('infinity' === $expiry) {
396
            return true;
397
        } elseif (false === $expiry) {
398
            return false;
399
        } else {
400
            return new DateTime($expiry);
401
        }
402
    }
403
404
    /**
405
     * Is this user currently blocked on the given project?
406
     * @param Project $project The project.
407
     * @return bool
408
     */
409
    public function isBlocked(Project $project): bool
410
    {
411
        return false !== $this->getBlockExpiry($project);
412
    }
413
414
    /**
415
     * Does the user have enough edits that we want to require login?
416
     * @param Project $project
417
     * @return bool
418
     */
419
    public function hasManyEdits(Project $project): bool
420
    {
421
        $editCount = $this->getEditCount($project);
422
        return $editCount > $this->numEditsRequiringLogin();
423
    }
424
425
    /**
426
     * Does the user have more edits than maximum amount allowed for processing?
427
     * @param Project $project
428
     * @return bool
429
     */
430
    public function hasTooManyEdits(Project $project): bool
431
    {
432
        $editCount = $this->getEditCount($project);
433
        return $this->maxEdits() > 0 && $editCount > $this->maxEdits();
434
    }
435
436
    /**
437
     * Get edit count within given timeframe and namespace
438
     * @param Project $project
439
     * @param int|string $namespace Namespace ID or 'all' for all namespaces
440
     * @param int|false $start Start date as Unix timestamp.
441
     * @param int|false $end End date as Unix timestamp.
442
     * @return int
443
     */
444
    public function countEdits(Project $project, $namespace = 'all', $start = false, $end = false): int
445
    {
446
        return $this->repository->countEdits($project, $this, $namespace, $start, $end);
0 ignored issues
show
Bug introduced by
The method countEdits() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRepository. ( Ignorable by Annotation )

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

446
        return $this->repository->/** @scrutinizer ignore-call */ countEdits($project, $this, $namespace, $start, $end);
Loading history...
447
    }
448
449
    /**
450
     * Is this user the same as the current XTools user?
451
     * @return bool
452
     */
453
    public function isCurrentlyLoggedIn(): bool
454
    {
455
        try {
456
            $ident = $this->repository->getXtoolsUserInfo();
0 ignored issues
show
Bug introduced by
The method getXtoolsUserInfo() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRepository. ( Ignorable by Annotation )

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

456
            /** @scrutinizer ignore-call */ 
457
            $ident = $this->repository->getXtoolsUserInfo();
Loading history...
457
        } catch (Exception $exception) {
458
            return false;
459
        }
460
        return isset($ident->username) && $ident->username === $this->getUsername();
461
    }
462
}
463