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/Controller/XtoolsController.php (18 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
namespace App\Controller;
6
7
use App\Exception\XtoolsHttpException;
8
use App\Helper\I18nHelper;
9
use App\Model\Page;
10
use App\Model\Project;
11
use App\Model\User;
12
use App\Repository\PageRepository;
13
use App\Repository\ProjectRepository;
14
use App\Repository\UserRepository;
15
use DateTime;
16
use Doctrine\DBAL\Connection;
17
use Doctrine\DBAL\Exception;
18
use Doctrine\Persistence\ManagerRegistry;
19
use GuzzleHttp\Client;
20
use Psr\Cache\CacheItemPoolInterface;
21
use Psr\Container\ContainerInterface;
22
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
23
use Symfony\Component\HttpFoundation\Cookie;
24
use Symfony\Component\HttpFoundation\JsonResponse;
25
use Symfony\Component\HttpFoundation\Request;
26
use Symfony\Component\HttpFoundation\RequestStack;
27
use Symfony\Component\HttpFoundation\Response;
28
use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
29
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
30
use Symfony\Component\HttpKernel\Exception\HttpException;
31
use Twig\Environment;
32
use Twig\Markup;
33
use Wikimedia\IPUtils;
34
35
/**
36
 * XtoolsController supplies a variety of methods around parsing and validating parameters, and initializing
37
 * Project/User instances. These are used in other controllers in the App\Controller namespace.
38
 * @abstract
39
 */
40
abstract class XtoolsController extends AbstractController
41
{
42
    /** DEPENDENCIES */
43
44
    protected CacheItemPoolInterface $cache;
45
    protected Client $guzzle;
46
    protected Environment $twig;
47
    protected FlashBagInterface $flashBag;
48
    protected I18nHelper $i18n;
49
    protected ManagerRegistry $managerRegistry;
50
    protected ProjectRepository $projectRepo;
51
    protected UserRepository $userRepo;
52
    protected PageRepository $pageRepo;
53
54
    /** @var bool Whether this is a WMF installation. */
55
    protected bool $isWMF;
56
57
    /** @var string The configured default project. */
58
    protected string $defaultProject;
59
60
    /** OTHER CLASS PROPERTIES */
61
62
    /** @var Request The request object. */
63
    protected Request $request;
64
65
    /** @var string Name of the action within the child controller that is being executed. */
66
    protected string $controllerAction;
67
68
    /** @var array Hash of params parsed from the Request. */
69
    protected array $params;
70
71
    /** @var bool Whether this is a request to an API action. */
72
    protected bool $isApi;
73
74
    /** @var Project Relevant Project parsed from the Request. */
75
    protected Project $project;
76
77
    /** @var User|null Relevant User parsed from the Request. */
78
    protected ?User $user = null;
79
80
    /** @var Page|null Relevant Page parsed from the Request. */
81
    protected ?Page $page = null;
82
83
    /** @var int|false Start date parsed from the Request. */
84
    protected $start = false;
85
86
    /** @var int|false End date parsed from the Request. */
87
    protected $end = false;
88
89
    /** @var int|string|null Namespace parsed from the Request, ID as int or 'all' for all namespaces. */
90
    protected $namespace;
91
92
    /** @var int|false Unix timestamp. Pagination offset that substitutes for $end. */
93
    protected $offset = false;
94
95
    /** @var int|null Number of results to return. */
96
    protected ?int $limit = 50;
97
98
    /** @var bool Is the current request a subrequest? */
99
    protected bool $isSubRequest;
100
101
    /**
102
     * Stores user preferences such default project.
103
     * This may get altered from the Request and updated in the Response.
104
     * @var array
105
     */
106
    protected array $cookies = [
107
        'XtoolsProject' => null,
108
    ];
109
110
    /** OVERRIDABLE METHODS */
111
112
    /**
113
     * Require the tool's index route (initial form) be defined here. This should also
114
     * be the name of the associated model, if present.
115
     * @return string
116
     */
117
    abstract protected function getIndexRoute(): string;
118
119
    /**
120
     * Override this to activate the 'too high edit count' functionality. The return value
121
     * should represent the route name that we should be redirected to if the requested user
122
     * has too high of an edit count.
123
     * @return string|null Name of route to redirect to.
124
     */
125
    protected function tooHighEditCountRoute(): ?string
126
    {
127
        return null;
128
    }
129
130
    /**
131
     * Override this to specify which actions
132
     * @return string[]
133
     */
134
    protected function tooHighEditCountActionAllowlist(): array
135
    {
136
        return [];
137
    }
138
139
    /**
140
     * Override to restrict a tool's access to only the specified projects, instead of any valid project.
141
     * @return string[] Domain or DB names.
142
     */
143
    protected function supportedProjects(): array
144
    {
145
        return [];
146
    }
147
148
    /**
149
     * Override this to set which API actions for the controller require the
150
     * target user to opt in to the restricted statistics.
151
     * @see https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats
152
     * @return array
153
     */
154
    protected function restrictedApiActions(): array
155
    {
156
        return [];
157
    }
158
159
    /**
160
     * Override to set the maximum number of days allowed for the given date range.
161
     * This will be used as the default date span unless $this->defaultDays() is overridden.
162
     * @see XtoolsController::getUnixFromDateParams()
163
     * @return int|null
164
     */
165
    public function maxDays(): ?int
166
    {
167
        return null;
168
    }
169
170
    /**
171
     * Override to set default days from current day, to use as the start date if none was provided.
172
     * If this is null and $this->maxDays() is non-null, the latter will be used as the default.
173
     * @return int|null
174
     */
175
    protected function defaultDays(): ?int
176
    {
177
        return null;
178
    }
179
180
    /**
181
     * Override to set the maximum number of results to show per page, default 5000.
182
     * @return int
183
     */
184
    protected function maxLimit(): int
185
    {
186
        return 5000;
187
    }
188
189
    /**
190
     * XtoolsController constructor.
191
     * @param ContainerInterface $container
192
     * @param RequestStack $requestStack
193
     * @param ManagerRegistry $managerRegistry
194
     * @param CacheItemPoolInterface $cache
195
     * @param FlashBagInterface $flashBag
196
     * @param Client $guzzle
197
     * @param I18nHelper $i18n
198
     * @param ProjectRepository $projectRepo
199
     * @param UserRepository $userRepo
200
     * @param PageRepository $pageRepo
201
     * @param bool $isWMF
202
     * @param string $defaultProject
203
     */
204
    public function __construct(
205
        ContainerInterface $container,
206
        RequestStack $requestStack,
207
        ManagerRegistry $managerRegistry,
208
        CacheItemPoolInterface $cache,
209
        FlashBagInterface $flashBag,
210
        Client $guzzle,
211
        I18nHelper $i18n,
212
        ProjectRepository $projectRepo,
213
        UserRepository $userRepo,
214
        PageRepository $pageRepo,
215
        Environment $twig,
216
        bool $isWMF,
217
        string $defaultProject
218
    ) {
219
        $this->container = $container;
220
        $this->request = $requestStack->getCurrentRequest();
221
        $this->managerRegistry = $managerRegistry;
222
        $this->cache = $cache;
223
        $this->flashBag = $flashBag;
224
        $this->guzzle = $guzzle;
225
        $this->i18n = $i18n;
226
        $this->projectRepo = $projectRepo;
227
        $this->userRepo = $userRepo;
228
        $this->pageRepo = $pageRepo;
229
        $this->twig = $twig;
230
        $this->isWMF = $isWMF;
231
        $this->defaultProject = $defaultProject;
232
        $this->params = $this->parseQueryParams();
233
234
        // Parse out the name of the controller and action.
235
        $pattern = "#::([a-zA-Z]*)Action#";
236
        $matches = [];
237
        // The blank string here only happens in the unit tests, where the request may not be made to an action.
238
        preg_match($pattern, $this->request->get('_controller') ?? '', $matches);
239
        $this->controllerAction = $matches[1] ?? '';
240
241
        // Whether the action is an API action.
242
        $this->isApi = 'Api' === substr($this->controllerAction, -3) || 'recordUsage' === $this->controllerAction;
243
244
        // Whether we're making a subrequest (the view makes a request to another action).
245
        $this->isSubRequest = $this->request->get('htmlonly')
246
            || null !== $requestStack->getParentRequest();
247
248
        // Disallow AJAX (unless it's an API or subrequest).
249
        $this->checkIfAjax();
250
251
        // Load user options from cookies.
252
        $this->loadCookies();
253
254
        // Set the class-level properties based on params.
255
        if (false !== strpos(strtolower($this->controllerAction), 'index')) {
256
            // Index pages should only set the project, and no other class properties.
257
            $this->setProject($this->getProjectFromQuery());
258
259
            // ...except for transforming IP ranges. Because Symfony routes are separated by slashes, we need a way to
260
            // indicate a CIDR range because otherwise i.e. the path /sc/enwiki/192.168.0.0/24 could be interpreted as
261
            // the Simple Edit Counter for 192.168.0.0 in the namespace with ID 24. So we prefix ranges with 'ipr-'.
262
            // Further IP range handling logic is in the User class, i.e. see User::__construct, User::isIpRange.
263
            if (isset($this->params['username']) && IPUtils::isValidRange($this->params['username'])) {
264
                $this->params['username'] = 'ipr-'.$this->params['username'];
265
            }
266
        } else {
267
            $this->setProperties(); // Includes the project.
268
        }
269
270
        // Check if the request is to a restricted API endpoint, where the target user has to opt-in to statistics.
271
        $this->checkRestrictedApiEndpoint();
272
    }
273
274
    /**
275
     * Check if the request is AJAX, and disallow it unless they're using the API or if it's a subrequest.
276
     */
277
    private function checkIfAjax(): void
278
    {
279
        if ($this->request->isXmlHttpRequest() && !$this->isApi && !$this->isSubRequest) {
280
            throw new HttpException(
281
                Response::HTTP_FORBIDDEN,
282
                $this->i18n->msg('error-automation', ['https://www.mediawiki.org/Special:MyLanguage/XTools/API'])
283
            );
284
        }
285
    }
286
287
    /**
288
     * Check if the request is to a restricted API endpoint, and throw an exception if the target user hasn't opted-in.
289
     * @throws XtoolsHttpException
290
     */
291
    private function checkRestrictedApiEndpoint(): void
292
    {
293
        $restrictedAction = in_array($this->controllerAction, $this->restrictedApiActions());
294
295
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn($this->user)) {
0 ignored issues
show
It seems like $this->user can also be of type null; however, parameter $user of App\Model\Project::userHasOptedIn() does only seem to accept App\Model\User, maybe add an additional type check? ( Ignorable by Annotation )

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

295
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn(/** @scrutinizer ignore-type */ $this->user)) {
Loading history...
296
            throw new XtoolsHttpException(
297
                $this->i18n->msg('not-opted-in', [
0 ignored issues
show
It seems like $this->i18n->msg('not-op...'not-opted-in-login'))) can also be of type null; however, parameter $message of App\Exception\XtoolsHttpException::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

297
                /** @scrutinizer ignore-type */ $this->i18n->msg('not-opted-in', [
Loading history...
298
                    $this->getOptedInPage()->getTitle(),
299
                    $this->i18n->msg('not-opted-in-link') .
300
                        ' <https://www.mediawiki.org/wiki/Special:MyLanguage/XTools/Edit_Counter#restricted_stats>',
301
                    $this->i18n->msg('not-opted-in-login'),
302
                ]),
303
                '',
304
                $this->params,
305
                true,
306
                Response::HTTP_UNAUTHORIZED
307
            );
308
        }
309
    }
310
311
    /**
312
     * Get the path to the opt-in page for restricted statistics.
313
     * @return Page
314
     */
315
    protected function getOptedInPage(): Page
316
    {
317
        return new Page($this->pageRepo, $this->project, $this->project->userOptInPage($this->user));
0 ignored issues
show
It seems like $this->user can also be of type null; however, parameter $user of App\Model\Project::userOptInPage() does only seem to accept App\Model\User, maybe add an additional type check? ( Ignorable by Annotation )

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

317
        return new Page($this->pageRepo, $this->project, $this->project->userOptInPage(/** @scrutinizer ignore-type */ $this->user));
Loading history...
318
    }
319
320
    /***********
321
     * COOKIES *
322
     ***********/
323
324
    /**
325
     * Load user preferences from the associated cookies.
326
     */
327
    private function loadCookies(): void
328
    {
329
        // Not done for subrequests.
330
        if ($this->isSubRequest) {
331
            return;
332
        }
333
334
        foreach (array_keys($this->cookies) as $name) {
335
            $this->cookies[$name] = $this->request->cookies->get($name);
336
        }
337
    }
338
339
    /**
340
     * Set cookies on the given Response.
341
     * @param Response $response
342
     */
343
    private function setCookies(Response $response): void
344
    {
345
        // Not done for subrequests.
346
        if ($this->isSubRequest) {
347
            return;
348
        }
349
350
        foreach ($this->cookies as $name => $value) {
351
            $response->headers->setCookie(
352
                Cookie::create($name, $value)
353
            );
354
        }
355
    }
356
357
    /**
358
     * Sets the project, with the domain in $this->cookies['XtoolsProject'] that will
359
     * later get set on the Response headers in self::getFormattedResponse().
360
     * @param Project $project
361
     */
362
    private function setProject(Project $project): void
363
    {
364
        $this->project = $project;
365
        $this->cookies['XtoolsProject'] = $project->getDomain();
366
    }
367
368
    /****************************
369
     * SETTING CLASS PROPERTIES *
370
     ****************************/
371
372
    /**
373
     * Normalize all common parameters used by the controllers and set class properties.
374
     */
375
    private function setProperties(): void
376
    {
377
        $this->namespace = $this->params['namespace'] ?? null;
378
379
        // Offset is given as ISO timestamp and is stored as a UNIX timestamp (or false).
380
        if (isset($this->params['offset'])) {
381
            $this->offset = strtotime($this->params['offset']);
382
        }
383
384
        // Limit needs to be an int.
385
        if (isset($this->params['limit'])) {
386
            // Normalize.
387
            $this->params['limit'] = min(max(1, (int)$this->params['limit']), $this->maxLimit());
388
            $this->limit = $this->params['limit'];
389
        }
390
391
        if (isset($this->params['project'])) {
392
            $this->setProject($this->validateProject($this->params['project']));
393
        } elseif (null !== $this->cookies['XtoolsProject']) {
394
            // Set from cookie.
395
            $this->setProject(
396
                $this->validateProject($this->cookies['XtoolsProject'])
397
            );
398
        }
399
400
        if (isset($this->params['username'])) {
401
            $this->user = $this->validateUser($this->params['username']);
402
        }
403
        if (isset($this->params['page'])) {
404
            $this->page = $this->getPageFromNsAndTitle($this->namespace, $this->params['page']);
405
        }
406
407
        $this->setDates();
408
    }
409
410
    /**
411
     * Set class properties for dates, if such params were passed in.
412
     */
413
    private function setDates(): void
414
    {
415
        $start = $this->params['start'] ?? false;
416
        $end = $this->params['end'] ?? false;
417
        if ($start || $end || null !== $this->maxDays()) {
0 ignored issues
show
Are you sure the usage of $this->maxDays() targeting App\Controller\XtoolsController::maxDays() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
418
            [$this->start, $this->end] = $this->getUnixFromDateParams($start, $end);
419
420
            // Set $this->params accordingly too, so that for instance API responses will include it.
421
            $this->params['start'] = is_int($this->start) ? date('Y-m-d', $this->start) : false;
422
            $this->params['end'] = is_int($this->end) ? date('Y-m-d', $this->end) : false;
423
        }
424
    }
425
426
    /**
427
     * Construct a fully qualified page title given the namespace and title.
428
     * @param int|string $ns Namespace ID.
429
     * @param string $title Page title.
430
     * @param bool $rawTitle Return only the title (and not a Page).
431
     * @return Page|string
432
     */
433
    protected function getPageFromNsAndTitle($ns, string $title, bool $rawTitle = false)
434
    {
435
        if (0 === (int)$ns) {
436
            return $rawTitle ? $title : $this->validatePage($title);
437
        }
438
439
        // Prepend namespace and strip out duplicates.
440
        $nsName = $this->project->getNamespaces()[$ns] ?? $this->i18n->msg('unknown');
441
        $title = $nsName.':'.preg_replace('/^'.$nsName.':/', '', $title);
442
        return $rawTitle ? $title : $this->validatePage($title);
443
    }
444
445
    /**
446
     * Get a Project instance from the project string, using defaults if the given project string is invalid.
447
     * @return Project
448
     */
449
    public function getProjectFromQuery(): Project
450
    {
451
        // Set default project so we can populate the namespace selector on index pages.
452
        // Defaults to project stored in cookie, otherwise project specified in parameters.yml.
453
        if (isset($this->params['project'])) {
454
            $project = $this->params['project'];
455
        } elseif (null !== $this->cookies['XtoolsProject']) {
456
            $project = $this->cookies['XtoolsProject'];
457
        } else {
458
            $project = $this->defaultProject;
459
        }
460
461
        $projectData = $this->projectRepo->getProject($project);
462
463
        // Revert back to defaults if we've established the given project was invalid.
464
        if (!$projectData->exists()) {
465
            $projectData = $this->projectRepo->getProject($this->defaultProject);
466
        }
467
468
        return $projectData;
469
    }
470
471
    /*************************
472
     * GETTERS / VALIDATIONS *
473
     *************************/
474
475
    /**
476
     * Validate the given project, returning a Project if it is valid or false otherwise.
477
     * @param string $projectQuery Project domain or database name.
478
     * @return Project
479
     * @throws XtoolsHttpException
480
     */
481
    public function validateProject(string $projectQuery): Project
482
    {
483
        $project = $this->projectRepo->getProject($projectQuery);
484
485
        // Check if it is an explicitly allowed project for the current tool.
486
        if ($this->supportedProjects() && !in_array($project->getDomain(), $this->supportedProjects())) {
487
            $this->throwXtoolsException(
488
                $this->getIndexRoute(),
489
                'error-authorship-unsupported-project',
490
                [$this->params['project']],
491
                'project'
492
            );
493
        }
494
495
        if (!$project->exists()) {
496
            $this->throwXtoolsException(
497
                $this->getIndexRoute(),
498
                'invalid-project',
499
                [$this->params['project']],
500
                'project'
501
            );
502
        }
503
504
        return $project;
505
    }
506
507
    /**
508
     * Validate the given user, returning a User or Redirect if they don't exist.
509
     * @param string $username
510
     * @return User
511
     * @throws XtoolsHttpException
512
     */
513
    public function validateUser(string $username): User
514
    {
515
        $user = new User($this->userRepo, $username);
516
517
        // Allow querying for any IP, currently with no edit count limitation...
518
        // Once T188677 is resolved IPs will be affected by the EXPLAIN results.
519
        if ($user->isAnon()) {
520
            // Validate CIDR limits.
521
            if (!$user->isQueryableRange()) {
522
                $limit = $user->isIPv6() ? User::MAX_IPV6_CIDR : User::MAX_IPV4_CIDR;
523
                $this->throwXtoolsException($this->getIndexRoute(), 'ip-range-too-wide', [$limit], 'username');
524
            }
525
            return $user;
526
        }
527
528
        // Check against centralauth for global tools.
529
        $isGlobalTool = str_contains($this->request->get('_controller', ''), 'Global');
530
        if ($isGlobalTool && !$user->existsGlobally()) {
531
            $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username');
532
        } elseif (!$isGlobalTool && isset($this->project) && !$user->existsOnProject($this->project)) {
533
            // Don't continue if the user doesn't exist.
534
            $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username');
535
        }
536
537
        if (isset($this->project) && $user->hasManyEdits($this->project)) {
538
            $this->handleHasManyEdits($user);
539
        }
540
541
        return $user;
542
    }
543
544
    private function handleHasManyEdits(User $user): void
545
    {
546
        $originalParams = $this->params;
547
        $actionAllowlisted = in_array($this->controllerAction, $this->tooHighEditCountActionAllowlist());
548
549
        // Reject users with a crazy high edit count.
550
        if ($this->tooHighEditCountRoute() &&
0 ignored issues
show
Are you sure the usage of $this->tooHighEditCountRoute() targeting App\Controller\XtoolsCon...tooHighEditCountRoute() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
551
            !$actionAllowlisted &&
552
            $user->hasTooManyEdits($this->project)
553
        ) {
554
            /** TODO: Somehow get this to use self::throwXtoolsException */
555
556
            // If redirecting to a different controller, show an informative message accordingly.
557
            if ($this->tooHighEditCountRoute() !== $this->getIndexRoute()) {
0 ignored issues
show
Are you sure the usage of $this->tooHighEditCountRoute() targeting App\Controller\XtoolsCon...tooHighEditCountRoute() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
The condition $this->tooHighEditCountR... $this->getIndexRoute() is always true.
Loading history...
558
                // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,
559
                //   so this bit is hardcoded. We need to instead give the i18n key of the route.
560
                $redirMsg = $this->i18n->msg('too-many-edits-redir', [
561
                    $this->i18n->msg('tool-simpleeditcounter'),
562
                ]);
563
                $msg = $this->i18n->msg('too-many-edits', [
564
                        $this->i18n->numberFormat($user->maxEdits()),
565
                    ]).'. '.$redirMsg;
566
                $this->addFlashMessage('danger', $msg);
567
            } else {
568
                $this->addFlashMessage('danger', 'too-many-edits', [
569
                    $this->i18n->numberFormat($user->maxEdits()),
570
                ]);
571
572
                // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).
573
                unset($this->params['username']);
574
            }
575
576
            // Clear flash bag for API responses, since they get intercepted in ExceptionListener
577
            // and would otherwise be shown in subsequent requests.
578
            if ($this->isApi) {
579
                $this->flashBag->clear();
580
            }
581
582
            throw new XtoolsHttpException(
583
                $this->i18n->msg('too-many-edits', [ $user->maxEdits() ]),
0 ignored issues
show
It seems like $this->i18n->msg('too-ma...ray($user->maxEdits())) can also be of type null; however, parameter $message of App\Exception\XtoolsHttpException::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

583
                /** @scrutinizer ignore-type */ $this->i18n->msg('too-many-edits', [ $user->maxEdits() ]),
Loading history...
584
                $this->generateUrl($this->tooHighEditCountRoute(), $this->params),
0 ignored issues
show
$this->tooHighEditCountRoute() of type void is incompatible with the type string expected by parameter $route of Symfony\Bundle\Framework...ntroller::generateUrl(). ( Ignorable by Annotation )

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

584
                $this->generateUrl(/** @scrutinizer ignore-type */ $this->tooHighEditCountRoute(), $this->params),
Loading history...
Are you sure the usage of $this->tooHighEditCountRoute() targeting App\Controller\XtoolsCon...tooHighEditCountRoute() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
585
                $originalParams,
586
                $this->isApi,
587
                Response::HTTP_NOT_IMPLEMENTED
588
            );
589
        }
590
591
        // Require login for users with a semi-crazy high edit count.
592
        // For now, this only effects HTML requests and not the API.
593
        if (!$this->isApi && !$actionAllowlisted && !$this->request->getSession()->get('logged_in_user')) {
594
            throw new AccessDeniedHttpException('error-login-required');
595
        }
596
    }
597
598
    /**
599
     * Get a Page instance from the given page title, and validate that it exists.
600
     * @param string $pageTitle
601
     * @return Page
602
     * @throws XtoolsHttpException
603
     */
604
    public function validatePage(string $pageTitle): Page
605
    {
606
        $page = new Page($this->pageRepo, $this->project, $pageTitle);
607
608
        if (!$page->exists()) {
609
            $this->throwXtoolsException(
610
                $this->getIndexRoute(),
611
                'no-result',
612
                [$this->params['page'] ?? null],
613
                'page'
614
            );
615
        }
616
617
        return $page;
618
    }
619
620
    /**
621
     * Throw an XtoolsHttpException, which the given error message and redirects to specified action.
622
     * @param string $redirectAction Name of action to redirect to.
623
     * @param string $message i18n key of error message. Shown in API responses.
624
     *   If no message with this key exists, $message is shown as-is.
625
     * @param array $messageParams
626
     * @param string|null $invalidParam This will be removed from $this->params. Omit if you don't want this to happen.
627
     * @throws XtoolsHttpException
628
     */
629
    public function throwXtoolsException(
630
        string $redirectAction,
631
        string $message,
632
        array $messageParams = [],
633
        ?string $invalidParam = null
634
    ): void {
635
        $this->addFlashMessage('danger', $message, $messageParams);
636
        $originalParams = $this->params;
637
638
        // Remove invalid parameter if it was given.
639
        if (is_string($invalidParam)) {
640
            unset($this->params[$invalidParam]);
641
        }
642
643
        // We sometimes are redirecting to the index page, so also remove project (otherwise we'd get a redirect loop).
644
        /**
645
         * FIXME: Index pages should have a 'nosubmit' parameter to prevent submission.
646
         * Then we don't even need to remove $invalidParam.
647
         * Better, we should show the error on the results page, with no results.
648
         */
649
        unset($this->params['project']);
650
651
        // Throw exception which will redirect to $redirectAction.
652
        throw new XtoolsHttpException(
653
            $this->i18n->msgIfExists($message, $messageParams),
654
            $this->generateUrl($redirectAction, $this->params),
655
            $originalParams,
656
            $this->isApi
657
        );
658
    }
659
660
    /******************
661
     * PARSING PARAMS *
662
     ******************/
663
664
    /**
665
     * Get all standardized parameters from the Request, either via URL query string or routing.
666
     * @return string[]
667
     */
668
    public function getParams(): array
669
    {
670
        $paramsToCheck = [
671
            'project',
672
            'username',
673
            'namespace',
674
            'page',
675
            'categories',
676
            'group',
677
            'redirects',
678
            'deleted',
679
            'start',
680
            'end',
681
            'offset',
682
            'limit',
683
            'format',
684
            'tool',
685
            'tools',
686
            'q',
687
            'include_pattern',
688
            'exclude_pattern',
689
            'classonly',
690
691
            // Legacy parameters.
692
            'user',
693
            'name',
694
            'article',
695
            'wiki',
696
            'wikifam',
697
            'lang',
698
            'wikilang',
699
            'begin',
700
        ];
701
702
        /** @var string[] $params Each parameter that was detected along with its value. */
703
        $params = [];
704
705
        foreach ($paramsToCheck as $param) {
706
            // Pull in either from URL query string or route.
707
            $value = $this->request->query->get($param) ?: $this->request->get($param);
708
709
            // Only store if value is given ('namespace' or 'username' could be '0').
710
            if (null !== $value && '' !== $value) {
711
                $params[$param] = rawurldecode((string)$value);
712
            }
713
        }
714
715
        return $params;
716
    }
717
718
    /**
719
     * Parse out common parameters from the request. These include the 'project', 'username', 'namespace' and 'page',
720
     * along with their legacy counterparts (e.g. 'lang' and 'wiki').
721
     * @return string[] Normalized parameters (no legacy params).
722
     */
723
    public function parseQueryParams(): array
724
    {
725
        $params = $this->getParams();
726
727
        // Covert any legacy parameters, if present.
728
        $params = $this->convertLegacyParams($params);
729
730
        // Remove blank values.
731
        return array_filter($params, function ($param) {
732
            // 'namespace' or 'username' could be '0'.
733
            return null !== $param && '' !== $param;
734
        });
735
    }
736
737
    /**
738
     * Get Unix timestamps from given start and end string parameters. This also makes $start $maxDays() before
739
     * $end if not present, and makes $end the current time if not present.
740
     * The date range will not exceed $this->maxDays() days, if this public class property is set.
741
     * @param int|string|false $start Unix timestamp or string accepted by strtotime.
742
     * @param int|string|false $end Unix timestamp or string accepted by strtotime.
743
     * @return int[] Start and end date as UTC timestamps.
744
     */
745
    public function getUnixFromDateParams($start, $end): array
746
    {
747
        $today = strtotime('today midnight');
748
749
        // start time should not be in the future.
750
        $startTime = min(
751
            is_int($start) ? $start : strtotime((string)$start),
752
            $today
753
        );
754
755
        // end time defaults to now, and will not be in the future.
756
        $endTime = min(
757
            (is_int($end) ? $end : strtotime((string)$end)) ?: $today,
758
            $today
759
        );
760
761
        // Default to $this->defaultDays() or $this->maxDays() before end time if start is not present.
762
        $daysOffset = $this->defaultDays() ?? $this->maxDays();
0 ignored issues
show
Are you sure the usage of $this->defaultDays() targeting App\Controller\XtoolsController::defaultDays() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
Are you sure the usage of $this->maxDays() targeting App\Controller\XtoolsController::maxDays() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
763
        if (false === $startTime && $daysOffset) {
0 ignored issues
show
$daysOffset is of type null, thus it always evaluated to false.
Loading history...
764
            $startTime = strtotime("-$daysOffset days", $endTime);
765
        }
766
767
        // Default to $this->defaultDays() or $this->maxDays() after start time if end is not present.
768
        if (false === $end && $daysOffset) {
0 ignored issues
show
$daysOffset is of type null, thus it always evaluated to false.
Loading history...
769
            $endTime = min(
770
                strtotime("+$daysOffset days", $startTime),
771
                $today
772
            );
773
        }
774
775
        // Reverse if start date is after end date.
776
        if ($startTime > $endTime && false !== $startTime && false !== $end) {
777
            $newEndTime = $startTime;
778
            $startTime = $endTime;
779
            $endTime = $newEndTime;
780
        }
781
782
        // Finally, don't let the date range exceed $this->maxDays().
783
        $startObj = DateTime::createFromFormat('U', (string)$startTime);
784
        $endObj = DateTime::createFromFormat('U', (string)$endTime);
785
        if ($this->maxDays() && $startObj->diff($endObj)->days > $this->maxDays()) {
0 ignored issues
show
Are you sure the usage of $this->maxDays() targeting App\Controller\XtoolsController::maxDays() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
786
            // Show warnings that the date range was truncated.
787
            $this->addFlashMessage('warning', 'date-range-too-wide', [$this->maxDays()]);
0 ignored issues
show
Are you sure the usage of $this->maxDays() targeting App\Controller\XtoolsController::maxDays() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
788
789
            $startTime = strtotime('-' . $this->maxDays() . ' days', $endTime);
0 ignored issues
show
Are you sure $this->maxDays() of type void can be used in concatenation? ( Ignorable by Annotation )

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

789
            $startTime = strtotime('-' . /** @scrutinizer ignore-type */ $this->maxDays() . ' days', $endTime);
Loading history...
Are you sure the usage of $this->maxDays() targeting App\Controller\XtoolsController::maxDays() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
790
        }
791
792
        return [$startTime, $endTime];
793
    }
794
795
    /**
796
     * Given the params hash, normalize any legacy parameters to their modern equivalent.
797
     * @param string[] $params
798
     * @return string[]
799
     */
800
    private function convertLegacyParams(array $params): array
801
    {
802
        $paramMap = [
803
            'user' => 'username',
804
            'name' => 'username',
805
            'article' => 'page',
806
            'begin' => 'start',
807
808
            // Copy super legacy project params to legacy so we can concatenate below.
809
            'wikifam' => 'wiki',
810
            'wikilang' => 'lang',
811
        ];
812
813
        // Copy legacy parameters to modern equivalent.
814
        foreach ($paramMap as $legacy => $modern) {
815
            if (isset($params[$legacy])) {
816
                $params[$modern] = $params[$legacy];
817
                unset($params[$legacy]);
818
            }
819
        }
820
821
        // Separate parameters for language and wiki.
822
        if (isset($params['wiki']) && isset($params['lang'])) {
823
            // 'wikifam' may be like '.wikipedia.org', vs just 'wikipedia',
824
            // so we must remove leading periods and trailing .org's.
825
            $params['project'] = $params['lang'].'.'.rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
826
            unset($params['wiki']);
827
            unset($params['lang']);
828
        }
829
830
        return $params;
831
    }
832
833
    /************************
834
     * FORMATTING RESPONSES *
835
     ************************/
836
837
    /**
838
     * Get the rendered template for the requested format. This method also updates the cookies.
839
     * @param string $templatePath Path to template without format,
840
     *   such as '/editCounter/latest_global'.
841
     * @param array $ret Data that should be passed to the view.
842
     * @return Response
843
     * @codeCoverageIgnore
844
     */
845
    public function getFormattedResponse(string $templatePath, array $ret): Response
846
    {
847
        $format = $this->request->query->get('format', 'html');
848
        if ('' == $format) {
849
            // The default above doesn't work when the 'format' parameter is blank.
850
            $format = 'html';
851
        }
852
853
        // Merge in common default parameters, giving $ret (from the caller) the priority.
854
        $ret = array_merge([
855
            'project' => $this->project,
856
            'user' => $this->user,
857
            'page' => $this->page ?? null,
858
            'namespace' => $this->namespace,
859
            'start' => $this->start,
860
            'end' => $this->end,
861
        ], $ret);
862
863
        $formatMap = [
864
            'wikitext' => 'text/plain',
865
            'csv' => 'text/csv',
866
            'tsv' => 'text/tab-separated-values',
867
            'json' => 'application/json',
868
        ];
869
870
        $response = new Response();
871
872
        // Set cookies. Note this must be done before rendering the view, as the view may invoke subrequests.
873
        $this->setCookies($response);
874
875
        // If requested format does not exist, assume HTML.
876
        if (false === $this->twig->getLoader()->exists("$templatePath.$format.twig")) {
877
            $format = 'html';
878
        }
879
880
        $response = $this->render("$templatePath.$format.twig", $ret, $response);
881
882
        $contentType = $formatMap[$format] ?? 'text/html';
883
        $response->headers->set('Content-Type', $contentType);
884
885
        if (in_array($format, ['csv', 'tsv'])) {
886
            $filename = $this->getFilenameForRequest();
887
            $response->headers->set(
888
                'Content-Disposition',
889
                "attachment; filename=\"{$filename}.$format\""
890
            );
891
        }
892
893
        return $response;
894
    }
895
896
    /**
897
     * Returns given filename from the current Request, with problematic characters filtered out.
898
     * @return string
899
     */
900
    private function getFilenameForRequest(): string
901
    {
902
        $filename = trim($this->request->getPathInfo(), '/');
903
        return trim(preg_replace('/[-\/\\:;*?|<>%#"]+/', '-', $filename));
904
    }
905
906
    /**
907
     * Return a JsonResponse object pre-supplied with the requested params.
908
     * @param array $data
909
     * @param int $responseCode
910
     * @return JsonResponse
911
     */
912
    public function getFormattedApiResponse(array $data, int $responseCode = Response::HTTP_OK): JsonResponse
913
    {
914
        $response = new JsonResponse();
915
        $response->setEncodingOptions(JSON_NUMERIC_CHECK);
916
        $response->setStatusCode($responseCode);
917
918
        // Normalize display of IP ranges (they are prefixed with 'ipr-' in the params).
919
        if ($this->user && $this->user->isIpRange()) {
920
            $this->params['username'] = $this->user->getUsername();
921
        }
922
923
        $ret = array_merge($this->params, [
924
            // In some controllers, $this->params['project'] may be overridden with a Project object.
925
            'project' => $this->project->getDomain(),
926
        ], $data);
927
928
        // Merge in flash messages, putting them at the top.
929
        $flashes = $this->flashBag->peekAll();
930
        $ret = array_merge($flashes, $ret);
931
932
        // Flashes now can be cleared after merging into the response.
933
        $this->flashBag->clear();
934
935
        // Normalize path param values.
936
        $ret = self::normalizeApiProperties($ret);
937
938
        $response->setData($ret);
939
940
        return $response;
941
    }
942
943
    /**
944
     * Normalize the response data, adding in the elapsed_time.
945
     * @param array $params
946
     * @return array
947
     */
948
    public static function normalizeApiProperties(array $params): array
949
    {
950
        foreach ($params as $param => $value) {
951
            if (false === $value) {
952
                // False values must be empty params.
953
                unset($params[$param]);
954
            } elseif (is_string($value) && false !== strpos($value, '|')) {
955
                // Any pipe-separated values should be returned as an array.
956
                $params[$param] = explode('|', $value);
957
            } elseif ($value instanceof DateTime) {
958
                // Convert DateTime objects to ISO 8601 strings.
959
                $params[$param] = $value->format('Y-m-d\TH:i:s\Z');
960
            }
961
        }
962
963
        $elapsedTime = round(
964
            microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'],
965
            3
966
        );
967
        return array_merge($params, ['elapsed_time' => $elapsedTime]);
968
    }
969
970
    /**
971
     * Parse a boolean value from the query string, treating 'false' and '0' as false.
972
     * @param string $param
973
     * @return bool
974
     */
975
    public function getBoolVal(string $param): bool
976
    {
977
        return isset($this->params[$param]) &&
978
            !in_array($this->params[$param], ['false', '0']);
979
    }
980
981
    /**
982
     * Used to standardized the format of API responses that contain revisions.
983
     * Adds a 'full_page_title' key and value to each entry in $data.
984
     * If there are as many entries in $data as there are $this->limit, pagination is assumed
985
     *   and a 'continue' key is added to the end of the response body.
986
     * @param string $key Key accessing the list of revisions in $data.
987
     * @param array $out Whatever data needs to appear above the $data in the response body.
988
     * @param array $data The data set itself.
989
     * @return array
990
     */
991
    public function addFullPageTitlesAndContinue(string $key, array $out, array $data): array
992
    {
993
        // Add full_page_title (in addition to the existing page_title and namespace keys).
994
        $out[$key] = array_map(function ($rev) {
995
            return array_merge([
996
                'full_page_title' => $this->getPageFromNsAndTitle(
997
                    (int)$rev['namespace'],
998
                    $rev['page_title'],
999
                    true
1000
                ),
1001
            ], $rev);
1002
        }, $data);
1003
1004
        // Check if pagination is needed.
1005
        if (count($out[$key]) === $this->limit && count($out[$key]) > 0) {
1006
            // Use the timestamp of the last Edit as the value for the 'continue' return key,
1007
            //   which can be used as a value for 'offset' in order to paginate results.
1008
            $timestamp = array_slice($out[$key], -1, 1)[0]['timestamp'];
1009
            $out['continue'] = (new DateTime($timestamp))->format('Y-m-d\TH:i:s\Z');
1010
        }
1011
1012
        return $out;
1013
    }
1014
1015
    /*********
1016
     * OTHER *
1017
     *********/
1018
1019
    /**
1020
     * Record usage of an API endpoint.
1021
     * @param string $endpoint
1022
     * @codeCoverageIgnore
1023
     */
1024
    public function recordApiUsage(string $endpoint): void
1025
    {
1026
        /** @var Connection $conn */
1027
        $conn = $this->managerRegistry->getConnection('default');
1028
        $date = date('Y-m-d');
1029
1030
        // Increment count in timeline
1031
        try {
1032
            $sql = "INSERT INTO usage_api_timeline
1033
                    VALUES(NULL, :date, :endpoint, 1)
1034
                    ON DUPLICATE KEY UPDATE `count` = `count` + 1";
1035
            $conn->executeStatement($sql, [
1036
                'date' => $date,
1037
                'endpoint' => $endpoint,
1038
            ]);
1039
        } catch (Exception $e) {
1040
            // Do nothing. API response should still be returned rather than erroring out.
1041
        }
1042
    }
1043
1044
    /**
1045
     * Add a flash message.
1046
     * @param string $type
1047
     * @param string|Markup $key i18n key or raw message.
1048
     * @param array $vars
1049
     */
1050
    public function addFlashMessage(string $type, $key, array $vars = []): void
1051
    {
1052
        if ($key instanceof Markup || !$this->i18n->msgExists($key, $vars)) {
1053
            $msg = $key;
1054
        } else {
1055
            $msg = $this->i18n->msg($key, $vars);
1056
        }
1057
        $this->addFlash($type, $msg);
1058
    }
1059
}
1060