Test Failed
Push — dependency-injection ( 7565fa )
by MusikAnimal
07:05
created

XtoolsController::maxDays()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace App\Controller;
6
7
use App\Exception\XtoolsHttpException;
8
use App\Helper\I18nHelper;
9
use App\Model\Page;
10
use App\Model\Project;
11
use App\Model\User;
12
use App\Repository\PageRepository;
13
use App\Repository\ProjectRepository;
14
use App\Repository\UserRepository;
15
use DateTime;
16
use Doctrine\DBAL\Exception;
17
use GuzzleHttp\Client;
18
use Psr\Cache\CacheItemPoolInterface;
19
use Psr\Container\ContainerInterface;
20
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
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
use Wikimedia\IPUtils;
28
29
/**
30
 * XtoolsController supplies a variety of methods around parsing and validating parameters, and initializing
31
 * Project/User instances. These are used in other controllers in the App\Controller namespace.
32
 * @abstract
33
 */
34
abstract class XtoolsController extends AbstractController
35
{
36
    /** DEPENDENCIES */
37
38
    protected CacheItemPoolInterface $cache;
39
    protected Client $guzzle;
40
    protected I18nHelper $i18n;
41
    protected ProjectRepository $projectRepo;
42
    protected UserRepository $userRepo;
43
    protected PageRepository $pageRepo;
44
45
    /** OTHER CLASS PROPERTIES */
46
47
    /** @var Request The request object. */
48
    protected Request $request;
49
50
    /** @var string Name of the action within the child controller that is being executed. */
51
    protected string $controllerAction;
52
53
    /** @var array Hash of params parsed from the Request. */
54
    protected array $params;
55
56
    /** @var bool Whether this is a request to an API action. */
57
    protected bool $isApi;
58
59
    /** @var Project Relevant Project parsed from the Request. */
60
    protected Project $project;
61
62
    /** @var User|null Relevant User parsed from the Request. */
63
    protected ?User $user = null;
64
65
    /** @var Page|null Relevant Page parsed from the Request. */
66
    protected ?Page $page = null;
67
68
    /** @var int|false Start date parsed from the Request. */
69
    protected $start = false;
70
71
    /** @var int|false End date parsed from the Request. */
72
    protected $end = false;
73
74
    /** @var int|string|null Namespace parsed from the Request, ID as int or 'all' for all namespaces. */
75
    protected $namespace;
76
77
    /** @var int|false Unix timestamp. Pagination offset that substitutes for $end. */
78
    protected $offset = false;
79
80
    /** @var int Number of results to return. */
81
    protected int $limit = 50;
82
83
    /**
84
     * Maximum number of results to show per page. Can be overridden in the child controller's constructor.
85
     * @var int
86
     */
87
    public int $maxLimit = 5000;
88
89
    /** @var bool Is the current request a subrequest? */
90
    protected bool $isSubRequest;
91
92
    /**
93
     * Stores user preferences such default project.
94
     * This may get altered from the Request and updated in the Response.
95
     * @var array
96
     */
97
    protected array $cookies = [
98
        'XtoolsProject' => null,
99
    ];
100
101
    /**
102
     * Require the tool's index route (initial form) be defined here. This should also
103
     * be the name of the associated model, if present.
104
     * @return string
105
     */
106
    abstract protected function getIndexRoute(): string;
107
108
    /**
109
     * Override this to activate the 'too high edit count' functionality. The return value
110
     * should represent the route name that we should be redirected to if the requested user
111
     * has too high of an edit count.
112
     * @return string|null Name of route to redirect to.
113
     */
114
    protected function tooHighEditCountRoute(): ?string
115
    {
116
        return null;
117
    }
118
119
    /**
120
     * Override this to specify which actions
121
     * @return string[]
122
     */
123
    protected function tooHighEditCountActionAllowlist(): array
124
    {
125
        return [];
126
    }
127
128
    /**
129
     * Override to restrict a tool's access to only the specified projects, instead of any valid project.
130
     * @return string[] Domain or DB names.
131
     */
132
    protected function supportedProjects(): array
133
    {
134
        return [];
135
    }
136
137
    /**
138
     * Override this to set which API actions for the controller require the
139
     * target user to opt in to the restricted statistics.
140
     * @see https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats
141
     * @return array
142
     */
143
    protected function restrictedApiActions(): array
144
    {
145
        return [];
146
    }
147
148
    /**
149
     * Override to set the maximum number of days allowed for the given date range.
150
     * This will be used as the default date span unless $this->defaultDays() is overridden.
151
     * @see XtoolsController::getUnixFromDateParams()
152
     * @return int|null
153
     */
154
    protected function maxDays(): ?int
155
    {
156
        return null;
157
    }
158
159
    /**
160
     * Override to set default days from current day, to use as the start date if none was provided.
161
     * If this is null and $this->maxDays() is non-null, the latter will be used as the default.
162
     * @return int|null
163
     */
164
    protected function defaultDays(): ?int
165
    {
166
        return null;
167
    }
168
169
    /**
170
     * XtoolsController constructor.
171
     * @param RequestStack $requestStack
172
     * @param ContainerInterface $container
173
     * @param CacheItemPoolInterface $cache
174
     * @param Client $guzzle
175
     * @param I18nHelper $i18n
176
     */
177
    public function __construct(
178
        RequestStack $requestStack,
179
        ContainerInterface $container,
180
        CacheItemPoolInterface $cache,
181
        Client $guzzle,
182
        I18nHelper $i18n,
183
        ProjectRepository $projectRepo,
184
        UserRepository $userRepo,
185
        PageRepository $pageRepo
186
    ) {
187
        $this->request = $requestStack->getCurrentRequest();
188
        $this->container = $container;
189
        $this->cache = $cache;
190
        $this->guzzle = $guzzle;
191
        $this->i18n = $i18n;
192
        $this->projectRepo = $projectRepo;
193
        $this->userRepo = $userRepo;
194
        $this->pageRepo = $pageRepo;
195
        $this->params = $this->parseQueryParams();
196
197
        // Parse out the name of the controller and action.
198
        $pattern = "#::([a-zA-Z]*)Action#";
199
        $matches = [];
200
        // The blank string here only happens in the unit tests, where the request may not be made to an action.
201
        preg_match($pattern, $this->request->get('_controller') ?? '', $matches);
202
        $this->controllerAction = $matches[1] ?? '';
203
204
        // Whether the action is an API action.
205
        $this->isApi = 'Api' === substr($this->controllerAction, -3) || 'recordUsage' === $this->controllerAction;
206
207
        // Whether we're making a subrequest (the view makes a request to another action).
208
        $this->isSubRequest = $this->request->get('htmlonly')
209
            || null !== $this->get('request_stack')->getParentRequest();
210
211
        // Disallow AJAX (unless it's an API or subrequest).
212
        $this->checkIfAjax();
213
214
        // Load user options from cookies.
215
        $this->loadCookies();
216
217
        // Set the class-level properties based on params.
218
        if (false !== strpos(strtolower($this->controllerAction), 'index')) {
219
            // Index pages should only set the project, and no other class properties.
220
            $this->setProject($this->getProjectFromQuery());
221
222
            // ...except for transforming IP ranges. Because Symfony routes are separated by slashes, we need a way to
223
            // indicate a CIDR range because otherwise i.e. the path /sc/enwiki/192.168.0.0/24 could be interpreted as
224
            // the Simple Edit Counter for 192.168.0.0 in the namespace with ID 24. So we prefix ranges with 'ipr-'.
225
            // Further IP range handling logic is in the User class, i.e. see User::__construct, User::isIpRange.
226
            if (isset($this->params['username']) && IPUtils::isValidRange($this->params['username'])) {
227
                $this->params['username'] = 'ipr-'.$this->params['username'];
228
            }
229
        } else {
230
            $this->setProperties(); // Includes the project.
231
        }
232
233
        // Check if the request is to a restricted API endpoint, where the target user has to opt-in to statistics.
234
        $this->checkRestrictedApiEndpoint();
235
    }
236
237
    /**
238
     * Check if the request is AJAX, and disallow it unless they're using the API or if it's a subrequest.
239
     */
240
    private function checkIfAjax(): void
241
    {
242
        if ($this->request->isXmlHttpRequest() && !$this->isApi && !$this->isSubRequest) {
243
            throw new HttpException(
244
                403,
245
                $this->i18n->msg('error-automation', ['https://www.mediawiki.org/Special:MyLanguage/XTools/API'])
246
            );
247
        }
248
    }
249
250
    /**
251
     * Check if the request is to a restricted API endpoint, and throw an exception if the target user hasn't opted-in.
252
     * @throws XtoolsHttpException
253
     */
254
    private function checkRestrictedApiEndpoint(): void
255
    {
256
        $restrictedAction = in_array($this->controllerAction, $this->restrictedApiActions());
257
258
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn($this->user)) {
0 ignored issues
show
Bug introduced by
It seems like $this->user can also be of type null; however, parameter $user of App\Model\Project::userHasOptedIn() does only seem to accept App\Model\User, 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

258
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn(/** @scrutinizer ignore-type */ $this->user)) {
Loading history...
259
            throw new XtoolsHttpException(
260
                $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 App\Exception\XtoolsHttpException::__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

260
                /** @scrutinizer ignore-type */ $this->i18n->msg('not-opted-in', [
Loading history...
261
                    $this->getOptedInPage()->getTitle(),
262
                    $this->i18n->msg('not-opted-in-link') .
263
                        ' <https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats>',
264
                    $this->i18n->msg('not-opted-in-login'),
265
                ]),
266
                '',
267
                $this->params,
268
                true,
269
                Response::HTTP_UNAUTHORIZED
270
            );
271
        }
272
    }
