Test Failed
Push — ip-ranges ( af8775 )
by MusikAnimal
07:44
created

XtoolsController::getParams()   A

Complexity

Conditions 5
Paths 3

Size

Total Lines 45
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 5

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 31
c 1
b 0
f 0
nc 3
nop 0
dl 0
loc 45
ccs 8
cts 8
cp 1
crap 5
rs 9.1128
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 Doctrine\DBAL\DBALException;
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
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 AppBundle\Controller namespace.
32
 * @abstract
33
 */
34
abstract class XtoolsController extends Controller
35
{
36
    /** @var I18nHelper i18n helper. */
37
    protected $i18n;
38
39
    /** @var Request The request object. */
40
    protected $request;
41
42
    /** @var string Name of the action within the child controller that is being executed. */
43
    protected $controllerAction;
44
45
    /** @var array Hash of params parsed from the Request. */
46
    protected $params;
47
48
    /** @var bool Whether this is a request to an API action. */
49
    protected $isApi;
50
51
    /** @var Project Relevant Project parsed from the Request. */
52
    protected $project;
53
54
    /** @var User Relevant User parsed from the Request. */
55
    protected $user;
56
57
    /** @var Page Relevant Page parsed from the Request. */
58
    protected $page;
59
60
    /** @var int|false Start date parsed from the Request. */
61
    protected $start = false;
62
63
    /** @var int|false End date parsed from the Request. */
64
    protected $end = false;
65
66
    /**
67
     * Default days from current day, to use as the start date if none was provided.
68
     * If this is null and $maxDays is non-null, the latter will be used as the default.
69
     * Is public visibility evil here? I don't think so.
70
     * @var int|null
71
     */
72
    public $defaultDays = null;
73
74
    /**
75
     * Maximum number of days allowed for the given date range.
76
     * Set this in the controller's constructor to enforce the given date range to be within this range.
77
     * This will be used as the default date span unless $defaultDays is defined.
78
     * @see XtoolsController::getUnixFromDateParams()
79
     * @var int|null
80
     */
81
    public $maxDays = null;
82
83
    /** @var int|string|null Namespace parsed from the Request, ID as int or 'all' for all namespaces. */
84
    protected $namespace;
85
86
    /** @var int|false Unix timestamp. Pagination offset that substitutes for $end. */
87
    protected $offset = false;
88
89
    /** @var int Number of results to return. */
90
    protected $limit;
91
92
    /** @var bool Is the current request a subrequest? */
93
    protected $isSubRequest;
94
95
    /**
96
     * Stores user preferences such default project.
97
     * This may get altered from the Request and updated in the Response.
98
     * @var array
99
     */
100
    protected $cookies = [
101
        'XtoolsProject' => null,
102
    ];
103
104
    /**
105
     * This activates the 'too high edit count' functionality. This property represents the
106
     * action that should be redirected to if the user has too high of an edit count.
107
     * @var string
108
     */
109
    protected $tooHighEditCountAction;
110
111
    /** @var array Actions that are exempt from edit count limitations. */
112
    protected $tooHighEditCountActionBlacklist = [];
113
114
    /**
115
     * Actions that require the target user to opt in to the restricted statistics.
116
     * @see https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats
117
     * @var string[]
118
     */
119
    protected $restrictedActions = [];
120
121
    /**
122
     * XtoolsController::validateProject() will ensure the given project matches one of these domains,
123
     * instead of any valid project.
124
     * @var string[]
125
     */
126
    protected $supportedProjects;
127
128
    /**
129
     * Require the tool's index route (initial form) be defined here. This should also
130
     * be the name of the associated model, if present.
131
     * @return string
132
     */
133
    abstract protected function getIndexRoute(): string;
134
135
    /**
136
     * XtoolsController constructor.
137
     * @param RequestStack $requestStack
138
     * @param ContainerInterface $container
139
     * @param I18nHelper $i18n
140
     */
141
    public function __construct(RequestStack $requestStack, ContainerInterface $container, I18nHelper $i18n)
142
    {
143
        $this->request = $requestStack->getCurrentRequest();
144
        $this->container = $container;
145
        $this->i18n = $i18n;
146
        $this->params = $this->parseQueryParams();
147
148
        // Parse out the name of the controller and action.
149
        $pattern = "#::([a-zA-Z]*)Action#";
150
        $matches = [];
151
        // The blank string here only happens in the unit tests, where the request may not be made to an action.
152
        preg_match($pattern, $this->request->get('_controller') ?? '', $matches);
153
        $this->controllerAction = $matches[1] ?? '';
154
155
        // Whether the action is an API action.
156
        $this->isApi = 'Api' === substr($this->controllerAction, -3) || 'recordUsage' === $this->controllerAction;
157
158
        // Whether we're making a subrequest (the view makes a request to another action).
159
        $this->isSubRequest = $this->request->get('htmlonly')
160
            || null !== $this->get('request_stack')->getParentRequest();
161
162
        // Disallow AJAX (unless it's an API or subrequest).
163
        $this->checkIfAjax();
164
165
        // Load user options from cookies.
166
        $this->loadCookies();
167
168
        // Set the class-level properties based on params.
169
        if (false !== strpos(strtolower($this->controllerAction), 'index')) {
170
            // Index pages should only set the project, and no other class properties.
171
            $this->setProject($this->getProjectFromQuery());
172
        } else {
173
            $this->setProperties(); // Includes the project.
174
        }
175
176
        // Check if the request is to a restricted API endpoint, where the target user has to opt-in to statistics.
177
        $this->checkRestrictedApiEndpoint();
178
    }
179
180
    /**
181
     * Check if the request is AJAX, and disallow it unless they're using the API or if it's a subrequest.
182
     */
183
    private function checkIfAjax(): void
184
    {
185
        if ($this->request->isXmlHttpRequest() && !$this->isApi && !$this->isSubRequest) {
186
            throw new HttpException(
187
                403,
188
                $this->i18n->msg('error-automation', ['https://www.mediawiki.org/Special:MyLanguage/XTools/API'])
189
            );
190
        }
191
    }
192
193
    /**
194
     * Check if the request is to a restricted API endpoint, and throw an exception if the target user hasn't opted-in.
195
     * @throws XtoolsHttpException
196
     */
197
    private function checkRestrictedApiEndpoint(): void
198
    {
199
        $restrictedAction = in_array($this->controllerAction, $this->restrictedActions);
200
201
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn($this->user)) {
202
            throw new XtoolsHttpException(
203
                $this->i18n->msg('not-opted-in', [
204
                    $this->getOptedInPage()->getTitle(),
205
                    $this->i18n->msg('not-opted-in-link') .
206
                        ' <https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats>',
207
                    $this->i18n->msg('not-opted-in-login'),
208
                ]),
209
                '',
210
                $this->params,
211
                true,
212
                Response::HTTP_UNAUTHORIZED
213
            );
214
        }
215
    }
