Passed
Push — main ( e39e67...507cd6 )
by MusikAnimal
03:58
created

XtoolsController::convertLegacyParams()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 31
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

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

289
        if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn(/** @scrutinizer ignore-type */ $this->user)) {
Loading history...
290
            throw new XtoolsHttpException(
291
                $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

291
                /** @scrutinizer ignore-type */ $this->i18n->msg('not-opted-in', [
Loading history...
292
                    $this->getOptedInPage()->getTitle(),
293
                    $this->i18n->msg('not-opted-in-link') .
294
                        ' <https://www.mediawiki.org/wiki/Special:MyLanguage/XTools/Edit_Counter#restricted_stats>',
295
                    $this->i18n->msg('not-opted-in-login'),
296
                ]),
297
                '',
298
                $this->params,
299
                true,
300
                Response::HTTP_UNAUTHORIZED
301
            );
302
        }
303
    }
304
305
    /**
306
     * Get the path to the opt-in page for restricted statistics.
307
     * @return Page
308
     */
309
    protected function getOptedInPage(): Page
310
    {
311
        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

311
        return new Page($this->pageRepo, $this->project, $this->project->userOptInPage(/** @scrutinizer ignore-type */ $this->user));
Loading history...
312
    }
313
314
    /***********
315
     * COOKIES *
316
     ***********/
317
318
    /**
319
     * Load user preferences from the associated cookies.
320
     */
321
    private function loadCookies(): void
322
    {
323
        // Not done for subrequests.
324
        if ($this->isSubRequest) {
325
            return;
326
        }
327
328
        foreach (array_keys($this->cookies) as $name) {
329
            $this->cookies[$name] = $this->request->cookies->get($name);
330
        }
331
    }
332
333
    /**
334
     * Set cookies on the given Response.
335
     * @param Response $response
336
     */
337
    private function setCookies(Response $response): void
338
    {
339
        // Not done for subrequests.
340
        if ($this->isSubRequest) {
341
            return;
342
        }
343
344
        foreach ($this->cookies as $name => $value) {
345
            $response->headers->setCookie(
346
                Cookie::create($name, $value)
347
            );
348
        }
349
    }
350
351
    /**
352
     * Sets the project, with the domain in $this->cookies['XtoolsProject'] that will
353
     * later get set on the Response headers in self::getFormattedResponse().
354
     * @param Project $project
355
     */
356
    private function setProject(Project $project): void
357
    {
358
        // TODO: Remove after deprecated routes are retired.
359
        if (false !== strpos((string)$this->request->get('_controller'), 'GlobalContribs')) {
360
            return;
361
        }
362
363
        $this->project = $project;
364
        $this->cookies['XtoolsProject'] = $project->getDomain();
365
    }
366
367
    /****************************
368
     * SETTING CLASS PROPERTIES *
369
     ****************************/
370
371
    /**
372
     * Normalize all common parameters used by the controllers and set class properties.
373
     */
374
    private function setProperties(): void
375
    {
376
        $this->namespace = $this->params['namespace'] ?? null;
377
378
        // Offset is given as ISO timestamp and is stored as a UNIX timestamp (or false).
379
        if (isset($this->params['offset'])) {
380
            $this->offset = strtotime($this->params['offset']);
381
        }
382
383
        // Limit needs to be an int.
384
        if (isset($this->params['limit'])) {
385
            // Normalize.
386
            $this->params['limit'] = min(max(1, (int)$this->params['limit']), $this->maxLimit());
387
            $this->limit = $this->params['limit'];
388
        }
389
390
        if (isset($this->params['project'])) {
391
            $this->setProject($this->validateProject($this->params['project']));
392
        } elseif (null !== $this->cookies['XtoolsProject']) {
393
            // Set from cookie.
394
            $this->setProject(
395
                $this->validateProject($this->cookies['XtoolsProject'])
396
            );
397
        }
398
399
        if (isset($this->params['username'])) {
400
            $this->user = $this->validateUser($this->params['username']);
401
        }
402
        if (isset($this->params['page'])) {
403
            $this->page = $this->getPageFromNsAndTitle($this->namespace, $this->params['page']);
404
        }
405
406
        $this->setDates();
407
    }
