Passed
Push — master ( 51e55a...c4fb69 )
by MusikAnimal
06:12
created

XtoolsController::validateProject()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2.686

Importance

Changes 0
Metric Value
cc 2
eloc 8
nc 2
nop 1
dl 0
loc 15
ccs 4
cts 9
cp 0.4444
crap 2.686
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains the abstract XtoolsController, which all other controllers will extend.
4
 */
5
6
declare(strict_types=1);
7
8
namespace 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 {@see 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::getUTCFromDateParams()
78
     * @var int|null
79
     */
80
    public $maxDays = null;
81
82
    /** @var int|string Namespace parsed from the Request, ID as int or 'all' for all namespaces. */
83
    protected $namespace;
84
85
    /** @var int Pagination offset parsed from the Request. */
86
    protected $offset = 0;
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://xtools.readthedocs.io/en/stable/opt-in.html
116
     * @var string[]
117
     */
118
    protected $restrictedActions = [];
119
120
    /**
121
     * Require the tool's index route (initial form) be defined here. This should also
122
     * be the name of the associated model, if present.
123
     * @return string
124
     */
125
    abstract protected function getIndexRoute(): string;
126
127
    /**
128
     * XtoolsController constructor.
129
     * @param RequestStack $requestStack
130
     * @param ContainerInterface $container
131
     * @param I18nHelper $i18n
132
     */
133 16
    public function __construct(RequestStack $requestStack, ContainerInterface $container, I18nHelper $i18n)
134
    {
135 16
        $this->request = $requestStack->getCurrentRequest();
136 16
        $this->container = $container;
137 16
        $this->i18n = $i18n;
138 16
        $this->params = $this->parseQueryParams();
139
140
        // Parse out the name of the controller and action.
141 16
        $pattern = "#::([a-zA-Z]*)Action#";
142 16
        $matches = [];
143
        // The blank string here only happens in the unit tests, where the request may not be made to an action.
144 16
        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

144
        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...
145 16
        $this->controllerAction = $matches[1] ?? '';
146
147
        // Whether the action is an API action.
148 16
        $this->isApi = 'Api' === substr($this->controllerAction, -3) || 'recordUsage' === $this->controllerAction;
149
150
        // Whether we're making a subrequest (the view makes a request to another action).
151 16
        $this->isSubRequest = $this->request->get('htmlonly')
152 16
            || null !== $this->get('request_stack')->getParentRequest();
153
154
        // Disallow AJAX (unless it's an API or subrequest).
155 16
        $this->checkIfAjax();
156
157
        // Load user options from cookies.
158 16
        $this->loadCookies();
159
160
        // Set the class-level properties based on params.
161 16
        if (false !== strpos(strtolower($this->controllerAction), 'index')) {
162
            // Index pages should only set the project, and no other class properties.
163 10
            $this->setProject($this->getProjectFromQuery());
164
        } else {
165 6
            $this->setProperties(); // Includes the project.
166
        }
167
168
        // Check if the request is to a restricted API endpoint, where the target user has to opt-in to statistics.
169 16
        $this->checkRestrictedApiEndpoint();
170 16
    }
171
172
    /**
173
     * Check if the request is AJAX, and disallow it unless they're using the API or if it's a subrequest.
174
     */
175 16
    private function checkIfAjax(): void
176
    {
177 16
        if ($this->request->isXmlHttpRequest() && !$this->isApi && !$this->isSubRequest) {
178
            throw new HttpException(
179
                403,
180
                $this->i18n->msg('error-automation', ['https://xtools.readthedocs.io/en/stable/api/'])
181
            );
182
        }
183 16
    }
184
185
    /**
186
     * Check if the request is to a restricted API endpoint, and throw an exception if the target user hasn't opted-in.
187
     * @throws XtoolsHttpException
188
     */
189 16
    private function checkRestrictedApiEndpoint(): void
190
    {
191 16
        $restrictedAction = in_array($this->controllerAction, $this->restrictedActions);
192
193 16
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn($this->user)) {
194
            throw new XtoolsHttpException(
195
                $this->i18n->msg('not-opted-in', [$this->getOptedInPage()->getTitle()]),
0 ignored issues
show
Bug introduced by
It seems like $this->i18n->msg('not-op...dInPage()->getTitle())) 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

195
                /** @scrutinizer ignore-type */ $this->i18n->msg('not-opted-in', [$this->getOptedInPage()->getTitle()]),
Loading history...
196
                '',
197
                $this->params,
198
                true
199
            );
200
        }
201 16
    }
202
203
    /**
204
     * Get the path to the opt-in page for restricted statistics.
205
     * @return Page
206
     */
207
    protected function getOptedInPage(): Page
208
    {
209
        return $this->project
210
            ->getRepository()
211
            ->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

211
            ->/** @scrutinizer ignore-call */ getPage($this->project, $this->project->userOptInPage($this->user));
Loading history...
212
    }
213
214
    /***********
215
     * COOKIES *
216
     ***********/
217
218
    /**
219
     * Load user preferences from the associated cookies.
220
     */
221 16
    private function loadCookies(): void
222
    {
223
        // Not done for subrequests.
224 16
        if ($this->isSubRequest) {
225
            return;
226
        }
227
228 16
        foreach (array_keys($this->cookies) as $name) {
229 16
            $this->cookies[$name] = $this->request->cookies->get($name);
230
        }
231 16
    }
232
233
    /**
234
     * Set cookies on the given Response.
235
     * @param Response $response
236
     */
237
    private function setCookies(Response &$response): void
238
    {
239
        // Not done for subrequests.
240
        if ($this->isSubRequest) {
241
            return;
242
        }
243
244
        foreach ($this->cookies as $name => $value) {
245
            $response->headers->setCookie(
246
                new Cookie($name, $value)
247
            );
248
        }
249
    }
250
251
    /**
252
     * Sets the project, with the domain in $this->cookies['XtoolsProject'] that will
253
     * later get set on the Response headers in self::getFormattedResponse().
254
     * @param Project $project
255
     */
256 12
    private function setProject(Project $project): void
257
    {
258
        // TODO: Remove after deprecated routes are retired.
259 12
        if (false !== strpos((string)$this->request->get('_controller'), 'GlobalContribs')) {
260
            return;
261
        }
262
263 12
        $this->project = $project;
264 12
        $this->cookies['XtoolsProject'] = $project->getDomain();
265 12
    }
266
267
    /****************************
268
     * SETTING CLASS PROPERTIES *
269
     ****************************/
270
271
    /**
272
     * Normalize all common parameters used by the controllers and set class properties.
273
     */
274 6
    private function setProperties(): void
275
    {
276 6
        $this->namespace = $this->params['namespace'] ?? null;
277
278
        // Offset and limit need to be ints.
279 6
        foreach (['offset', 'limit'] as $param) {
280 6
            if (isset($this->params[$param])) {
281 6
                $this->{$param} = (int)$this->params[$param];
282
            }
283
        }
284
285 6
        if (isset($this->params['project'])) {
286 2
            $this->setProject($this->validateProject($this->params['project']));
287 4
        } elseif (null !== $this->cookies['XtoolsProject']) {
288
            // Set from cookie.
289
            $this->setProject(
290
                $this->validateProject($this->cookies['XtoolsProject'])
291
            );
292
        }
293
294 6
        if (isset($this->params['username'])) {
295
            $this->user = $this->validateUser($this->params['username']);
296
        }
297 6
        if (isset($this->params['page'])) {
298
            $this->page = $this->getPageFromNsAndTitle($this->namespace, $this->params['page']);
299
        }
300
301 6
        $this->setDates();
302 6
    }
303
304
    /**
305
     * Set class properties for dates, if such params were passed in.
306
     */
307 6
    private function setDates(): void
308
    {
309 6
        $start = $this->params['start'] ?? false;
310 6
        $end = $this->params['end'] ?? false;
311 6
        if ($start || $end || null !== $this->maxDays) {
312
            [$this->start, $this->end] = $this->getUTCFromDateParams($start, $end);
313
314
            // Set $this->params accordingly too, so that for instance API responses will include it.
315
            $this->params['start'] = is_int($this->start) ? date('Y-m-d', $this->start) : false;
316
            $this->params['end'] = is_int($this->end) ? date('Y-m-d', $this->end) : false;
317
        }
318 6
    }
319
320
    /**
321
     * Construct a fully qualified page title given the namespace and title.
322
     * @param int|string $ns Namespace ID.
323
     * @param string $title Page title.
324
     * @param bool $rawTitle Return only the title (and not a Page).
325
     * @return Page|string
326
     */
327
    protected function getPageFromNsAndTitle($ns, string $title, bool $rawTitle = false)
328
    {
329
        if (0 === (int)$ns) {
330
            return $rawTitle ? $title : $this->validatePage($title);
331
        }
332
333
        // Prepend namespace and strip out duplicates.
334
        $nsName = $this->project->getNamespaces()[$ns] ?? $this->i18n->msg('unknown');
335
        $title = $nsName.':'.preg_replace('/^'.$nsName.':/', '', $title);
336
        return $rawTitle ? $title : $this->validatePage($title);
337
    }
338
339
    /**
340
     * Get a Project instance from the project string, using defaults if the given project string is invalid.
341
     * @return Project
342
     */
343 10
    public function getProjectFromQuery(): Project
344
    {
345
        // Set default project so we can populate the namespace selector on index pages.
346
        // Defaults to project stored in cookie, otherwise project specified in parameters.yml.
347 10
        if (isset($this->params['project'])) {
348 2
            $project = $this->params['project'];
349 8
        } elseif (null !== $this->cookies['XtoolsProject']) {
350
            $project = $this->cookies['XtoolsProject'];
351
        } else {
352 8
            $project = $this->container->getParameter('default_project');
353
        }
354
355 10
        $projectData = ProjectRepository::getProject($project, $this->container);
356
357
        // Revert back to defaults if we've established the given project was invalid.
358 10
        if (!$projectData->exists()) {
359
            $projectData = ProjectRepository::getProject(
360
                $this->container->getParameter('default_project'),
361
                $this->container
362
            );
363
        }
364
365 10
        return $projectData;
366
    }
367
368
    /*************************
369
     * GETTERS / VALIDATIONS *
370
     *************************/
371
372
    /**
373
     * Validate the given project, returning a Project if it is valid or false otherwise.
374
     * @param string $projectQuery Project domain or database name.
375
     * @return Project
376
     * @throws XtoolsHttpException
377
     */
378 2
    public function validateProject(string $projectQuery): Project
379
    {
380
        /** @var Project $project */
381 2
        $project = ProjectRepository::getProject($projectQuery, $this->container);
382
383 2
        if (!$project->exists()) {
384
            $this->throwXtoolsException(
385
                $this->getIndexRoute(),
386
                'invalid-project',
387
                [$this->params['project']],
388
                'project'
389
            );
390
        }
391
392 2
        return $project;
393
    }
394
395
    /**
396
     * Validate the given user, returning a User or Redirect if they don't exist.
397
     * @param string $username
398
     * @return User
399
     * @throws XtoolsHttpException
400
     */
401
    public function validateUser(string $username): User
402
    {
403
        $user = UserRepository::getUser($username, $this->container);
404
405
        // Allow querying for any IP, currently with no edit count limitation...
406
        // Once T188677 is resolved IPs will be affected by the EXPLAIN results.
407
        if ($user->isAnon()) {
408
            return $user;
409
        }
410
411
        $originalParams = $this->params;
412
413
        // Don't continue if the user doesn't exist.
414
        if ($this->project && !$user->existsOnProject($this->project)) {
415
            $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username');
416
        }
417
418
        // Reject users with a crazy high edit count.
419
        if (isset($this->tooHighEditCountAction) &&
420
            !in_array($this->controllerAction, $this->tooHighEditCountActionBlacklist) &&
421
            $user->hasTooManyEdits($this->project)
422
        ) {
423
            /** TODO: Somehow get this to use self::throwXtoolsException */
424
425
            // If redirecting to a different controller, show an informative message accordingly.
426
            if ($this->tooHighEditCountAction !== $this->getIndexRoute()) {
427
                // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,
428
                //   so this bit is hardcoded. We need to instead give the i18n key of the route.
429
                $redirMsg = $this->i18n->msg('too-many-edits-redir', [
430
                    $this->i18n->msg('tool-simpleeditcounter'),
431
                ]);
432
                $msg = $this->i18n->msg('too-many-edits', [
433
                    $this->i18n->numberFormat($user->maxEdits()),
434
                ]).'. '.$redirMsg;
435
                $this->addFlashMessage('danger', $msg);
436
            } else {
437
                $this->addFlashMessage('danger', 'too-many-edits', [
438
                    $this->i18n->numberFormat($user->maxEdits()),
439
                ]);
440
441
                // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).
442
                unset($this->params['username']);
443
            }
444
445
            // Clear flash bag for API responses, since they get intercepted in ExceptionListener
446
            // and would otherwise be shown in subsequent requests.
447
            if ($this->isApi) {
448
                $this->get('session')->getFlashBag()->clear();
449
            }
450
451
            throw new XtoolsHttpException(
452
                'User has made too many edits! Maximum '.$user->maxEdits(),
453
                $this->generateUrl($this->tooHighEditCountAction, $this->params),
454
                $originalParams,
455
                $this->isApi
456
            );
457
        }
458
459
        return $user;
460
    }
461
462
    /**
463
     * Get a Page instance from the given page title, and validate that it exists.
464
     * @param string $pageTitle
465
     * @return Page
466
     * @throws XtoolsHttpException
467
     */
468
    public function validatePage(string $pageTitle): Page
469
    {
470
        $page = new Page($this->project, $pageTitle);
471
        $pageRepo = new PageRepository();
472
        $pageRepo->setContainer($this->container);
473
        $page->setRepository($pageRepo);
474
475
        if (!$page->exists()) {
476
            $this->throwXtoolsException(
477
                $this->getIndexRoute(),
478
                'no-result',
479
                [$this->params['page'] ?? null],
480
                'page'
481
            );
482
        }
483
484
        return $page;
485
    }
486
487
    /**
488
     * Throw an XtoolsHttpException, which the given error message and redirects to specified action.
489
     * @param string $redirectAction Name of action to redirect to.
490
     * @param string $message i18n key of error message. Shown in API responses.
491
     *   If no message with this key exists, $message is shown as-is.
492
     * @param array $messageParams
493
     * @param string $invalidParam This will be removed from $this->params. Omit if you don't want this to happen.
494
     * @throws XtoolsHttpException
495
     */
496
    public function throwXtoolsException(
497
        string $redirectAction,
498
        string $message,
499
        array $messageParams = [],
500
        ?string $invalidParam = null
501
    ): void {
502
        $this->addFlashMessage('danger', $message, $messageParams);
503
        $originalParams = $this->params;
504
505
        // Remove invalid parameter if it was given.
506
        if (is_string($invalidParam)) {
507
            unset($this->params[$invalidParam]);
508
        }
509
510
        // We sometimes are redirecting to the index page, so also remove project (otherwise we'd get a redirect loop).
511
        /**
512
         * FIXME: Index pages should have a 'nosubmit' parameter to prevent submission.
513
         * Then we don't even need to remove $invalidParam.
514
         * Better, we should show the error on the results page, with no results.
515
         */
516
        unset($this->params['project']);
517
518
        // Throw exception which will redirect to $redirectAction.
519
        throw new XtoolsHttpException(
520
            $this->i18n->msgIfExists($message, $messageParams),
521
            $this->generateUrl($redirectAction, $this->params),
522
            $originalParams,
523
            $this->isApi
524
        );
525
    }
526
527
    /**
528
     * Get the first error message stored in the session's FlashBag.
529
     * @return string
530
     */
531
    public function getFlashMessage(): string
532
    {
533
        $key = $this->get('session')->getFlashBag()->get('danger')[0];
534
        $param = null;
535
536
        if (is_array($key)) {
537
            [$key, $param] = $key;
538
        }
539
540
        return $this->render('message.twig', [
541
            'key' => $key,
542
            'params' => [$param],
543
        ])->getContent();
544
    }
545
546
    /******************
547
     * PARSING PARAMS *
548
     ******************/
549
550
    /**
551
     * Get all standardized parameters from the Request, either via URL query string or routing.
552
     * @return string[]
553
     */
554 16
    public function getParams(): array
555
    {
556
        $paramsToCheck = [
557 16
            'project',
558
            'username',
559
            'namespace',
560
            'page',
561
            'categories',
562
            'group',
563
            'redirects',
564
            'deleted',
565
            'start',
566
            'end',
567
            'offset',
568
            'limit',
569
            'format',
570
            'tool',
571
572
            // Legacy parameters.
573
            'user',
574
            'name',
575
            'article',
576
            'wiki',
577
            'wikifam',
578
            'lang',
579
            'wikilang',
580
            'begin',
581
        ];
582
583
        /** @var string[] $params Each parameter that was detected along with its value. */
584 16
        $params = [];
585
586 16
        foreach ($paramsToCheck as $param) {
587
            // Pull in either from URL query string or route.
588 16
            $value = $this->request->query->get($param) ?: $this->request->get($param);
589
590
            // Only store if value is given ('namespace' or 'username' could be '0').
591 16
            if (null !== $value && '' !== $value) {
592 16
                $params[$param] = rawurldecode((string)$value);
593
            }
594
        }
595
596 16
        return $params;
597
    }
598
599
    /**
600
     * Parse out common parameters from the request. These include the 'project', 'username', 'namespace' and 'page',
601
     * along with their legacy counterparts (e.g. 'lang' and 'wiki').
602
     * @return string[] Normalized parameters (no legacy params).
603
     */
604 16
    public function parseQueryParams(): array
605
    {
606
        /** @var string[] $params Each parameter and value that was detected. */
607 16
        $params = $this->getParams();
608
609
        // Covert any legacy parameters, if present.
610 16
        $params = $this->convertLegacyParams($params);
611
612
        // Remove blank values.
613 16
        return array_filter($params, function ($param) {
614
            // 'namespace' or 'username' could be '0'.
615 4
            return null !== $param && '' !== $param;
616 16
        });
617
    }
618
619
    /**
620
     * Get UTC timestamps from given start and end string parameters. This also makes $start $maxDays before
621
     * $end if not present, and makes $end the current time if not present.
622
     * The date range will not exceed $this->maxDays days, if this public class property is set.
623
     * @param int|string|false $start Unix timestamp or string accepted by strtotime.
624
     * @param int|string|false $end Unix timestamp or string accepted by strtotime.
625
     * @return int[] Start and end date as UTC timestamps.
626
     */
627 1
    public function getUTCFromDateParams($start, $end): array
628
    {
629 1
        $today = strtotime('today midnight');
630
631
        // start time should not be in the future.
632 1
        $startTime = min(
633 1
            is_int($start) ? $start : strtotime((string)$start),
634 1
            $today
635
        );
636
637
        // end time defaults to now, and will not be in the future.
638 1
        $endTime = min(
639 1
            (is_int($end) ? $end : strtotime((string)$end)) ?: $today,
640 1
            $today
641
        );
642
643
        // Default to $this->defaultDays or $this->maxDays before end time if start is not present.
644 1
        $daysOffset = $this->defaultDays ?? $this->maxDays;
645 1
        if (false === $startTime && is_int($daysOffset)) {
646 1
            $startTime = strtotime("-$daysOffset days", $endTime);
647
        }
648
649
        // Default to $this->defaultDays or $this->maxDays after start time if end is not present.
650 1
        if (false === $end && is_int($daysOffset)) {
651
            $endTime = min(
652
                strtotime("+$daysOffset days", $startTime),
653
                $today
654
            );
655
        }
656
657
        // Reverse if start date is after end date.
658 1
        if ($startTime > $endTime && false !== $startTime && false !== $end) {
659 1
            $newEndTime = $startTime;
660 1
            $startTime = $endTime;
661 1
            $endTime = $newEndTime;
662
        }
663
664
        // Finally, don't let the date range exceed $this->maxDays.
665 1
        $startObj = DateTime::createFromFormat('U', (string)$startTime);
666 1
        $endObj = DateTime::createFromFormat('U', (string)$endTime);
667 1
        if (is_int($this->maxDays) && $startObj->diff($endObj)->days > $this->maxDays) {
0 ignored issues
show
Bug introduced by
It seems like $endObj can also be of type false; however, parameter $datetime2 of DateTime::diff() does only seem to accept DateTimeInterface, 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

667
        if (is_int($this->maxDays) && $startObj->diff(/** @scrutinizer ignore-type */ $endObj)->days > $this->maxDays) {
Loading history...
668
            // Show warnings that the date range was truncated.
669 1
            $this->addFlashMessage('warning', 'date-range-too-wide', [$this->maxDays]);
670
671 1
            $startTime = strtotime("-$this->maxDays days", $endTime);
672
        }
673
674 1
        return [$startTime, $endTime];
675
    }
676
677
    /**
678
     * Given the params hash, normalize any legacy parameters to their modern equivalent.
679
     * @param string[] $params
680
     * @return string[]
681
     */
682 16
    private function convertLegacyParams(array $params): array
683
    {
684
        $paramMap = [
685 16
            'user' => 'username',
686
            'name' => 'username',
687
            'article' => 'page',
688
            'begin' => 'start',
689
690
            // Copy super legacy project params to legacy so we can concatenate below.
691
            'wikifam' => 'wiki',
692
            'wikilang' => 'lang',
693
        ];
694
695
        // Copy legacy parameters to modern equivalent.
696 16
        foreach ($paramMap as $legacy => $modern) {
697 16
            if (isset($params[$legacy])) {
698
                $params[$modern] = $params[$legacy];
699 16
                unset($params[$legacy]);
700
            }
701
        }
702
703
        // Separate parameters for language and wiki.
704 16
        if (isset($params['wiki']) && isset($params['lang'])) {
705
            // 'wikifam' will be like '.wikipedia.org', vs just 'wikipedia',
706
            // so we must remove leading periods and trailing .org's.
707
            $params['project'] = rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
708
709
            /** @var string[] $languagelessProjects Projects for which there is no specific language association. */
710
            $languagelessProjects = $this->container->getParameter('languageless_wikis');
711
712
            // Prepend language if applicable.
713
            if (isset($params['lang']) && !in_array($params['wiki'], $languagelessProjects)) {
714
                $params['project'] = $params['lang'].'.'.$params['project'];
715
            }
716
717
            unset($params['wiki']);
718
            unset($params['lang']);
719
        }
720
721 16
        return $params;
722
    }
723
724
    /************************
725
     * FORMATTING RESPONSES *
726
     ************************/
727
728
    /**
729
     * Get the rendered template for the requested format. This method also updates the cookies.
730
     * @param string $templatePath Path to template without format,
731
     *   such as '/editCounter/latest_global'.
732
     * @param array $ret Data that should be passed to the view.
733
     * @return Response
734
     * @codeCoverageIgnore
735
     */
736
    public function getFormattedResponse(string $templatePath, array $ret): Response
737
    {
738
        $format = $this->request->query->get('format', 'html');
739
        if ('' == $format) {
740
            // The default above doesn't work when the 'format' parameter is blank.
741
            $format = 'html';
742
        }
743
744
        // Merge in common default parameters, giving $ret (from the caller) the priority.
745
        $ret = array_merge([
746
            'project' => $this->project,
747
            'user' => $this->user,
748
            'page' => $this->page,
749
        ], $ret);
750
751
        $formatMap = [
752
            'wikitext' => 'text/plain',
753
            'csv' => 'text/csv',
754
            'tsv' => 'text/tab-separated-values',
755
            'json' => 'application/json',
756
        ];
757
758
        $response = new Response();
759
760
        // Set cookies. Note this must be done before rendering the view, as the view may invoke subrequests.
761
        $this->setCookies($response);
762
763
        // If requested format does not exist, assume HTML.
764
        if (false === $this->get('twig')->getLoader()->exists("$templatePath.$format.twig")) {
765
            $format = 'html';
766
        }
767
768
        $response = $this->render("$templatePath.$format.twig", $ret, $response);
769
770
        $contentType = $formatMap[$format] ?? 'text/html';
771
        $response->headers->set('Content-Type', $contentType);
772
773
        if (in_array($format, ['csv', 'tsv'])) {
774
            $filename = $this->getFilenameForRequest();
775
            $response->headers->set(
776
                'Content-Disposition',
777
                "attachment; filename=\"{$filename}.$format\""
778
            );
779
        }
780
781
        return $response;
782
    }
783
784
    /**
785
     * Returns given filename from the current Request, with problematic characters filtered out.
786
     * @return string
787
     */
788
    private function getFilenameForRequest(): string
789
    {
790
        $filename = trim($this->request->getPathInfo(), '/');
791
        return trim(preg_replace('/[-\/\\:;*?|<>%#"]+/', '-', $filename));
792
    }
793
794
    /**
795
     * Return a JsonResponse object pre-supplied with the requested params.
796
     * @param array $data
797
     * @return JsonResponse
798
     */
799 2
    public function getFormattedApiResponse(array $data): JsonResponse
800
    {
801 2
        $response = new JsonResponse();
802 2
        $response->setEncodingOptions(JSON_NUMERIC_CHECK);
803 2
        $response->setStatusCode(Response::HTTP_OK);
804
805 2
        $elapsedTime = round(
806 2
            microtime(true) - $this->request->server->get('REQUEST_TIME_FLOAT'),
807 2
            3
808
        );
809
810
        // Any pipe-separated values should be returned as an array.
811 2
        foreach ($this->params as $param => $value) {
812 2
            if (is_string($value) && false !== strpos($value, '|')) {
813 2
                $this->params[$param] = explode('|', $value);
814
            }
815
        }
816
817 2
        $ret = array_merge($this->params, [
818
            // In some controllers, $this->params['project'] may be overridden with a Project object.
819 2
            'project' => $this->project->getDomain(),
820 2
        ], $data, ['elapsed_time' => $elapsedTime]);
821
822
        // Merge in flash messages, putting them at the top.
823 2
        $flashes = $this->get('session')->getFlashBag()->peekAll();
824 2
        $ret = array_merge($flashes, $ret);
825
826
        // Flashes now can be cleared after merging into the response.
827 2
        $this->get('session')->getFlashBag()->clear();
828
829 2
        $response->setData($ret);
830
831 2
        return $response;
832
    }
833
834
    /*********
835
     * OTHER *
836
     *********/
837
838
    /**
839
     * Record usage of an API endpoint.
840
     * @param string $endpoint
841
     * @codeCoverageIgnore
842
     */
843
    public function recordApiUsage(string $endpoint): void
844
    {
845
        /** @var \Doctrine\DBAL\Connection $conn */
846
        $conn = $this->container->get('doctrine')
847
            ->getManager('default')
848
            ->getConnection();
849
        $date =  date('Y-m-d');
850
851
        // Increment count in timeline
852
        $existsSql = "SELECT 1 FROM usage_api_timeline
853
                      WHERE date = '$date'
854
                      AND endpoint = '$endpoint'";
855
856
        if (0 === count($conn->query($existsSql)->fetchAll())) {
857
            $createSql = "INSERT INTO usage_api_timeline
858
                          VALUES(NULL, '$date', '$endpoint', 1)";
859
            $conn->query($createSql);
860
        } else {
861
            $updateSql = "UPDATE usage_api_timeline
862
                          SET count = count + 1
863
                          WHERE endpoint = '$endpoint'
864
                          AND date = '$date'";
865
            $conn->query($updateSql);
866
        }
867
    }
868
869
    /**
870
     * Add a flash message.
871
     * @param string $type
872
     * @param string $key i18n key or raw message.
873
     * @param array $vars
874
     */
875 1
    public function addFlashMessage(string $type, string $key, array $vars = []): void
876
    {
877 1
        $this->addFlash(
878 1
            $type,
879 1
            $this->i18n->msgExists($key, $vars) ? $this->i18n->msg($key, $vars) : $key
0 ignored issues
show
Bug introduced by
It seems like $this->i18n->msgExists($...msg($key, $vars) : $key can also be of type null; however, parameter $message of Symfony\Bundle\Framework...\Controller::addFlash() 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

879
            /** @scrutinizer ignore-type */ $this->i18n->msgExists($key, $vars) ? $this->i18n->msg($key, $vars) : $key
Loading history...
880
        );
881 1
    }
882
}
883