User::getPrettyUsername()   A
last analyzed

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

134
                $orig = substr(/** @scrutinizer ignore-type */ $lastAddrHex, $pos, 1);
Loading history...
135
                $origVal = hexdec($orig);
136
                $newVal = $origVal | (pow(2, min(4, $flexBits)) - 1);
137
                $new = dechex($newVal);
138
                $lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1);
139
                $flexBits -= 4;
140
                $pos -= 1;
141
            }
142
143
            $lastAddrBin = pack('H*', $lastAddrHex);
144
            $range[1] = inet_ntop($lastAddrBin);
145
        } else {
146
            $cidr = explode('/', $this->username);
147
            $range[0] = long2ip(ip2long($cidr[0]) & -1 << (32 - (int)$cidr[1]));
148
            $range[1] = long2ip(ip2long($range[0]) + pow(2, (32 - (int)$cidr[1])) - 1);
149
        }
150
151
        // Find the leftmost common characters between the two addresses.
152
        $common = '';
153
        $startSplit = str_split(strtoupper($range[0]));
154
        $endSplit = str_split(strtoupper($range[1]));
155
        foreach ($startSplit as $index => $char) {
156
            if ($endSplit[$index] === $char) {
157
                $common .= $char;
158
            } else {
159
                break;
160
            }
161
        }
162
163
        return $common;
164
    }
165
166
    /**
167
     * Is this IP range outside the queryable limits?
168
     * @return bool
169
     */
170
    public function isQueryableRange(): bool
171
    {
172
        if (!$this->isIpRange()) {
173
            return true;
174
        }
175
176
        [, $bits] = IPUtils::parseCIDR($this->username);
177
        $limit = $this->isIPv6() ? self::MAX_IPV6_CIDR : self::MAX_IPV4_CIDR;
178
        return (int)$bits >= $limit;
179
    }
180
181
    /**
182
     * Get the user's ID on the given project.
183
     * @param Project $project
184
     * @return int|null
185
     */
186
    public function getId(Project $project): ?int
187
    {
188
        $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

188
        /** @scrutinizer ignore-call */ 
189
        $ret = $this->repository->getIdAndRegistration(
Loading history...
189
            $project->getDatabaseName(),
190
            $this->getUsername()
191
        );
192
193
        return $ret ? (int)$ret['userId'] : null;
194
    }
195
196
    /**
197
     * Get the user's actor ID on the given project.
198
     * @param Project $project
199
     * @return int
200
     */
201
    public function getActorId(Project $project): int
202
    {
203
        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

203
        return (int)$this->repository->/** @scrutinizer ignore-call */ getActorId(
Loading history...
204
            $project->getDatabaseName(),
205
            $this->getUsername()
206
        );
207
    }
208
209
    /**
210
     * Get the user's registration date on the given project.
211
     * @param Project $project
212
     * @return DateTime|null null if no registration date was found.
213
     */
214
    public function getRegistrationDate(Project $project): ?DateTime
215
    {
216
        $ret = $this->repository->getIdAndRegistration(
217
            $project->getDatabaseName(),
218
            $this->getUsername()
219
        );
220
221
        return null !== $ret['regDate']
222
            ? DateTime::createFromFormat('YmdHis', $ret['regDate'])
223
            : null;
224
    }
225
226
    /**
227
     * Get a user's local user rights on the given Project.
228
     * @param Project $project
229
     * @return string[]
230
     */
231
    public function getUserRights(Project $project): array
232
    {
233
        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

233
        return $this->repository->/** @scrutinizer ignore-call */ getUserRights($project, $this);
Loading history...
234
    }
235
236
    /**
237
     * Get a list of this user's global rights.
238
     * @param Project|null $project A project to query; if not provided, the default will be used.
239
     * @return string[]
240
     */
241
    public function getGlobalUserRights(?Project $project = null): array
242
    {
243
        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

243
        return $this->repository->/** @scrutinizer ignore-call */ getGlobalUserRights($this->getUsername(), $project);
Loading history...
244
    }
245
246
    /**
247
     * Get the user's (system) edit count.
248
     * @param Project $project
249
     * @return int
250
     */
251
    public function getEditCount(Project $project): int
252
    {
253
        $domain = $project->getDomain();
254
        if (isset($this->editCounts[$domain])) {
255
            return $this->editCounts[$domain];
256
        }
257
258
        $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

258
        $this->editCounts[$domain] = (int)$this->repository->/** @scrutinizer ignore-call */ getEditCount(
Loading history...
259
            $project->getDatabaseName(),
260
            $this->getUsername()
261
        );
262
263
        return $this->editCounts[$domain];
264
    }