408
409
    /**
410
     * Set class properties for dates, if such params were passed in.
411
     */
412
    private function setDates(): void
413
    {
414
        $start = $this->params['start'] ?? false;
415
        $end = $this->params['end'] ?? false;
416
        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...
417
            [$this->start, $this->end] = $this->getUnixFromDateParams($start, $end);
418
419
            // Set $this->params accordingly too, so that for instance API responses will include it.
420
            $this->params['start'] = is_int($this->start) ? date('Y-m-d', $this->start) : false;
421
            $this->params['end'] = is_int($this->end) ? date('Y-m-d', $this->end) : false;
422
        }
423
    }
424
425
    /**
426
     * Construct a fully qualified page title given the namespace and title.
427
     * @param int|string $ns Namespace ID.
428
     * @param string $title Page title.
429
     * @param bool $rawTitle Return only the title (and not a Page).
430
     * @return Page|string
431
     */
432
    protected function getPageFromNsAndTitle($ns, string $title, bool $rawTitle = false)
433
    {
434
        if (0 === (int)$ns) {
435
            return $rawTitle ? $title : $this->validatePage($title);
436
        }
437
438
        // Prepend namespace and strip out duplicates.
439
        $nsName = $this->project->getNamespaces()[$ns] ?? $this->i18n->msg('unknown');
440
        $title = $nsName.':'.preg_replace('/^'.$nsName.':/', '', $title);
441
        return $rawTitle ? $title : $this->validatePage($title);
442
    }
443
444
    /**
445
     * Get a Project instance from the project string, using defaults if the given project string is invalid.
446
     * @return Project
447
     */
448
    public function getProjectFromQuery(): Project
449
    {
450
        // Set default project so we can populate the namespace selector on index pages.
451
        // Defaults to project stored in cookie, otherwise project specified in parameters.yml.
452
        if (isset($this->params['project'])) {
453
            $project = $this->params['project'];
454
        } elseif (null !== $this->cookies['XtoolsProject']) {
455
            $project = $this->cookies['XtoolsProject'];
456
        } else {
457
            $project = $this->defaultProject;
458
        }
459
460
        $projectData = $this->projectRepo->getProject($project);
461
462
        // Revert back to defaults if we've established the given project was invalid.
463
        if (!$projectData->exists()) {
464
            $projectData = $this->projectRepo->getProject($this->defaultProject);
465
        }
466
467
        return $projectData;
468
    }
469
470
    /*************************
471
     * GETTERS / VALIDATIONS *
472
     *************************/
473
474
    /**
475
     * Validate the given project, returning a Project if it is valid or false otherwise.
476
     * @param string $projectQuery Project domain or database name.
477
     * @return Project
478
     * @throws XtoolsHttpException
479
     */
480
    public function validateProject(string $projectQuery): Project
481
    {
482
        $project = $this->projectRepo->getProject($projectQuery);
483
484
        // Check if it is an explicitly allowed project for the current tool.
485
        if ($this->supportedProjects() && !in_array($project->getDomain(), $this->supportedProjects())) {
486
            $this->throwXtoolsException(
487
                $this->getIndexRoute(),
488
                'error-authorship-unsupported-project',
489
                [$this->params['project']],
490
                'project'
491
            );
492
        }
493
494
        if (!$project->exists()) {
495
            $this->throwXtoolsException(
496
                $this->getIndexRoute(),
497
                'invalid-project',
498
                [$this->params['project']],
499
                'project'
500
            );
501
        }
502
503
        return $project;
504
    }
505
506
    /**
507
     * Validate the given user, returning a User or Redirect if they don't exist.
508
     * @param string $username
509
     * @return User
510
     * @throws XtoolsHttpException
511
     */
512
    public function validateUser(string $username): User
