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/PageInfo.php (9 issues)

1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Model;
6
7
use DateTime;
8
9
/**
10
 * A PageInfo provides statistics about a page on a project.
11
 */
12
class PageInfo extends PageInfoApi
13
{
14
    /** @var int Number of revisions that were actually processed. */
15
    protected int $numRevisionsProcessed;
16
17
    /**
18
     * Various statistics about editors to the page. These are not User objects
19
     * so as to preserve memory.
20
     * @var array
21
     */
22
    protected array $editors = [];
23
24
    /** @var array The top 10 editors to the page by number of edits. */
25
    protected array $topTenEditorsByEdits;
26
27
    /** @var array The top 10 editors to the page by added text. */
28
    protected array $topTenEditorsByAdded;
29
30
    /** @var int Number of edits made by the top 10 editors. */
31
    protected int $topTenCount;
32
33
    /** @var array Various counts about each individual year and month of the page's history. */
34
    protected array $yearMonthCounts;
35
36
    /** @var string[] Localized labels for the years, to be used in the 'Year counts' chart. */
37
    protected array $yearLabels = [];
38
39
    /** @var string[] Localized labels for the months, to be used in the 'Month counts' chart. */
40
    protected array $monthLabels = [];
41
42
    /** @var Edit|null The first edit to the page. */
43
    protected ?Edit $firstEdit = null;
44
45
    /** @var Edit|null The last edit to the page. */
46
    protected ?Edit $lastEdit = null;
47
48
    /** @var Edit|null Edit that made the largest addition by number of bytes. */
49
    protected ?Edit $maxAddition = null;
50
51
    /** @var Edit|null Edit that made the largest deletion by number of bytes. */
52
    protected ?Edit $maxDeletion = null;
53
54
    /**
55
     * Maximum number of edits that were created across all months. This is used as a comparison
56
     * for the bar charts in the months section.
57
     * @var int
58
     */
59
    protected int $maxEditsPerMonth = 0;
60
61
    /** @var string[][] List of (semi-)automated tools that were used to edit the page. */
62
    protected array $tools = [];
63
64
    /**
65
     * Total number of bytes added throughout the page's history. This is used as a comparison
66
     * when computing the top 10 editors by added text.
67
     * @var int
68
     */
69
    protected int $addedBytes = 0;
70
71
    /** @var int Number of days between first and last edit. */
72
    protected int $totalDays;
73
74
    /** @var int Number of minor edits to the page. */
75
    protected int $minorCount = 0;
76
77
    /** @var int Number of anonymous edits to the page. */
78
    protected int $anonCount = 0;
79
80
    /** @var int Number of automated edits to the page. */
81
    protected int $automatedCount = 0;
82
83
    /** @var int Number of edits to the page that were reverted with the subsequent edit. */
84
    protected int $revertCount = 0;
85
86
    /** @var int[] The "edits per <time>" counts. */
87
    protected array $countHistory = [
88
        'day' => 0,
89
        'week' => 0,
90
        'month' => 0,
91
        'year' => 0,
92
    ];
93
94
    /** @var int Number of revisions with deleted information that could effect accuracy of the stats. */
95
    protected int $numDeletedRevisions = 0;
96
97
    /**
98
     * Get the day of last date we should show in the month/year sections,
99
     * based on $this->end or the current date.
100
     * @return int As Unix timestamp.
101
     */
102
    private function getLastDay(): int
103
    {
104
        if (is_int($this->end)) {
105
            return (new DateTime("@$this->end"))
106
                ->modify('last day of this month')
107
                ->getTimestamp();
108
        } else {
109
            return strtotime('last day of this month');
110
        }
111
    }
112
113
    /**
114
     * Return the start/end date values as associative array, with YYYY-MM-DD as the date format.
115
     * This is used mainly as a helper to pass to the pageviews Twig macros.
116
     * @return array
117
     */
118
    public function getDateParams(): array
119
    {
120
        if (!$this->hasDateRange()) {
121
            return [];
122
        }
123
124
        $ret = [
125
            'start' => $this->firstEdit->getTimestamp()->format('Y-m-d'),
0 ignored issues
show
The method getTimestamp() does not exist on null. ( Ignorable by Annotation )

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

125
            'start' => $this->firstEdit->/** @scrutinizer ignore-call */ getTimestamp()->format('Y-m-d'),

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
126
            'end' => $this->lastEdit->getTimestamp()->format('Y-m-d'),
0 ignored issues
show
The method getTimestamp() does not exist on null. ( Ignorable by Annotation )

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

126
            'end' => $this->lastEdit->/** @scrutinizer ignore-call */ getTimestamp()->format('Y-m-d'),

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
127
        ];
128
129
        if (is_int($this->start)) {
130
            $ret['start'] = date('Y-m-d', $this->start);
131
        }
132
        if (is_int($this->end)) {
133
            $ret['end'] = date('Y-m-d', $this->end);
134
        }
135
136
        return $ret;
137
    }
138
139
    /**
140
     * Get the number of revisions that are actually getting processed. This goes by the APP_MAX_PAGE_REVISIONS
141
     * env variable, or the actual number of revisions, whichever is smaller.
142
     * @return int
143
     */
144
    public function getNumRevisionsProcessed(): int
145
    {
146
        if (isset($this->numRevisionsProcessed)) {
147
            return $this->numRevisionsProcessed;
148
        }
149
150
        if ($this->tooManyRevisions()) {
151
            $this->numRevisionsProcessed = $this->repository->getMaxPageRevisions();
0 ignored issues
show
The method getMaxPageRevisions() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageInfoRepository. ( Ignorable by Annotation )

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

151
            /** @scrutinizer ignore-call */ 
152
            $this->numRevisionsProcessed = $this->repository->getMaxPageRevisions();
Loading history...
152
        } else {
153
            $this->numRevisionsProcessed = $this->getNumRevisions();
154
        }
155
156
        return $this->numRevisionsProcessed;
157
    }
158
159
    /**
160
     * Fetch and store all the data we need to show the PageInfo view.
161
     * @codeCoverageIgnore
162
     */
163
    public function prepareData(): void
164
    {
165
        $this->parseHistory();
166
        $this->setLogsEvents();
167
168
        // Bots need to be set before setting top 10 counts.
169
        $this->bots = $this->getBots();
170
171
        $this->doPostPrecessing();
172
    }
173
174
    /**
175
     * Get the number of editors that edited the page.
176
     * @return int
177
     */
178
    public function getNumEditors(): int
179
    {
180
        return count($this->editors);
181
    }
182
183
    /**
184
     * Get the number of days between the first and last edit.
185
     * @return int
186
     */
187
    public function getTotalDays(): int
188
    {
189
        if (isset($this->totalDays)) {
190
            return $this->totalDays;
191
        }
192
        $dateFirst = $this->firstEdit->getTimestamp();
193
        $dateLast = $this->lastEdit->getTimestamp();
194
        $interval = date_diff($dateLast, $dateFirst, true);
195
        $this->totalDays = (int)$interval->format('%a');
196
        return $this->totalDays;
197
    }
198
199
    /**
200
     * Returns length of the page.
201
     * @return int|null
202
     */
203
    public function getLength(): ?int
204
    {
205
        if ($this->hasDateRange()) {
206
            return $this->lastEdit->getLength();
207
        }
208
209
        return $this->page->getLength();
0 ignored issues
show
The method getLength() does not exist on null. ( Ignorable by Annotation )

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

209
        return $this->page->/** @scrutinizer ignore-call */ getLength();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
210
    }
211
212
    /**
213
     * Get the average number of days between edits to the page.
214
     * @return float
215
     */
216
    public function averageDaysPerEdit(): float
217
    {
218
        return round($this->getTotalDays() / $this->getNumRevisionsProcessed(), 1);
219
    }
220
221
    /**
222
     * Get the average number of edits per day to the page.
223
     * @return float
224
     */
225
    public function editsPerDay(): float
226
    {
227
        $editsPerDay = $this->getTotalDays()
228
            ? $this->getNumRevisionsProcessed() / ($this->getTotalDays() / (365 / 12 / 24))
229
            : 0;
230
        return round($editsPerDay, 1);
231
    }
232
233
    /**
234
     * Get the average number of edits per month to the page.
235
     * @return float
236
     */
237
    public function editsPerMonth(): float
238
    {
239
        $editsPerMonth = $this->getTotalDays()
240
            ? $this->getNumRevisionsProcessed() / ($this->getTotalDays() / (365 / 12))
241
            : 0;
242
        return min($this->getNumRevisionsProcessed(), round($editsPerMonth, 1));
243
    }
244
245
    /**
246
     * Get the average number of edits per year to the page.
247
     * @return float
248
     */
249
    public function editsPerYear(): float
250
    {
251
        $editsPerYear = $this->getTotalDays()
252
            ? $this->getNumRevisionsProcessed() / ($this->getTotalDays() / 365)
253
            : 0;
254
        return min($this->getNumRevisionsProcessed(), round($editsPerYear, 1));
255
    }
256
257
    /**
258
     * Get the average number of edits per editor.
259
     * @return float
260
     */
261
    public function editsPerEditor(): float
262
    {
263
        if (count($this->editors) > 0) {
264
            return round($this->getNumRevisionsProcessed() / count($this->editors), 1);
265
        }
266
267
        // To prevent division by zero error; can happen if all usernames are removed (see T303724).
268
        return 0;
269
    }
270
271
    /**
272
     * Get the percentage of minor edits to the page.
273
     * @return float
274
     */
275
    public function minorPercentage(): float
276
    {
277
        return round(
278
            ($this->minorCount / $this->getNumRevisionsProcessed()) * 100,
279
            1
280
        );
281
    }
282
283
    /**
284
     * Get the percentage of anonymous edits to the page.
285
     * @return float
286
     */
287
    public function anonPercentage(): float
288
    {
289
        return round(
290
            ($this->anonCount / $this->getNumRevisionsProcessed()) * 100,
291
            1
292
        );
293
    }
294
295
    /**
296
     * Get the percentage of edits made by the top 10 editors.
297
     * @return float
298
     */
299
    public function topTenPercentage(): float
300
    {
301
        return round(($this->topTenCount / $this->getNumRevisionsProcessed()) * 100, 1);
302
    }
303
304
    /**
305
     * Get the number of automated edits made to the page.
306
     * @return int
307
     */
308
    public function getAutomatedCount(): int
309
    {
310
        return $this->automatedCount;
311
    }
312
313
    /**
314
     * Get the number of edits to the page that were reverted with the subsequent edit.
315
     * @return int
316
     */
317
    public function getRevertCount(): int
318
    {
319
        return $this->revertCount;
320
    }
321
322
    /**
323
     * Get the number of edits to the page made by logged out users.
324
     * @return int
325
     */
326
    public function getAnonCount(): int
327
    {
328
        return $this->anonCount;
329
    }
330
331
    /**
332
     * Get the number of minor edits to the page.
333
     * @return int
334
     */
335
    public function getMinorCount(): int
336
    {
337
        return $this->minorCount;
338
    }
339
340
    /**
341
     * Get the number of edits to the page made in the past day, week, month and year.
342
     * @return int[] With keys 'day', 'week', 'month' and 'year'.
343
     */
344
    public function getCountHistory(): array
345
    {
346
        return $this->countHistory;
347
    }
348
349
    /**
350
     * Get the number of edits to the page made by the top 10 editors.
351
     * @return int
352
     */
353
    public function getTopTenCount(): int
354
    {
355
        return $this->topTenCount;
356
    }
357
358
    /**
359
     * Get the first edit to the page.
360
     * @return Edit
361
     */
362
    public function getFirstEdit(): Edit
363
    {
364
        return $this->firstEdit;
365
    }
366
367
    /**
368
     * Get the last edit to the page.
369
     * @return Edit
370
     */
371
    public function getLastEdit(): Edit
372
    {
373
        return $this->lastEdit;
374
    }
375
376
    /**
377
     * Get the edit that made the largest addition to the page (by number of bytes).
378
     * @return Edit|null
379
     */
380
    public function getMaxAddition(): ?Edit
381
    {
382
        return $this->maxAddition;
383
    }
384
385
    /**
386
     * Get the edit that made the largest removal to the page (by number of bytes).
387
     * @return Edit|null
388
     */
389
    public function getMaxDeletion(): ?Edit
390
    {
391
        return $this->maxDeletion;
392
    }
393
394
    /**
395
     * Get the list of editors to the page, including various statistics.
396
     * @return array
397
     */
398
    public function getEditors(): array
399
    {
400
        return $this->editors;
401
    }
402
403
    /**
404
     * Get usernames of human editors (not bots).
405
     * @param int|null $limit
406
     * @return string[]
407
     */
408
    public function getHumans(?int $limit = null): array
409
    {
410
        return array_slice(array_diff(array_keys($this->getEditors()), array_keys($this->getBots())), 0, $limit);
411
    }
412
413
    /**
414
     * Get the list of the top editors to the page (by edits), including various statistics.
415
     * @return array
416
     */
417
    public function topTenEditorsByEdits(): array
418
    {
419
        return $this->topTenEditorsByEdits;
420
    }
421
422
    /**
423
     * Get the list of the top editors to the page (by added text), including various statistics.
424
     * @return array
425
     */
426
    public function topTenEditorsByAdded(): array
427
    {
428
        return $this->topTenEditorsByAdded;
429
    }
430
431
    /**
432
     * Get various counts about each individual year and month of the page's history.
433
     * @return array
434
     */
435
    public function getYearMonthCounts(): array
436
    {
437
        return $this->yearMonthCounts;
438
    }
439
440
    /**
441
     * Get the localized labels for the 'Year counts' chart.
442
     * @return string[]
443
     */
444
    public function getYearLabels(): array
445
    {
446
        return $this->yearLabels;
447
    }
448
449
    /**
450
     * Get the localized labels for the 'Month counts' chart.
451
     * @return string[]
452
     */
453
    public function getMonthLabels(): array
454
    {
455
        return $this->monthLabels;
456
    }
457
458
    /**
459
     * Get the maximum number of edits that were created across all months. This is used as a
460
     * comparison for the bar charts in the months section.
461
     * @return int
462
     */
463
    public function getMaxEditsPerMonth(): int
464
    {
465
        return $this->maxEditsPerMonth;
466
    }
467
468
    /**
469
     * Get a list of (semi-)automated tools that were used to edit the page, including
470
     * the number of times they were used, and a link to the tool's homepage.
471
     * @return string[]
472
     */
473
    public function getTools(): array
474
    {
475
        return $this->tools;
476
    }
477
478
    /**
479
     * Parse the revision history, collecting our core statistics.
480
     *
481
     * Untestable because it relies on getting a PDO statement. All the important
482
     * logic lives in other methods which are tested.
483
     * @codeCoverageIgnore
484
     */
485
    private function parseHistory(): void
486
    {
487
        $limit = $this->tooManyRevisions() ? $this->repository->getMaxPageRevisions() : null;
488
489
        // Third parameter is ignored if $limit is null.
490
        $revStmt = $this->page->getRevisionsStmt(
491
            null,
492
            $limit,
493
            $this->getNumRevisions(),
494
            $this->start,
495
            $this->end
496
        );
497
        $revCount = 0;
498
499
        /**
500
         * Data about previous edits so that we can use them as a basis for comparison.
501
         * @var Edit[] $prevEdits
502
         */
503
        $prevEdits = [
504
            // The previous Edit, used to discount content that was reverted.
505
            'prev' => null,
506
507
            // The SHA-1 of the edit *before* the previous edit. Used for more
508
            // accurate revert detection.
509
            'prevSha' => null,
510
511
            // The last edit deemed to be the max addition of content. This is kept track of
512
            // in case we find out the next edit was reverted (and was also a max edit),
513
            // in which case we'll want to discount it and use this one instead.
514
            'maxAddition' => null,
515
516
            // Same as with maxAddition, except the maximum amount of content deleted.
517
            // This is used to discount content that was reverted.
518
            'maxDeletion' => null,
519
        ];
520
521
        while ($rev = $revStmt->fetchAssociative()) {
0 ignored issues
show
The method fetchAssociative() does not exist on Doctrine\DBAL\Driver\ResultStatement. It seems like you code against a sub-type of said class. However, the method does not exist in Doctrine\DBAL\Driver\Statement. Are you sure you never get one of those? ( Ignorable by Annotation )

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

521
        while ($rev = $revStmt->/** @scrutinizer ignore-call */ fetchAssociative()) {
Loading history...
522
            /** @var Edit $edit */
523
            $edit = $this->repository->getEdit($this->page, $rev);
0 ignored issues
show
The method getEdit() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\TopEditsRepository or App\Repository\PageInfoRepository. ( Ignorable by Annotation )

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

523
            /** @scrutinizer ignore-call */ 
524
            $edit = $this->repository->getEdit($this->page, $rev);
Loading history...
524
525
            if (0 !== $edit->getDeleted()) {
526
                $this->numDeletedRevisions++;
527
            }
528
529
            if (0 === $revCount) {
530
                $this->firstEdit = $edit;
531
            }
532
533
            // Sometimes, with old revisions (2001 era), the revisions from 2002 come before 2001
534
            if ($edit->getTimestamp() < $this->firstEdit->getTimestamp()) {
535
                $this->firstEdit = $edit;
536
            }
537
538
            $prevEdits = $this->updateCounts($edit, $prevEdits);
539
540
            $revCount++;
541
        }
542
543
        $this->numRevisionsProcessed = $revCount;
544
545
        // Various sorts
546
        arsort($this->editors);
547
        ksort($this->yearMonthCounts);
548
        if ($this->tools) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->tools of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
549
            arsort($this->tools);
550
        }
551
    }
552
553
    /**
554
     * Update various counts based on the current edit.
555
     * @param Edit $edit
556
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'
557
     * @return Edit[] Updated version of $prevEdits.
558
     */
559
    private function updateCounts(Edit $edit, array $prevEdits): array
560
    {
561
        // Update the counts for the year and month of the current edit.
562
        $this->updateYearMonthCounts($edit);
563
564
        // Update counts for the user who made the edit.
565
        $this->updateUserCounts($edit);
566
567
        // Update the year/month/user counts of anon and minor edits.
568
        $this->updateAnonMinorCounts($edit);
569
570
        // Update counts for automated tool usage, if applicable.
571
        $this->updateToolCounts($edit);
572
573
        // Increment "edits per <time>" counts
574
        $this->updateCountHistory($edit);
575
576
        // Update figures regarding content addition/removal, and the revert count.
577
        $prevEdits = $this->updateContentSizes($edit, $prevEdits);
578
579
        // Now that we've updated all the counts, we can reset
580
        // the prev and last edits, which are used for tracking.
581
        // But first, let's copy over the SHA of the actual previous edit
582
        // and put it in our $prevEdits['prev'], so that we'll know
583
        // that content added after $prevEdit['prev'] was reverted.
584
        if (null !== $prevEdits['prev']) {
585
            $prevEdits['prevSha'] = $prevEdits['prev']->getSha();
586
        }
587
        $prevEdits['prev'] = $edit;
588
        $this->lastEdit = $edit;
589
590
        return $prevEdits;
591
    }
592
593
    /**
594
     * Update various figures about content sizes based on the given edit.
595
     * @param Edit $edit
596
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'.
597
     * @return Edit[] Updated version of $prevEdits.
598
     */
599
    private function updateContentSizes(Edit $edit, array $prevEdits): array
600
    {
601
        // Check if it was a revert
602
        if ($this->isRevert($edit, $prevEdits)) {
603
            $edit->setReverted(true);
604
            return $this->updateContentSizesRevert($prevEdits);
605
        } else {
606
            return $this->updateContentSizesNonRevert($edit, $prevEdits);
607
        }
608
    }
609
610
    /**
611
     * Is the given Edit a revert?
612
     * @param Edit $edit
613
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'.
614
     * @return bool
615
     */
616
    private function isRevert(Edit $edit, array $prevEdits): bool
617
    {
618
        return $edit->getSha() === $prevEdits['prevSha'] || $edit->isRevert();
619
    }
620
621
    /**
622
     * Updates the figures on content sizes assuming the given edit was a revert of the previous one.
623
     * In such a case, we don't want to treat the previous edit as legit content addition or removal.
624
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'.
625
     * @return Edit[] Updated version of $prevEdits, for tracking.
626
     */
627
    private function updateContentSizesRevert(array $prevEdits): array
628
    {
629
        $this->revertCount++;
630
631
        // Adjust addedBytes given this edit was a revert of the previous one.
632
        if ($prevEdits['prev'] && false === $prevEdits['prev']->isReverted() && $prevEdits['prev']->getSize() > 0) {
633
            $this->addedBytes -= $prevEdits['prev']->getSize();
634
635
            // Also deduct from the user's individual added byte count.
636
            // We don't do this if the previous edit was reverted, since that would make the net bytes zero.
637
            if ($prevEdits['prev']->getUser()) {
638
                $username = $prevEdits['prev']->getUser()->getUsername();
639
                $this->editors[$username]['added'] -= $prevEdits['prev']->getSize();
640
            }
641
        }
642
643
        // @TODO: Test this against an edit war (use your sandbox).
644
        // Also remove as max added or deleted, if applicable.
645
        if ($this->maxAddition && $prevEdits['prev']->getId() === $this->maxAddition->getId()) {
646
            $this->maxAddition = $prevEdits['maxAddition'];
647
            $prevEdits['maxAddition'] = $prevEdits['prev']; // In the event of edit wars.
648
        } elseif ($this->maxDeletion && $prevEdits['prev']->getId() === $this->maxDeletion->getId()) {
649
            $this->maxDeletion = $prevEdits['maxDeletion'];
650
            $prevEdits['maxDeletion'] = $prevEdits['prev']; // In the event of edit wars.
651
        }
652
653
        return $prevEdits;
654
    }
655
656
    /**
657
     * Updates the figures on content sizes assuming the given edit was NOT a revert of the previous edit.
658
     * @param Edit $edit
659
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'.
660
     * @return Edit[] Updated version of $prevEdits, for tracking.
661
     */
662
    private function updateContentSizesNonRevert(Edit $edit, array $prevEdits): array
663
    {
664
        $editSize = $this->getEditSize($edit, $prevEdits);
665
666
        // Edit was not a revert, so treat size > 0 as content added.
667
        if ($editSize > 0) {
668
            $this->addedBytes += $editSize;
669
670
            if ($edit->getUser()) {
671
                $this->editors[$edit->getUser()->getUsername()]['added'] += $editSize;
672
            }
673
674
            // Keep track of edit with max addition.
675
            if (!$this->maxAddition || $editSize > $this->maxAddition->getSize()) {
676
                // Keep track of old maxAddition in case we find out the next $edit was reverted
677
                // (and was also a max edit), in which case we'll want to use this one ($edit).
678
                $prevEdits['maxAddition'] = $this->maxAddition;
679
680
                $this->maxAddition = $edit;
681
            }
682
        } elseif ($editSize < 0 && (!$this->maxDeletion || $editSize < $this->maxDeletion->getSize())) {
683
            // Keep track of old maxDeletion in case we find out the next edit was reverted
684
            // (and was also a max deletion), in which case we'll want to use this one.
685
            $prevEdits['maxDeletion'] = $this->maxDeletion;
686
687
            $this->maxDeletion = $edit;
688
        }
689
690
        return $prevEdits;
691
    }
692
693
    /**
694
     * Get the size of the given edit, based on the previous edit (if present).
695
     * We also don't return the actual edit size if last revision had a length of null.
696
     * This happens when the edit follows other edits that were revision-deleted.
697
     * @see T148857 for more information.
698
     * @todo Remove once T101631 is resolved.
699
     * @param Edit $edit
700
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'.
701
     * @return int|null
702
     */
703
    private function getEditSize(Edit $edit, array $prevEdits): ?int
704
    {
705
        if ($prevEdits['prev'] && null === $prevEdits['prev']->getLength()) {
706
            return 0;
707
        } else {
708
            return $edit->getSize();
709
        }
710
    }
711
712
    /**
713
     * Update counts of automated tool usage for the given edit.
714
     * @param Edit $edit
715
     */
716
    private function updateToolCounts(Edit $edit): void
717
    {
718
        $automatedTool = $edit->getTool();
719
720
        if (!$automatedTool) {
721
            // Nothing to do.
722
            return;
723
        }
724
725
        $editYear = $edit->getYear();
726
        $editMonth = $edit->getMonth();
727
728
        $this->automatedCount++;
729
        $this->yearMonthCounts[$editYear]['automated']++;
730
        $this->yearMonthCounts[$editYear]['months'][$editMonth]['automated']++;
731
732
        if (!isset($this->tools[$automatedTool['name']])) {
733
            $this->tools[$automatedTool['name']] = [
734
                'count' => 1,
735
                'link' => $automatedTool['link'],
736
            ];
737
        } else {
738
            $this->tools[$automatedTool['name']]['count']++;
739
        }
740
    }
741
742
    /**
743
     * Update various counts for the year and month of the given edit.
744
     * @param Edit $edit
745
     */
746
    private function updateYearMonthCounts(Edit $edit): void
747
    {
748
        $editYear = $edit->getYear();
749
        $editMonth = $edit->getMonth();
750
751
        // Fill in the blank arrays for the year and 12 months if needed.
752
        if (!isset($this->yearMonthCounts[$editYear])) {
753
            $this->addYearMonthCountEntry($edit);
754
        }
755
756
        // Increment year and month counts for all edits
757
        $this->yearMonthCounts[$editYear]['all']++;
758
        $this->yearMonthCounts[$editYear]['months'][$editMonth]['all']++;
759
        // This will ultimately be the size of the page by the end of the year
760
        $this->yearMonthCounts[$editYear]['size'] = $edit->getLength();
761
762
        // Keep track of which month had the most edits
763
        $editsThisMonth = $this->yearMonthCounts[$editYear]['months'][$editMonth]['all'];
764
        if ($editsThisMonth > $this->maxEditsPerMonth) {
765
            $this->maxEditsPerMonth = $editsThisMonth;
766
        }
767
    }
768
769
    /**
770
     * Add a new entry to $this->yearMonthCounts for the given year,
771
     * with blank values for each month. This called during self::parseHistory().
772
     * @param Edit $edit
773
     */
774
    private function addYearMonthCountEntry(Edit $edit): void
775
    {
776
        $this->yearLabels[] = $this->i18n->dateFormat($edit->getTimestamp(), 'yyyy');
777
        $editYear = $edit->getYear();
778
779
        // Beginning of the month at 00:00:00.
780
        $firstEditTime = mktime(0, 0, 0, (int)$this->firstEdit->getMonth(), 1, (int)$this->firstEdit->getYear());
781
782
        $this->yearMonthCounts[$editYear] = [
783
            'all' => 0,
784
            'minor' => 0,
785
            'anon' => 0,
786
            'automated' => 0,
787
            'size' => 0, // Keep track of the size by the end of the year.
788
            'events' => [],
789
            'months' => [],
790
        ];
791
792
        for ($i = 1; $i <= 12; $i++) {
793
            $timeObj = mktime(0, 0, 0, $i, 1, (int)$editYear);
794
795
            // Don't show zeros for months before the first edit or after the current month.
796
            if ($timeObj < $firstEditTime || $timeObj > $this->getLastDay()) {
797
                continue;
798
            }
799
800
            $this->monthLabels[] = $this->i18n->dateFormat($timeObj, 'yyyy-MM');
801
            $this->yearMonthCounts[$editYear]['months'][sprintf('%02d', $i)] = [
802
                'all' => 0,
803
                'minor' => 0,
804
                'anon' => 0,
805
                'automated' => 0,
806
            ];
807
        }
808
    }
809
810
    /**
811
     * Update the counts of anon and minor edits for year, month, and user of the given edit.
812
     * @param Edit $edit
813
     */
814
    private function updateAnonMinorCounts(Edit $edit): void
815
    {
816
        $editYear = $edit->getYear();
817
        $editMonth = $edit->getMonth();
818
819
        // If anonymous, increase counts
820
        if ($edit->isAnon()) {
821
            $this->anonCount++;
822
            $this->yearMonthCounts[$editYear]['anon']++;
823
            $this->yearMonthCounts[$editYear]['months'][$editMonth]['anon']++;
824
        }
825
826
        // If minor edit, increase counts
827
        if ($edit->isMinor()) {
828
            $this->minorCount++;
829
            $this->yearMonthCounts[$editYear]['minor']++;
830
            $this->yearMonthCounts[$editYear]['months'][$editMonth]['minor']++;
831
        }
832
    }
833
834
    /**
835
     * Update various counts for the user of the given edit.
836
     * @param Edit $edit
837
     */
838
    private function updateUserCounts(Edit $edit): void
839
    {
840
        if (!$edit->getUser()) {
841
            return;
842
        }
843
844
        $username = $edit->getUser()->getUsername();
845
846
        // Initialize various user stats if needed.
847
        if (!isset($this->editors[$username])) {
848
            $this->editors[$username] = [
849
                'all' => 0,
850
                'minor' => 0,
851
                'minorPercentage' => 0,
852
                'first' => $edit->getTimestamp(),
853
                'firstId' => $edit->getId(),
854
                'last' => null,
855
                'atbe' => null,
856
                'added' => 0,
857
            ];
858
        }
859
860
        // Increment user counts
861
        $this->editors[$username]['all']++;
862
        $this->editors[$username]['last'] = $edit->getTimestamp();
863
        $this->editors[$username]['lastId'] = $edit->getId();
864
865
        // Increment minor counts for this user
866
        if ($edit->isMinor()) {
867
            $this->editors[$username]['minor']++;
868
        }
869
    }
870
871
    /**
872
     * Increment "edits per <time>" counts based on the given edit.
873
     * @param Edit $edit
874
     */
875
    private function updateCountHistory(Edit $edit): void
876
    {
877
        $editTimestamp = $edit->getTimestamp();
878
879
        if ($editTimestamp > new DateTime('-1 day')) {
880
            $this->countHistory['day']++;
881
        }
882
        if ($editTimestamp > new DateTime('-1 week')) {
883
            $this->countHistory['week']++;
884
        }
885
        if ($editTimestamp > new DateTime('-1 month')) {
886
            $this->countHistory['month']++;
887
        }
888
        if ($editTimestamp > new DateTime('-1 year')) {
889
            $this->countHistory['year']++;
890
        }
891
    }
892
893
    /**
894
     * Query for log events during each year of the page's history, and set the results in $this->yearMonthCounts.
895
     */
896
    private function setLogsEvents(): void
897
    {
898
        $logData = $this->repository->getLogEvents(
0 ignored issues
show
The method getLogEvents() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageInfoRepository. ( Ignorable by Annotation )

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

898
        /** @scrutinizer ignore-call */ 
899
        $logData = $this->repository->getLogEvents(
Loading history...
899
            $this->page,
900
            $this->start,
901
            $this->end
902
        );
903
904
        foreach ($logData as $event) {
905
            $time = strtotime($event['timestamp']);
906
            $year = date('Y', $time);
907
908
            if (!isset($this->yearMonthCounts[$year])) {
909
                break;
910
            }
911
912
            $yearEvents = $this->yearMonthCounts[$year]['events'];
913
914
            // Convert log type value to i18n key.
915
            switch ($event['log_type']) {
916
                // count pending-changes protections along with normal protections.
917
                case 'stable':
918
                case 'protect':
919
                    $action = 'protections';
920
                    break;
921
                case 'delete':
922
                    $action = 'deletions';
923
                    break;
924
                case 'move':
925
                    $action = 'moves';
926
                    break;
927
            }
928
929
            if (empty($yearEvents[$action])) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $action does not seem to be defined for all execution paths leading up to this point.
Loading history...
930
                $yearEvents[$action] = 1;
931
            } else {
932
                $yearEvents[$action]++;
933
            }
934
935
            $this->yearMonthCounts[$year]['events'] = $yearEvents;
936
        }
937
    }
938
939
    /**
940
     * Set statistics about the top 10 editors by added text and number of edits.
941
     * This is ran *after* parseHistory() since we need the grand totals first.
942
     * Various stats are also set for each editor in $this->editors to be used in the charts.
943
     */
944
    private function doPostPrecessing(): void
945
    {
946
        $topTenCount = $counter = 0;
947
        $topTenEditorsByEdits = [];
948
949
        foreach ($this->editors as $editor => $info) {
950
            // Count how many users are in the top 10% by number of edits, excluding bots.
951
            if ($counter < 10 && !in_array($editor, array_keys($this->bots))) {
952
                $topTenCount += $info['all'];
953
                $counter++;
954
955
                // To be used in the Top Ten charts.
956
                $topTenEditorsByEdits[] = [
957
                    'label' => $editor,
958
                    'value' => $info['all'],
959
                ];
960
            }
961
962
            // Compute the percentage of minor edits the user made.
963
            $this->editors[$editor]['minorPercentage'] = $info['all']
964
                ? ($info['minor'] / $info['all']) * 100
965
                : 0;
966
967
            if ($info['all'] > 1) {
968
                // Number of seconds/days between first and last edit.
969
                $secs = $info['last']->getTimestamp() - $info['first']->getTimestamp();
970
                $days = $secs / (60 * 60 * 24);
971
972
                // Average time between edits (in days).
973
                $this->editors[$editor]['atbe'] = round($days / ($info['all'] - 1), 1);
974
            }
975
        }
976
977
        // Loop through again and add percentages.
978
        $this->topTenEditorsByEdits = array_map(function ($editor) use ($topTenCount) {
979
            $editor['percentage'] = 100 * ($editor['value'] / $topTenCount);
980
            return $editor;
981
        }, $topTenEditorsByEdits);
982
983
        $this->topTenEditorsByAdded = $this->getTopTenByAdded();
984
985
        $this->topTenCount = $topTenCount;
986
    }
987
988
    /**
989
     * Get the top ten editors by added text.
990
     * @return array With keys 'label', 'value' and 'percentage', ready to be used by the pieChart Twig helper.
991
     */
992
    private function getTopTenByAdded(): array
993
    {
994
        // First sort editors array by the amount of text they added.
995
        $topTenEditorsByAdded = $this->editors;
996
        uasort($topTenEditorsByAdded, function ($a, $b) {
997
            if ($a['added'] === $b['added']) {
998
                return 0;
999
            }
1000
            return $a['added'] > $b['added'] ? -1 : 1;
1001
        });
1002
1003
        // Slice to the top 10.
1004
        $topTenEditorsByAdded = array_keys(array_slice($topTenEditorsByAdded, 0, 10, true));
1005
1006
         // Get the sum of added text so that we can add in percentages.
1007
         $topTenTotalAdded = array_sum(array_map(function ($editor) {
1008
             return $this->editors[$editor]['added'];
1009
         }, $topTenEditorsByAdded));
1010
1011
        // Then build a new array of top 10 editors by added text in the data structure needed for the chart.
1012
        return array_map(function ($editor) use ($topTenTotalAdded) {
1013
            $added = $this->editors[$editor]['added'];
1014
            return [
1015
                'label' => $editor,
1016
                'value' => $added,
1017
                'percentage' => 0 === $this->addedBytes
1018
                    ? 0
1019
                    : 100 * ($added / $topTenTotalAdded),
1020
            ];
1021
        }, $topTenEditorsByAdded);
1022
    }
1023
1024
    /**
1025
     * Get the number of times the page has been viewed in the last PageInfoApi::PAGEVIEWS_OFFSET days.
1026
     * If the PageInfo instance has a date range, it is used instead of the last N days.
1027
     * To reduce logic in the view, this method returns an array also containing the localized string
1028
     * for the pageviews count, as well as the tooltip to be used on the link to the Pageviews tool.
1029
     * @return array With keys 'count'<int>, 'formatted'<string> and 'tooltip'<string>
1030
     *@see PageInfoApi::PAGEVIEWS_OFFSET
1031
     */
1032
    public function getPageviews(): ?array
1033
    {
1034
        if (!$this->hasDateRange()) {
1035
            $pageviews = $this->page->getLatestPageviews();
1036
        } else {
1037
            $dateRange = $this->getDateParams();
1038
            $pageviews = $this->page->getPageviews($dateRange['start'], $dateRange['end']);
1039
        }
1040
1041
        return [
1042
            'count' => $pageviews,
1043
            'formatted' => $this->getPageviewsFormatted($pageviews),
1044
            'tooltip' => $this->getPageviewsTooltip($pageviews),
1045
        ];
1046
    }
1047
1048
    /**
1049
     * Convenience method for the view to get the value of the offset constant.
1050
     * (Twig code like `ai.PAGEVIEWS_OFFSET` just looks odd!)
1051
     * @return int
1052
     *@see PageInfoApi::PAGEVIEWS_OFFSET
1053
     */
1054
    public function getPageviewsOffset(): int
1055
    {
1056
        return PageInfoApi::PAGEVIEWS_OFFSET;
1057
    }
1058
1059
    /**
1060
     * Used to avoid putting too much logic in the view.
1061
     * @param int|null $pageviews
1062
     * @return string Formatted number or "Data unavailable".
1063
     */
1064
    private function getPageviewsFormatted(?int $pageviews): string
1065
    {
1066
        return null !== $pageviews
1067
            ? $this->i18n->numberFormat($pageviews)
1068
            : $this->i18n->msg('data-unavailable');
1069
    }
1070
1071
    /**
1072
     * Another convenience method for the view. Simply checks if there's data available,
1073
     * and if not, provides an informative message to be used in the tooltip.
1074
     * @param int|null $pageviews
1075
     * @return string
1076
     */
1077
    private function getPageviewsTooltip(?int $pageviews): string
1078
    {
1079
        return $pageviews ? '' : $this->i18n->msg('api-error-wikimedia', ['Pageviews']);
1080
    }
1081
1082
    /**
1083
     * Number of revisions with deleted information that could effect accuracy of the stats.
1084
     * @return int
1085
     */
1086
    public function numDeletedRevisions(): int
1087
    {
1088
        return $this->numDeletedRevisions;
1089
    }
1090
}
1091