273
274
    /**
275
     * Get the path to the opt-in page for restricted statistics.
276
     * @return Page
277
     */
278
    protected function getOptedInPage(): Page
279
    {
280
        return new Page($this->pageRepo, $this->project, $this->project->userOptInPage($this->user));
0 ignored issues
show
Bug introduced by
It seems like $this->user can also be of type null; however, parameter $user of App\Model\Project::userOptInPage() does only seem to accept App\Model\User, 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

280
        return new Page($this->pageRepo, $this->project, $this->project->userOptInPage(/** @scrutinizer ignore-type */ $this->user));
Loading history...
281
    }
282
283
    /***********
284
     * COOKIES *
285
     ***********/
286
287
    /**
288
     * Load user preferences from the associated cookies.
289
     */
290
    private function loadCookies(): void
291
    {
292
        // Not done for subrequests.
293
        if ($this->isSubRequest) {
294
            return;
295
        }
296
297
        foreach (array_keys($this->cookies) as $name) {
298
            $this->cookies[$name] = $this->request->cookies->get($name);
299
        }
300
    }
301
302
    /**
303
     * Set cookies on the given Response.
304
     * @param Response $response
305
     */
306
    private function setCookies(Response &$response): void
307
    {
308
        // Not done for subrequests.
309
        if ($this->isSubRequest) {
310
            return;
311
        }
312
313
        foreach ($this->cookies as $name => $value) {
314
            $response->headers->setCookie(
315
                Cookie::create($name, $value)
316
            );
317
        }
318
    }