513
    {
514
        $user = new User($this->userRepo, $username);
515
516
        // Allow querying for any IP, currently with no edit count limitation...
517
        // Once T188677 is resolved IPs will be affected by the EXPLAIN results.
518
        if ($user->isAnon()) {
519
            // Validate CIDR limits.
520
            if (!$user->isQueryableRange()) {
521
                $limit = $user->isIPv6() ? User::MAX_IPV6_CIDR : User::MAX_IPV4_CIDR;
522
                $this->throwXtoolsException($this->getIndexRoute(), 'ip-range-too-wide', [$limit], 'username');
523
            }
524
            return $user;
525
        }
526
527
        $originalParams = $this->params;
528
529
        // Don't continue if the user doesn't exist.
530
        if (isset($this->project) && !$user->existsOnProject($this->project)) {
531
            $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username');
532
        }
533
534
        // Reject users with a crazy high edit count.
535
        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...
536
            !in_array($this->controllerAction, $this->tooHighEditCountActionAllowlist()) &&
537
            $user->hasTooManyEdits($this->project)
538
        ) {
539
            /** TODO: Somehow get this to use self::throwXtoolsException */
540
541
            // If redirecting to a different controller, show an informative message accordingly.
542
            if ($this->tooHighEditCountRoute() !== $this->getIndexRoute()) {
0 ignored issues
show
introduced by
The condition $this->tooHighEditCountR... $this->getIndexRoute() is always true.
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...
543
                // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,
544
                //   so this bit is hardcoded. We need to instead give the i18n key of the route.
545
                $redirMsg = $this->i18n->msg('too-many-edits-redir', [
546
                    $this->i18n->msg('tool-simpleeditcounter'),
547
                ]);
548
                $msg = $this->i18n->msg('too-many-edits', [
549
                    $this->i18n->numberFormat($user->maxEdits()),
550
                ]).'. '.$redirMsg;
551
                $this->addFlashMessage('danger', $msg);
552
            } else {
553
                $this->addFlashMessage('danger', 'too-many-edits', [
554
                    $this->i18n->numberFormat($user->maxEdits()),
555
                ]);
556
557
                // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).
558
                unset($this->params['username']);
559
            }
560
561
            // Clear flash bag for API responses, since they get intercepted in ExceptionListener
562
            // and would otherwise be shown in subsequent requests.
563
            if ($this->isApi) {
564
                $this->flashBag->clear();
565
            }
566
567
            throw new XtoolsHttpException(
568
                $this->i18n->msg('too-many-edits', [ $user->maxEdits() ]),
0 ignored issues
show
Bug introduced by
It seems like $this->i18n->msg('too-ma...ray($user->maxEdits())) 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

568
                /** @scrutinizer ignore-type */ $this->i18n->msg('too-many-edits', [ $user->maxEdits() ]),
Loading history...
569
                $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

569
                $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...
570
                $originalParams,
571
                $this->isApi
572
            );
573
        }
574
575
        return $user;
576
    }
577
578
    /**
579
     * Get a Page instance from the given page title, and validate that it exists.
580
     * @param string $pageTitle
581
     * @return Page
582
     * @throws XtoolsHttpException
583
     */
584
    public function validatePage(string $pageTitle): Page
585
    {
586
        $page = new Page($this->pageRepo, $this->project, $pageTitle);
587
588
        if (!$page->exists()) {
589
            $this->throwXtoolsException(
590
                $this->getIndexRoute(),
591
                'no-result',
592
                [$this->params['page'] ?? null],
593
                'page'
594
            );
595
        }
596
597
        return $page;
598
    }
599
600
    /**
601
     * Throw an XtoolsHttpException, which the given error message and redirects to specified action.
602
     * @param string $redirectAction Name of action to redirect to.
603
     * @param string $message i18n key of error message. Shown in API responses.
604
     *   If no message with this key exists, $message is shown as-is.
605
     * @param array $messageParams
606
     * @param string|null $invalidParam This will be removed from $this->params. Omit if you don't want this to happen.
607
     * @throws XtoolsHttpException
608
     */