216
217
    /**
218
     * Get the path to the opt-in page for restricted statistics.
219
     * @return Page
220
     */
221
    protected function getOptedInPage(): Page
222
    {
223
        return $this->project
224
            ->getRepository()
225
            ->getPage($this->project, $this->project->userOptInPage($this->user));
226
    }
227
228
    /***********
229
     * COOKIES *
230
     ***********/
231
232
    /**
233
     * Load user preferences from the associated cookies.
234
     */
235
    private function loadCookies(): void
236
    {
237
        // Not done for subrequests.
238
        if ($this->isSubRequest) {
239
            return;
240
        }
241
242
        foreach (array_keys($this->cookies) as $name) {
243
            $this->cookies[$name] = $this->request->cookies->get($name);
244
        }
245
    }
246
247
    /**
248
     * Set cookies on the given Response.
249
     * @param Response $response
250
     */
251
    private function setCookies(Response &$response): void
252
    {
253
        // Not done for subrequests.
254
        if ($this->isSubRequest) {
255
            return;
256
        }
257
258
        foreach ($this->cookies as $name => $value) {
259
            $response->headers->setCookie(
260
                Cookie::create($name, $value)
261
            );
262
        }
263
    }
264
265
    /**
266
     * Sets the project, with the domain in $this->cookies['XtoolsProject'] that will
267
     * later get set on the Response headers in self::getFormattedResponse().
268
     * @param Project $project
269
     */
