Passed
Pull Request — main (#442)
by MusikAnimal
08:40 queued 04:21
created

XtoolsController::getFlashMessage()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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

266
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn(/** @scrutinizer ignore-type */ $this->user)) {
Loading history...
267
            throw new XtoolsHttpException(
268
                $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

268
                /** @scrutinizer ignore-type */ $this->i18n->msg('not-opted-in', [
Loading history...
269
                    $this->getOptedInPage()->getTitle(),
270
                    $this->i18n->msg('not-opted-in-link') .
271
                        ' <https://www.mediawiki.org/wiki/Special:MyLanguage/XTools/Edit_Counter#restricted_stats>',
272
                    $this->i18n->msg('not-opted-in-login'),
273
                ]),
274
                '',
275
                $this->params,
276
                true,
277
                Response::HTTP_UNAUTHORIZED
278
            );
279
        }
280
    }
281
282
    /**
283
     * Get the path to the opt-in page for restricted statistics.
284
     * @return Page
285
     */
286
    protected function getOptedInPage(): Page
287
    {
288
        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

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

547
                /** @scrutinizer ignore-type */ $this->i18n->msg('too-many-edits', [ $user->maxEdits() ]),
Loading history...
548
                $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

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

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