609
    public function throwXtoolsException(
610
        string $redirectAction,
611
        string $message,
612
        array $messageParams = [],
613
        ?string $invalidParam = null
614
    ): void {
615
        $this->addFlashMessage('danger', $message, $messageParams);
616
        $originalParams = $this->params;
617
618
        // Remove invalid parameter if it was given.
619
        if (is_string($invalidParam)) {
620
            unset($this->params[$invalidParam]);
621
        }
622
623
        // We sometimes are redirecting to the index page, so also remove project (otherwise we'd get a redirect loop).
624
        /**
625
         * FIXME: Index pages should have a 'nosubmit' parameter to prevent submission.
626
         * Then we don't even need to remove $invalidParam.
627
         * Better, we should show the error on the results page, with no results.
628
         */
629
        unset($this->params['project']);
630
631
        // Throw exception which will redirect to $redirectAction.
632
        throw new XtoolsHttpException(
633
            $this->i18n->msgIfExists($message, $messageParams),
634
            $this->generateUrl($redirectAction, $this->params),
635
            $originalParams,
636
            $this->isApi
637
        );
638
    }
639
640
    /******************
641
     * PARSING PARAMS *
642
     ******************/
643
644
    /**
645
     * Get all standardized parameters from the Request, either via URL query string or routing.
646
     * @return string[]
647
     */
648
    public function getParams(): array
649
    {
650
        $paramsToCheck = [
651
            'project',
652
            'username',
653
            'namespace',
654
            'page',
655
            'categories',
656
            'group',
657
            'redirects',
658
            'deleted',
659
            'start',
660
            'end',
661
            'offset',
662
            'limit',
663
            'format',
664
            'tool',
665
            'tools',
666
            'q',
667
            'include_pattern',
668
            'exclude_pattern',
669
670
            // Legacy parameters.
671
            'user',
672
            'name',
673
            'article',
674
            'wiki',
675
            'wikifam',
676
            'lang',
677
            'wikilang',
678
            'begin',
679
        ];
680
681
        /** @var string[] $params Each parameter that was detected along with its value. */
682
        $params = [];
683
684
        foreach ($paramsToCheck as $param) {
685
            // Pull in either from URL query string or route.
686
            $value = $this->request->query->get($param) ?: $this->request->get($param);
687
688
            // Only store if value is given ('namespace' or 'username' could be '0').
689
            if (null !== $value && '' !== $value) {
690
                $params[$param] = rawurldecode((string)$value);
691
            }
692
        }
693
694
        return $params;
695
    }
696
697
    /**
698
     * Parse out common parameters from the request. These include the 'project', 'username', 'namespace' and 'page',
699
     * along with their legacy counterparts (e.g. 'lang' and 'wiki').
700
     * @return string[] Normalized parameters (no legacy params).
701
     */
702
    public function parseQueryParams(): array
703
    {
704
        $params = $this->getParams();
705
706
        // Covert any legacy parameters, if present.
707
        $params = $this->convertLegacyParams($params);
708
709
        // Remove blank values.
710
        return array_filter($params, function ($param) {
711
            // 'namespace' or 'username' could be '0'.
712
            return null !== $param && '' !== $param;
713
        });
714
    }
715
716
    /**
717
     * Get Unix timestamps from given start and end string parameters. This also makes $start $maxDays() before
718
     * $end if not present, and makes $end the current time if not present.
719
     * The date range will not exceed $this->maxDays() days, if this public class property is set.
720
     * @param int|string|false $start Unix timestamp or string accepted by strtotime.
721
     * @param int|string|false $end Unix timestamp or string accepted by strtotime.
722
     * @return int[] Start and end date as UTC timestamps.
723
     */
724
    public function getUnixFromDateParams($start, $end): array