319
320
    /**
321
     * Sets the project, with the domain in $this->cookies['XtoolsProject'] that will
322
     * later get set on the Response headers in self::getFormattedResponse().
323
     * @param Project $project
324
     */
325
    private function setProject(Project $project): void
326
    {
327
        // TODO: Remove after deprecated routes are retired.
328
        if (false !== strpos((string)$this->request->get('_controller'), 'GlobalContribs')) {
329
            return;
330
        }
331
332
        $this->project = $project;
333
        $this->cookies['XtoolsProject'] = $project->getDomain();
334
    }
335
336
    /****************************
337
     * SETTING CLASS PROPERTIES *
338
     ****************************/
339
340
    /**
341
     * Normalize all common parameters used by the controllers and set class properties.
342
     */
343
    private function setProperties(): void
344
    {
345
        $this->namespace = $this->params['namespace'] ?? null;
346
347
        // Offset is given as ISO timestamp and is stored as a UNIX timestamp (or false).
348
        if (isset($this->params['offset'])) {
349
            $this->offset = strtotime($this->params['offset']);
350
        }
351
352
        // Limit needs to be an int.
353
        if (isset($this->params['limit'])) {
354
            // Normalize.
355
            $this->params['limit'] = min(max(1, (int)$this->params['limit']), $this->maxLimit);
356
            $this->limit = $this->params['limit'];
357
        }
358
359
        if (isset($this->params['project'])) {
360
            $this->setProject($this->validateProject($this->params['project']));
361
        } elseif (null !== $this->cookies['XtoolsProject']) {
362
            // Set from cookie.
363
            $this->setProject(
364
                $this->validateProject($this->cookies['XtoolsProject'])
365
            );
366
        }
367
368
        if (isset($this->params['username'])) {
369
            $this->user = $this->validateUser($this->params['username']);
370
        }
371
        if (isset($this->params['page'])) {
372
            $this->page = $this->getPageFromNsAndTitle($this->namespace, $this->params['page']);
373
        }
374
375
        $this->setDates();
376
    }
377
378
    /**
379
     * Set class properties for dates, if such params were passed in.
380
     */
381
    private function setDates(): void
382
    {
383
        $start = $this->params['start'] ?? false;
384
        $end = $this->params['end'] ?? false;
385
        if ($start || $end || null !== $this->maxDays()) {
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->maxDays() targeting App\Controller\XtoolsController::maxDays() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
386
            [$this->start, $this->end] = $this->getUnixFromDateParams($start, $end);
387
388
            // Set $this->params accordingly too, so that for instance API responses will include it.
389
            $this->params['start'] = is_int($this->start) ? date('Y-m-d', $this->start) : false;
390
            $this->params['end'] = is_int($this->end) ? date('Y-m-d', $this->end) : false;
391
        }
392
    }
