Passed
Push — main ( 66dd99...851719 )
by MusikAnimal
17:51 queued 06:59
created

XtoolsController   F

Complexity

Total Complexity 101

Size/Duplication

Total Lines 880
Duplicated Lines 0 %

Test Coverage

Coverage 50%

Importance

Changes 7
Bugs 0 Features 0
Metric Value
eloc 327
c 7
b 0
f 0
dl 0
loc 880
ccs 129
cts 258
cp 0.5
rs 2
wmc 101

25 Methods

Rating   Name   Duplication   Size   Complexity  
A checkIfAjax() 0 6 4
A __construct() 0 37 4
A recordApiUsage() 0 23 2
A parseQueryParams() 0 12 2
A getFormattedResponse() 0 49 4
B setProperties() 0 33 7
A setProject() 0 9 2
A validateProject() 0 25 4
B convertLegacyParams() 0 40 7
A checkRestrictedApiEndpoint() 0 16 4
A setCookies() 0 10 3
A getProjectFromQuery() 0 23 4
B validateUser() 0 59 9
A validatePage() 0 17 2
A getFormattedApiResponse() 0 33 4
A addFlashMessage() 0 5 2
A getParams() 0 45 5
A loadCookies() 0 9 3
A setDates() 0 10 6
C getUnixFromDateParams() 0 48 13
A getOptedInPage() 0 5 1
A getPageFromNsAndTitle() 0 10 4
A getFilenameForRequest() 0 4 1
A getFlashMessage() 0 13 2
A throwXtoolsException() 0 28 2

How to fix   Complexity   

Complex Class

Complex classes like XtoolsController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use XtoolsController, and based on these observations, apply Extract Interface, too.

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 AppBundle\Controller;
9
10
use AppBundle\Exception\XtoolsHttpException;
11
use AppBundle\Helper\I18nHelper;
12
use AppBundle\Model\Page;
13
use AppBundle\Model\Project;
14
use AppBundle\Model\User;
15
use AppBundle\Repository\PageRepository;
16
use AppBundle\Repository\ProjectRepository;
17
use AppBundle\Repository\UserRepository;
18
use DateTime;
19
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
20
use Symfony\Component\DependencyInjection\ContainerInterface;
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
28
/**
29
 * XtoolsController supplies a variety of methods around parsing and validating parameters, and initializing
30
 * Project/User instances. These are used in other controllers in the AppBundle\Controller namespace.
31
 * @abstract
32
 */
33
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

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

151
        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...
152 15
        $this->controllerAction = $matches[1] ?? '';
153
154
        // Whether the action is an API action.
155 15
        $this->isApi = 'Api' === substr($this->controllerAction, -3) || 'recordUsage' === $this->controllerAction;
156
157
        // Whether we're making a subrequest (the view makes a request to another action).
158 15
        $this->isSubRequest = $this->request->get('htmlonly')
159 15
            || null !== $this->get('request_stack')->getParentRequest();
160
161
        // Disallow AJAX (unless it's an API or subrequest).
162 15
        $this->checkIfAjax();
163
164
        // Load user options from cookies.
165 15
        $this->loadCookies();
166
167
        // Set the class-level properties based on params.
168 15
        if (false !== strpos(strtolower($this->controllerAction), 'index')) {
169
            // Index pages should only set the project, and no other class properties.
170 10
            $this->setProject($this->getProjectFromQuery());
171
        } else {
172 5
            $this->setProperties(); // Includes the project.
173
        }
174
175
        // Check if the request is to a restricted API endpoint, where the target user has to opt-in to statistics.
176 15
        $this->checkRestrictedApiEndpoint();
177 15
    }
178
179
    /**
180
     * Check if the request is AJAX, and disallow it unless they're using the API or if it's a subrequest.
181
     */
182 15
    private function checkIfAjax(): void
183
    {
184 15
        if ($this->request->isXmlHttpRequest() && !$this->isApi && !$this->isSubRequest) {
185
            throw new HttpException(
186
                403,
187
                $this->i18n->msg('error-automation', ['https://www.mediawiki.org/Special:MyLanguage/XTools/API'])
188
            );
189
        }
190 15
    }
191
192
    /**
193
     * Check if the request is to a restricted API endpoint, and throw an exception if the target user hasn't opted-in.
194
     * @throws XtoolsHttpException
195
     */
196 15
    private function checkRestrictedApiEndpoint(): void
197
    {
198 15
        $restrictedAction = in_array($this->controllerAction, $this->restrictedActions);
199
200 15
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn($this->user)) {
201
            throw new XtoolsHttpException(
202
                $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 AppBundle\Exception\Xtoo...xception::__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

202
                /** @scrutinizer ignore-type */ $this->i18n->msg('not-opted-in', [
Loading history...
203
                    $this->getOptedInPage()->getTitle(),
204
                    $this->i18n->msg('not-opted-in-link') .
205
                        ' <https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats>',
206
                    $this->i18n->msg('not-opted-in-login'),
207
                ]),
208
                '',
209
                $this->params,
210
                true,
211
                Response::HTTP_UNAUTHORIZED
212
            );
213
        }
214 15
    }
215
216
    /**
217
     * Get the path to the opt-in page for restricted statistics.
218
     * @return Page
219
     */
220
    protected function getOptedInPage(): Page
221
    {
222
        return $this->project
223
            ->getRepository()
224
            ->getPage($this->project, $this->project->userOptInPage($this->user));
0 ignored issues
show
Bug introduced by
The method getPage() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\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

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