270
    private function setProject(Project $project): void
271
    {
272
        // TODO: Remove after deprecated routes are retired.
273
        if (false !== strpos((string)$this->request->get('_controller'), 'GlobalContribs')) {
274
            return;
275
        }
276
277
        $this->project = $project;
278
        $this->cookies['XtoolsProject'] = $project->getDomain();
279
    }
280
281
    /****************************
282
     * SETTING CLASS PROPERTIES *
283
     ****************************/
284
285
    /**
286
     * Normalize all common parameters used by the controllers and set class properties.
287
     */
288
    private function setProperties(): void
289
    {
290
        $this->namespace = $this->params['namespace'] ?? null;
291
292
        // Offset is given as ISO timestamp and is stored as a UNIX timestamp (or false).
293
        if (isset($this->params['offset'])) {
294
            $this->offset = strtotime($this->params['offset']);
295
        }
296
297
        // Limit need to be an int.
298
        if (isset($this->params['limit'])) {
299
            // Normalize.
300
            $this->params['limit'] = max(0, (int)$this->params['limit']);
301
            $this->limit = $this->params['limit'];
302
        }
303
304
        if (isset($this->params['project'])) {
305
            $this->setProject($this->validateProject($this->params['project']));
306
        } elseif (null !== $this->cookies['XtoolsProject']) {
307
            // Set from cookie.
308
            $this->setProject(
309
                $this->validateProject($this->cookies['XtoolsProject'])
310
            );
311
        }
312
313
        if (isset($this->params['username'])) {
314
            $this->user = $this->validateUser($this->params['username']);
315
        }
316
        if (isset($this->params['page'])) {
317
            $this->page = $this->getPageFromNsAndTitle($this->namespace, $this->params['page']);
318
        }
319
320
        $this->setDates();
321
    }
322
323
    /**
324
     * Set class properties for dates, if such params were passed in.
325
     */
326
    private function setDates(): void
327
    {
328
        $start = $this->params['start'] ?? false;
329
        $end = $this->params['end'] ?? false;
330
        if ($start || $end || null !== $this->maxDays) {
331
            [$this->start, $this->end] = $this->getUnixFromDateParams($start, $end);
332
333
            // Set $this->params accordingly too, so that for instance API responses will include it.
334
            $this->params['start'] = is_int($this->start) ? date('Y-m-d', $this->start) : false;
335
            $this->params['end'] = is_int($this->end) ? date('Y-m-d', $this->end) : false;
336
        }
337
    }
338
339
    /**
340
     * Construct a fully qualified page title given the namespace and title.
341
     * @param int|string $ns Namespace ID.
342
     * @param string $title Page title.
343
     * @param bool $rawTitle Return only the title (and not a Page).
344
     * @return Page|string
345
     */
346
    protected function getPageFromNsAndTitle($ns, string $title, bool $rawTitle = false)
347
    {
348
        if (0 === (int)$ns) {
349
            return $rawTitle ? $title : $this->validatePage($title);
350
        }
351
352
        // Prepend namespace and strip out duplicates.
353
        $nsName = $this->project->getNamespaces()[$ns] ?? $this->i18n->msg('unknown');
354
        $title = $nsName.':'.preg_replace('/^'.$nsName.':/', '', $title);
355
        return $rawTitle ? $title : $this->validatePage($title);
356
    }
357
358
    /**
359
     * Get a Project instance from the project string, using defaults if the given project string is invalid.
360
     * @return Project
361
     */