393
394
    /**
395
     * Construct a fully qualified page title given the namespace and title.
396
     * @param int|string $ns Namespace ID.
397
     * @param string $title Page title.
398
     * @param bool $rawTitle Return only the title (and not a Page).
399
     * @return Page|string
400
     */
401
    protected function getPageFromNsAndTitle($ns, string $title, bool $rawTitle = false)
402
    {
403
        if (0 === (int)$ns) {
404
            return $rawTitle ? $title : $this->validatePage($title);
405
        }
406
407
        // Prepend namespace and strip out duplicates.
408
        $nsName = $this->project->getNamespaces()[$ns] ?? $this->i18n->msg('unknown');
409
        $title = $nsName.':'.preg_replace('/^'.$nsName.':/', '', $title);
410
        return $rawTitle ? $title : $this->validatePage($title);
411
    }
412
413
    /**
414
     * Get a Project instance from the project string, using defaults if the given project string is invalid.
415
     * @return Project
416
     */
417
    public function getProjectFromQuery(): Project
418
    {
419
        // Set default project so we can populate the namespace selector on index pages.
420
        // Defaults to project stored in cookie, otherwise project specified in parameters.yml.
421
        if (isset($this->params['project'])) {
422
            $project = $this->params['project'];
423
        } elseif (null !== $this->cookies['XtoolsProject']) {
424
            $project = $this->cookies['XtoolsProject'];
425
        } else {
426
            $project = $this->getParameter('default_project');
427
        }
428
429
        $projectData = $this->projectRepo->getProject($project);
430
431
        // Revert back to defaults if we've established the given project was invalid.
432
        if (!$projectData->exists()) {
433
            $projectData = $this->projectRepo->getProject(
434
                $this->getParameter('default_project')
435
            );
436
        }
437
438
        return $projectData;
439
    }
440
441
    /*************************
442
     * GETTERS / VALIDATIONS *
443
     *************************/
444
445
    /**
446
     * Validate the given project, returning a Project if it is valid or false otherwise.
447
     * @param string $projectQuery Project domain or database name.
448
     * @return Project
449
     * @throws XtoolsHttpException
450
     */
451
    public function validateProject(string $projectQuery): Project
452
    {
453
        $project = $this->projectRepo->getProject($projectQuery);
454
455
        // Check if it is an explicitly allowed project for the current tool.
456
        if ($this->supportedProjects() && !in_array($project->getDomain(), $this->supportedProjects())) {
457
            $this->throwXtoolsException(
458
                $this->getIndexRoute(),
459
                'error-authorship-unsupported-project',
460
                [$this->params['project']],
461
                'project'
462
            );
463
        }
464
465
        if (!$project->exists()) {
466
            $this->throwXtoolsException(
467
                $this->getIndexRoute(),
468
                'invalid-project',
469
                [$this->params['project']],
470
                'project'
471
            );
472
        }
473
474
        return $project;
475
    }
476
477
    /**
478
     * Validate the given user, returning a User or Redirect if they don't exist.
479
     * @param string $username
480
     * @return User
481
     * @throws XtoolsHttpException
482
     */
483
    public function validateUser(string $username): User
