Issues (196)

Security Analysis    6 potential vulnerabilities

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  SQL Injection (4)
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Variable Injection (1)
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

src/Model/EditCounter.php (11 issues)

1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Model;
6
7
use App\Helper\I18nHelper;
8
use App\Repository\EditCounterRepository;
9
use DateInterval;
10
use DatePeriod;
11
use DateTime;
12
13
/**
14
 * An EditCounter provides statistics about a user's edits on a project.
15
 */
16
class EditCounter extends Model
17
{
18
    protected I18nHelper $i18n;
19
    protected UserRights $userRights;
20
21
    /** @var int[] Revision and page counts etc. */
22
    protected array $pairData;
23
24
    /** @var string[] The IDs and timestamps of first/latest edit and logged action. */
25
    protected array $firstAndLatestActions;
26
27
    /** @var int[] The lot totals. */
28
    protected array $logCounts;
29
30
    /** @var array Total numbers of edits per month */
31
    protected array $monthCounts;
32
33
    /** @var array Total numbers of edits per year */
34
    protected array $yearCounts;
35
36
    /** @var array Block data, with keys 'set' and 'received'. */
37
    protected array $blocks;
38
39
    /** @var integer[] Array keys are namespace IDs, values are the edit counts. */
40
    protected array $namespaceTotals;
41
42
    /** @var int Number of semi-automated edits. */
43
    protected int $autoEditCount;
44
45
    /** @var string[] Data needed for time card chart. */
46
    protected array $timeCardData;
47
48
    /**
49
     * Revision size data, with keys 'average_size', 'large_edits' and 'small_edits'.
50
     * @var string[] As returned by the DB, unconverted to int or float
51
     */
52
    protected array $editSizeData;
53
54
    /**
55
     * Duration of the longest block in seconds; -1 if indefinite,
56
     *   or false if could not be parsed from log params
57
     * @var int|bool
58
     */
59
    protected $longestBlockSeconds;
60
61
    /** @var int Number of times the user has been thanked. */
62
    protected int $thanksReceived;
63
64
    /**
65
     * EditCounter constructor.
66
     * @param EditCounterRepository $repository
67
     * @param I18nHelper $i18n
68
     * @param UserRights $userRights
69
     * @param Project $project The base project to count edits
70
     * @param User $user
71
     */
72
    public function __construct(
73
        EditCounterRepository $repository,
74
        I18nHelper $i18n,
75
        UserRights $userRights,
76
        Project $project,
77
        User $user
78
    ) {
79
        $this->repository = $repository;
80
        $this->i18n = $i18n;
81
        $this->userRights = $userRights;
82
        $this->project = $project;
83
        $this->user = $user;
84
    }
85
86
    /**
87
     * @return UserRights
88
     */
89
    public function getUserRights(): UserRights
90
    {
91
        return $this->userRights;
92
    }
93
94
    /**
95
     * Get revision and page counts etc.
96
     * @return int[]
97
     */
98
    public function getPairData(): array
99
    {
100
        if (!isset($this->pairData)) {
101
            $this->pairData = $this->repository->getPairData($this->project, $this->user);
0 ignored issues
show
The method getPairData() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

101
            /** @scrutinizer ignore-call */ 
102
            $this->pairData = $this->repository->getPairData($this->project, $this->user);
Loading history...
102
        }
103
        return $this->pairData;
104
    }
105
106
    /**
107
     * Get revision dates.
108
     * @return array
109
     */
110
    public function getLogCounts(): array
111
    {
112
        if (!isset($this->logCounts)) {
113
            $this->logCounts = $this->repository->getLogCounts($this->project, $this->user);
0 ignored issues
show
The method getLogCounts() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

113
            /** @scrutinizer ignore-call */ 
114
            $this->logCounts = $this->repository->getLogCounts($this->project, $this->user);
Loading history...
114
        }
115
        return $this->logCounts;
116
    }
117
118
    /**
119
     * Get the IDs and timestamps of the latest edit and logged action.
120
     * @return string[] With keys 'rev_first', 'rev_latest', 'log_latest', each with 'id' and 'timestamp'.
121
     */
122
    public function getFirstAndLatestActions(): array
123
    {
124
        if (!isset($this->firstAndLatestActions)) {
125
            $this->firstAndLatestActions = $this->repository->getFirstAndLatestActions(
0 ignored issues
show
The method getFirstAndLatestActions() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

125
            /** @scrutinizer ignore-call */ 
126
            $this->firstAndLatestActions = $this->repository->getFirstAndLatestActions(
Loading history...
126
                $this->project,
127
                $this->user
128
            );
129
        }
130
        return $this->firstAndLatestActions;
131
    }
132
133
    /**
134
     * Get the number of times the user was thanked.
135
     * @return int
136
     * @codeCoverageIgnore Simply returns the result of an SQL query.
137
     */
138
    public function getThanksReceived(): int
139
    {
140
        if (!isset($this->thanksReceived)) {
141
            $this->thanksReceived = $this->repository->getThanksReceived($this->project, $this->user);
0 ignored issues
show
The method getThanksReceived() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

141
            /** @scrutinizer ignore-call */ 
142
            $this->thanksReceived = $this->repository->getThanksReceived($this->project, $this->user);
Loading history...
142
        }
143
        return $this->thanksReceived;
144
    }
145
146
    /**
147
     * Get block data.
148
     * @param string $type Either 'set', 'received'
149
     * @param bool $blocksOnly Whether to include only blocks, and not reblocks and unblocks.
150
     * @return array
151
     */
152
    protected function getBlocks(string $type, bool $blocksOnly = true): array
153
    {
154
        if (isset($this->blocks[$type]) && is_array($this->blocks[$type])) {
155
            return $this->blocks[$type];
156
        }
157
        $method = "getBlocks".ucfirst($type);
158
        $blocks = $this->repository->$method($this->project, $this->user);
159
        $this->blocks[$type] = $blocks;
160
161
        // Filter out unblocks unless requested.
162
        if ($blocksOnly) {
163
            $blocks = array_filter($blocks, function ($block) {
164
                return 'block' === $block['log_action'];
165
            });
166
        }
167
168
        return $blocks;
169
    }
170
171
    /**
172
     * Get the total number of currently-live revisions.
173
     * @return int
174
     */
175
    public function countLiveRevisions(): int
176
    {
177
        $revCounts = $this->getPairData();
178
        return $revCounts['live'] ?? 0;
179
    }
180
181
    /**
182
     * Get the total number of the user's revisions that have been deleted.
183
     * @return int
184
     */
185
    public function countDeletedRevisions(): int
186
    {
187
        $revCounts = $this->getPairData();
188
        return $revCounts['deleted'] ?? 0;
189
    }
190
191
    /**
192
     * Get the total edit count (live + deleted).
193
     * @return int
194
     */
195
    public function countAllRevisions(): int
196
    {
197
        return $this->countLiveRevisions() + $this->countDeletedRevisions();
198
    }
199
200
    /**
201
     * Get the total number of revisions marked as 'minor' by the user.
202
     * @return int
203
     */
204
    public function countMinorRevisions(): int
205
    {
206
        $revCounts = $this->getPairData();
207
        return $revCounts['minor'] ?? 0;
208
    }
209
210
    /**
211
     * Get the total number of non-deleted pages edited by the user.
212
     * @return int
213
     */
214
    public function countLivePagesEdited(): int
215
    {
216
        $pageCounts = $this->getPairData();
217
        return $pageCounts['edited-live'] ?? 0;
218
    }
219
220
    /**
221
     * Get the total number of deleted pages ever edited by the user.
222
     * @return int
223
     */
224
    public function countDeletedPagesEdited(): int
225
    {
226
        $pageCounts = $this->getPairData();
227
        return $pageCounts['edited-deleted'] ?? 0;
228
    }
229
230
    /**
231
     * Get the total number of pages ever edited by this user (both live and deleted).
232
     * @return int
233
     */
234
    public function countAllPagesEdited(): int
235
    {
236
        return $this->countLivePagesEdited() + $this->countDeletedPagesEdited();
237
    }
238
239
    /**
240
     * Get the total number of pages (both still live and those that have been deleted) created
241
     * by the user.
242
     * @return int
243
     */
244
    public function countPagesCreated(): int
245
    {
246
        return $this->countCreatedPagesLive() + $this->countPagesCreatedDeleted();
247
    }
248
249
    /**
250
     * Get the total number of pages created by the user, that have not been deleted.
251
     * @return int
252
     */
253
    public function countCreatedPagesLive(): int
254
    {
255
        $pageCounts = $this->getPairData();
256
        return $pageCounts['created-live'] ?? 0;
257
    }
258
259
    /**
260
     * Get the total number of pages created by the user, that have since been deleted.
261
     * @return int
262
     */
263
    public function countPagesCreatedDeleted(): int
264
    {
265
        $pageCounts = $this->getPairData();
266
        return $pageCounts['created-deleted'] ?? 0;
267
    }
268
269
    /**
270
     * Get the total number of pages that have been deleted by the user.
271
     * @return int
272
     */
273
    public function countPagesDeleted(): int
274
    {
275
        $logCounts = $this->getLogCounts();
276
        return $logCounts['delete-delete'] ?? 0;
277
    }
278
279
    /**
280
     * Get the total number of pages moved by the user.
281
     * @return int
282
     */
283
    public function countPagesMoved(): int
284
    {
285
        $logCounts = $this->getLogCounts();
286
        return $logCounts['move-move'] ?? 0;
287
    }
288
289
    /**
290
     * Get the total number of times the user has blocked a user.
291
     * @return int
292
     */
293
    public function countBlocksSet(): int
294
    {
295
        $logCounts = $this->getLogCounts();
296
        return $logCounts['block-block'] ?? 0;
297
    }
298
299
    /**
300
     * Get the total number of times the user has re-blocked a user.
301
     * @return int
302
     */
303
    public function countReblocksSet(): int
304
    {
305
        $logCounts = $this->getLogCounts();
306
        return $logCounts['block-reblock'] ?? 0;
307
    }
308
309
    /**
310
     * Get the total number of times the user has unblocked a user.
311
     * @return int
312
     */
313
    public function countUnblocksSet(): int
314
    {
315
        $logCounts = $this->getLogCounts();
316
        return $logCounts['block-unblock'] ?? 0;
317
    }
318
319
    /**
320
     * Get the total number of times the user has been blocked.
321
     * @return int
322
     */
323
    public function countBlocksReceived(): int
324
    {
325
        $blocks = $this->getBlocks('received');
326
        return count($blocks);
327
    }
328
329
    /**
330
     * Get the length of the longest block the user received, in seconds.
331
     * If the user is blocked, the time since the block is returned. If the block is
332
     * indefinite, -1 is returned. 0 if there was never a block.
333
     * @return int|false Number of seconds or false if it could not be determined.
334
     */
335
    public function getLongestBlockSeconds()
336
    {
337
        if (isset($this->longestBlockSeconds)) {
338
            return $this->longestBlockSeconds;
339
        }
340
341
        $blocks = $this->getBlocks('received', false);
342
        $this->longestBlockSeconds = false;
343
344
        // If there was never a block, the longest was zero seconds.
345
        if (empty($blocks)) {
346
            return 0;
347
        }
348
349
        /**
350
         * Keep track of the last block so we can determine the duration
351
         * if the current block in the loop is an unblock.
352
         * @var int[] $lastBlock
353
         *   [
354
         *     Unix timestamp,
355
         *     Duration in seconds (-1 if indefinite)
356
         *   ]
357
         */
358
        $lastBlock = [null, null];
359
360
        foreach (array_values($blocks) as $block) {
361
            [$timestamp, $duration] = $this->parseBlockLogEntry($block);
362
363
            if ('block' === $block['log_action']) {
364
                // This is a new block, so first see if the duration of the last
365
                // block exceeded our longest duration. -1 duration means indefinite.
366
                if ($lastBlock[1] > $this->longestBlockSeconds || -1 === $lastBlock[1]) {
367
                    $this->longestBlockSeconds = $lastBlock[1];
368
                }
369
370
                // Now set this as the last block.
371
                $lastBlock = [$timestamp, $duration];
372
            } elseif ('unblock' === $block['log_action']) {
373
                // The last block was lifted. So the duration will be the time from when the
374
                // last block was set to the time of the unblock.
375
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
376
                if ($timeSinceLastBlock > $this->longestBlockSeconds) {
377
                    $this->longestBlockSeconds = $timeSinceLastBlock;
378
379
                    // Reset the last block, as it has now been accounted for.
380
                    $lastBlock = [null, null];
381
                }
382
            } elseif ('reblock' === $block['log_action'] && -1 !== $lastBlock[1]) {
383
                // The last block was modified. So we will adjust $lastBlock to include
384
                // the difference of the duration of the new reblock, and time since the last block.
385
                // $lastBlock is left unchanged if its duration was indefinite.
386
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
387
                $lastBlock[1] = $timeSinceLastBlock + $duration;
388
            }
389
        }
390
391
        // If the last block was indefinite, we'll return that as the longest duration.
392
        if (-1 === $lastBlock[1]) {
393
            return -1;
394
        }
395
396
        // Test if the last block is still active, and if so use the expiry as the duration.
397
        $lastBlockExpiry = $lastBlock[0] + $lastBlock[1];
398
        if ($lastBlockExpiry > time() && $lastBlockExpiry > $this->longestBlockSeconds) {
399
            $this->longestBlockSeconds = $lastBlock[1];
400
        // Otherwise, test if the duration of the last block is now the longest overall.
401
        } elseif ($lastBlock[1] > $this->longestBlockSeconds) {
402
            $this->longestBlockSeconds = $lastBlock[1];
403
        }
404
405
        return $this->longestBlockSeconds;
406
    }
407
408
    /**
409
     * Given a block log entry from the database, get the timestamp and duration in seconds.
410
     * @param array $block Block log entry as fetched via self::getBlocks()
411
     * @return int[] [
412
     *                 Unix timestamp,
413
     *                 Duration in seconds (-1 if indefinite, null if unparsable or unblock)
414
     *               ]
415
     */
416
    public function parseBlockLogEntry(array $block): array
417
    {
418
        $timestamp = strtotime($block['log_timestamp']);
419
        $duration = null;
420
421
        // log_params may be null, but we need to treat it like a string.
422
        $block['log_params'] = (string)$block['log_params'];
423
424
        // First check if the string is serialized, and if so parse it to get the block duration.
425
        if (false !== @unserialize($block['log_params'])) {
426
            $parsedParams = unserialize($block['log_params']);
427
            $durationStr = $parsedParams['5::duration'] ?? '';
428
        } else {
429
            // Old format, the duration in English + block options separated by new lines.
430
            $durationStr = explode("\n", $block['log_params'])[0];
431
        }
432
433
        if (in_array($durationStr, ['indefinite', 'infinity', 'infinite'])) {
434
            $duration = -1;
435
        }
436
437
        // Make sure $durationStr is valid just in case it is in an older, unpredictable format.
438
        // If invalid, $duration is left as null.
439
        if (strtotime($durationStr)) {
440
            $expiry = strtotime($durationStr, $timestamp);
441
            $duration = $expiry - $timestamp;
442
        }
443
444
        return [$timestamp, $duration];
445
    }
446
447
    /**
448
     * Get the total number of pages protected by the user.
449
     * @return int
450
     */
451
    public function countPagesProtected(): int
452
    {
453
        $logCounts = $this->getLogCounts();
454
        return $logCounts['protect-protect'] ?? 0;
455
    }
456
457
    /**
458
     * Get the total number of pages reprotected by the user.
459
     * @return int
460
     */
461
    public function countPagesReprotected(): int
462
    {
463
        $logCounts = $this->getLogCounts();
464
        return $logCounts['protect-modify'] ?? 0;
465
    }
466
467
    /**
468
     * Get the total number of pages unprotected by the user.
469
     * @return int
470
     */
471
    public function countPagesUnprotected(): int
472
    {
473
        $logCounts = $this->getLogCounts();
474
        return $logCounts['protect-unprotect'] ?? 0;
475
    }
476
477
    /**
478
     * Get the total number of edits deleted by the user.
479
     * @return int
480
     */
481
    public function countEditsDeleted(): int
482
    {
483
        $logCounts = $this->getLogCounts();
484
        return $logCounts['delete-revision'] ?? 0;
485
    }
486
487
    /**
488
     * Get the total number of log entries deleted by the user.
489
     * @return int
490
     */
491
    public function countLogsDeleted(): int
492
    {
493
        $revCounts = $this->getLogCounts();
494
        return $revCounts['delete-event'] ?? 0;
495
    }
496
497
    /**
498
     * Get the total number of pages restored by the user.
499
     * @return int
500
     */
501
    public function countPagesRestored(): int
502
    {
503
        $logCounts = $this->getLogCounts();
504
        return $logCounts['delete-restore'] ?? 0;
505
    }
506
507
    /**
508
     * Get the total number of times the user has modified the rights of a user.
509
     * @return int
510
     */
511
    public function countRightsModified(): int
512
    {
513
        $logCounts = $this->getLogCounts();
514
        return $logCounts['rights-rights'] ?? 0;
515
    }
516
517
    /**
518
     * Get the total number of pages imported by the user (through any import mechanism:
519
     * interwiki, or XML upload).
520
     * @return int
521
     */
522
    public function countPagesImported(): int
523
    {
524
        $logCounts = $this->getLogCounts();
525
        $import = $logCounts['import-import'] ?? 0;
526
        $interwiki = $logCounts['import-interwiki'] ?? 0;
527
        $upload = $logCounts['import-upload'] ?? 0;
528
        return $import + $interwiki + $upload;
529
    }
530
531
    /**
532
     * Get the number of changes the user has made to AbuseFilters.
533
     * @return int
534
     */
535
    public function countAbuseFilterChanges(): int
536
    {
537
        $logCounts = $this->getLogCounts();
538
        return $logCounts['abusefilter-modify'] ?? 0;
539
    }
540
541
    /**
542
     * Get the number of page content model changes made by the user.
543
     * @return int
544
     */
545
    public function countContentModelChanges(): int
546
    {
547
        $logCounts = $this->getLogCounts();
548
        $new = $logCounts['contentmodel-new'] ?? 0;
549
        $modified = $logCounts['contentmodel-change'] ?? 0;
550
        return $new + $modified;
551
    }
552
553
    /**
554
     * Get the average number of edits per page (including deleted revisions and pages).
555
     * @return float
556
     */
557
    public function averageRevisionsPerPage(): float
558
    {
559
        if (0 == $this->countAllPagesEdited()) {
560
            return 0;
561
        }
562
        return round($this->countAllRevisions() / $this->countAllPagesEdited(), 3);
563
    }
564
565
    /**
566
     * Average number of edits made per day.
567
     * @return float
568
     */
569
    public function averageRevisionsPerDay(): float
570
    {
571
        if (0 == $this->getDays()) {
572
            return 0;
573
        }
574
        return round($this->countAllRevisions() / $this->getDays(), 3);
575
    }
576
577
    /**
578
     * Get the total number of edits made by the user with semi-automating tools.
579
     */
580
    public function countAutomatedEdits(): int
581
    {
582
        if ($this->autoEditCount) {
583
            return $this->autoEditCount;
584
        }
585
        $this->autoEditCount = $this->repository->countAutomatedEdits($this->project, $this->user);
0 ignored issues
show
The method countAutomatedEdits() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\EditCounterRepository or App\Repository\AutoEditsRepository. ( Ignorable by Annotation )

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

585
        /** @scrutinizer ignore-call */ 
586
        $this->autoEditCount = $this->repository->countAutomatedEdits($this->project, $this->user);
Loading history...
586
        return $this->autoEditCount;
587
    }
588
589
    /**
590
     * Get the count of (non-deleted) edits made in the given timeframe to now.
591
     * @param string $time One of 'day', 'week', 'month', or 'year'.
592
     * @return int The total number of live edits.
593
     */
594
    public function countRevisionsInLast(string $time): int
595
    {
596
        $revCounts = $this->getPairData();
597
        return $revCounts[$time] ?? 0;
598
    }
599
600
    /**
601
     * Get the number of days between the first and last edits.
602
     * If there's only one edit, this is counted as one day.
603
     * @return int
604
     */
605
    public function getDays(): int
606
    {
607
        $first = isset($this->getFirstAndLatestActions()['rev_first']['timestamp'])
608
            ? new DateTime($this->getFirstAndLatestActions()['rev_first']['timestamp'])
609
            : false;
610
        $latest = isset($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
611
            ? new DateTime($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
612
            : false;
613
614
        if (false === $first || false === $latest) {
615
            return 0;
616
        }
617
618
        $days = $latest->diff($first)->days;
0 ignored issues
show
Documentation Bug introduced by
It seems like $latest->diff($first)->days can also be of type boolean. However, the property $days is declared as type false|integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
619
620
        return $days > 0 ? $days : 1;
621
    }
622
623
    /**
624
     * Get the total number of files uploaded (including those now deleted).
625
     * @return int
626
     */
627
    public function countFilesUploaded(): int
628
    {
629
        $logCounts = $this->getLogCounts();
630
        return $logCounts['upload-upload'] ?: 0;
631
    }
632
633
    /**
634
     * Get the total number of files uploaded to Commons (including those now deleted).
635
     * This is only applicable for WMF labs installations.
636
     * @return int
637
     */
638
    public function countFilesUploadedCommons(): int
639
    {
640
        $fileCounts = $this->repository->getFileCounts($this->project, $this->user);
0 ignored issues
show
The method getFileCounts() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

640
        /** @scrutinizer ignore-call */ 
641
        $fileCounts = $this->repository->getFileCounts($this->project, $this->user);
Loading history...
641
        return $fileCounts['files_uploaded_commons'] ?? 0;
642
    }
643
644
    /**
645
     * Get the total number of files that were renamed (including those now deleted).
646
     */
647
    public function countFilesMoved(): int
648
    {
649
        $fileCounts = $this->repository->getFileCounts($this->project, $this->user);
650
        return $fileCounts['files_moved'] ?? 0;
651
    }
652
653
    /**
654
     * Get the total number of files that were renamed on Commons (including those now deleted).
655
     */
656
    public function countFilesMovedCommons(): int
657
    {
658
        $fileCounts = $this->repository->getFileCounts($this->project, $this->user);
659
        return $fileCounts['files_moved_commons'] ?? 0;
660
    }
661
662
    /**
663
     * Get the total number of revisions the user has sent thanks for.
664
     * @return int
665
     */
666
    public function thanks(): int
667
    {
668
        $logCounts = $this->getLogCounts();
669
        return $logCounts['thanks-thank'] ?: 0;
670
    }
671
672
    /**
673
     * Get the total number of approvals
674
     * @return int
675
     */
676
    public function approvals(): int
677
    {
678
        $logCounts = $this->getLogCounts();
679
        return (!empty($logCounts['review-approve']) ? $logCounts['review-approve'] : 0) +
680
            (!empty($logCounts['review-approve2']) ? $logCounts['review-approve2'] : 0) +
681
            (!empty($logCounts['review-approve-i']) ? $logCounts['review-approve-i'] : 0) +
682
            (!empty($logCounts['review-approve2-i']) ? $logCounts['review-approve2-i'] : 0);
683
    }
684
685
    /**
686
     * Get the total number of patrols performed by the user.
687
     * @return int
688
     */
689
    public function patrols(): int
690
    {
691
        $logCounts = $this->getLogCounts();
692
        return $logCounts['patrol-patrol'] ?: 0;
693
    }
694
695
    /**
696
     * Get the total number of accounts created by the user.
697
     * @return int
698
     */
699
    public function accountsCreated(): int
700
    {
701
        $logCounts = $this->getLogCounts();
702
        $create2 = $logCounts['newusers-create2'] ?: 0;
703
        $byemail = $logCounts['newusers-byemail'] ?: 0;
704
        return $create2 + $byemail;
705
    }
706
707
    /**
708
     * Get the number of history merges performed by the user.
709
     * @return int
710
     */
711
    public function merges(): int
712
    {
713
        $logCounts = $this->getLogCounts();
714
        return $logCounts['merge-merge'];
715
    }
716
717
    /**
718
     * Get the given user's total edit counts per namespace.
719
     * @return array Array keys are namespace IDs, values are the edit counts.
720
     */
721
    public function namespaceTotals(): array
722
    {
723
        if (isset($this->namespaceTotals)) {
724
            return $this->namespaceTotals;
725
        }
726
        $counts = $this->repository->getNamespaceTotals($this->project, $this->user);
0 ignored issues
show
The method getNamespaceTotals() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

726
        /** @scrutinizer ignore-call */ 
727
        $counts = $this->repository->getNamespaceTotals($this->project, $this->user);
Loading history...
727
        arsort($counts);
728
        $this->namespaceTotals = $counts;
729
        return $counts;
730
    }
731
732
    /**
733
     * Get the total number of live edits by summing the namespace totals.
734
     * This is used in the view for namespace totals so we don't unnecessarily run the self::getPairData() query.
735
     * @return int
736
     */
737
    public function liveRevisionsFromNamespaces(): int
738
    {
739
        return array_sum($this->namespaceTotals());
740
    }
741
742
    /**
743
     * Get a summary of the times of day and the days of the week that the user has edited.
744
     * @return string[]
745
     */
746
    public function timeCard(): array
747
    {
748
        if (isset($this->timeCardData)) {
749
            return $this->timeCardData;
750
        }
751
        $totals = $this->repository->getTimeCard($this->project, $this->user);
0 ignored issues
show
The method getTimeCard() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

751
        /** @scrutinizer ignore-call */ 
752
        $totals = $this->repository->getTimeCard($this->project, $this->user);
Loading history...
752
753
        // Scale the radii: get the max, then scale each radius.
754
        // This looks inefficient, but there's a max of 72 elements in this array.
755
        $max = 0;
756
        foreach ($totals as $total) {
757
            $max = max($max, $total['value']);
758
        }
759
        foreach ($totals as &$total) {
760
            $total['scale'] = round(($total['value'] / $max) * 20);
761
        }
762
763
        // Fill in zeros for timeslots that have no values.
764
        $sortedTotals = [];
765
        $index = 0;
766
        $sortedIndex = 0;
767
        foreach (range(1, 7) as $day) {
768
            foreach (range(0, 23) as $hour) {
769
                if (isset($totals[$index]) && (int)$totals[$index]['hour'] === $hour) {
770
                    $sortedTotals[$sortedIndex] = $totals[$index];
771
                    $index++;
772
                } else {
773
                    $sortedTotals[$sortedIndex] = [
774
                        'day_of_week' => $day,
775
                        'hour' => $hour,
776
                        'value' => 0,
777
                    ];
778
                }
779
                $sortedIndex++;
780
            }
781
        }
782
783
        $this->timeCardData = $sortedTotals;
784
        return $sortedTotals;
785
    }
786
787
    /**
788
     * Get the total numbers of edits per month.
789
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime.
790
     * @return array With keys 'yearLabels', 'monthLabels' and 'totals',
791
     *   the latter keyed by namespace, then year/month.
792
     */
793
    public function monthCounts(?DateTime $currentTime = null): array
794
    {
795
        if (isset($this->monthCounts)) {
796
            return $this->monthCounts;
797
        }
798
799
        // Set to current month if we're not unit-testing
800
        if (!($currentTime instanceof DateTime)) {
801
            $currentTime = new DateTime('last day of this month');
802
        }
803
804
        $totals = $this->repository->getMonthCounts($this->project, $this->user);
0 ignored issues
show
The method getMonthCounts() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

804
        /** @scrutinizer ignore-call */ 
805
        $totals = $this->repository->getMonthCounts($this->project, $this->user);
Loading history...
805
        $out = [
806
            'yearLabels' => [],  // labels for years
807
            'monthLabels' => [], // labels for months
808
            'totals' => [], // actual totals, grouped by namespace, year and then month
809
        ];
810
811
        /** Keep track of the date of their first edit. */
812
        $firstEdit = new DateTime();
813
814
        [$out, $firstEdit] = $this->fillInMonthCounts($out, $totals, $firstEdit);
815
816
        $dateRange = new DatePeriod(
817
            $firstEdit,
818
            new DateInterval('P1M'),
819
            $currentTime->modify('first day of this month')
820
        );
821
822
        $out = $this->fillInMonthTotalsAndLabels($out, $dateRange);
823
824
        // One more loop to sort by year/month
825
        foreach (array_keys($out['totals']) as $nsId) {
826
            ksort($out['totals'][$nsId]);
827
        }
828
829
        // Finally, sort the namespaces
830
        ksort($out['totals']);
831
832
        $this->monthCounts = $out;
833
        return $out;
834
    }
835
836
    /**
837
     * Get the counts keyed by month and then namespace.
838
     * Basically the opposite of self::monthCounts()['totals'].
839
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime.
840
     * @return array Months as keys, values are counts keyed by namesapce.
841
     * @fixme Create API for this!
842
     */
843
    public function monthCountsWithNamespaces(?DateTime $currentTime = null): array
844
    {
845
        $countsMonthNamespace = array_fill_keys(
846
            array_values($this->monthCounts($currentTime)['monthLabels']),
847
            []
848
        );
849
850
        foreach ($this->monthCounts($currentTime)['totals'] as $ns => $months) {
851
            foreach ($months as $month => $count) {
852
                $countsMonthNamespace[$month][$ns] = $count;
853
            }
854
        }
855
856
        return $countsMonthNamespace;
857
    }
858
859
    /**
860
     * Loop through the database results and fill in the values
861
     * for the months that we have data for.
862
     * @param array $out
863
     * @param array $totals
864
     * @param DateTime $firstEdit
865
     * @return array [
866
     *           string[] - Modified $out filled with month stats,
867
     *           DateTime - timestamp of first edit
868
     *         ]
869
     * Tests covered in self::monthCounts().
870
     * @codeCoverageIgnore
871
     */
872
    private function fillInMonthCounts(array $out, array $totals, DateTime $firstEdit): array
873
    {
874
        foreach ($totals as $total) {
875
            // Keep track of first edit
876
            $date = new DateTime($total['year'].'-'.$total['month'].'-01');
877
            if ($date < $firstEdit) {
878
                $firstEdit = $date;
879
            }
880
881
            // Collate the counts by namespace, and then YYYY-MM.
882
            $ns = $total['namespace'];
883
            $out['totals'][$ns][$date->format('Y-m')] = (int)$total['count'];
884
        }
885
886
        return [$out, $firstEdit];
887
    }
888
889
    /**
890
     * Given the output array, fill each month's totals and labels.
891
     * @param array $out
892
     * @param DatePeriod $dateRange From first edit to present.
893
     * @return array Modified $out filled with month stats.
894
     * Tests covered in self::monthCounts().
895
     * @codeCoverageIgnore
896
     */
897
    private function fillInMonthTotalsAndLabels(array $out, DatePeriod $dateRange): array
898
    {
899
        foreach ($dateRange as $monthObj) {
900
            $yearLabel = $monthObj->format('Y');
901
            $monthLabel = $monthObj->format('Y-m');
902
903
            // Fill in labels
904
            $out['monthLabels'][] = $monthLabel;
905
            if (!in_array($yearLabel, $out['yearLabels'])) {
906
                $out['yearLabels'][] = $yearLabel;
907
            }
908
909
            foreach (array_keys($out['totals']) as $nsId) {
910
                if (!isset($out['totals'][$nsId][$monthLabel])) {
911
                    $out['totals'][$nsId][$monthLabel] = 0;
912
                }
913
            }
914
        }
915
916
        return $out;
917
    }
918
919
    /**
920
     * Get the total numbers of edits per year.
921
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime.
922
     * @return array With keys 'yearLabels' and 'totals', the latter keyed by namespace then year.
923
     */
924
    public function yearCounts(?DateTime $currentTime = null): array
925
    {
926
        if (isset($this->yearCounts)) {
927
            return $this->yearCounts;
928
        }
929
930
        $monthCounts = $this->monthCounts($currentTime);
931
        $yearCounts = [
932
            'yearLabels' => $monthCounts['yearLabels'],
933
            'totals' => [],
934
        ];
935
936
        foreach ($monthCounts['totals'] as $nsId => $months) {
937
            foreach ($months as $month => $count) {
938
                $year = substr($month, 0, 4);
939
                if (!isset($yearCounts['totals'][$nsId][$year])) {
940
                    $yearCounts['totals'][$nsId][$year] = 0;
941
                }
942
                $yearCounts['totals'][$nsId][$year] += $count;
943
            }
944
        }
945
946
        $this->yearCounts = $yearCounts;
947
        return $yearCounts;
948
    }
949
950
    /**
951
     * Get the counts keyed by year and then namespace.
952
     * Basically the opposite of self::yearCounts()['totals'].
953
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
954
     *   so we can mock the current DateTime.
955
     * @return array Years as keys, values are counts keyed by namesapce.
956
     */
957
    public function yearCountsWithNamespaces(?DateTime $currentTime = null): array
958
    {
959
        $countsYearNamespace = array_fill_keys(
960
            array_keys($this->yearTotals($currentTime)),
961
            []
962
        );
963
964
        foreach ($this->yearCounts($currentTime)['totals'] as $ns => $years) {
965
            foreach ($years as $year => $count) {
966
                $countsYearNamespace[$year][$ns] = $count;
967
            }
968
        }
969
970
        return $countsYearNamespace;
971
    }
972
973
    /**
974
     * Get total edits for each year. Used in wikitext export.
975
     * @param null|DateTime $currentTime *USED ONLY FOR UNIT TESTING*
976
     * @return array With the years as the keys, counts as the values.
977
     */
978
    public function yearTotals(?DateTime $currentTime = null): array
979
    {
980
        $years = [];
981
982
        foreach ($this->yearCounts($currentTime)['totals'] as $nsData) {
983
            foreach ($nsData as $year => $count) {
984
                if (!isset($years[$year])) {
985
                    $years[$year] = 0;
986
                }
987
                $years[$year] += $count;
988
            }
989
        }
990
991
        return $years;
992
    }
993
994
    /**
995
     * Get average edit size, and number of large and small edits.
996
     * @return array
997
     */
998
    public function getEditSizeData(): array
999
    {
1000
        if (!isset($this->editSizeData)) {
1001
            $this->editSizeData = $this->repository
1002
                ->getEditSizeData($this->project, $this->user);
0 ignored issues
show
The method getEditSizeData() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

1002
            /** @scrutinizer ignore-call */ 
1003
            $this->editSizeData = $this->repository
Loading history...
1003
        }
1004
        return $this->editSizeData;
1005
    }
1006
1007
    /**
1008
     * Get the total edit count of this user or 5,000 if they've made more than 5,000 edits.
1009
     * This is used to ensure percentages of small and large edits are computed properly.
1010
     * @return int
1011
     */
1012
    public function countLast5000(): int
1013
    {
1014
        return $this->countLiveRevisions() > 5000 ? 5000 : $this->countLiveRevisions();
1015
    }
1016
1017
    /**
1018
     * Get the number of edits under 20 bytes of the user's past 5000 edits.
1019
     * @return int
1020
     */
1021
    public function countSmallEdits(): int
1022
    {
1023
        $editSizeData = $this->getEditSizeData();
1024
        return isset($editSizeData['small_edits']) ? (int) $editSizeData['small_edits'] : 0;
1025
    }
1026
1027
    /**
1028
     * Get the total number of edits over 1000 bytes of the user's past 5000 edits.
1029
     * @return int
1030
     */
1031
    public function countLargeEdits(): int
1032
    {
1033
        $editSizeData = $this->getEditSizeData();
1034
        return isset($editSizeData['large_edits']) ? (int) $editSizeData['large_edits'] : 0;
1035
    }
1036
1037
    /**
1038
     * Get the average size of the user's past 5000 edits.
1039
     * @return float Size in bytes.
1040
     */
1041
    public function averageEditSize(): float
1042
    {
1043
        $editSizeData = $this->getEditSizeData();
1044
        if (isset($editSizeData['average_size'])) {
1045
            return round((float)$editSizeData['average_size'], 3);
1046
        } else {
1047
            return 0;
1048
        }
1049
    }
1050
}
1051