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/Page.php (13 issues)

1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Model;
6
7
use App\Exception\BadGatewayException;
8
use App\Repository\PageRepository;
9
use DateTime;
10
use Doctrine\DBAL\Driver\ResultStatement;
11
use GuzzleHttp\Exception\ClientException;
12
13
/**
14
 * A Page is a single wiki page in one project.
15
 */
16
class Page extends Model
17
{
18
    /** @var string The page name as provided at instantiation. */
19
    protected string $unnormalizedPageName;
20
21
    /** @var string[]|null Metadata about this page. */
22
    protected ?array $pageInfo;
23
24
    /** @var string[] Revision history of this page. */
25
    protected array $revisions;
26
27
    /** @var int Number of revisions for this page. */
28
    protected int $numRevisions;
29
30
    /** @var string[] List of Wikidata sitelinks for this page. */
31
    protected array $wikidataItems;
32
33
    /** @var int Number of Wikidata sitelinks for this page. */
34
    protected int $numWikidataItems;
35
36
    /** @var int Length of the page in bytes. */
37
    protected int $length;
38
39
    /**
40
     * Page constructor.
41
     * @param PageRepository $repository
42
     * @param Project $project
43
     * @param string $pageName
44
     */
45
    public function __construct(PageRepository $repository, Project $project, string $pageName)
46
    {
47
        $this->repository = $repository;
48
        $this->project = $project;
49
        $this->unnormalizedPageName = $pageName;
50
    }
51
52
    /**
53
     * Get a Page instance given a database row (either from or JOINed on the page table).
54
     * @param PageRepository $repository
55
     * @param Project $project
56
     * @param array $row Must contain 'page_title' and 'namespace'. May contain 'length'.
57
     * @return static
58
     */
59
    public static function newFromRow(PageRepository $repository, Project $project, array $row): self
60
    {
61
        $pageTitle = $row['page_title'];
62
63
        if (0 === (int)$row['namespace']) {
64
            $fullPageTitle = $pageTitle;
65
        } else {
66
            $namespaces = $project->getNamespaces();
67
            $fullPageTitle = $namespaces[$row['namespace']].":$pageTitle";
68
        }
69
70
        $page = new self($repository, $project, $fullPageTitle);
71
        $page->pageInfo['ns'] = $row['namespace'];
72
        if (isset($row['length'])) {
73
            $page->length = (int)$row['length'];
74
        }
75
76
        return $page;
77
    }
78
79
    /**
80
     * Unique identifier for this Page, to be used in cache keys.
81
     * Use of md5 ensures the cache key does not contain reserved characters.
82
     * @see Repository::getCacheKey()
83
     * @return string
84
     * @codeCoverageIgnore
85
     */
86
    public function getCacheKey(): string
87
    {
88
        return md5((string)$this->getId());
89
    }
90
91
    /**
92
     * Get basic information about this page from the repository.
93
     * @return array|null
94
     */
95
    protected function getPageInfo(): ?array
96
    {
97
        if (!isset($this->pageInfo)) {
98
            $this->pageInfo = $this->repository->getPageInfo($this->project, $this->unnormalizedPageName);
0 ignored issues
show
The method getPageInfo() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

98
            /** @scrutinizer ignore-call */ 
99
            $this->pageInfo = $this->repository->getPageInfo($this->project, $this->unnormalizedPageName);
Loading history...
99
        }
100
        return $this->pageInfo;
101
    }
102
103
    /**
104
     * Get the page's title.
105
     * @param bool $useUnnormalized Use the unnormalized page title to avoid an API call. This should be used only if
106
     *   you fetched the page title via other means (SQL query), and is not from user input alone.
107
     * @return string
108
     */
109
    public function getTitle(bool $useUnnormalized = false): string
110
    {
111
        if ($useUnnormalized) {
112
            return $this->unnormalizedPageName;
113
        }
114
        $info = $this->getPageInfo();
115
        return $info['title'] ?? $this->unnormalizedPageName;
116
    }
117
118
    /**
119
     * Get the page's title without the namespace.
120
     * @return string
121
     */
122
    public function getTitleWithoutNamespace(): string
123
    {
124
        $info = $this->getPageInfo();
125
        $title = $info['title'] ?? $this->unnormalizedPageName;
126
        $nsName = $this->getNamespaceName();
127
        return $nsName
128
            ? str_replace($nsName . ':', '', $title)
129
            : $title;
130
    }
131
132
    /**
133
     * Get this page's database ID.
134
     * @return int|null Null if nonexistent.
135
     */
136
    public function getId(): ?int
137
    {
138
        $info = $this->getPageInfo();
139
        return isset($info['pageid']) ? (int)$info['pageid'] : null;
140
    }
141
142
    /**
143
     * Get this page's length in bytes.
144
     * @return int|null Null if nonexistent.
145
     */
146
    public function getLength(): ?int
147
    {
148
        if (isset($this->length)) {
149
            return $this->length;
150
        }
151
        $info = $this->getPageInfo();
152
        $this->length = isset($info['length']) ? (int)$info['length'] : null;
153
        return $this->length;
154
    }
155
156
    /**
157
     * Get HTML for the stylized display of the title.
158
     * The text will be the same as Page::getTitle().
159
     * @return string
160
     */
161
    public function getDisplayTitle(): string
162
    {
163
        $info = $this->getPageInfo();
164
        if (isset($info['displaytitle'])) {
165
            return $info['displaytitle'];
166
        }
167
        return $this->getTitle();
168
    }
169
170
    /**
171
     * Get the full URL of this page.
172
     * @return string|null Null if nonexistent.
173
     */
174
    public function getUrl(): ?string
175
    {
176
        $info = $this->getPageInfo();
177
        return $info['fullurl'] ?? null;
178
    }
179
180
    /**
181
     * Get the numerical ID of the namespace of this page.
182
     * @return int|null Null if page doesn't exist.
183
     */
184
    public function getNamespace(): ?int
185
    {
186
        if (isset($this->pageInfo['ns']) && is_numeric($this->pageInfo['ns'])) {
187
            return (int)$this->pageInfo['ns'];
188
        }
189
        $info = $this->getPageInfo();
190
        return isset($info['ns']) ? (int)$info['ns'] : null;
191
    }
192
193
    /**
194
     * Get the name of the namespace of this page.
195
     * @return string|null Null if could not be determined.
196
     */
197
    public function getNamespaceName(): ?string
198
    {
199
        $info = $this->getPageInfo();
200
        return isset($info['ns'])
201
            ? ($this->getProject()->getNamespaces()[$info['ns']] ?? null)
202
            : null;
203
    }
204
205
    /**
206
     * Get the number of page watchers.
207
     * @return int|null Null if unknown.
208
     */
209
    public function getWatchers(): ?int
210
    {
211
        $info = $this->getPageInfo();
212
        return isset($info['watchers']) ? (int)$info['watchers'] : null;
213
    }
214
215
    /**
216
     * Get the HTML content of the body of the page.
217
     * @param DateTime|int $target If a DateTime object, the
218
     *   revision at that time will be returned. If an integer, it is
219
     *   assumed to be the actual revision ID.
220
     * @return string
221
     */
222
    public function getHTMLContent($target = null): string
223
    {
224
        if (is_a($target, 'DateTime')) {
225
            $target = $this->repository->getRevisionIdAtDate($this, $target);
0 ignored issues
show
The method getRevisionIdAtDate() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

225
            /** @scrutinizer ignore-call */ 
226
            $target = $this->repository->getRevisionIdAtDate($this, $target);
Loading history...
226
        }
227
        return $this->repository->getHTMLContent($this, $target);
0 ignored issues
show
The method getHTMLContent() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

227
        return $this->repository->/** @scrutinizer ignore-call */ getHTMLContent($this, $target);
Loading history...
228
    }
229
230
    /**
231
     * Whether or not this page exists.
232
     * @return bool
233
     */
234
    public function exists(): bool
235
    {
236
        $info = $this->getPageInfo();
237
        return null !== $info && !isset($info['missing']) && !isset($info['invalid']) && !isset($info['interwiki']);
238
    }
239
240
    /**
241
     * Get the Project to which this page belongs.
242
     * @return Project
243
     */
244
    public function getProject(): Project
245
    {
246
        return $this->project;
247
    }
248
249
    /**
250
     * Get the language code for this page.
251
     * If not set, the language code for the project is returned.
252
     * @return string
253
     */
254
    public function getLang(): string
255
    {
256
        $info = $this->getPageInfo();
257
        return $info['pagelanguage'] ?? $this->getProject()->getLang();
258
    }
259
260
    /**
261
     * Get the Wikidata ID of this page.
262
     * @return string|null Null if none exists.
263
     */
264
    public function getWikidataId(): ?string
265
    {
266
        $info = $this->getPageInfo();
267
        return $info['pageprops']['wikibase_item'] ?? null;
268
    }
269
270
    /**
271
     * Get the number of revisions the page has.
272
     * @param ?User $user Optionally limit to those of this user.
273
     * @param false|int $start
274
     * @param false|int $end
275
     * @return int
276
     */
277
    public function getNumRevisions(?User $user = null, $start = false, $end = false): int
278
    {
279
        // If a user is given, we will not cache the result via instance variable.
280
        if (null !== $user) {
281
            return $this->repository->getNumRevisions($this, $user, $start, $end);
0 ignored issues
show
The method getNumRevisions() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

281
            return $this->repository->/** @scrutinizer ignore-call */ getNumRevisions($this, $user, $start, $end);
Loading history...
282
        }
283
284
        // Return cached value, if present.
285
        if (isset($this->numRevisions)) {
286
            return $this->numRevisions;
287
        }
288
289
        // Otherwise, return the count of all revisions if already present.
290
        if (isset($this->revisions)) {
291
            $this->numRevisions = count($this->revisions);
292
        } else {
293
            // Otherwise do a COUNT in the event fetching all revisions is not desired.
294
            $this->numRevisions = $this->repository->getNumRevisions($this, null, $start, $end);
295
        }
296
297
        return $this->numRevisions;
298
    }
299
300
    /**
301
     * Get all edits made to this page.
302
     * @param User|null $user Specify to get only revisions by the given user.
303
     * @param false|int $start
304
     * @param false|int $end
305
     * @return array
306
     */
307
    public function getRevisions(?User $user = null, $start = false, $end = false): array
308
    {
309
        if (isset($this->revisions)) {
310
            return $this->revisions;
311
        }
312
313
        $this->revisions = $this->repository->getRevisions($this, $user, $start, $end);
0 ignored issues
show
The method getRevisions() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\GlobalContribsRepository or App\Repository\PageRepository or App\Repository\EditSummaryRepository. ( Ignorable by Annotation )

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

313
        /** @scrutinizer ignore-call */ 
314
        $this->revisions = $this->repository->getRevisions($this, $user, $start, $end);
Loading history...
314
315
        return $this->revisions;
316
    }
317
318
    /**
319
     * Get the full page wikitext.
320
     * @return string|null Null if nothing was found.
321
     */
322
    public function getWikitext(): ?string
323
    {
324
        $content = $this->repository->getPagesWikitext(
0 ignored issues
show
The method getPagesWikitext() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

324
        /** @scrutinizer ignore-call */ 
325
        $content = $this->repository->getPagesWikitext(
Loading history...
325
            $this->getProject(),
326
            [ $this->getTitle() ]
327
        );
328
329
        return $content[$this->getTitle()] ?? null;
330
    }
331
332
    /**
333
     * Get the statement for a single revision, so that you can iterate row by row.
334
     * @see PageRepository::getRevisionsStmt()
335
     * @param User|null $user Specify to get only revisions by the given user.
336
     * @param ?int $limit Max number of revisions to process.
337
     * @param ?int $numRevisions Number of revisions, if known. This is used solely to determine the
338
     *   OFFSET if we are given a $limit. If $limit is set and $numRevisions is not set, a
339
     *   separate query is ran to get the nuber of revisions.
340
     * @param false|int $start
341
     * @param false|int $end
342
     * @return ResultStatement
343
     */
344
    public function getRevisionsStmt(
345
        ?User $user = null,
346
        ?int $limit = null,
347
        ?int $numRevisions = null,
348
        $start = false,
349
        $end = false
350
    ): ResultStatement {
351
        // If we have a limit, we need to know the total number of revisions so that PageRepo
352
        // will properly set the OFFSET. See PageRepository::getRevisionsStmt() for more info.
353
        if (isset($limit) && null === $numRevisions) {
354
            $numRevisions = $this->getNumRevisions($user, $start, $end);
355
        }
356
        return $this->repository->getRevisionsStmt($this, $user, $limit, $numRevisions, $start, $end);
0 ignored issues
show
The method getRevisionsStmt() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

356
        return $this->repository->/** @scrutinizer ignore-call */ getRevisionsStmt($this, $user, $limit, $numRevisions, $start, $end);
Loading history...
357
    }
358
359
    /**
360
     * Get the revision ID that immediately precedes the given date.
361
     * @param DateTime $date
362
     * @return int|null Null if none found.
363
     */
364
    public function getRevisionIdAtDate(DateTime $date): ?int
365
    {
366
        return $this->repository->getRevisionIdAtDate($this, $date);
367
    }
368
369
    /**
370
     * Get CheckWiki errors for this page
371
     * @return string[] See getErrors() for format
372
     */
373
    public function getCheckWikiErrors(): array
374
    {
375
        return $this->repository->getCheckWikiErrors($this);
0 ignored issues
show
The method getCheckWikiErrors() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

375
        return $this->repository->/** @scrutinizer ignore-call */ getCheckWikiErrors($this);
Loading history...
376
    }
377
378
    /**
379
     * Get Wikidata errors for this page
380
     * @return string[][] See getErrors() for format
381
     */
382
    public function getWikidataErrors(): array
383
    {
384
        $errors = [];
385
386
        if (empty($this->getWikidataId())) {
387
            return [];
388
        }
389
390
        $wikidataInfo = $this->repository->getWikidataInfo($this);
0 ignored issues
show
The method getWikidataInfo() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

390
        /** @scrutinizer ignore-call */ 
391
        $wikidataInfo = $this->repository->getWikidataInfo($this);
Loading history...
391
392
        $terms = array_map(function ($entry) {
393
            return $entry['term'];
394
        }, $wikidataInfo);
395
396
        $lang = $this->getLang();
397
398
        if (!in_array('label', $terms)) {
399
            $errors[] = [
400
                'prio' => 2,
401
                'name' => 'Wikidata',
402
                'notice' => "Label for language <em>$lang</em> is missing", // FIXME: i18n
403
                'explanation' => "See: <a target='_blank' " .
404
                    "href='//www.wikidata.org/wiki/Help:Label'>Help:Label</a>",
405
            ];
406
        }
407
408
        if (!in_array('description', $terms)) {
409
            $errors[] = [
410
                'prio' => 3,
411
                'name' => 'Wikidata',
412
                'notice' => "Description for language <em>$lang</em> is missing", // FIXME: i18n
413
                'explanation' => "See: <a target='_blank' " .
414
                    "href='//www.wikidata.org/wiki/Help:Description'>Help:Description</a>",
415
            ];
416
        }
417
418
        return $errors;
419
    }
420
421
    /**
422
     * Get Wikidata and CheckWiki errors, if present
423
     * @return string[][] List of errors in the format:
424
     *    [[
425
     *         'prio' => int,
426
     *         'name' => string,
427
     *         'notice' => string (HTML),
428
     *         'explanation' => string (HTML)
429
     *     ], ... ]
430
     */
431
    public function getErrors(): array
432
    {
433
        // Includes label and description
434
        $wikidataErrors = $this->getWikidataErrors();
435
436
        $checkWikiErrors = $this->getCheckWikiErrors();
437
438
        return array_merge($wikidataErrors, $checkWikiErrors);
439
    }
440
441
    /**
442
     * Get all wikidata items for the page, not just languages of sister projects
443
     * @return string[]
444
     */
445
    public function getWikidataItems(): array
446
    {
447
        if (!isset($this->wikidataItems)) {
448
            $this->wikidataItems = $this->repository->getWikidataItems($this);
0 ignored issues
show
The method getWikidataItems() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

448
            /** @scrutinizer ignore-call */ 
449
            $this->wikidataItems = $this->repository->getWikidataItems($this);
Loading history...
449
        }
450
        return $this->wikidataItems;
451
    }
452
453
    /**
454
     * Count wikidata items for the page, not just languages of sister projects
455
     * @return int Number of records.
456
     */
457
    public function countWikidataItems(): int
458
    {
459
        if (isset($this->wikidataItems)) {
460
            $this->numWikidataItems = count($this->wikidataItems);
461
        } elseif (!isset($this->numWikidataItems)) {
462
            $this->numWikidataItems = $this->repository->countWikidataItems($this);
0 ignored issues
show
The method countWikidataItems() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

462
            /** @scrutinizer ignore-call */ 
463
            $this->numWikidataItems = $this->repository->countWikidataItems($this);
Loading history...
463
        }
464
        return $this->numWikidataItems;
465
    }
466
467
    /**
468
     * Get number of in and outgoing links and redirects to this page.
469
     * @return string[] Counts with keys 'links_ext_count', 'links_out_count', 'links_in_count' and 'redirects_count'.
470
     */
471
    public function countLinksAndRedirects(): array
472
    {
473
        return $this->repository->countLinksAndRedirects($this);
0 ignored issues
show
The method countLinksAndRedirects() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

473
        return $this->repository->/** @scrutinizer ignore-call */ countLinksAndRedirects($this);
Loading history...
474
    }
475
476
    /**
477
     * Get the sum of pageviews for the given page and timeframe.
478
     * @param string|DateTime $start In the format YYYYMMDD
479
     * @param string|DateTime $end In the format YYYYMMDD
480
     * @return int|null Total pageviews or null if data is unavailable.
481
     */
482
    public function getPageviews($start, $end): ?int
483
    {
484
        try {
485
            $pageviews = $this->repository->getPageviews($this, $start, $end);
0 ignored issues
show
The method getPageviews() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageRepository. ( Ignorable by Annotation )

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

485
            /** @scrutinizer ignore-call */ 
486
            $pageviews = $this->repository->getPageviews($this, $start, $end);
Loading history...
486
        } catch (ClientException $e) {
487
            // 404 means zero pageviews
488
            return 0;
489
        } catch (BadGatewayException $e) {
490
            // Upstream error, so return null so the view can customize messaging.
491
            return null;
492
        }
493
494
        return array_sum(array_map(function ($item) {
495
            return (int)$item['views'];
496
        }, $pageviews['items']));
497
    }
498
499
    /**
500
     * Get the sum of pageviews over the last N days
501
     * @param int $days Default PageInfoApi::PAGEVIEWS_OFFSET
502
     * @return int|null Number of pageviews or null if data is unavailable.
503
     *@see PageInfoApi::PAGEVIEWS_OFFSET
504
     */
505
    public function getLatestPageviews(int $days = PageInfoApi::PAGEVIEWS_OFFSET): ?int
506
    {
507
        $start = date('Ymd', strtotime("-$days days"));
508
        $end = date('Ymd');
509
        return $this->getPageviews($start, $end);
510
    }
511
512
    /**
513
     * Is the page the project's Main Page?
514
     * @return bool
515
     */
516
    public function isMainPage(): bool
517
    {
518
        return $this->getProject()->getMainPage() === $this->getTitle();
519
    }
520
}
521