484
    {
485
        $user = new User($this->userRepo, $username);
486
487
        // Allow querying for any IP, currently with no edit count limitation...
488
        // Once T188677 is resolved IPs will be affected by the EXPLAIN results.
489
        if ($user->isAnon()) {
490
            // Validate CIDR limits.
491
            if (!$user->isQueryableRange()) {
492
                $limit = $user->isIPv6() ? User::MAX_IPV6_CIDR : User::MAX_IPV4_CIDR;
493
                $this->throwXtoolsException($this->getIndexRoute(), 'ip-range-too-wide', [$limit], 'username');
494
            }
495
            return $user;
496
        }
497
498
        $originalParams = $this->params;
499
500
        // Don't continue if the user doesn't exist.
501
        if ($this->project && !$user->existsOnProject($this->project)) {
502
            $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username');
503
        }
504
505
        // Reject users with a crazy high edit count.
506
        if ($this->tooHighEditCountRoute() &&
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->tooHighEditCountRoute() targeting App\Controller\XtoolsCon...tooHighEditCountRoute() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
507
            !in_array($this->controllerAction, $this->tooHighEditCountActionAllowlist()) &&
508
            $user->hasTooManyEdits($this->project)
509
        ) {
510
            /** TODO: Somehow get this to use self::throwXtoolsException */
511
512
            // If redirecting to a different controller, show an informative message accordingly.
513
            if ($this->tooHighEditCountRoute() !== $this->getIndexRoute()) {
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->tooHighEditCountRoute() targeting App\Controller\XtoolsCon...tooHighEditCountRoute() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
introduced by
The condition $this->tooHighEditCountR... $this->getIndexRoute() is always true.
Loading history...
514
                // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,
515
                //   so this bit is hardcoded. We need to instead give the i18n key of the route.
516
                $redirMsg = $this->i18n->msg('too-many-edits-redir', [
517
                    $this->i18n->msg('tool-simpleeditcounter'),
518
                ]);
519
                $msg = $this->i18n->msg('too-many-edits', [
520
                    $this->i18n->numberFormat($user->maxEdits()),
521
                ]).'. '.$redirMsg;
522
                $this->addFlashMessage('danger', $msg);
523
            } else {
524
                $this->addFlashMessage('danger', 'too-many-edits', [
525
                    $this->i18n->numberFormat($user->maxEdits()),
526
                ]);
527
528
                // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).
529
                unset($this->params['username']);
530
            }
531
532
            // Clear flash bag for API responses, since they get intercepted in ExceptionListener
533
            // and would otherwise be shown in subsequent requests.
534
            if ($this->isApi) {
535
                $this->get('session')->getFlashBag()->clear();
536
            }
537
538
            throw new XtoolsHttpException(
539
                'User has made too many edits! Maximum '.$user->maxEdits(),
540
                $this->generateUrl($this->tooHighEditCountRoute(), $this->params),
0 ignored issues
show
Bug introduced by
$this->tooHighEditCountRoute() of type void is incompatible with the type string expected by parameter $route of Symfony\Bundle\Framework...ntroller::generateUrl(). ( Ignorable by Annotation )

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

540
                $this->generateUrl(/** @scrutinizer ignore-type */ $this->tooHighEditCountRoute(), $this->params),
Loading history...
Bug introduced by
Are you sure the usage of $this->tooHighEditCountRoute() targeting App\Controller\XtoolsCon...tooHighEditCountRoute() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
541
                $originalParams,
542
                $this->isApi
543
            );
544
        }
545
546
        return $user;
547
    }
548
549
    /**
550
     * Get a Page instance from the given page title, and validate that it exists.
551
     * @param string $pageTitle
552
     * @return Page
553
     * @throws XtoolsHttpException
554
     */
555
    public function validatePage(string $pageTitle): Page
556
    {
557
        $page = new Page($this->pageRepo, $this->project, $pageTitle);
558
559
        if (!$page->exists()) {
560
            $this->throwXtoolsException(
561
                $this->getIndexRoute(),
562
                'no-result',
563
                [$this->params['page'] ?? null],
564
                'page'
565
            );
566
        }
567
568
        return $page;
569
    }
570
571
    /**
572
     * Throw an XtoolsHttpException, which the given error message and redirects to specified action.
573
     * @param string $redirectAction Name of action to redirect to.
574
     * @param string $message i18n key of error message. Shown in API responses.
575
     *   If no message with this key exists, $message is shown as-is.
576
     * @param array $messageParams
577
     * @param string $invalidParam This will be removed from $this->params. Omit if you don't want this to happen.
578
     * @throws XtoolsHttpException
579
     */
580
    public function throwXtoolsException(
581
        string $redirectAction,
582
        string $message,
583
        array $messageParams = [],
584
        ?string $invalidParam = null
585
    ): void {
586
        $this->addFlashMessage('danger', $message, $messageParams);
587
        $originalParams = $this->params;
588
589
        // Remove invalid parameter if it was given.
590
        if (is_string($invalidParam)) {
591
            unset($this->params[$invalidParam]);
592
        }
593
594
        // We sometimes are redirecting to the index page, so also remove project (otherwise we'd get a redirect loop).
595
        /**
596
         * FIXME: Index pages should have a 'nosubmit' parameter to prevent submission.
597
         * Then we don't even need to remove $invalidParam.
598
         * Better, we should show the error on the results page, with no results.
599
         */
600
        unset($this->params['project']);
601
602
        // Throw exception which will redirect to $redirectAction.
603
        throw new XtoolsHttpException(
604
            $this->i18n->msgIfExists($message, $messageParams),
605
            $this->generateUrl($redirectAction, $this->params),
606
            $originalParams,
607
            $this->isApi
608
        );
609
    }
610
611
    /**
612
     * Get the first error message stored in the session's FlashBag.
613
     * @return string
614
     */
615
    public function getFlashMessage(): string
616
    {
617
        $key = $this->get('session')->getFlashBag()->get('danger')[0];
618
        $param = null;
619
620
        if (is_array($key)) {
621
            [$key, $param] = $key;
622
        }
623
624
        return $this->render('message.twig', [
625
            'key' => $key,
626
            'params' => [$param],
627
        ])->getContent();
628
    }
