Passed
Push — api-consistency ( ff12f0 )
by MusikAnimal
06:36
created

XtoolsController::formatDateTimeForApi()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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

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

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

791
            $startTime = strtotime('-' . /** @scrutinizer ignore-type */ $this->maxDays() . ' days', $endTime);
Loading history...
792
        }
793
794
        return [$startTime, $endTime];
795
    }
796
797
    /**
798
     * Given the params hash, normalize any legacy parameters to their modern equivalent.
799
     * @param string[] $params
800
     * @return string[]
801
     */
802
    private function convertLegacyParams(array $params): array
803
    {
804
        $paramMap = [
805
            'user' => 'username',
806
            'name' => 'username',
807
            'article' => 'page',
808
            'begin' => 'start',
809
810
            // Copy super legacy project params to legacy so we can concatenate below.
811
            'wikifam' => 'wiki',
812
            'wikilang' => 'lang',
813
        ];
814
815
        // Copy legacy parameters to modern equivalent.
816
        foreach ($paramMap as $legacy => $modern) {
817
            if (isset($params[$legacy])) {
818
                $params[$modern] = $params[$legacy];
819
                unset($params[$legacy]);
820
            }
821
        }
822
823
        // Separate parameters for language and wiki.
824
        if (isset($params['wiki']) && isset($params['lang'])) {
825
            // 'wikifam' may be like '.wikipedia.org', vs just 'wikipedia',
826
            // so we must remove leading periods and trailing .org's.
827
            $params['project'] = $params['lang'].'.'.rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
828
            unset($params['wiki']);
829
            unset($params['lang']);
830
        }
831
832
        return $params;
833
    }
834
835
    /************************
836
     * FORMATTING RESPONSES *
837
     ************************/
838
839
    /**
840
     * Get the rendered template for the requested format. This method also updates the cookies.
841
     * @param string $templatePath Path to template without format,
842
     *   such as '/editCounter/latest_global'.
843
     * @param array $ret Data that should be passed to the view.
844
     * @return Response
845
     * @codeCoverageIgnore
846
     */
847
    public function getFormattedResponse(string $templatePath, array $ret): Response
848
    {
849
        $format = $this->request->query->get('format', 'html');
850
        if ('' == $format) {
851
            // The default above doesn't work when the 'format' parameter is blank.
852
            $format = 'html';
853
        }
854
855
        // Merge in common default parameters, giving $ret (from the caller) the priority.
856
        $ret = array_merge([
857
            'project' => $this->project,
858
            'user' => $this->user,
859
            'page' => $this->page ?? null,
860
            'namespace' => $this->namespace,
861
            'start' => $this->start,
862
            'end' => $this->end,
863
        ], $ret);
864
865
        $formatMap = [
866
            'wikitext' => 'text/plain',
867
            'csv' => 'text/csv',
868
            'tsv' => 'text/tab-separated-values',
869
            'json' => 'application/json',
870
        ];
871
872
        $response = new Response();
873
874
        // Set cookies. Note this must be done before rendering the view, as the view may invoke subrequests.
875
        $this->setCookies($response);
876
877
        // If requested format does not exist, assume HTML.
878
        if (false === $this->twig->getLoader()->exists("$templatePath.$format.twig")) {
879
            $format = 'html';
880
        }
881
882
        $response = $this->render("$templatePath.$format.twig", $ret, $response);
883
884
        $contentType = $formatMap[$format] ?? 'text/html';
885
        $response->headers->set('Content-Type', $contentType);
886
887
        if (in_array($format, ['csv', 'tsv'])) {
888
            $filename = $this->getFilenameForRequest();
889
            $response->headers->set(
890
                'Content-Disposition',
891
                "attachment; filename=\"{$filename}.$format\""
892
            );
893
        }
894
895
        return $response;
896
    }
897
898
    /**
899
     * Returns given filename from the current Request, with problematic characters filtered out.
900
     * @return string
901
     */
902
    private function getFilenameForRequest(): string
903
    {
904
        $filename = trim($this->request->getPathInfo(), '/');
905
        return trim(preg_replace('/[-\/\\:;*?|<>%#"]+/', '-', $filename));
906
    }
907
908
    /**
909
     * Return a JsonResponse object pre-supplied with the requested params.
910
     * @param array $data
911
     * @param int $responseCode
912
     * @return JsonResponse
913
     */
914
    public function getFormattedApiResponse(array $data, int $responseCode = Response::HTTP_OK): JsonResponse
915
    {
916
        $response = new JsonResponse();
917
        $response->setStatusCode($responseCode);
918
919
        // Normalize display of IP ranges (they are prefixed with 'ipr-' in the params).
920
        if ($this->user && $this->user->isIpRange()) {
921
            $this->params['username'] = $this->user->getUsername();
922
        }
923
924
        $ret = array_merge($this->params, [
925
            // In some controllers, $this->params['project'] may be overridden with a Project object.
926
            'project' => $this->project->getDomain(),
927
        ], $data);
928
929
        // Merge in flash messages, putting them at the top.
930
        $flashes = $this->flashBag->peekAll();
931
        $ret = array_merge($flashes, $ret);
932
933
        // Flashes now can be cleared after merging into the response.
934
        $this->flashBag->clear();
935
936
        // Normalize path param values, dates, etc.
937
        $ret = self::normalizeApiProperties($ret);
938
939
        $response->setData($ret);
940
941
        return $response;
942
    }
