Passed
Push — master ( eb8721...e77fba )
by MusikAnimal
10:54
created

XtoolsController::throwXtoolsException()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 28
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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

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

33
abstract class XtoolsController extends /** @scrutinizer ignore-deprecated */ Controller
Loading history...
34
{
35
    /** @var I18nHelper i18n helper. */
36
    protected $i18n;
37
38
    /** @var Request The request object. */
39
    protected $request;
40
41
    /** @var string Name of the action within the child controller that is being executed. */
42
    protected $controllerAction;
43
44
    /** @var array Hash of params parsed from the Request. */
45
    protected $params;
46
47
    /** @var bool Whether this is a request to an API action. */
48
    protected $isApi;
49
50
    /** @var Project Relevant Project parsed from the Request. */
51
    protected $project;
52
53
    /** @var User Relevant User parsed from the Request. */
54
    protected $user;
55
56
    /** @var Page Relevant Page parsed from the Request. */
57
    protected $page;
58
59
    /** @var int|false Start date parsed from the Request. */
60
    protected $start = false;
61
62
    /** @var int|false End date parsed from the Request. */
63
    protected $end = false;
64
65
    /**
66
     * Default days from current day, to use as the start date if none was provided.
67
     * If this is null and $maxDays is non-null, the latter will be used as the default.
68
     * Is public visibility evil here? I don't think so.
69
     * @var int|null
70
     */
71
    public $defaultDays = null;
72
73
    /**
74
     * Maximum number of days allowed for the given date range.
75
     * Set this in the controller's constructor to enforce the given date range to be within this range.
76
     * This will be used as the default date span unless $defaultDays is defined.
77
     * @see XtoolsController::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
     * XtoolsController::validateProject() will ensure the given project matches one of these domains,
122
     * instead of any valid project.
123
     * @var string[]
124
     */
125
    protected $supportedProjects;
126
127
    /**
128
     * Require the tool's index route (initial form) be defined here. This should also
129
     * be the name of the associated model, if present.
130
     * @return string
131
     */
132
    abstract protected function getIndexRoute(): string;
133
134
    /**
135
     * XtoolsController constructor.
136
     * @param RequestStack $requestStack
137
     * @param ContainerInterface $container
138
     * @param I18nHelper $i18n
139
     */
140 16
    public function __construct(RequestStack $requestStack, ContainerInterface $container, I18nHelper $i18n)
141
    {
142 16
        $this->request = $requestStack->getCurrentRequest();
143 16
        $this->container = $container;
144 16
        $this->i18n = $i18n;
145 16
        $this->params = $this->parseQueryParams();
146
147
        // Parse out the name of the controller and action.
148 16
        $pattern = "#::([a-zA-Z]*)Action#";
149 16
        $matches = [];
150
        // The blank string here only happens in the unit tests, where the request may not be made to an action.
151 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

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

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

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

Loading history...
152 16
        $this->controllerAction = $matches[1] ?? '';
153
154
        // Whether the action is an API action.
155 16
        $this->isApi = 'Api' === substr($this->controllerAction, -3) || 'recordUsage' === $this->controllerAction;
156
157
        // Whether we're making a subrequest (the view makes a request to another action).
158 16
        $this->isSubRequest = $this->request->get('htmlonly')
159 16
            || null !== $this->get('request_stack')->getParentRequest();
160
161
        // Disallow AJAX (unless it's an API or subrequest).
162 16
        $this->checkIfAjax();
163
164
        // Load user options from cookies.
165 16
        $this->loadCookies();
166
167
        // Set the class-level properties based on params.
168 16
        if (false !== strpos(strtolower($this->controllerAction), 'index')) {
169
            // Index pages should only set the project, and no other class properties.
170 10
            $this->setProject($this->getProjectFromQuery());
171
        } else {
172 6
            $this->setProperties(); // Includes the project.
173
        }
174
175
        // Check if the request is to a restricted API endpoint, where the target user has to opt-in to statistics.
176 16
        $this->checkRestrictedApiEndpoint();
177 16
    }
178
179
    /**
180
     * Check if the request is AJAX, and disallow it unless they're using the API or if it's a subrequest.
181
     */
182 16
    private function checkIfAjax(): void
183
    {
184 16
        if ($this->request->isXmlHttpRequest() && !$this->isApi && !$this->isSubRequest) {
185
            throw new HttpException(
186
                403,
187
                $this->i18n->msg('error-automation', ['https://xtools.readthedocs.io/en/stable/api/'])
188
            );
189
        }
190 16
    }
191
192
    /**
193
     * Check if the request is to a restricted API endpoint, and throw an exception if the target user hasn't opted-in.
194
     * @throws XtoolsHttpException
195
     */
196 16
    private function checkRestrictedApiEndpoint(): void
197
    {
198 16
        $restrictedAction = in_array($this->controllerAction, $this->restrictedActions);
199
200 16
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn($this->user)) {
201
            throw new XtoolsHttpException(
202
                $this->i18n->msg('not-opted-in', [
0 ignored issues
show
Bug introduced by
It seems like $this->i18n->msg('not-op...'not-opted-in-login'))) can also be of type null; however, parameter $message of AppBundle\Exception\Xtoo...xception::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

202
                /** @scrutinizer ignore-type */ $this->i18n->msg('not-opted-in', [
Loading history...
203
                    $this->getOptedInPage()->getTitle(),
204
                    $this->i18n->msg('not-opted-in-link').' <https://xtools.readthedocs.io/en/stable/opt-in.html>',
205
                    $this->i18n->msg('not-opted-in-login'),
206
                ]),
207
                '',
208
                $this->params,
209
                true
210
            );
211
        }
212 16
    }
213
214
    /**
215
     * Get the path to the opt-in page for restricted statistics.
216
     * @return Page
217
     */
218
    protected function getOptedInPage(): Page
219
    {
220
        return $this->project
221
            ->getRepository()
222
            ->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

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

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

901
            /** @scrutinizer ignore-type */ $this->i18n->msgExists($key, $vars) ? $this->i18n->msg($key, $vars) : $key
Loading history...
902
        );
903 1
    }
904
}
905