629
630
    /******************
631
     * PARSING PARAMS *
632
     ******************/
633
634
    /**
635
     * Get all standardized parameters from the Request, either via URL query string or routing.
636
     * @return string[]
637
     */
638
    public function getParams(): array
639
    {
640
        $paramsToCheck = [
641
            'project',
642
            'username',
643
            'namespace',
644
            'page',
645
            'categories',
646
            'group',
647
            'redirects',
648
            'deleted',
649
            'start',
650
            'end',
651
            'offset',
652
            'limit',
653
            'format',
654
            'tool',
655
            'tools',
656
            'q',
657
            'include_pattern',
658
            'exclude_pattern',
659
660
            // Legacy parameters.
661
            'user',
662
            'name',
663
            'article',
664
            'wiki',
665
            'wikifam',
666
            'lang',
667
            'wikilang',
668
            'begin',
669
        ];
670
671
        /** @var string[] $params Each parameter that was detected along with its value. */
672
        $params = [];
673
674
        foreach ($paramsToCheck as $param) {
675
            // Pull in either from URL query string or route.
676
            $value = $this->request->query->get($param) ?: $this->request->get($param);
677
678
            // Only store if value is given ('namespace' or 'username' could be '0').
679
            if (null !== $value && '' !== $value) {
680
                $params[$param] = rawurldecode((string)$value);
681
            }
682
        }
683
684
        return $params;
685
    }
686
687
    /**
688
     * Parse out common parameters from the request. These include the 'project', 'username', 'namespace' and 'page',
689
     * along with their legacy counterparts (e.g. 'lang' and 'wiki').
690
     * @return string[] Normalized parameters (no legacy params).
691
     */
692
    public function parseQueryParams(): array
693
    {
694
        /** @var string[] $params Each parameter and value that was detected. */
695
        $params = $this->getParams();
696
697
        // Covert any legacy parameters, if present.
698
        $params = $this->convertLegacyParams($params);
699
700
        // Remove blank values.
701
        return array_filter($params, function ($param) {
702
            // 'namespace' or 'username' could be '0'.
703
            return null !== $param && '' !== $param;
704
        });
705
    }
706
707
    /**
708
     * Get Unix timestamps from given start and end string parameters. This also makes $start $maxDays() before
709
     * $end if not present, and makes $end the current time if not present.
710
     * The date range will not exceed $this->maxDays() days, if this public class property is set.
711
     * @param int|string|false $start Unix timestamp or string accepted by strtotime.
712
     * @param int|string|false $end Unix timestamp or string accepted by strtotime.
713
     * @return int[] Start and end date as UTC timestamps.
714
     */
715
    public function getUnixFromDateParams($start, $end): array
716
    {
717
        $today = strtotime('today midnight');
718
719
        // start time should not be in the future.
720
        $startTime = min(
721
            is_int($start) ? $start : strtotime((string)$start),
722
            $today
723
        );
724
725
        // end time defaults to now, and will not be in the future.
726
        $endTime = min(
727
            (is_int($end) ? $end : strtotime((string)$end)) ?: $today,
728
            $today
729
        );
730
731
        // Default to $this->defaultDays() or $this->maxDays() before end time if start is not present.
732
        $daysOffset = $this->defaultDays() ?? $this->maxDays();
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->maxDays() targeting App\Controller\XtoolsController::maxDays() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
Bug introduced by
Are you sure the usage of $this->defaultDays() targeting App\Controller\XtoolsController::defaultDays() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
733
        if (false === $startTime && is_int($daysOffset)) {
0 ignored issues
show
introduced by
The condition is_int($daysOffset) is always false.
Loading history...
734
            $startTime = strtotime("-$daysOffset days", $endTime);
735
        }
736
737
        // Default to $this->defaultDays() or $this->maxDays() after start time if end is not present.
738
        if (false === $end && is_int($daysOffset)) {
0 ignored issues
show
introduced by
The condition is_int($daysOffset) is always false.
Loading history...
739
            $endTime = min(
740
                strtotime("+$daysOffset days", $startTime),
741
                $today
742
            );
743
        }
744
745
        // Reverse if start date is after end date.
746
        if ($startTime > $endTime && false !== $startTime && false !== $end) {
747
            $newEndTime = $startTime;
748
            $startTime = $endTime;
749
            $endTime = $newEndTime;
750
        }
751
752
        // Finally, don't let the date range exceed $this->maxDays().
753
        $startObj = DateTime::createFromFormat('U', (string)$startTime);
754
        $endObj = DateTime::createFromFormat('U', (string)$endTime);
755
        if (is_int($this->maxDays()) && $startObj->diff($endObj)->days > $this->maxDays()) {
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->maxDays() targeting App\Controller\XtoolsController::maxDays() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
introduced by
The condition is_int($this->maxDays()) is always false.
Loading history...
756
            // Show warnings that the date range was truncated.
757
            $this->addFlashMessage('warning', 'date-range-too-wide', [$this->maxDays()]);
758
759
            $startTime = strtotime('-' . $this->maxDays() . ' days', $endTime);
760
        }
761
762
        return [$startTime, $endTime];
763
    }
