Passed
Push — ip-ranges ( 157734...88dad8 )
by MusikAnimal
06:12 queued 01:19
created

XtoolsController::setProperties()   B

Complexity

Conditions 7
Paths 48

Size

Total Lines 33
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 16
c 1
b 0
f 0
nc 48
nop 0
dl 0
loc 33
rs 8.8333
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
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 AppBundle\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
    /** @var bool Is the current request a subrequest? */
94
    protected $isSubRequest;
95
96
    /**
97
     * Stores user preferences such default project.
98
     * This may get altered from the Request and updated in the Response.
99
     * @var array
100
     */
101
    protected $cookies = [
102
        'XtoolsProject' => null,
103
    ];
104
105
    /**
106
     * This activates the 'too high edit count' functionality. This property represents the
107
     * action that should be redirected to if the user has too high of an edit count.
108
     * @var string
109
     */
110
    protected $tooHighEditCountAction;
111
112
    /** @var array Actions that are exempt from edit count limitations. */
113
    protected $tooHighEditCountActionBlacklist = [];
114
115
    /**
116
     * Actions that require the target user to opt in to the restricted statistics.
117
     * @see https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats
118
     * @var string[]
119
     */
120
    protected $restrictedActions = [];
121
122
    /**
123
     * XtoolsController::validateProject() will ensure the given project matches one of these domains,
124
     * instead of any valid project.
125
     * @var string[]
126
     */
127
    protected $supportedProjects;
128
129
    /**
130
     * Require the tool's index route (initial form) be defined here. This should also
131
     * be the name of the associated model, if present.
132
     * @return string
133
     */
134
    abstract protected function getIndexRoute(): string;
135
136
    /**
137
     * XtoolsController constructor.
138
     * @param RequestStack $requestStack
139
     * @param ContainerInterface $container
140
     * @param I18nHelper $i18n
141
     */
142
    public function __construct(RequestStack $requestStack, ContainerInterface $container, I18nHelper $i18n)
143
    {
144
        $this->request = $requestStack->getCurrentRequest();
145
        $this->container = $container;
146
        $this->i18n = $i18n;
147
        $this->params = $this->parseQueryParams();
148
149
        // Parse out the name of the controller and action.
150
        $pattern = "#::([a-zA-Z]*)Action#";
151
        $matches = [];
152
        // The blank string here only happens in the unit tests, where the request may not be made to an action.
153
        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

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

212
                /** @scrutinizer ignore-type */ $this->i18n->msg('not-opted-in', [
Loading history...
213
                    $this->getOptedInPage()->getTitle(),
214
                    $this->i18n->msg('not-opted-in-link') .
215
                        ' <https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats>',
216
                    $this->i18n->msg('not-opted-in-login'),
217
                ]),
218
                '',
219
                $this->params,
220
                true,
221
                Response::HTTP_UNAUTHORIZED
222
            );
223
        }
224
    }
225
226
    /**
227
     * Get the path to the opt-in page for restricted statistics.
228
     * @return Page
229
     */
230
    protected function getOptedInPage(): Page
231
    {
232
        return $this->project
233
            ->getRepository()
234
            ->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

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