725
    {
726
        $today = strtotime('today midnight');
727
728
        // start time should not be in the future.
729
        $startTime = min(
730
            is_int($start) ? $start : strtotime((string)$start),
731
            $today
732
        );
733
734
        // end time defaults to now, and will not be in the future.
735
        $endTime = min(
736
            (is_int($end) ? $end : strtotime((string)$end)) ?: $today,
737
            $today
738
        );
739
740
        // Default to $this->defaultDays() or $this->maxDays() before end time if start is not present.
741
        $daysOffset = $this->defaultDays() ?? $this->maxDays();
0 ignored issues
show
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...
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...
742
        if (false === $startTime && $daysOffset) {
0 ignored issues
show
introduced by
$daysOffset is of type null, thus it always evaluated to false.
Loading history...
743
            $startTime = strtotime("-$daysOffset days", $endTime);
744
        }
745
746
        // Default to $this->defaultDays() or $this->maxDays() after start time if end is not present.
747
        if (false === $end && $daysOffset) {
0 ignored issues
show
introduced by
$daysOffset is of type null, thus it always evaluated to false.
Loading history...
748
            $endTime = min(
749
                strtotime("+$daysOffset days", $startTime),
750
                $today
751
            );
752
        }
753
754
        // Reverse if start date is after end date.
755
        if ($startTime > $endTime && false !== $startTime && false !== $end) {
756
            $newEndTime = $startTime;
757
            $startTime = $endTime;
758
            $endTime = $newEndTime;
759
        }
760
761
        // Finally, don't let the date range exceed $this->maxDays().
762
        $startObj = DateTime::createFromFormat('U', (string)$startTime);
763
        $endObj = DateTime::createFromFormat('U', (string)$endTime);
764
        if ($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...
765
            // Show warnings that the date range was truncated.
766
            $this->addFlashMessage('warning', 'date-range-too-wide', [$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...
767
768
            $startTime = strtotime('-' . $this->maxDays() . ' days', $endTime);
0 ignored issues
show
Bug introduced by
Are you sure $this->maxDays() of type void can be used in concatenation? ( Ignorable by Annotation )

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

768
            $startTime = strtotime('-' . /** @scrutinizer ignore-type */ $this->maxDays() . ' days', $endTime);
Loading history...
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...
769
        }
770
771
        return [$startTime, $endTime];
772
    }
773
774
    /**
775
     * Given the params hash, normalize any legacy parameters to their modern equivalent.
776
     * @param string[] $params
777
     * @return string[]
778
     */
779
    private function convertLegacyParams(array $params): array
780
    {
781
        $paramMap = [
782
            'user' => 'username',
783
            'name' => 'username',
784
            'article' => 'page',
785
            'begin' => 'start',
786
787
            // Copy super legacy project params to legacy so we can concatenate below.
788
            'wikifam' => 'wiki',
789
            'wikilang' => 'lang',
790
        ];
791
792
        // Copy legacy parameters to modern equivalent.
793
        foreach ($paramMap as $legacy => $modern) {
794
            if (isset($params[$legacy])) {
795
                $params[$modern] = $params[$legacy];
796
                unset($params[$legacy]);
797
            }
798
        }
799
800
        // Separate parameters for language and wiki.
801
        if (isset($params['wiki']) && isset($params['lang'])) {
802
            // 'wikifam' may be like '.wikipedia.org', vs just 'wikipedia',
803
            // so we must remove leading periods and trailing .org's.
804
            $params['project'] = $params['lang'].'.'.rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
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->flashBag->peekAll();
920
        $ret = array_merge($flashes, $ret);
921
922
        // Flashes now can be cleared after merging into the response.
923
        $this->flashBag->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 Connection $conn */
976
        $conn = $this->managerRegistry->getConnection('default');
977
        $date = date('Y-m-d');
978
979
        // Increment count in timeline
980
        try {
981
            $sql = "INSERT INTO usage_api_timeline
982
                    VALUES(NULL, :date, :endpoint, 1)
983
                    ON DUPLICATE KEY UPDATE `count` = `count` + 1";
984
            $conn->executeStatement($sql, [
985
                'date' => $date,
986
                'endpoint' => $endpoint,
987
            ]);
988
        } catch (Exception $e) {
989
            // Do nothing. API response should still be returned rather than erroring out.
990
        }
991
    }
992
993
    /**
994
     * Add a flash message.
995
     * @param string $type
996
     * @param string $key i18n key or raw message.
997
     * @param array $vars
998
     */
999
    public function addFlashMessage(string $type, string $key, array $vars = []): void
1000
    {
1001
        $this->addFlash(
1002
            $type,
1003
            $this->i18n->msgExists($key, $vars) ? $this->i18n->msg($key, $vars) : $key
1004
        );
1005
    }
1006
}
1007