764
765
    /**
766
     * Given the params hash, normalize any legacy parameters to their modern equivalent.
767
     * @param string[] $params
768
     * @return string[]
769
     */
770
    private function convertLegacyParams(array $params): array
771
    {
772
        $paramMap = [
773
            'user' => 'username',
774
            'name' => 'username',
775
            'article' => 'page',
776
            'begin' => 'start',
777
778
            // Copy super legacy project params to legacy so we can concatenate below.
779
            'wikifam' => 'wiki',
780
            'wikilang' => 'lang',
781
        ];
782
783
        // Copy legacy parameters to modern equivalent.
784
        foreach ($paramMap as $legacy => $modern) {
785
            if (isset($params[$legacy])) {
786
                $params[$modern] = $params[$legacy];
787
                unset($params[$legacy]);
788
            }
789
        }
790
791
        // Separate parameters for language and wiki.
792
        if (isset($params['wiki']) && isset($params['lang'])) {
793
            // 'wikifam' will be like '.wikipedia.org', vs just 'wikipedia',
794
            // so we must remove leading periods and trailing .org's.
795
            $params['project'] = rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
796
797
            /** @var string[] $languagelessProjects Projects for which there is no specific language association. */
798
            $languagelessProjects = $this->getParameter('app.multilingual_wikis');
799
800
            // Prepend language if applicable.
801
            if (isset($params['lang']) && !in_array($params['wiki'], $languagelessProjects)) {
802
                $params['project'] = $params['lang'].'.'.$params['project'];
803
            }
804
805
            unset($params['wiki']);
806
            unset($params['lang']);
807
        }
808
809
        return $params;
810
    }
811
812
    /************************
813
     * FORMATTING RESPONSES *
814
     ************************/
815
816
    /**
817
     * Get the rendered template for the requested format. This method also updates the cookies.
818
     * @param string $templatePath Path to template without format,
819
     *   such as '/editCounter/latest_global'.
820
     * @param array $ret Data that should be passed to the view.
821
     * @return Response
822
     * @codeCoverageIgnore
823
     */
824
    public function getFormattedResponse(string $templatePath, array $ret): Response
825
    {
826
        $format = $this->request->query->get('format', 'html');
827
        if ('' == $format) {
828
            // The default above doesn't work when the 'format' parameter is blank.
829
            $format = 'html';
830
        }
831
832
        // Merge in common default parameters, giving $ret (from the caller) the priority.
833
        $ret = array_merge([
834
            'project' => $this->project,
835
            'user' => $this->user,
836
            'page' => $this->page ?? null,
837
            'namespace' => $this->namespace,
838
            'start' => $this->start,
839
            'end' => $this->end,
840
        ], $ret);
841
842
        $formatMap = [
843
            'wikitext' => 'text/plain',
844
            'csv' => 'text/csv',
845
            'tsv' => 'text/tab-separated-values',
846
            'json' => 'application/json',
847
        ];
848
849
        $response = new Response();
850
851
        // Set cookies. Note this must be done before rendering the view, as the view may invoke subrequests.
852
        $this->setCookies($response);
853
854
        // If requested format does not exist, assume HTML.
855
        if (false === $this->get('twig')->getLoader()->exists("$templatePath.$format.twig")) {
856
            $format = 'html';
857
        }
858
859
        $response = $this->render("$templatePath.$format.twig", $ret, $response);
860
861
        $contentType = $formatMap[$format] ?? 'text/html';
862
        $response->headers->set('Content-Type', $contentType);
863
864
        if (in_array($format, ['csv', 'tsv'])) {
865
            $filename = $this->getFilenameForRequest();
866
            $response->headers->set(
867
                'Content-Disposition',
868
                "attachment; filename=\"{$filename}.$format\""
869
            );
870
        }
871
872
        return $response;
873
    }
874
875
    /**
876
     * Returns given filename from the current Request, with problematic characters filtered out.
877
     * @return string
878
     */
879
    private function getFilenameForRequest(): string
880
    {
881
        $filename = trim($this->request->getPathInfo(), '/');
882
        return trim(preg_replace('/[-\/\\:;*?|<>%#"]+/', '-', $filename));
883
    }