362
    public function getProjectFromQuery(): Project
363
    {
364
        // Set default project so we can populate the namespace selector on index pages.
365
        // Defaults to project stored in cookie, otherwise project specified in parameters.yml.
366
        if (isset($this->params['project'])) {
367
            $project = $this->params['project'];
368
        } elseif (null !== $this->cookies['XtoolsProject']) {
369
            $project = $this->cookies['XtoolsProject'];
370
        } else {
371
            $project = $this->container->getParameter('default_project');
372
        }
373
374
        $projectData = ProjectRepository::getProject($project, $this->container);
375
376
        // Revert back to defaults if we've established the given project was invalid.
377
        if (!$projectData->exists()) {
378
            $projectData = ProjectRepository::getProject(
379
                $this->container->getParameter('default_project'),
380
                $this->container
381
            );
382
        }
383
384
        return $projectData;
385
    }
386
387
    /*************************
388
     * GETTERS / VALIDATIONS *
389
     *************************/
390
391
    /**
392
     * Validate the given project, returning a Project if it is valid or false otherwise.
393
     * @param string $projectQuery Project domain or database name.
394
     * @return Project
395
     * @throws XtoolsHttpException
396
     */
397
    public function validateProject(string $projectQuery): Project
398
    {
399
        /** @var Project $project */
400
        $project = ProjectRepository::getProject($projectQuery, $this->container);
401
402
        // Check if it is an explicitly allowed project for the current tool.
403
        if (isset($this->supportedProjects) && !in_array($project->getDomain(), $this->supportedProjects)) {
404
            $this->throwXtoolsException(
405
                $this->getIndexRoute(),
406
                'error-authorship-unsupported-project',
407
                [$this->params['project']],
408
                'project'
409
            );
410
        }
411
412
        if (!$project->exists()) {
413
            $this->throwXtoolsException(
414
                $this->getIndexRoute(),
415
                'invalid-project',
416
                [$this->params['project']],
417
                'project'
418
            );
419
        }
420
421
        return $project;
422
    }
423
424
    /**
425
     * Validate the given user, returning a User or Redirect if they don't exist.
426
     * @param string $username
427
     * @return User
428
     * @throws XtoolsHttpException
429
     */
430
    public function validateUser(string $username): User
431
    {
432
        $user = UserRepository::getUser($username, $this->container);
433
434
        // Allow querying for any IP, currently with no edit count limitation...
435
        // Once T188677 is resolved IPs will be affected by the EXPLAIN results.
436
        if ($user->isAnon()) {
437
            if ($user->isIpRange() && )
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected ')' on line 437 at column 38
Loading history...
438
            return $user;
439
        }
440
441
        $originalParams = $this->params;
442
443
        // Don't continue if the user doesn't exist.
444
        if ($this->project && !$user->existsOnProject($this->project)) {
445
            $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username');
446
        }
447
448
        // Reject users with a crazy high edit count.
449
        if (isset($this->tooHighEditCountAction) &&
450
            !in_array($this->controllerAction, $this->tooHighEditCountActionBlacklist) &&
451
            $user->hasTooManyEdits($this->project)
452
        ) {
453
            /** TODO: Somehow get this to use self::throwXtoolsException */
454
455
            // If redirecting to a different controller, show an informative message accordingly.
456
            if ($this->tooHighEditCountAction !== $this->getIndexRoute()) {
457
                // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,
458
                //   so this bit is hardcoded. We need to instead give the i18n key of the route.
459
                $redirMsg = $this->i18n->msg('too-many-edits-redir', [
460
                    $this->i18n->msg('tool-simpleeditcounter'),
461
                ]);
462
                $msg = $this->i18n->msg('too-many-edits', [
463
                    $this->i18n->numberFormat($user->maxEdits()),
464
                ]).'. '.$redirMsg;
465
                $this->addFlashMessage('danger', $msg);
466
            } else {
467
                $this->addFlashMessage('danger', 'too-many-edits', [
468
                    $this->i18n->numberFormat($user->maxEdits()),
469
                ]);
470
471
                // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).
472
                unset($this->params['username']);
473
            }
474
475
            // Clear flash bag for API responses, since they get intercepted in ExceptionListener
476
            // and would otherwise be shown in subsequent requests.
477
            if ($this->isApi) {
478
                $this->get('session')->getFlashBag()->clear();
479
            }
480
481
            throw new XtoolsHttpException(
482
                'User has made too many edits! Maximum '.$user->maxEdits(),
483
                $this->generateUrl($this->tooHighEditCountAction, $this->params),
484
                $originalParams,
485
                $this->isApi
486
            );
487
        }
488
489
        return $user;
490
    }
