Passed
Push — master ( 75dacc...adc049 )
by MusikAnimal
05:30
created

XtoolsController   F

Complexity

Total Complexity 89

Size/Duplication

Total Lines 781
Duplicated Lines 0 %

Test Coverage

Coverage 55.11%

Importance

Changes 0
Metric Value
eloc 285
dl 0
loc 781
ccs 124
cts 225
cp 0.5511
rs 2
c 0
b 0
f 0
wmc 89

22 Methods

Rating   Name   Duplication   Size   Complexity  
A validateProject() 0 15 2
A setCookies() 0 10 3
A checkIfAjax() 0 6 4
A getProjectFromQuery() 0 23 4
A loadCookies() 0 9 3
A setDates() 0 10 6
A getPageFromNsAndTitle() 0 10 4
B setProperties() 0 28 7
A setProject() 0 4 1
A __construct() 0 33 4
A recordApiUsage() 0 23 2
A getFormattedResponse() 0 38 3
B convertLegacyParams() 0 40 7
B validateUser() 0 53 7
A validatePage() 0 17 2
A parseQueryParams() 0 12 2
A getFormattedApiResponse() 0 33 4
A addFlashMessage() 0 5 2
A getParams() 0 43 5
A getFlashMessage() 0 13 2
A throwXtoolsException() 0 28 2
C getUTCFromDateParams() 0 48 13

How to fix   Complexity   

Complex Class

Complex classes like XtoolsController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use XtoolsController, and based on these observations, apply Extract Interface, too.

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
    /**
111
     * @var array Actions that are exempt from edit count limitations.
112
     */
113
    protected $tooHighEditCountActionBlacklist = [];
114
115
    /**
116
     * Require the tool's index route (initial form) be defined here. This should also
117
     * be the name of the associated model, if present.
118
     * @return string
119
     */
120
    abstract protected function getIndexRoute(): string;
121
122
    /**
123
     * XtoolsController constructor.
124
     * @param RequestStack $requestStack
125
     * @param ContainerInterface $container
126
     * @param I18nHelper $i18n
127
     */
128 16
    public function __construct(RequestStack $requestStack, ContainerInterface $container, I18nHelper $i18n)
129
    {
130 16
        $this->request = $requestStack->getCurrentRequest();
131 16
        $this->container = $container;
132 16
        $this->i18n = $i18n;
133 16
        $this->params = $this->parseQueryParams();
134
135
        // Parse out the name of the controller and action.
136 16
        $pattern = "#::([a-zA-Z]*)Action#";
137 16
        $matches = [];
138
        // The blank string here only happens in the unit tests, where the request may not be made to an action.
139 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

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

619
        if (is_int($this->maxDays) && $startObj->diff(/** @scrutinizer ignore-type */ $endObj)->days > $this->maxDays) {
Loading history...
620
            // Show warnings that the date range was truncated.
621 1
            $this->addFlashMessage('warning', 'date-range-too-wide', [$this->maxDays]);
622
623 1
            $startTime = strtotime("-$this->maxDays days", $endTime);
624
        }
625
626 1
        return [$startTime, $endTime];
627
    }
628
629
    /**
630
     * Given the params hash, normalize any legacy parameters to their modern equivalent.
631
     * @param string[] $params
632
     * @return string[]
633
     */
634 16
    private function convertLegacyParams(array $params): array
635
    {
636
        $paramMap = [
637 16
            'user' => 'username',
638
            'name' => 'username',
639
            'article' => 'page',
640
            'begin' => 'start',
641
642
            // Copy super legacy project params to legacy so we can concatenate below.
643
            'wikifam' => 'wiki',
644
            'wikilang' => 'lang',
645
        ];
646
647
        // Copy legacy parameters to modern equivalent.
648 16
        foreach ($paramMap as $legacy => $modern) {
649 16
            if (isset($params[$legacy])) {
650
                $params[$modern] = $params[$legacy];
651 16
                unset($params[$legacy]);
652
            }
653
        }
654
655
        // Separate parameters for language and wiki.
656 16
        if (isset($params['wiki']) && isset($params['lang'])) {
657
            // 'wikifam' will be like '.wikipedia.org', vs just 'wikipedia',
658
            // so we must remove leading periods and trailing .org's.
659
            $params['project'] = rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
660
661
            /** @var string[] $languagelessProjects Projects for which there is no specific language association. */
662
            $languagelessProjects = $this->container->getParameter('languageless_wikis');
663
664
            // Prepend language if applicable.
665
            if (isset($params['lang']) && !in_array($params['wiki'], $languagelessProjects)) {
666
                $params['project'] = $params['lang'].'.'.$params['project'];
667
            }
668
669
            unset($params['wiki']);
670
            unset($params['lang']);
671
        }
672
673 16
        return $params;
674
    }
675
676
    /************************
677
     * FORMATTING RESPONSES *
678
     ************************/
