Passed
Push — swagger-docs ( 6837d7 )
by MusikAnimal
11:33
created

XtoolsController::getBoolVal()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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

294
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn(/** @scrutinizer ignore-type */ $this->user)) {
Loading history...
295
            throw new XtoolsHttpException(
296
                $this->i18n->msg('not-opted-in', [
0 ignored issues
show
Bug introduced by
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

296
                /** @scrutinizer ignore-type */ $this->i18n->msg('not-opted-in', [
Loading history...
297
                    $this->getOptedInPage()->getTitle(),
298
                    $this->i18n->msg('not-opted-in-link') .
299
                        ' <https://www.mediawiki.org/wiki/Special:MyLanguage/XTools/Edit_Counter#restricted_stats>',
300
                    $this->i18n->msg('not-opted-in-login'),
301
                ]),
302
                '',
303
                $this->params,
304
                true,
305
                Response::HTTP_UNAUTHORIZED
306
            );
307
        }
308
    }
309
310
    /**
311
     * Get the path to the opt-in page for restricted statistics.
312
     * @return Page
313
     */
314
    protected function getOptedInPage(): Page
315
    {
316
        return new Page($this->pageRepo, $this->project, $this->project->userOptInPage($this->user));
0 ignored issues
show
Bug introduced by
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

316
        return new Page($this->pageRepo, $this->project, $this->project->userOptInPage(/** @scrutinizer ignore-type */ $this->user));
Loading history...
317
    }
318
319
    /***********
320
     * COOKIES *
321
     ***********/
322
323
    /**
324
     * Load user preferences from the associated cookies.
325
     */
326
    private function loadCookies(): void
327
    {
328
        // Not done for subrequests.
329
        if ($this->isSubRequest) {
330
            return;
331
        }
332
333
        foreach (array_keys($this->cookies) as $name) {
334
            $this->cookies[$name] = $this->request->cookies->get($name);
335
        }
336
    }
337
338
    /**
339
     * Set cookies on the given Response.
340
     * @param Response $response
341
     */
342
    private function setCookies(Response $response): void
343
    {
344
        // Not done for subrequests.
345
        if ($this->isSubRequest) {
346
            return;
347
        }
348
349
        foreach ($this->cookies as $name => $value) {
350
            $response->headers->setCookie(
351
                Cookie::create($name, $value)
352
            );
353
        }
354
    }
355
356
    /**
357
     * Sets the project, with the domain in $this->cookies['XtoolsProject'] that will
358
     * later get set on the Response headers in self::getFormattedResponse().
359
     * @param Project $project
360
     */
361
    private function setProject(Project $project): void
362
    {
363
        // TODO: Remove after deprecated routes are retired.
364
        if (false !== strpos((string)$this->request->get('_controller'), 'GlobalContribs')) {
365
            return;
366
        }
367
368
        $this->project = $project;
369
        $this->cookies['XtoolsProject'] = $project->getDomain();
370
    }
371
372
    /****************************
373
     * SETTING CLASS PROPERTIES *
374
     ****************************/
375
376
    /**
377
     * Normalize all common parameters used by the controllers and set class properties.
378
     */
379
    private function setProperties(): void
380
    {
381
        $this->namespace = $this->params['namespace'] ?? null;
382
383
        // Offset is given as ISO timestamp and is stored as a UNIX timestamp (or false).
384
        if (isset($this->params['offset'])) {
385
            $this->offset = strtotime($this->params['offset']);
386
        }
387
388
        // Limit needs to be an int.
389
        if (isset($this->params['limit'])) {
390
            // Normalize.
391
            $this->params['limit'] = min(max(1, (int)$this->params['limit']), $this->maxLimit());
392
            $this->limit = $this->params['limit'];
393
        }
394
395
        if (isset($this->params['project'])) {
396
            $this->setProject($this->validateProject($this->params['project']));
397
        } elseif (null !== $this->cookies['XtoolsProject']) {
398
            // Set from cookie.
399
            $this->setProject(
400
                $this->validateProject($this->cookies['XtoolsProject'])
401
            );
402
        }
403
404
        if (isset($this->params['username'])) {
405
            $this->user = $this->validateUser($this->params['username']);
406
        }
407
        if (isset($this->params['page'])) {
408
            $this->page = $this->getPageFromNsAndTitle($this->namespace, $this->params['page']);
409
        }
410
411
        $this->setDates();
412
    }
413
414
    /**
415
     * Set class properties for dates, if such params were passed in.
416
     */
417
    private function setDates(): void
418
    {
419
        $start = $this->params['start'] ?? false;
420
        $end = $this->params['end'] ?? false;
421
        if ($start || $end || null !== $this->maxDays()) {
0 ignored issues
show
Bug introduced by
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...
422
            [$this->start, $this->end] = $this->getUnixFromDateParams($start, $end);
423
424
            // Set $this->params accordingly too, so that for instance API responses will include it.
425
            $this->params['start'] = is_int($this->start) ? date('Y-m-d', $this->start) : false;
426
            $this->params['end'] = is_int($this->end) ? date('Y-m-d', $this->end) : false;
427
        }
428
    }
429
430
    /**
431
     * Construct a fully qualified page title given the namespace and title.
432
     * @param int|string $ns Namespace ID.
433
     * @param string $title Page title.
434
     * @param bool $rawTitle Return only the title (and not a Page).
435
     * @return Page|string
436
     */
437
    protected function getPageFromNsAndTitle($ns, string $title, bool $rawTitle = false)
438
    {
439
        if (0 === (int)$ns) {
440
            return $rawTitle ? $title : $this->validatePage($title);
441
        }
442
443
        // Prepend namespace and strip out duplicates.
444
        $nsName = $this->project->getNamespaces()[$ns] ?? $this->i18n->msg('unknown');
445
        $title = $nsName.':'.preg_replace('/^'.$nsName.':/', '', $title);
446
        return $rawTitle ? $title : $this->validatePage($title);
447
    }
448
449
    /**
450
     * Get a Project instance from the project string, using defaults if the given project string is invalid.
451
     * @return Project
452
     */
453
    public function getProjectFromQuery(): Project
454
    {
455
        // Set default project so we can populate the namespace selector on index pages.
456
        // Defaults to project stored in cookie, otherwise project specified in parameters.yml.
457
        if (isset($this->params['project'])) {
458
            $project = $this->params['project'];
459
        } elseif (null !== $this->cookies['XtoolsProject']) {
460
            $project = $this->cookies['XtoolsProject'];
461
        } else {
462
            $project = $this->defaultProject;
463
        }
464
465
        $projectData = $this->projectRepo->getProject($project);
466
467
        // Revert back to defaults if we've established the given project was invalid.
468
        if (!$projectData->exists()) {
469
            $projectData = $this->projectRepo->getProject($this->defaultProject);
470
        }
471
472
        return $projectData;
473
    }
474
475
    /*************************
476
     * GETTERS / VALIDATIONS *
477
     *************************/
478
479
    /**
480
     * Validate the given project, returning a Project if it is valid or false otherwise.
481
     * @param string $projectQuery Project domain or database name.
482
     * @return Project
483
     * @throws XtoolsHttpException
484
     */
485
    public function validateProject(string $projectQuery): Project
486
    {
487
        $project = $this->projectRepo->getProject($projectQuery);
488
489
        // Check if it is an explicitly allowed project for the current tool.
490
        if ($this->supportedProjects() && !in_array($project->getDomain(), $this->supportedProjects())) {
491
            $this->throwXtoolsException(
492
                $this->getIndexRoute(),
493
                'error-authorship-unsupported-project',
494
                [$this->params['project']],
495
                'project'
496
            );
497
        }
498
499
        if (!$project->exists()) {
500
            $this->throwXtoolsException(
501
                $this->getIndexRoute(),
502
                'invalid-project',
503
                [$this->params['project']],
504
                'project'
505
            );
506
        }
507
508
        return $project;
509
    }
510
511
    /**
512
     * Validate the given user, returning a User or Redirect if they don't exist.
513
     * @param string $username
514
     * @return User
515
     * @throws XtoolsHttpException
516
     */
517
    public function validateUser(string $username): User
518
    {
519
        $user = new User($this->userRepo, $username);
520
521
        // Allow querying for any IP, currently with no edit count limitation...
522
        // Once T188677 is resolved IPs will be affected by the EXPLAIN results.
523
        if ($user->isAnon()) {
524
            // Validate CIDR limits.
525
            if (!$user->isQueryableRange()) {
526
                $limit = $user->isIPv6() ? User::MAX_IPV6_CIDR : User::MAX_IPV4_CIDR;
527
                $this->throwXtoolsException($this->getIndexRoute(), 'ip-range-too-wide', [$limit], 'username');
528
            }
529
            return $user;
530
        }
531
532
        $originalParams = $this->params;
533
534
        // Don't continue if the user doesn't exist.
535
        if (isset($this->project) && !$user->existsOnProject($this->project)) {
536
            $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username');
537
        }
538
539
        // Reject users with a crazy high edit count.
540
        if ($this->tooHighEditCountRoute() &&
0 ignored issues
show
Bug introduced by
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...
541
            !in_array($this->controllerAction, $this->tooHighEditCountActionAllowlist()) &&
542
            $user->hasTooManyEdits($this->project)
543
        ) {
544
            /** TODO: Somehow get this to use self::throwXtoolsException */
545
546
            // If redirecting to a different controller, show an informative message accordingly.
547
            if ($this->tooHighEditCountRoute() !== $this->getIndexRoute()) {
0 ignored issues
show
introduced by
The condition $this->tooHighEditCountR... $this->getIndexRoute() is always true.
Loading history...
Bug introduced by
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...
548
                // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,
549
                //   so this bit is hardcoded. We need to instead give the i18n key of the route.
550
                $redirMsg = $this->i18n->msg('too-many-edits-redir', [
551
                    $this->i18n->msg('tool-simpleeditcounter'),
552
                ]);
553
                $msg = $this->i18n->msg('too-many-edits', [
554
                    $this->i18n->numberFormat($user->maxEdits()),
555
                ]).'. '.$redirMsg;
556
                $this->addFlashMessage('danger', $msg);
557
            } else {
558
                $this->addFlashMessage('danger', 'too-many-edits', [
559
                    $this->i18n->numberFormat($user->maxEdits()),
560
                ]);
561
562
                // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).
563
                unset($this->params['username']);
564
            }
565
566
            // Clear flash bag for API responses, since they get intercepted in ExceptionListener
567
            // and would otherwise be shown in subsequent requests.
568
            if ($this->isApi) {
569
                $this->flashBag->clear();
570
            }
571
572
            throw new XtoolsHttpException(
573
                $this->i18n->msg('too-many-edits', [ $user->maxEdits() ]),
0 ignored issues
show
Bug introduced by
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

573
                /** @scrutinizer ignore-type */ $this->i18n->msg('too-many-edits', [ $user->maxEdits() ]),
Loading history...
574
                $this->generateUrl($this->tooHighEditCountRoute(), $this->params),
0 ignored issues
show
Bug introduced by
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...
Bug introduced by
$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

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

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