491
492
    /**
493
     * Get a Page instance from the given page title, and validate that it exists.
494
     * @param string $pageTitle
495
     * @return Page
496
     * @throws XtoolsHttpException
497
     */
498
    public function validatePage(string $pageTitle): Page
499
    {
500
        $page = new Page($this->project, $pageTitle);
501
        $pageRepo = new PageRepository();
502
        $pageRepo->setContainer($this->container);
503
        $page->setRepository($pageRepo);
504
505
        if (!$page->exists()) {
506
            $this->throwXtoolsException(
507
                $this->getIndexRoute(),
508
                'no-result',
509
                [$this->params['page'] ?? null],
510
                'page'
511
            );
512
        }
513
514
        return $page;
515
    }
516
517
    /**
518
     * Throw an XtoolsHttpException, which the given error message and redirects to specified action.
519
     * @param string $redirectAction Name of action to redirect to.
520
     * @param string $message i18n key of error message. Shown in API responses.
521
     *   If no message with this key exists, $message is shown as-is.
522
     * @param array $messageParams
523
     * @param string $invalidParam This will be removed from $this->params. Omit if you don't want this to happen.
524
     * @throws XtoolsHttpException
525
     */
526
    public function throwXtoolsException(
527
        string $redirectAction,
528
        string $message,
529
        array $messageParams = [],
530
        ?string $invalidParam = null
531
    ): void {
532
        $this->addFlashMessage('danger', $message, $messageParams);
533
        $originalParams = $this->params;
534
535
        // Remove invalid parameter if it was given.
536
        if (is_string($invalidParam)) {
537
            unset($this->params[$invalidParam]);
538
        }
539
540
        // We sometimes are redirecting to the index page, so also remove project (otherwise we'd get a redirect loop).
541
        /**
542
         * FIXME: Index pages should have a 'nosubmit' parameter to prevent submission.
543
         * Then we don't even need to remove $invalidParam.
544
         * Better, we should show the error on the results page, with no results.
545
         */
546
        unset($this->params['project']);
547
548
        // Throw exception which will redirect to $redirectAction.
549
        throw new XtoolsHttpException(
550
            $this->i18n->msgIfExists($message, $messageParams),
551
            $this->generateUrl($redirectAction, $this->params),
552
            $originalParams,
553
            $this->isApi
554
        );
555
    }
556
557
    /**
558
     * Get the first error message stored in the session's FlashBag.
559
     * @return string
560
     */
561
    public function getFlashMessage(): string
562
    {
563
        $key = $this->get('session')->getFlashBag()->get('danger')[0];
564
        $param = null;
565
566
        if (is_array($key)) {
567
            [$key, $param] = $key;
568
        }
569
570
        return $this->render('message.twig', [
571
            'key' => $key,
572
            'params' => [$param],
573
        ])->getContent();
574
    }
575
576
    /******************
577
     * PARSING PARAMS *
578
     ******************/
579
580
    /**
581
     * Get all standardized parameters from the Request, either via URL query string or routing.
582
     * @return string[]
583
     */
584
    public function getParams(): array
