Test Setup Failed
Pull Request — main (#426)
by MusikAnimal
17:10 queued 11:44
created

XtoolsController::validateProject()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 25
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 14
nc 4
nop 1
dl 0
loc 25
rs 9.7998
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains the abstract XtoolsController, which all other controllers will extend.
4
 */
5
6
declare(strict_types=1);
7
8
namespace App\Controller;
9
10
use App\Exception\XtoolsHttpException;
11
use App\Helper\I18nHelper;
12
use App\Model\Page;
13
use App\Model\Project;
14
use App\Model\User;
15
use App\Repository\PageRepository;
16
use App\Repository\ProjectRepository;
17
use App\Repository\UserRepository;
18
use DateTime;
19
use Doctrine\DBAL\Exception;
20
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
21
use Symfony\Component\DependencyInjection\ContainerInterface;
22
use Symfony\Component\HttpFoundation\Cookie;
23
use Symfony\Component\HttpFoundation\JsonResponse;
24
use Symfony\Component\HttpFoundation\Request;
25
use Symfony\Component\HttpFoundation\RequestStack;
26
use Symfony\Component\HttpFoundation\Response;
27
use Symfony\Component\HttpKernel\Exception\HttpException;
28
use Wikimedia\IPUtils;
29
30
/**
31
 * XtoolsController supplies a variety of methods around parsing and validating parameters, and initializing
32
 * Project/User instances. These are used in other controllers in the App\Controller namespace.
33
 * @abstract
34
 */
35
abstract class XtoolsController extends Controller
0 ignored issues
show
Deprecated Code introduced by
The class Symfony\Bundle\Framework...e\Controller\Controller has been deprecated: since Symfony 4.2, use "Symfony\Bundle\FrameworkBundle\Controller\AbstractController" instead. ( Ignorable by Annotation )

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

35
abstract class XtoolsController extends /** @scrutinizer ignore-deprecated */ Controller
Loading history...
36
{
37
    /** @var I18nHelper i18n helper. */
38
    protected $i18n;
39
40
    /** @var Request The request object. */
41
    protected $request;
42
43
    /** @var string Name of the action within the child controller that is being executed. */
44
    protected $controllerAction;
45
46
    /** @var array Hash of params parsed from the Request. */
47
    protected $params;
48
49
    /** @var bool Whether this is a request to an API action. */
50
    protected $isApi;
51
52
    /** @var Project Relevant Project parsed from the Request. */
53
    protected $project;
54
55
    /** @var User Relevant User parsed from the Request. */
56
    protected $user;
57
58
    /** @var Page Relevant Page parsed from the Request. */
59
    protected $page;
60
61
    /** @var int|false Start date parsed from the Request. */
62
    protected $start = false;
63
64
    /** @var int|false End date parsed from the Request. */
65
    protected $end = false;
66
67
    /**
68
     * Default days from current day, to use as the start date if none was provided.
69
     * If this is null and $maxDays is non-null, the latter will be used as the default.
70
     * Is public visibility evil here? I don't think so.
71
     * @var int|null
72
     */
73
    public $defaultDays = null;
74
75
    /**
76
     * Maximum number of days allowed for the given date range.
77
     * Set this in the controller's constructor to enforce the given date range to be within this range.
78
     * This will be used as the default date span unless $defaultDays is defined.
79
     * @see XtoolsController::getUnixFromDateParams()
80
     * @var int|null
81
     */
82
    public $maxDays = null;
83
84
    /** @var int|string|null Namespace parsed from the Request, ID as int or 'all' for all namespaces. */
85
    protected $namespace;
86
87
    /** @var int|false Unix timestamp. Pagination offset that substitutes for $end. */
88
    protected $offset = false;
89
90
    /** @var int Number of results to return. */
91
    protected $limit;
92
93
    /**
94
     * Maximum number of results to show per page. Can be overridden in the child controller's constructor.
95
     * @var int
96
     */
97
    public $maxLimit = 5000;
98
99
    /** @var bool Is the current request a subrequest? */
100
    protected $isSubRequest;
101
102
    /**
103
     * Stores user preferences such default project.
104
     * This may get altered from the Request and updated in the Response.
105
     * @var array
106
     */
107
    protected $cookies = [
108
        'XtoolsProject' => null,
109
    ];
110
111
    /**
112
     * This activates the 'too high edit count' functionality. This property represents the
113
     * action that should be redirected to if the user has too high of an edit count.
114
     * @var string
115
     */
116
    protected $tooHighEditCountAction;
117
118
    /** @var array Actions that are exempt from edit count limitations. */
119
    protected $tooHighEditCountActionBlacklist = [];
120
121
    /**
122
     * Actions that require the target user to opt in to the restricted statistics.
123
     * @see https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats
124
     * @var string[]
125
     */
126
    protected $restrictedActions = [];
127
128
    /**
129
     * XtoolsController::validateProject() will ensure the given project matches one of these domains,
130
     * instead of any valid project.
131
     * @var string[]
132
     */
133
    protected $supportedProjects;
134
135
    /**
136
     * Require the tool's index route (initial form) be defined here. This should also
137
     * be the name of the associated model, if present.
138
     * @return string
139
     */
140
    abstract protected function getIndexRoute(): string;
141
142
    /**
143
     * XtoolsController constructor.
144
     * @param RequestStack $requestStack
145
     * @param ContainerInterface $container
146
     * @param I18nHelper $i18n
147
     */
148
    public function __construct(RequestStack $requestStack, ContainerInterface $container, I18nHelper $i18n)
149
    {
150
        $this->request = $requestStack->getCurrentRequest();
151
        $this->container = $container;
152
        $this->i18n = $i18n;
153
        $this->params = $this->parseQueryParams();
154
155
        // Parse out the name of the controller and action.
156
        $pattern = "#::([a-zA-Z]*)Action#";
157
        $matches = [];
158
        // The blank string here only happens in the unit tests, where the request may not be made to an action.
159
        preg_match($pattern, $this->request->get('_controller') ?? '', $matches);
0 ignored issues
show
Bug introduced by
The method get() does not exist on null. ( Ignorable by Annotation )

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

159
        preg_match($pattern, $this->request->/** @scrutinizer ignore-call */ get('_controller') ?? '', $matches);

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

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

Loading history...
160
        $this->controllerAction = $matches[1] ?? '';
161
162
        // Whether the action is an API action.
163
        $this->isApi = 'Api' === substr($this->controllerAction, -3) || 'recordUsage' === $this->controllerAction;
164
165
        // Whether we're making a subrequest (the view makes a request to another action).
166
        $this->isSubRequest = $this->request->get('htmlonly')
167
            || null !== $this->get('request_stack')->getParentRequest();
168
169
        // Disallow AJAX (unless it's an API or subrequest).
170
        $this->checkIfAjax();
171
172
        // Load user options from cookies.
173
        $this->loadCookies();
174
175
        // Set the class-level properties based on params.
176
        if (false !== strpos(strtolower($this->controllerAction), 'index')) {
177
            // Index pages should only set the project, and no other class properties.
178
            $this->setProject($this->getProjectFromQuery());
179
180
            // ...except for transforming IP ranges. Because Symfony routes are separated by slashes, we need a way to
181
            // indicate a CIDR range because otherwise i.e. the path /sc/enwiki/192.168.0.0/24 could be interpreted as
182
            // the Simple Edit Counter for 192.168.0.0 in the namespace with ID 24. So we prefix ranges with 'ipr-'.
183
            // Further IP range handling logic is in the User class, i.e. see User::__construct, User::isIpRange.
184
            if (isset($this->params['username']) && IPUtils::isValidRange($this->params['username'])) {
185
                $this->params['username'] = 'ipr-'.$this->params['username'];
186
            }
187
        } else {
188
            $this->setProperties(); // Includes the project.
189
        }
190
191
        // Check if the request is to a restricted API endpoint, where the target user has to opt-in to statistics.
192
        $this->checkRestrictedApiEndpoint();
193
    }
194
195
    /**
196
     * Check if the request is AJAX, and disallow it unless they're using the API or if it's a subrequest.
197
     */
198
    private function checkIfAjax(): void
199
    {
200
        if ($this->request->isXmlHttpRequest() && !$this->isApi && !$this->isSubRequest) {
201
            throw new HttpException(
202
                403,
203
                $this->i18n->msg('error-automation', ['https://www.mediawiki.org/Special:MyLanguage/XTools/API'])
204
            );
205
        }
206
    }
207
208
    /**
209
     * Check if the request is to a restricted API endpoint, and throw an exception if the target user hasn't opted-in.
210
     * @throws XtoolsHttpException
211
     */
212
    private function checkRestrictedApiEndpoint(): void
213
    {
214
        $restrictedAction = in_array($this->controllerAction, $this->restrictedActions);
215
216
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn($this->user)) {
217
            throw new XtoolsHttpException(
218
                $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

218
                /** @scrutinizer ignore-type */ $this->i18n->msg('not-opted-in', [
Loading history...
219
                    $this->getOptedInPage()->getTitle(),
220
                    $this->i18n->msg('not-opted-in-link') .
221
                        ' <https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats>',
222
                    $this->i18n->msg('not-opted-in-login'),
223
                ]),
224
                '',
225
                $this->params,
226
                true,
227
                Response::HTTP_UNAUTHORIZED
228
            );
229
        }
230
    }
231
232
    /**
233
     * Get the path to the opt-in page for restricted statistics.
234
     * @return Page
235
     */
236
    protected function getOptedInPage(): Page
237
    {
238
        return $this->project
239
            ->getRepository()
240
            ->getPage($this->project, $this->project->userOptInPage($this->user));
0 ignored issues
show
Bug introduced by
The method getPage() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\ProjectRepository. ( Ignorable by Annotation )

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

240
            ->/** @scrutinizer ignore-call */ getPage($this->project, $this->project->userOptInPage($this->user));
Loading history...
241
    }
242
243
    /***********
244
     * COOKIES *
245
     ***********/
246
247
    /**
248
     * Load user preferences from the associated cookies.
249
     */
250
    private function loadCookies(): void
251
    {
252
        // Not done for subrequests.
253
        if ($this->isSubRequest) {
254
            return;
255
        }
256
257
        foreach (array_keys($this->cookies) as $name) {
258
            $this->cookies[$name] = $this->request->cookies->get($name);
259
        }
260
    }
261
262
    /**
263
     * Set cookies on the given Response.
264
     * @param Response $response
265
     */
266
    private function setCookies(Response &$response): void
267
    {
268
        // Not done for subrequests.
269
        if ($this->isSubRequest) {
270
            return;
271
        }
272
273
        foreach ($this->cookies as $name => $value) {
274
            $response->headers->setCookie(
275
                Cookie::create($name, $value)
276
            );
277
        }
278
    }
279
280
    /**
281
     * Sets the project, with the domain in $this->cookies['XtoolsProject'] that will
282
     * later get set on the Response headers in self::getFormattedResponse().
283
     * @param Project $project
284
     */
285
    private function setProject(Project $project): void
286
    {
287
        // TODO: Remove after deprecated routes are retired.
288
        if (false !== strpos((string)$this->request->get('_controller'), 'GlobalContribs')) {
289
            return;
290
        }
291
292
        $this->project = $project;
293
        $this->cookies['XtoolsProject'] = $project->getDomain();
294
    }
295
296
    /****************************
297
     * SETTING CLASS PROPERTIES *
298
     ****************************/
299
300
    /**
301
     * Normalize all common parameters used by the controllers and set class properties.
302
     */
303
    private function setProperties(): void
304
    {
305
        $this->namespace = $this->params['namespace'] ?? null;
306
307
        // Offset is given as ISO timestamp and is stored as a UNIX timestamp (or false).
308
        if (isset($this->params['offset'])) {
309
            $this->offset = strtotime($this->params['offset']);
310
        }
311
312
        // Limit needs to be an int.
313
        if (isset($this->params['limit'])) {
314
            // Normalize.
315
            $this->params['limit'] = min(max(1, (int)$this->params['limit']), $this->maxLimit);
316
            $this->limit = $this->params['limit'];
317
        }
318
319
        if (isset($this->params['project'])) {
320
            $this->setProject($this->validateProject($this->params['project']));
321
        } elseif (null !== $this->cookies['XtoolsProject']) {
322
            // Set from cookie.
323
            $this->setProject(
324
                $this->validateProject($this->cookies['XtoolsProject'])
325
            );
326
        }
327
328
        if (isset($this->params['username'])) {
329
            $this->user = $this->validateUser($this->params['username']);
330
        }
331
        if (isset($this->params['page'])) {
332
            $this->page = $this->getPageFromNsAndTitle($this->namespace, $this->params['page']);
333
        }
334
335
        $this->setDates();
336
    }
337
338
    /**
339
     * Set class properties for dates, if such params were passed in.
340
     */
341
    private function setDates(): void
342
    {
343
        $start = $this->params['start'] ?? false;
344
        $end = $this->params['end'] ?? false;
345
        if ($start || $end || null !== $this->maxDays) {
346
            [$this->start, $this->end] = $this->getUnixFromDateParams($start, $end);
347
348
            // Set $this->params accordingly too, so that for instance API responses will include it.
349
            $this->params['start'] = is_int($this->start) ? date('Y-m-d', $this->start) : false;
350
            $this->params['end'] = is_int($this->end) ? date('Y-m-d', $this->end) : false;
351
        }
352
    }
353
354
    /**
355
     * Construct a fully qualified page title given the namespace and title.
356
     * @param int|string $ns Namespace ID.
357
     * @param string $title Page title.
358
     * @param bool $rawTitle Return only the title (and not a Page).
359
     * @return Page|string
360
     */
361
    protected function getPageFromNsAndTitle($ns, string $title, bool $rawTitle = false)
362
    {
363
        if (0 === (int)$ns) {
364
            return $rawTitle ? $title : $this->validatePage($title);
365
        }
366
367
        // Prepend namespace and strip out duplicates.
368
        $nsName = $this->project->getNamespaces()[$ns] ?? $this->i18n->msg('unknown');
369
        $title = $nsName.':'.preg_replace('/^'.$nsName.':/', '', $title);
370
        return $rawTitle ? $title : $this->validatePage($title);
371
    }
372
373
    /**
374
     * Get a Project instance from the project string, using defaults if the given project string is invalid.
375
     * @return Project
376
     */
377
    public function getProjectFromQuery(): Project
378
    {
379
        // Set default project so we can populate the namespace selector on index pages.
380
        // Defaults to project stored in cookie, otherwise project specified in parameters.yml.
381
        if (isset($this->params['project'])) {
382
            $project = $this->params['project'];
383
        } elseif (null !== $this->cookies['XtoolsProject']) {
384
            $project = $this->cookies['XtoolsProject'];
385
        } else {
386
            $project = $this->container->getParameter('default_project');
387
        }
388
389
        $projectData = ProjectRepository::getProject($project, $this->container);
390
391
        // Revert back to defaults if we've established the given project was invalid.
392
        if (!$projectData->exists()) {
393
            $projectData = ProjectRepository::getProject(
394
                $this->container->getParameter('default_project'),
395
                $this->container
396
            );
397
        }
398
399
        return $projectData;
400
    }
401
402
    /*************************
403
     * GETTERS / VALIDATIONS *
404
     *************************/
405
406
    /**
407
     * Validate the given project, returning a Project if it is valid or false otherwise.
408
     * @param string $projectQuery Project domain or database name.
409
     * @return Project
410
     * @throws XtoolsHttpException
411
     */
412
    public function validateProject(string $projectQuery): Project
413
    {
414
        /** @var Project $project */
415
        $project = ProjectRepository::getProject($projectQuery, $this->container);
416
417
        // Check if it is an explicitly allowed project for the current tool.
418
        if (isset($this->supportedProjects) && !in_array($project->getDomain(), $this->supportedProjects)) {
419
            $this->throwXtoolsException(
420
                $this->getIndexRoute(),
421
                'error-authorship-unsupported-project',
422
                [$this->params['project']],
423
                'project'
424
            );
425
        }
426
427
        if (!$project->exists()) {
428
            $this->throwXtoolsException(
429
                $this->getIndexRoute(),
430
                'invalid-project',
431
                [$this->params['project']],
432
                'project'
433
            );
434
        }
435
436
        return $project;
437
    }
438
439
    /**
440
     * Validate the given user, returning a User or Redirect if they don't exist.
441
     * @param string $username
442
     * @return User
443
     * @throws XtoolsHttpException
444
     */
445
    public function validateUser(string $username): User
446
    {
447
        $user = UserRepository::getUser($username, $this->container);
448
449
        // Allow querying for any IP, currently with no edit count limitation...
450
        // Once T188677 is resolved IPs will be affected by the EXPLAIN results.
451
        if ($user->isAnon()) {
452
            // Validate CIDR limits.
453
            if (!$user->isQueryableRange()) {
454
                $limit = $user->isIPv6() ? User::MAX_IPV6_CIDR : User::MAX_IPV4_CIDR;
455
                $this->throwXtoolsException($this->getIndexRoute(), 'ip-range-too-wide', [$limit], 'username');
456
            }
457
            return $user;
458
        }
459
460
        $originalParams = $this->params;
461
462
        // Don't continue if the user doesn't exist.
463
        if ($this->project && !$user->existsOnProject($this->project)) {
464
            $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username');
465
        }
466
467
        // Reject users with a crazy high edit count.
468
        if (isset($this->tooHighEditCountAction) &&
469
            !in_array($this->controllerAction, $this->tooHighEditCountActionBlacklist) &&
470
            $user->hasTooManyEdits($this->project)
471
        ) {
472
            /** TODO: Somehow get this to use self::throwXtoolsException */
473
474
            // If redirecting to a different controller, show an informative message accordingly.
475
            if ($this->tooHighEditCountAction !== $this->getIndexRoute()) {
476
                // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,
477
                //   so this bit is hardcoded. We need to instead give the i18n key of the route.
478
                $redirMsg = $this->i18n->msg('too-many-edits-redir', [
479
                    $this->i18n->msg('tool-simpleeditcounter'),
480
                ]);
481
                $msg = $this->i18n->msg('too-many-edits', [
482
                    $this->i18n->numberFormat($user->maxEdits()),
483
                ]).'. '.$redirMsg;
484
                $this->addFlashMessage('danger', $msg);
485
            } else {
486
                $this->addFlashMessage('danger', 'too-many-edits', [
487
                    $this->i18n->numberFormat($user->maxEdits()),
488
                ]);
489
490
                // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).
491
                unset($this->params['username']);
492
            }
493
494
            // Clear flash bag for API responses, since they get intercepted in ExceptionListener
495
            // and would otherwise be shown in subsequent requests.
496
            if ($this->isApi) {
497
                $this->get('session')->getFlashBag()->clear();
498
            }
499
500
            throw new XtoolsHttpException(
501
                'User has made too many edits! Maximum '.$user->maxEdits(),
502
                $this->generateUrl($this->tooHighEditCountAction, $this->params),
503
                $originalParams,
504
                $this->isApi
505
            );
506
        }
507
508
        return $user;
509
    }
510
511
    /**
512
     * Get a Page instance from the given page title, and validate that it exists.
513
     * @param string $pageTitle
514
     * @return Page
515
     * @throws XtoolsHttpException
516
     */
517
    public function validatePage(string $pageTitle): Page
518
    {
519
        $page = new Page($this->project, $pageTitle);
520
        $pageRepo = new PageRepository();
521
        $pageRepo->setContainer($this->container);
522
        $page->setRepository($pageRepo);
523
524
        if (!$page->exists()) {
525
            $this->throwXtoolsException(
526
                $this->getIndexRoute(),
527
                'no-result',
528
                [$this->params['page'] ?? null],
529
                'page'
530
            );
531
        }
532
533
        return $page;
534
    }
535
536
    /**
537
     * Throw an XtoolsHttpException, which the given error message and redirects to specified action.
538
     * @param string $redirectAction Name of action to redirect to.
539
     * @param string $message i18n key of error message. Shown in API responses.
540
     *   If no message with this key exists, $message is shown as-is.
541
     * @param array $messageParams
542
     * @param string $invalidParam This will be removed from $this->params. Omit if you don't want this to happen.
543
     * @throws XtoolsHttpException
544
     */
545
    public function throwXtoolsException(
546
        string $redirectAction,
547
        string $message,
548
        array $messageParams = [],
549
        ?string $invalidParam = null
550
    ): void {
551
        $this->addFlashMessage('danger', $message, $messageParams);
552
        $originalParams = $this->params;
553
554
        // Remove invalid parameter if it was given.
555
        if (is_string($invalidParam)) {
556
            unset($this->params[$invalidParam]);
557
        }
558
559
        // We sometimes are redirecting to the index page, so also remove project (otherwise we'd get a redirect loop).
560
        /**
561
         * FIXME: Index pages should have a 'nosubmit' parameter to prevent submission.
562
         * Then we don't even need to remove $invalidParam.
563
         * Better, we should show the error on the results page, with no results.
564
         */
565
        unset($this->params['project']);
566
567
        // Throw exception which will redirect to $redirectAction.
568
        throw new XtoolsHttpException(
569
            $this->i18n->msgIfExists($message, $messageParams),
570
            $this->generateUrl($redirectAction, $this->params),
571
            $originalParams,
572
            $this->isApi
573
        );
574
    }
575
576
    /**
577
     * Get the first error message stored in the session's FlashBag.
578
     * @return string
579
     */
580
    public function getFlashMessage(): string
581
    {
582
        $key = $this->get('session')->getFlashBag()->get('danger')[0];
583
        $param = null;
584
585
        if (is_array($key)) {
586
            [$key, $param] = $key;
587
        }
588
589
        return $this->render('message.twig', [
590
            'key' => $key,
591
            'params' => [$param],
592
        ])->getContent();
593
    }
594
595
    /******************
596
     * PARSING PARAMS *
597
     ******************/
598
599
    /**
600
     * Get all standardized parameters from the Request, either via URL query string or routing.
601
     * @return string[]
602
     */
603
    public function getParams(): array
604
    {
605
        $paramsToCheck = [
606
            'project',
607
            'username',
608
            'namespace',
609
            'page',
610
            'categories',
611
            'group',
612
            'redirects',
613
            'deleted',
614
            'start',
615
            'end',
616
            'offset',
617
            'limit',
618
            'format',
619
            'tool',
620
            'tools',
621
            'q',
622
            'include_pattern',
623
            'exclude_pattern',
624
625
            // Legacy parameters.
626
            'user',
627
            'name',
628
            'article',
629
            'wiki',
630
            'wikifam',
631
            'lang',
632
            'wikilang',
633
            'begin',
634
        ];
635
636
        /** @var string[] $params Each parameter that was detected along with its value. */
637
        $params = [];
638
639
        foreach ($paramsToCheck as $param) {
640
            // Pull in either from URL query string or route.
641
            $value = $this->request->query->get($param) ?: $this->request->get($param);
642
643
            // Only store if value is given ('namespace' or 'username' could be '0').
644
            if (null !== $value && '' !== $value) {
645
                $params[$param] = rawurldecode((string)$value);
646
            }
647
        }
648
649
        return $params;
650
    }
651
652
    /**
653
     * Parse out common parameters from the request. These include the 'project', 'username', 'namespace' and 'page',
654
     * along with their legacy counterparts (e.g. 'lang' and 'wiki').
655
     * @return string[] Normalized parameters (no legacy params).
656
     */
657
    public function parseQueryParams(): array
658
    {
659
        /** @var string[] $params Each parameter and value that was detected. */
660
        $params = $this->getParams();
661
662
        // Covert any legacy parameters, if present.
663
        $params = $this->convertLegacyParams($params);
664
665
        // Remove blank values.
666
        return array_filter($params, function ($param) {
667
            // 'namespace' or 'username' could be '0'.
668
            return null !== $param && '' !== $param;
669
        });
670
    }
671
672
    /**
673
     * Get Unix timestamps from given start and end string parameters. This also makes $start $maxDays before
674
     * $end if not present, and makes $end the current time if not present.
675
     * The date range will not exceed $this->maxDays days, if this public class property is set.
676
     * @param int|string|false $start Unix timestamp or string accepted by strtotime.
677
     * @param int|string|false $end Unix timestamp or string accepted by strtotime.
678
     * @return int[] Start and end date as UTC timestamps.
679
     */
680
    public function getUnixFromDateParams($start, $end): array
681
    {
682
        $today = strtotime('today midnight');
683
684
        // start time should not be in the future.
685
        $startTime = min(
686
            is_int($start) ? $start : strtotime((string)$start),
687
            $today
688
        );
689
690
        // end time defaults to now, and will not be in the future.
691
        $endTime = min(
692
            (is_int($end) ? $end : strtotime((string)$end)) ?: $today,
693
            $today
694
        );
695
696
        // Default to $this->defaultDays or $this->maxDays before end time if start is not present.
697
        $daysOffset = $this->defaultDays ?? $this->maxDays;
698
        if (false === $startTime && is_int($daysOffset)) {
699
            $startTime = strtotime("-$daysOffset days", $endTime);
700
        }
701
702
        // Default to $this->defaultDays or $this->maxDays after start time if end is not present.
703
        if (false === $end && is_int($daysOffset)) {
704
            $endTime = min(
705
                strtotime("+$daysOffset days", $startTime),
706
                $today
707
            );
708
        }
709
710
        // Reverse if start date is after end date.
711
        if ($startTime > $endTime && false !== $startTime && false !== $end) {
712
            $newEndTime = $startTime;
713
            $startTime = $endTime;
714
            $endTime = $newEndTime;
715
        }
716
717
        // Finally, don't let the date range exceed $this->maxDays.
718
        $startObj = DateTime::createFromFormat('U', (string)$startTime);
719
        $endObj = DateTime::createFromFormat('U', (string)$endTime);
720
        if (is_int($this->maxDays) && $startObj->diff($endObj)->days > $this->maxDays) {
721
            // Show warnings that the date range was truncated.
722
            $this->addFlashMessage('warning', 'date-range-too-wide', [$this->maxDays]);
723
724
            $startTime = strtotime("-$this->maxDays days", $endTime);
725
        }
726
727
        return [$startTime, $endTime];
728
    }
729
730
    /**
731
     * Given the params hash, normalize any legacy parameters to their modern equivalent.
732
     * @param string[] $params
733
     * @return string[]
734
     */
735
    private function convertLegacyParams(array $params): array
736
    {
737
        $paramMap = [
738
            'user' => 'username',
739
            'name' => 'username',
740
            'article' => 'page',
741
            'begin' => 'start',
742
743
            // Copy super legacy project params to legacy so we can concatenate below.
744
            'wikifam' => 'wiki',
745
            'wikilang' => 'lang',
746
        ];
747
748
        // Copy legacy parameters to modern equivalent.
749
        foreach ($paramMap as $legacy => $modern) {
750
            if (isset($params[$legacy])) {
751
                $params[$modern] = $params[$legacy];
752
                unset($params[$legacy]);
753
            }
754
        }
755
756
        // Separate parameters for language and wiki.
757
        if (isset($params['wiki']) && isset($params['lang'])) {
758
            // 'wikifam' will be like '.wikipedia.org', vs just 'wikipedia',
759
            // so we must remove leading periods and trailing .org's.
760
            $params['project'] = rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
761
762
            /** @var string[] $languagelessProjects Projects for which there is no specific language association. */
763
            $languagelessProjects = $this->container->getParameter('app.multilingual_wikis');
764
765
            // Prepend language if applicable.
766
            if (isset($params['lang']) && !in_array($params['wiki'], $languagelessProjects)) {
767
                $params['project'] = $params['lang'].'.'.$params['project'];
768
            }
769
770
            unset($params['wiki']);
771
            unset($params['lang']);
772
        }
773
774
        return $params;
775
    }
776
777
    /************************
778
     * FORMATTING RESPONSES *
779
     ************************/
780
781
    /**
782
     * Get the rendered template for the requested format. This method also updates the cookies.
783
     * @param string $templatePath Path to template without format,
784
     *   such as '/editCounter/latest_global'.
785
     * @param array $ret Data that should be passed to the view.
786
     * @return Response
787
     * @codeCoverageIgnore
788
     */
789
    public function getFormattedResponse(string $templatePath, array $ret): Response
790
    {
791
        $format = $this->request->query->get('format', 'html');
792
        if ('' == $format) {
793
            // The default above doesn't work when the 'format' parameter is blank.
794
            $format = 'html';
795
        }
796
797
        // Merge in common default parameters, giving $ret (from the caller) the priority.
798
        $ret = array_merge([
799
            'project' => $this->project,
800
            'user' => $this->user,
801
            'page' => $this->page,
802
            'namespace' => $this->namespace,
803
            'start' => $this->start,
804
            'end' => $this->end,
805
        ], $ret);
806
807
        $formatMap = [
808
            'wikitext' => 'text/plain',
809
            'csv' => 'text/csv',
810
            'tsv' => 'text/tab-separated-values',
811
            'json' => 'application/json',
812
        ];
813
814
        $response = new Response();
815
816
        // Set cookies. Note this must be done before rendering the view, as the view may invoke subrequests.
817
        $this->setCookies($response);
818
819
        // If requested format does not exist, assume HTML.
820
        if (false === $this->get('twig')->getLoader()->exists("$templatePath.$format.twig")) {
821
            $format = 'html';
822
        }
823
824
        $response = $this->render("$templatePath.$format.twig", $ret, $response);
825
826
        $contentType = $formatMap[$format] ?? 'text/html';
827
        $response->headers->set('Content-Type', $contentType);
828
829
        if (in_array($format, ['csv', 'tsv'])) {
830
            $filename = $this->getFilenameForRequest();
831
            $response->headers->set(
832
                'Content-Disposition',
833
                "attachment; filename=\"{$filename}.$format\""
834
            );
835
        }
836
837
        return $response;
838
    }
839
840
    /**
841
     * Returns given filename from the current Request, with problematic characters filtered out.
842
     * @return string
843
     */
844
    private function getFilenameForRequest(): string
845
    {
846
        $filename = trim($this->request->getPathInfo(), '/');
847
        return trim(preg_replace('/[-\/\\:;*?|<>%#"]+/', '-', $filename));
848
    }
849
850
    /**
851
     * Return a JsonResponse object pre-supplied with the requested params.
852
     * @param array $data
853
     * @return JsonResponse
854
     */
855
    public function getFormattedApiResponse(array $data): JsonResponse
856
    {
857
        $response = new JsonResponse();
858
        $response->setEncodingOptions(JSON_NUMERIC_CHECK);
859
        $response->setStatusCode(Response::HTTP_OK);
860
861
        // Normalize display of IP ranges (they are prefixed with 'ipr-' in the params).
862
        if ($this->user && $this->user->isIpRange()) {
863
            $this->params['username'] = $this->user->getUsername();
864
        }
865
866
        $elapsedTime = round(
867
            microtime(true) - $this->request->server->get('REQUEST_TIME_FLOAT'),
868
            3
869
        );
870
871
        // Any pipe-separated values should be returned as an array.
872
        foreach ($this->params as $param => $value) {
873
            if (is_string($value) && false !== strpos($value, '|')) {
874
                $this->params[$param] = explode('|', $value);
875
            }
876
        }
877
878
        $ret = array_merge($this->params, [
879
            // In some controllers, $this->params['project'] may be overridden with a Project object.
880
            'project' => $this->project->getDomain(),
881
        ], $data, ['elapsed_time' => $elapsedTime]);
882
883
        // Merge in flash messages, putting them at the top.
884
        $flashes = $this->get('session')->getFlashBag()->peekAll();
885
        $ret = array_merge($flashes, $ret);
886
887
        // Flashes now can be cleared after merging into the response.
888
        $this->get('session')->getFlashBag()->clear();
889
890
        $response->setData($ret);
891
892
        return $response;
893
    }
894
895
    /**
896
     * Used to standardized the format of API responses that contain revisions.
897
     * Adds a 'full_page_title' key and value to each entry in $data.
898
     * If there are as many entries in $data as there are $this->limit, pagination is assumed
899
     *   and a 'continue' key is added to the end of the response body.
900
     * @param string $key Key accessing the list of revisions in $data.
901
     * @param array $out Whatever data needs to appear above the $data in the response body.
902
     * @param array $data The data set itself.
903
     * @return array
904
     */
905
    public function addFullPageTitlesAndContinue(string $key, array $out, array $data): array
906
    {
907
        // Add full_page_title (in addition to the existing page_title and page_namespace keys).
908
        $out[$key] = array_map(function ($rev) {
909
            return array_merge([
910
                'full_page_title' => $this->getPageFromNsAndTitle(
911
                    (int)$rev['page_namespace'],
912
                    $rev['page_title'],
913
                    true
914
                ),
915
            ], $rev);
916
        }, $data);
917
918
        // Check if pagination is needed.
919
        if (count($out[$key]) === $this->limit && count($out[$key]) > 0) {
920
            // Use the timestamp of the last Edit as the value for the 'continue' return key,
921
            //   which can be used as a value for 'offset' in order to paginate results.
922
            $timestamp = array_slice($out[$key], -1, 1)[0]['timestamp'];
923
            $out['continue'] = (new DateTime($timestamp))->format('Y-m-d\TH:i:s');
924
        }
925
926
        return $out;
927
    }
928
929
    /*********
930
     * OTHER *
931
     *********/
932
933
    /**
934
     * Record usage of an API endpoint.
935
     * @param string $endpoint
936
     * @codeCoverageIgnore
937
     */
938
    public function recordApiUsage(string $endpoint): void
939
    {
940
        /** @var \Doctrine\DBAL\Connection $conn */
941
        $conn = $this->container->get('doctrine')
942
            ->getManager('default')
943
            ->getConnection();
944
        $date =  date('Y-m-d');
945
946
        // Increment count in timeline
947
        try {
948
            $sql = "INSERT INTO usage_api_timeline
949
                    VALUES(NULL, :date, :endpoint, 1)
950
                    ON DUPLICATE KEY UPDATE `count` = `count` + 1";
951
            $conn->executeStatement($sql, [
952
                'date' => $date,
953
                'endpoint' => $endpoint,
954
            ]);
955
        } catch (Exception $e) {
956
            // Do nothing. API response should still be returned rather than erroring out.
957
        }
958
    }
959
960
    /**
961
     * Add a flash message.
962
     * @param string $type
963
     * @param string $key i18n key or raw message.
964
     * @param array $vars
965
     */
966
    public function addFlashMessage(string $type, string $key, array $vars = []): void
967
    {
968
        $this->addFlash(
969
            $type,
970
            $this->i18n->msgExists($key, $vars) ? $this->i18n->msg($key, $vars) : $key
971
        );
972
    }
973
}
974