265
266
    /**
267
     * Number of edits which if exceeded, will require the user to log in.
268
     * @return int
269
     */
270
    public function numEditsRequiringLogin(): int
271
    {
272
        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

272
        return $this->repository->/** @scrutinizer ignore-call */ numEditsRequiringLogin();
Loading history...
273
    }
274
275
    /**
276
     * Maximum number of edits to process, based on configuration.
277
     * @return int
278
     */
279
    public function maxEdits(): int
280
    {
281
        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

281
        return $this->repository->/** @scrutinizer ignore-call */ maxEdits();
Loading history...
282
    }
283
284
    /**
285
     * Does this user exist on the given project?
286
     * @param Project $project
287
     * @return bool
288
     */
289
    public function existsOnProject(Project $project): bool
290
    {
291
        return $this->getId($project) > 0;
292
    }
293
294
    /**
295
     * Does this user exist globally?
296
     * @return bool
297
     */
298
    public function existsGlobally(): bool
299
    {
300
        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

300
        return $this->repository->/** @scrutinizer ignore-call */ existsGlobally($this);
Loading history...
301
    }
302
303
    /**
304
     * Is this user an Administrator on the given project?
305
     * @param Project $project The project.
306
     * @return bool
307
     */
308
    public function isAdmin(Project $project): bool
309
    {
310
        return false !== array_search('sysop', $this->getUserRights($project));
311
    }
312
313
    /**
314
     * Is this user an anonymous user (IP)?
315
     * @return bool
316
     */
317
    public function isAnon(): bool
318
    {
319
        return IPUtils::isIPAddress($this->username);
320
    }
321
322
    /**
323
     * Get the expiry of the current block on the user
324
     * @param Project $project The project.
325
     * @return DateTime|bool Expiry as DateTime, true if indefinite, or false if they are not blocked.
326
     */
327
    public function getBlockExpiry(Project $project)
328
    {
329
        $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

329
        /** @scrutinizer ignore-call */ 
330
        $expiry = $this->repository->getBlockExpiry(
Loading history...
330
            $project->getDatabaseName(),
331
            $this->getUsername()
332
        );
333
334
        if ('infinity' === $expiry) {
335
            return true;
336
        } elseif (false === $expiry) {
337
            return false;
338
        } else {
339
            return new DateTime($expiry);
340
        }
341
    }
342
343
    /**
344
     * Is this user currently blocked on the given project?
345
     * @param Project $project The project.
346
     * @return bool
347
     */
348
    public function isBlocked(Project $project): bool
349
    {
350
        return false !== $this->getBlockExpiry($project);
351
    }
352
353
    /**
354
     * Does the user have enough edits that we want to require login?
355
     * @param Project $project
356
     * @return bool
357
     */
358
    public function hasManyEdits(Project $project): bool
359
    {
360
        $editCount = $this->getEditCount($project);
361
        return $editCount > $this->numEditsRequiringLogin();
362
    }
363
364
    /**
365
     * Does the user have more edits than maximum amount allowed for processing?
366
     * @param Project $project
367
     * @return bool
368
     */
369
    public function hasTooManyEdits(Project $project): bool
370
    {
371
        $editCount = $this->getEditCount($project);
372
        return $this->maxEdits() > 0 && $editCount > $this->maxEdits();
373
    }
374
375
    /**
376
     * Get edit count within given timeframe and namespace
377
     * @param Project $project
378
     * @param int|string $namespace Namespace ID or 'all' for all namespaces
379
     * @param int|false $start Start date as Unix timestamp.
380
     * @param int|false $end End date as Unix timestamp.
381
     * @return int
382
     */
383
    public function countEdits(Project $project, $namespace = 'all', $start = false, $end = false): int
384
    {
385
        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

385
        return $this->repository->/** @scrutinizer ignore-call */ countEdits($project, $this, $namespace, $start, $end);
Loading history...
386
    }
387
388
    /**
389
     * Is this user the same as the current XTools user?
390
     * @return bool
391
     */
392
    public function isCurrentlyLoggedIn(): bool
393
    {
394
        try {
395
            $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

395
            /** @scrutinizer ignore-call */ 
396
            $ident = $this->repository->getXtoolsUserInfo();
Loading history...
396
        } catch (Exception $exception) {
397
            return false;
398
        }
399
        return isset($ident->username) && $ident->username === $this->getUsername();
400
    }
401
}
402