Passed
Branch master (b33856)
by MusikAnimal
10:47
created

XtoolsController::validateUser()   B

Complexity

Conditions 9
Paths 11

Size

Total Lines 59
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 90

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 9
eloc 28
c 1
b 0
f 0
nc 11
nop 1
dl 0
loc 59
ccs 0
cts 28
cp 0
crap 90
rs 8.0555

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
    /** @var string One of 'noredirects', 'onlyredirects' or 'all' for both. */
66
    protected $redirects;
67
68
    /** @var string One of 'live', 'deleted' or 'all' for both. */
69
    protected $deleted;
70
71
    /**
72
     * Default days from current day, to use as the start date if none was provided.
73
     * If this is null and $maxDays is non-null, the latter will be used as the default.
74
     * Is public visibility evil here? I don't think so.
75
     * @var int|null
76
     */
77
    public $defaultDays = null;
78
79
    /**
80
     * Maximum number of days allowed for the given date range.
81
     * Set this in the controller's constructor to enforce the given date range to be within this range.
82
     * This will be used as the default date span unless $defaultDays is defined.
83
     * @see XtoolsController::getUTCFromDateParams()
84
     * @var int|null
85
     */
86
    public $maxDays = null;
87
88
    /** @var int|string|null Namespace parsed from the Request, ID as int or 'all' for all namespaces. */
89
    protected $namespace;
90
91
    /** @var int Pagination offset parsed from the Request. */
92
    protected $offset = 0;
93
94
    /** @var int Number of results to return. */
95
    protected $limit;
96
97
    /** @var bool Is the current request a subrequest? */
98
    protected $isSubRequest;
99
100
    /**
101
     * Stores user preferences such default project.
102
     * This may get altered from the Request and updated in the Response.
103
     * @var array
104
     */
105
    protected $cookies = [
106
        'XtoolsProject' => null,
107
    ];
108
109
    /**
110
     * This activates the 'too high edit count' functionality. This property represents the
111
     * action that should be redirected to if the user has too high of an edit count.
112
     * @var string
113
     */
114
    protected $tooHighEditCountAction;
115
116
    /** @var array Actions that are exempt from edit count limitations. */
117
    protected $tooHighEditCountActionBlacklist = [];
118
119
    /**
120
     * Actions that require the target user to opt in to the restricted statistics.
121
     * @see https://xtools.readthedocs.io/en/stable/opt-in.html
122
     * @var string[]
123
     */
124
    protected $restrictedActions = [];
125
126
    /**
127
     * XtoolsController::validateProject() will ensure the given project matches one of these domains,
128
     * instead of any valid project.
129
     * @var string[]
130
     */
131
    protected $supportedProjects;
132
133
    /**
134
     * Require the tool's index route (initial form) be defined here. This should also
135
     * be the name of the associated model, if present.
136
     * @return string
137
     */
138
    abstract protected function getIndexRoute(): string;
139
140
    /**
141
     * XtoolsController constructor.
142
     * @param RequestStack $requestStack
143
     * @param ContainerInterface $container
144
     * @param I18nHelper $i18n
145
     */
146 16
    public function __construct(RequestStack $requestStack, ContainerInterface $container, I18nHelper $i18n)
147
    {
148 16
        $this->request = $requestStack->getCurrentRequest();
149 16
        $this->container = $container;
150 16
        $this->i18n = $i18n;
151 16
        $this->params = $this->parseQueryParams();
152
153
        // Parse out the name of the controller and action.
154 16
        $pattern = "#::([a-zA-Z]*)Action#";
155 16
        $matches = [];
156
        // The blank string here only happens in the unit tests, where the request may not be made to an action.
157 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

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

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

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

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

913
            /** @scrutinizer ignore-type */ $this->i18n->msgExists($key, $vars) ? $this->i18n->msg($key, $vars) : $key
Loading history...
914
        );
915 1
    }
916
}
917