943
944
    /**
945
     * Normalize the response data, adding in the elapsed_time.
946
     * @param array $params
947
     * @return array
948
     */
949
    public static function normalizeApiProperties(array $params): array
950
    {
951
        foreach ($params as $param => $value) {
952
            if (false === $value) {
953
                // False values must be empty params.
954
                unset($params[$param]);
955
            } elseif (is_string($value) && false !== strpos($value, '|')) {
956
                // Any pipe-separated values should be returned as an array.
957
                $params[$param] = explode('|', $value);
958
            } elseif ($value instanceof DateTime || 'timestamp' === $param) {
959
                // Convert DateTime objects to ISO 8601 strings.
960
                $params[$param] = self::formatDateTimeForApi($value);
961
            } elseif ('namespace' === $param && is_numeric($value)) {
962
                $params[$param] = (int)$value;
963
            } elseif ($value instanceof Edit) {
964
                $params[$param] = $value->getForJson();
965
            }
966
        }
967
968
        $elapsedTime = round(
969
            microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'],
970
            3
971
        );
972
        return array_merge($params, ['elapsed_time' => $elapsedTime]);
973
    }
974
975
    public static function formatDateTimeForApi($dateTime): string
976
    {
977
        if (is_string($dateTime)) {
978
            $dateTime = new DateTime($dateTime);
979
        }
980
        return $dateTime->format('Y-m-d\TH:i:s\Z');
981
    }
982
983
    /**
984
     * Parse a boolean value from the query string, treating 'false' and '0' as false.
985
     * @param string $param
986
     * @return bool
987
     */
988
    public function getBoolVal(string $param): bool
989
    {
990
        return isset($this->params[$param]) &&
991
            !in_array($this->params[$param], ['false', '0']);
992
    }
993
994
    /**
995
     * Used to standardized the format of API responses that contain revisions.
996
     * Adds a 'full_page_title' key and value to each entry in $data.
997
     * If there are as many entries in $data as there are $this->limit, pagination is assumed
998
     *   and a 'continue' key is added to the end of the response body.
999
     * @param string $key Key accessing the list of revisions in $data.
1000
     * @param array $out Whatever data needs to appear above the $data in the response body.
1001
     * @param array $data The data set itself.
1002
     * @return array
1003
     */
1004
    public function addFullPageTitlesAndContinue(string $key, array $out, array $data): array
1005
    {
1006
        // Add full_page_title (in addition to the existing page_title and namespace keys).
1007
        $out[$key] = array_map(function ($rev) {
1008
            if ($rev instanceof Edit) {
1009
                $rev = $rev->getForJson();
1010
            }
1011
            return array_merge([
1012
                'full_page_title' => $this->getPageFromNsAndTitle(
1013
                    (int)$rev['namespace'],
1014
                    $rev['page_title'],
1015
                    true
1016
                ),
1017
            ], $rev);
1018
        }, $data);
1019
1020
        // Check if pagination is needed.
1021
        if (count($out[$key]) === $this->limit && count($out[$key]) > 0) {
1022
            // Use the timestamp of the last Edit as the value for the 'continue' return key,
1023
            //   which can be used as a value for 'offset' in order to paginate results.
1024
            $timestamp = array_slice($out[$key], -1, 1)[0]['timestamp'];
1025
            $out['continue'] = (new DateTime($timestamp))->format('Y-m-d\TH:i:s');
1026
        }
1027
1028
        return $out;
1029
    }
1030
1031
    /*********
1032
     * OTHER *
1033
     *********/
1034
1035
    /**
1036
     * Record usage of an API endpoint.
1037
     * @param string $endpoint
1038
     * @codeCoverageIgnore
1039
     */
1040
    public function recordApiUsage(string $endpoint): void
1041
    {
1042
        /** @var Connection $conn */
1043
        $conn = $this->managerRegistry->getConnection('default');
1044
        $date = date('Y-m-d');
1045
1046
        // Increment count in timeline
1047
        try {
1048
            $sql = "INSERT INTO usage_api_timeline
1049
                    VALUES(NULL, :date, :endpoint, 1)
1050
                    ON DUPLICATE KEY UPDATE `count` = `count` + 1";
1051
            $conn->executeStatement($sql, [
1052
                'date' => $date,
1053
                'endpoint' => $endpoint,
1054
            ]);
1055
        } catch (Exception $e) {
1056
            // Do nothing. API response should still be returned rather than erroring out.
1057
        }
1058
    }
1059
1060
    /**
1061
     * Add a flash message.
1062
     * @param string $type
1063
     * @param string|Markup $key i18n key or raw message.
1064
     * @param array $vars
1065
     */
1066
    public function addFlashMessage(string $type, $key, array $vars = []): void
1067
    {
1068
        if ($key instanceof Markup || !$this->i18n->msgExists($key, $vars)) {
1069
            $msg = $key;
1070
        } else {
1071
            $msg = $this->i18n->msg($key, $vars);
1072
        }
1073
        $this->addFlash($type, $msg);
1074
    }
1075
}
1076