585
    {
586
        $paramsToCheck = [
587
            'project',
588
            'username',
589
            'namespace',
590
            'page',
591
            'categories',
592
            'group',
593
            'redirects',
594
            'deleted',
595
            'start',
596
            'end',
597
            'offset',
598
            'limit',
599
            'format',
600
            'tool',
601
            'tools',
602
            'q',
603
604
            // Legacy parameters.
605
            'user',
606
            'name',
607
            'article',
608
            'wiki',
609
            'wikifam',
610
            'lang',
611
            'wikilang',
612
            'begin',
613
        ];
614
615
        /** @var string[] $params Each parameter that was detected along with its value. */
616
        $params = [];
617
618
        foreach ($paramsToCheck as $param) {
619
            // Pull in either from URL query string or route.
620
            $value = $this->request->query->get($param) ?: $this->request->get($param);
621
622
            // Only store if value is given ('namespace' or 'username' could be '0').
623
            if (null !== $value && '' !== $value) {
624
                $params[$param] = rawurldecode((string)$value);
625
            }
626
        }
627
628
        return $params;
629
    }
630
631
    /**
632
     * Parse out common parameters from the request. These include the 'project', 'username', 'namespace' and 'page',
633
     * along with their legacy counterparts (e.g. 'lang' and 'wiki').
634
     * @return string[] Normalized parameters (no legacy params).
635
     */
636
    public function parseQueryParams(): array
637
    {
638
        /** @var string[] $params Each parameter and value that was detected. */
639
        $params = $this->getParams();
640
641
        // Covert any legacy parameters, if present.
642
        $params = $this->convertLegacyParams($params);
643
644
        // Remove blank values.
645
        return array_filter($params, function ($param) {
646
            // 'namespace' or 'username' could be '0'.
647
            return null !== $param && '' !== $param;
648
        });
649
    }
650
651
    /**
652
     * Get Unix timestamps from given start and end string parameters. This also makes $start $maxDays before
653
     * $end if not present, and makes $end the current time if not present.
654
     * The date range will not exceed $this->maxDays days, if this public class property is set.
655
     * @param int|string|false $start Unix timestamp or string accepted by strtotime.
656
     * @param int|string|false $end Unix timestamp or string accepted by strtotime.
657
     * @return int[] Start and end date as UTC timestamps.
658
     */
659
    public function getUnixFromDateParams($start, $end): array
660
    {
661
        $today = strtotime('today midnight');
662
663
        // start time should not be in the future.
664
        $startTime = min(
665
            is_int($start) ? $start : strtotime((string)$start),
666
            $today
667
        );
668
669
        // end time defaults to now, and will not be in the future.
670
        $endTime = min(
671
            (is_int($end) ? $end : strtotime((string)$end)) ?: $today,
672
            $today
673
        );
674
675
        // Default to $this->defaultDays or $this->maxDays before end time if start is not present.
676
        $daysOffset = $this->defaultDays ?? $this->maxDays;
677
        if (false === $startTime && is_int($daysOffset)) {
678
            $startTime = strtotime("-$daysOffset days", $endTime);
679
        }
680
681
        // Default to $this->defaultDays or $this->maxDays after start time if end is not present.
682
        if (false === $end && is_int($daysOffset)) {
683
            $endTime = min(
684
                strtotime("+$daysOffset days", $startTime),
685
                $today
686
            );
687
        }
688
689
        // Reverse if start date is after end date.
690
        if ($startTime > $endTime && false !== $startTime && false !== $end) {
691
            $newEndTime = $startTime;
692
            $startTime = $endTime;
693
            $endTime = $newEndTime;
694
        }
695
696
        // Finally, don't let the date range exceed $this->maxDays.
697
        $startObj = DateTime::createFromFormat('U', (string)$startTime);
698
        $endObj = DateTime::createFromFormat('U', (string)$endTime);
699
        if (is_int($this->maxDays) && $startObj->diff($endObj)->days > $this->maxDays) {
700
            // Show warnings that the date range was truncated.
701
            $this->addFlashMessage('warning', 'date-range-too-wide', [$this->maxDays]);
702
703
            $startTime = strtotime("-$this->maxDays days", $endTime);
704
        }
705
706
        return [$startTime, $endTime];
707
    }