884
885
    /**
886
     * Return a JsonResponse object pre-supplied with the requested params.
887
     * @param array $data
888
     * @return JsonResponse
889
     */
890
    public function getFormattedApiResponse(array $data): JsonResponse
891
    {
892
        $response = new JsonResponse();
893
        $response->setEncodingOptions(JSON_NUMERIC_CHECK);
894
        $response->setStatusCode(Response::HTTP_OK);
895
896
        // Normalize display of IP ranges (they are prefixed with 'ipr-' in the params).
897
        if ($this->user && $this->user->isIpRange()) {
898
            $this->params['username'] = $this->user->getUsername();
899
        }
900
901
        $elapsedTime = round(
902
            microtime(true) - $this->request->server->get('REQUEST_TIME_FLOAT'),
903
            3
904
        );
905
906
        // Any pipe-separated values should be returned as an array.
907
        foreach ($this->params as $param => $value) {
908
            if (is_string($value) && false !== strpos($value, '|')) {
909
                $this->params[$param] = explode('|', $value);
910
            }
911
        }
912
913
        $ret = array_merge($this->params, [
914
            // In some controllers, $this->params['project'] may be overridden with a Project object.
915
            'project' => $this->project->getDomain(),
916
        ], $data, ['elapsed_time' => $elapsedTime]);
917
918
        // Merge in flash messages, putting them at the top.
919
        $flashes = $this->get('session')->getFlashBag()->peekAll();
920
        $ret = array_merge($flashes, $ret);
921
922
        // Flashes now can be cleared after merging into the response.
923
        $this->get('session')->getFlashBag()->clear();
924
925
        $response->setData($ret);
926
927
        return $response;
928
    }
929
930
    /**
931
     * Used to standardized the format of API responses that contain revisions.
932
     * Adds a 'full_page_title' key and value to each entry in $data.
933
     * If there are as many entries in $data as there are $this->limit, pagination is assumed
934
     *   and a 'continue' key is added to the end of the response body.
935
     * @param string $key Key accessing the list of revisions in $data.
936
     * @param array $out Whatever data needs to appear above the $data in the response body.
937
     * @param array $data The data set itself.
938
     * @return array
939
     */
940
    public function addFullPageTitlesAndContinue(string $key, array $out, array $data): array
941
    {
942
        // Add full_page_title (in addition to the existing page_title and page_namespace keys).
943
        $out[$key] = array_map(function ($rev) {
944
            return array_merge([
945
                'full_page_title' => $this->getPageFromNsAndTitle(
946
                    (int)$rev['page_namespace'],
947
                    $rev['page_title'],
948
                    true
949
                ),
950
            ], $rev);
951
        }, $data);
952
953
        // Check if pagination is needed.
954
        if (count($out[$key]) === $this->limit && count($out[$key]) > 0) {
955
            // Use the timestamp of the last Edit as the value for the 'continue' return key,
956
            //   which can be used as a value for 'offset' in order to paginate results.
957
            $timestamp = array_slice($out[$key], -1, 1)[0]['timestamp'];
958
            $out['continue'] = (new DateTime($timestamp))->format('Y-m-d\TH:i:s');
959
        }
960
961
        return $out;
962
    }
963
964
    /*********
965
     * OTHER *
966
     *********/
967
968
    /**
969
     * Record usage of an API endpoint.
970
     * @param string $endpoint
971
     * @codeCoverageIgnore
972
     */
973
    public function recordApiUsage(string $endpoint): void
974
    {
975
        /** @var \Doctrine\DBAL\Connection $conn */
976
        $conn = $this->container->get('doctrine')
977
            ->getManager('default')
978
            ->getConnection();
979
        $date =  date('Y-m-d');
980
981
        // Increment count in timeline
982
        try {
983
            $sql = "INSERT INTO usage_api_timeline
984
                    VALUES(NULL, :date, :endpoint, 1)
985
                    ON DUPLICATE KEY UPDATE `count` = `count` + 1";
986
            $conn->executeStatement($sql, [
987
                'date' => $date,
988
                'endpoint' => $endpoint,
989
            ]);
990
        } catch (Exception $e) {
991
            // Do nothing. API response should still be returned rather than erroring out.
992
        }
993
    }
994
995
    /**
996
     * Add a flash message.
997
     * @param string $type
998
     * @param string $key i18n key or raw message.
999
     * @param array $vars
1000
     */
1001
    public function addFlashMessage(string $type, string $key, array $vars = []): void
1002
    {
1003
        $this->addFlash(
1004
            $type,
1005
            $this->i18n->msgExists($key, $vars) ? $this->i18n->msg($key, $vars) : $key
1006
        );
1007
    }
1008
}
1009