679
680
    /**
681
     * Get the rendered template for the requested format. This method also updates the cookies.
682
     * @param string $templatePath Path to template without format,
683
     *   such as '/editCounter/latest_global'.
684
     * @param array $ret Data that should be passed to the view.
685
     * @return Response
686
     * @codeCoverageIgnore
687
     */
688
    public function getFormattedResponse(string $templatePath, array $ret): Response
689
    {
690
        $format = $this->request->query->get('format', 'html');
691
        if ('' == $format) {
692
            // The default above doesn't work when the 'format' parameter is blank.
693
            $format = 'html';
694
        }
695
696
        // Merge in common default parameters, giving $ret (from the caller) the priority.
697
        $ret = array_merge([
698
            'project' => $this->project,
699
            'user' => $this->user,
700
            'page' => $this->page,
701
        ], $ret);
702
703
        $formatMap = [
704
            'wikitext' => 'text/plain',
705
            'csv' => 'text/csv',
706
            'tsv' => 'text/tab-separated-values',
707
            'json' => 'application/json',
708
        ];
709
710
        $response = new Response();
711
712
        // Set cookies. Note this must be done before rendering the view, as the view may invoke subrequests.
713
        $this->setCookies($response);
714
715
        // If requested format does not exist, assume HTML.
716
        if (false === $this->get('twig')->getLoader()->exists("$templatePath.$format.twig")) {
717
            $format = 'html';
718
        }
719
720
        $response = $this->render("$templatePath.$format.twig", $ret, $response);
721
722
        $contentType = $formatMap[$format] ?? 'text/html';
723
        $response->headers->set('Content-Type', $contentType);
724
725
        return $response;
726
    }
727
728
    /**
729
     * Return a JsonResponse object pre-supplied with the requested params.
730
     * @param array $data
731
     * @return JsonResponse
732
     */
733 2
    public function getFormattedApiResponse(array $data): JsonResponse
734
    {
735 2
        $response = new JsonResponse();
736 2
        $response->setEncodingOptions(JSON_NUMERIC_CHECK);
737 2
        $response->setStatusCode(Response::HTTP_OK);
738
739 2
        $elapsedTime = round(
740 2
            microtime(true) - $this->request->server->get('REQUEST_TIME_FLOAT'),
741 2
            3
742
        );
743
744
        // Any pipe-separated values should be returned as an array.
745 2
        foreach ($this->params as $param => $value) {
746 2
            if (is_string($value) && false !== strpos($value, '|')) {
747 2
                $this->params[$param] = explode('|', $value);
748
            }
749
        }
750
751 2
        $ret = array_merge($this->params, [
752
            // In some controllers, $this->params['project'] may be overridden with a Project object.
753 2
            'project' => $this->project->getDomain(),
754 2
        ], $data, ['elapsed_time' => $elapsedTime]);
755
756
        // Merge in flash messages, putting them at the top.
757 2
        $flashes = $this->get('session')->getFlashBag()->peekAll();
758 2
        $ret = array_merge($flashes, $ret);
759
760
        // Flashes now can be cleared after merging into the response.
761 2
        $this->get('session')->getFlashBag()->clear();
762
763 2
        $response->setData($ret);
764
765 2
        return $response;
766
    }
767
768
    /*********
769
     * OTHER *
770
     *********/
771
772
    /**
773
     * Record usage of an API endpoint.
774
     * @param string $endpoint
775
     * @codeCoverageIgnore
776
     */
777
    public function recordApiUsage(string $endpoint): void
778
    {
779
        /** @var \Doctrine\DBAL\Connection $conn */
780
        $conn = $this->container->get('doctrine')
781
            ->getManager('default')
782
            ->getConnection();
783
        $date =  date('Y-m-d');
784
785
        // Increment count in timeline
786
        $existsSql = "SELECT 1 FROM usage_api_timeline
787
                      WHERE date = '$date'
788
                      AND endpoint = '$endpoint'";
789
790
        if (0 === count($conn->query($existsSql)->fetchAll())) {
791
            $createSql = "INSERT INTO usage_api_timeline
792
                          VALUES(NULL, '$date', '$endpoint', 1)";
793
            $conn->query($createSql);
794
        } else {
795
            $updateSql = "UPDATE usage_api_timeline
796
                          SET count = count + 1
797
                          WHERE endpoint = '$endpoint'
798
                          AND date = '$date'";
799
            $conn->query($updateSql);
800
        }
801
    }
802
803
    /**
804
     * Add a flash message.
805
     * @param string $type
806
     * @param string $key i18n key or raw message.
807
     * @param array $vars
808
     */
809 1
    public function addFlashMessage(string $type, string $key, array $vars = []): void
810
    {
811 1
        $this->addFlash(
812 1
            $type,
813 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

813
            /** @scrutinizer ignore-type */ $this->i18n->msgExists($key, $vars) ? $this->i18n->msg($key, $vars) : $key
Loading history...
814
        );
815 1
    }
816
}
817