708
709
    /**
710
     * Given the params hash, normalize any legacy parameters to their modern equivalent.
711
     * @param string[] $params
712
     * @return string[]
713
     */
714
    private function convertLegacyParams(array $params): array
715
    {
716
        $paramMap = [
717
            'user' => 'username',
718
            'name' => 'username',
719
            'article' => 'page',
720
            'begin' => 'start',
721
722
            // Copy super legacy project params to legacy so we can concatenate below.
723
            'wikifam' => 'wiki',
724
            'wikilang' => 'lang',
725
        ];
726
727
        // Copy legacy parameters to modern equivalent.
728
        foreach ($paramMap as $legacy => $modern) {
729
            if (isset($params[$legacy])) {
730
                $params[$modern] = $params[$legacy];
731
                unset($params[$legacy]);
732
            }
733
        }
734
735
        // Separate parameters for language and wiki.
736
        if (isset($params['wiki']) && isset($params['lang'])) {
737
            // 'wikifam' will be like '.wikipedia.org', vs just 'wikipedia',
738
            // so we must remove leading periods and trailing .org's.
739
            $params['project'] = rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
740
741
            /** @var string[] $languagelessProjects Projects for which there is no specific language association. */
742
            $languagelessProjects = $this->container->getParameter('app.multilingual_wikis');
743
744
            // Prepend language if applicable.
745
            if (isset($params['lang']) && !in_array($params['wiki'], $languagelessProjects)) {
746
                $params['project'] = $params['lang'].'.'.$params['project'];
747
            }
748
749
            unset($params['wiki']);
750
            unset($params['lang']);
751
        }
752
753
        return $params;
754
    }
755
756
    /************************
757
     * FORMATTING RESPONSES *
758
     ************************/
759
760
    /**
761
     * Get the rendered template for the requested format. This method also updates the cookies.
762
     * @param string $templatePath Path to template without format,
763
     *   such as '/editCounter/latest_global'.
764
     * @param array $ret Data that should be passed to the view.
765
     * @return Response
766
     * @codeCoverageIgnore
767
     */
768
    public function getFormattedResponse(string $templatePath, array $ret): Response
769
    {
770
        $format = $this->request->query->get('format', 'html');
771
        if ('' == $format) {
772
            // The default above doesn't work when the 'format' parameter is blank.
773
            $format = 'html';
774
        }
775
776
        // Merge in common default parameters, giving $ret (from the caller) the priority.
777
        $ret = array_merge([
778
            'project' => $this->project,
779
            'user' => $this->user,
780
            'page' => $this->page,
781
            'namespace' => $this->namespace,
782
            'start' => $this->start,
783
            'end' => $this->end,
784
        ], $ret);
785
786
        $formatMap = [
787
            'wikitext' => 'text/plain',
788
            'csv' => 'text/csv',
789
            'tsv' => 'text/tab-separated-values',
790
            'json' => 'application/json',
791
        ];
792
793
        $response = new Response();
794
795
        // Set cookies. Note this must be done before rendering the view, as the view may invoke subrequests.
796
        $this->setCookies($response);
797
798
        // If requested format does not exist, assume HTML.
799
        if (false === $this->get('twig')->getLoader()->exists("$templatePath.$format.twig")) {
800
            $format = 'html';
801
        }
802
803
        $response = $this->render("$templatePath.$format.twig", $ret, $response);
804
805
        $contentType = $formatMap[$format] ?? 'text/html';
806
        $response->headers->set('Content-Type', $contentType);
807
808
        if (in_array($format, ['csv', 'tsv'])) {
809
            $filename = $this->getFilenameForRequest();
810
            $response->headers->set(
811
                'Content-Disposition',
812
                "attachment; filename=\"{$filename}.$format\""
813
            );
814
        }
815
816
        return $response;
817
    }
818
819
    /**
820
     * Returns given filename from the current Request, with problematic characters filtered out.
821
     * @return string
822
     */
823
    private function getFilenameForRequest(): string
824
    {
825
        $filename = trim($this->request->getPathInfo(), '/');
826
        return trim(preg_replace('/[-\/\\:;*?|<>%#"]+/', '-', $filename));
827
    }
828
829
    /**
830
     * Return a JsonResponse object pre-supplied with the requested params.
831
     * @param array $data
832
     * @return JsonResponse
833
     */
834
    public function getFormattedApiResponse(array $data): JsonResponse
835
    {
836
        $response = new JsonResponse();
837
        $response->setEncodingOptions(JSON_NUMERIC_CHECK);
838
        $response->setStatusCode(Response::HTTP_OK);
839
840
        // Normalize display of IP ranges (they are prefixed with 'ipr-' in the params).
841
        if ($this->user && $this->user->isIpRange()) {
842
            $this->params['username'] = $this->user->getUsername();
843
        }
844
845
        $elapsedTime = round(
846
            microtime(true) - $this->request->server->get('REQUEST_TIME_FLOAT'),
847
            3
848
        );
849
850
        // Any pipe-separated values should be returned as an array.
851
        foreach ($this->params as $param => $value) {
852
            if (is_string($value) && false !== strpos($value, '|')) {
853
                $this->params[$param] = explode('|', $value);
854
            }
855
        }
856
857
        $ret = array_merge($this->params, [
858
            // In some controllers, $this->params['project'] may be overridden with a Project object.
859
            'project' => $this->project->getDomain(),
860
        ], $data, ['elapsed_time' => $elapsedTime]);
861
862
        // Merge in flash messages, putting them at the top.
863
        $flashes = $this->get('session')->getFlashBag()->peekAll();
864
        $ret = array_merge($flashes, $ret);
865
866
        // Flashes now can be cleared after merging into the response.
867
        $this->get('session')->getFlashBag()->clear();
868
869
        $response->setData($ret);
870
871
        return $response;
872
    }
873
874
    /*********
875
     * OTHER *
876
     *********/
877
878
    /**
879
     * Record usage of an API endpoint.
880
     * @param string $endpoint
881
     * @codeCoverageIgnore
882
     */
883
    public function recordApiUsage(string $endpoint): void
884
    {
885
        /** @var \Doctrine\DBAL\Connection $conn */
886
        $conn = $this->container->get('doctrine')
887
            ->getManager('default')
888
            ->getConnection();
889
        $date =  date('Y-m-d');
890
891
        // Increment count in timeline
892
        $existsSql = "SELECT 1 FROM usage_api_timeline
893
                      WHERE date = '$date'
894
                      AND endpoint = '$endpoint'";
895
896
        try {
897
            if (0 === count($conn->query($existsSql)->fetchAll())) {
898
                $createSql = "INSERT INTO usage_api_timeline
899
                          VALUES(NULL, '$date', '$endpoint', 1)";
900
                $conn->query($createSql);
901
            } else {
902
                $updateSql = "UPDATE usage_api_timeline
903
                          SET count = count + 1
904
                          WHERE endpoint = '$endpoint'
905
                          AND date = '$date'";
906
                $conn->query($updateSql);
907
            }
908
        } catch (DBALException $e) {
909
            // Do nothing. API response should still be returned rather than erroring out.
910
        }
911
    }
912
913
    /**
914
     * Add a flash message.
915
     * @param string $type
916
     * @param string $key i18n key or raw message.
917
     * @param array $vars
918
     */
919
    public function addFlashMessage(string $type, string $key, array $vars = []): void
920
    {
921
        $this->addFlash(
922
            $type,
923
            $this->i18n->msgExists($key, $vars) ? $this->i18n->msg($key, $vars) : $key
924
        );
925
    }
926
}
927