Passed
Pull Request — main (#442)
by MusikAnimal
08:21 queued 04:15
created

tooHighEditCountActionAllowlist()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace App\Controller;
6
7
use App\Exception\XtoolsHttpException;
8
use App\Helper\I18nHelper;
9
use App\Model\EditCounter;
10
use App\Model\GlobalContribs;
11
use App\Model\UserRights;
12
use App\Repository\EditCounterRepository;
13
use App\Repository\EditRepository;
14
use App\Repository\GlobalContribsRepository;
15
use App\Repository\PageRepository;
16
use App\Repository\ProjectRepository;
17
use App\Repository\UserRepository;
18
use App\Repository\UserRightsRepository;
19
use GuzzleHttp\Client;
20
use Psr\Cache\CacheItemPoolInterface;
21
use Psr\Container\ContainerInterface;
22
use Symfony\Component\HttpFoundation\Cookie;
23
use Symfony\Component\HttpFoundation\JsonResponse;
24
use Symfony\Component\HttpFoundation\RedirectResponse;
25
use Symfony\Component\HttpFoundation\RequestStack;
26
use Symfony\Component\HttpFoundation\Response;
27
use Symfony\Component\Routing\Annotation\Route;
28
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
29
30
/**
31
 * Class EditCounterController
32
 */
33
class EditCounterController extends XtoolsController
34
{
35
    /**
36
     * Available statistic sections. These can be hand-picked on the index form so that you only get the data you
37
     * want and hence speed up the tool. Keys are the i18n messages (and DOM IDs), values are the action names.
38
     */
39
    private const AVAILABLE_SECTIONS = [
40
        'general-stats' => 'EditCounterGeneralStats',
41
        'namespace-totals' => 'EditCounterNamespaceTotals',
42
        'year-counts' => 'EditCounterYearCounts',
43
        'month-counts' => 'EditCounterMonthCounts',
44
        'timecard' => 'EditCounterTimecard',
45
        'top-edited-pages' => 'TopEditsResultNamespace',
46
        'rights-changes' => 'EditCounterRightsChanges',
47
    ];
48
49
    protected EditCounter $editCounter;
50
    protected EditCounterRepository $editCounterRepo;
51
    protected UserRights $userRights;
52
    protected UserRightsRepository $userRightsRepo;
53
54
    /** @var string[] Which sections to show. */
55
    protected array $sections;
56
57
    /**
58
     * Get the name of the tool's index route. This is also the name of the associated model.
59
     * @return string
60
     * @codeCoverageIgnore
61
     */
62
    public function getIndexRoute(): string
63
    {
64
        return 'EditCounter';
65
    }
66
67
    /**
68
     * EditCounterController constructor.
69
     * @param RequestStack $requestStack
70
     * @param ContainerInterface $container
71
     * @param CacheItemPoolInterface $cache
72
     * @param Client $guzzle
73
     * @param I18nHelper $i18n
74
     * @param ProjectRepository $projectRepo
75
     * @param UserRepository $userRepo
76
     * @param EditCounterRepository $editCounterRepo
77
     * @param UserRightsRepository $userRightsRepo
78
     * @param PageRepository $pageRepo
79
     */
80
    public function __construct(
81
        RequestStack $requestStack,
82
        ContainerInterface $container,
83
        CacheItemPoolInterface $cache,
84
        Client $guzzle,
85
        I18nHelper $i18n,
86
        ProjectRepository $projectRepo,
87
        UserRepository $userRepo,
88
        EditCounterRepository $editCounterRepo,
89
        UserRightsRepository $userRightsRepo,
90
        PageRepository $pageRepo
91
    ) {
92
        $this->editCounterRepo = $editCounterRepo;
93
        $this->userRightsRepo = $userRightsRepo;
94
        parent::__construct($requestStack, $container, $cache, $guzzle, $i18n, $projectRepo, $userRepo, $pageRepo);
95
    }
96
97
    /**
98
     * Causes the tool to redirect to the Simple Edit Counter if the user has too high of an edit count.
99
     * @inheritDoc
100
     */
101
    public function tooHighEditCountRoute(): string
102
    {
103
        return 'SimpleEditCounterResult';
104
    }
105
106
    /**
107
     * @inheritDoc
108
     */
109
    public function tooHighEditCountActionAllowlist(): array
110
    {
111
        return ['rightsChanges'];
112
    }
113
114
    /**
115
     * @inheritDoc
116
     */
117
    public function restrictedApiActions(): array
118
    {
119
        return ['monthCountsApi', 'timecardApi'];
120
    }
121
122
    /**
123
     * Every action in this controller (other than 'index') calls this first.
124
     * If a response is returned, the calling action is expected to return it.
125
     * @throws AccessDeniedException If attempting to access internal endpoint.
126
     * @throws XtoolsHttpException If an API request to restricted endpoint when user has not opted in.
127
     * @codeCoverageIgnore
128
     */
129
    protected function setUpEditCounter(): void
130
    {
131
        // Whether we're making a subrequest (the view makes a request to another action).
132
        // Subrequests to the same controller do not re-instantiate a new controller, and hence
133
        // this flag would not be set in XtoolsController::__construct(), so we must do it here as well.
134
        $this->isSubRequest = $this->request->get('htmlonly')
135
            || null !== $this->get('request_stack')->getParentRequest();
136
137
        // Return the EditCounter if we already have one.
138
        if (isset($this->editCounter)) {
139
            return;
140
        }
141
142
        // Will redirect to Simple Edit Counter if they have too many edits, as defined self::construct.
143
        $this->validateUser($this->user->getUsername());
0 ignored issues
show
Bug introduced by
The method getUsername() does not exist on null. ( Ignorable by Annotation )

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

143
        $this->validateUser($this->user->/** @scrutinizer ignore-call */ getUsername());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
144
145
        // Store which sections of the Edit Counter they requested.
146
        $this->sections = $this->getRequestedSections();
147
148
        $this->userRights = new UserRights($this->userRightsRepo, $this->project, $this->user, $this->i18n);
0 ignored issues
show
Bug introduced by
It seems like $this->user can also be of type null; however, parameter $user of App\Model\UserRights::__construct() 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

148
        $this->userRights = new UserRights($this->userRightsRepo, $this->project, /** @scrutinizer ignore-type */ $this->user, $this->i18n);
Loading history...
149
150
        // Instantiate EditCounter.
151
        $this->editCounter = new EditCounter(
152
            $this->editCounterRepo,
153
            $this->i18n,
154
            $this->userRights,
155
            $this->project,
156
            $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\EditCounter::__construct() 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

156
            /** @scrutinizer ignore-type */ $this->user
Loading history...
157
        );
158
    }
159
160
    /**
161
     * The initial GET request that displays the search form.
162
     * @Route("/ec", name="EditCounter")
163
     * @Route("/ec/index.php", name="EditCounterIndexPhp")
164
     * @Route("/ec/{project}", name="EditCounterProject")
165
     * @return RedirectResponse|Response
166
     */
167
    public function indexAction()
168
    {
169
        if (isset($this->params['project']) && isset($this->params['username'])) {
170
            return $this->redirectFromSections();
171
        }
172
173
        $this->sections = $this->getRequestedSections(true);
174
175
        // Otherwise fall through.
176
        return $this->render('editCounter/index.html.twig', [
177
            'xtPageTitle' => 'tool-editcounter',
178
            'xtSubtitle' => 'tool-editcounter-desc',
179
            'xtPage' => 'EditCounter',
180
            'project' => $this->project,
181
            'sections' => $this->sections,
182
            'availableSections' => $this->getSectionNames(),
183
            'isAllSections' => $this->sections === $this->getSectionNames(),
184
        ]);
185
    }
186
187
    /**
188
     * Get the requested sections either from the URL, cookie, or the defaults (all sections).
189
     * @param bool $useCookies Whether or not to check cookies for the preferred sections.
190
     *   This option should not be true except on the index form.
191
     * @return array|mixed|string[]
192
     * @codeCoverageIgnore
193
     */
194
    private function getRequestedSections(bool $useCookies = false)
195
    {
196
        // Happens from sub-tool index pages, e.g. see self::generalStatsIndexAction().
197
        if (isset($this->sections)) {
198
            return $this->sections;
199
        }
200
201
        // Query param for sections gets priority.
202
        $sectionsQuery = $this->request->get('sections', '');
203
204
        // If not present, try the cookie, and finally the defaults (all sections).
205
        if ($useCookies && '' == $sectionsQuery) {
206
            $sectionsQuery = $this->request->cookies->get('XtoolsEditCounterOptions', '');
207
        }
208
209
        // Either a pipe-separated string or an array.
210
        $sections = is_array($sectionsQuery) ? $sectionsQuery : explode('|', $sectionsQuery);
211
212
        // Filter out any invalid section IDs.
213
        $sections = array_filter($sections, function ($section) {
214
            return in_array($section, $this->getSectionNames());
215
        });
216
217
        // Fallback for when no valid sections were requested or provided by the cookie.
218
        if (0 === count($sections)) {
219
            $sections = $this->getSectionNames();
220
        }
221
222
        return $sections;
223
    }
224
225
    /**
226
     * Get the names of the available sections.
227
     * @return string[]
228
     * @codeCoverageIgnore
229
     */
230
    private function getSectionNames(): array
231
    {
232
        return array_keys(self::AVAILABLE_SECTIONS);
233
    }
234
235
    /**
236
     * Redirect to the appropriate action based on what sections are being requested.
237
     * @return RedirectResponse
238
     * @codeCoverageIgnore
239
     */
240
    private function redirectFromSections(): RedirectResponse
241
    {
242
        $this->sections = $this->getRequestedSections();
243
244
        if (1 === count($this->sections)) {
245
            // Redirect to dedicated route.
246
            $response = $this->redirectToRoute(self::AVAILABLE_SECTIONS[$this->sections[0]], $this->params);
247
        } elseif ($this->sections === $this->getSectionNames()) {
248
            $response = $this->redirectToRoute('EditCounterResult', $this->params);
249
        } else {
250
            // Add sections to the params, which $this->generalUrl() will append to the URL.
251
            $this->params['sections'] = implode('|', $this->sections);
252
253
            // We want a pretty URL, with pipes | instead of the encoded value %7C
254
            $url = str_replace('%7C', '|', $this->generateUrl('EditCounterResult', $this->params));
255
256
            $response = $this->redirect($url);
257
        }
258
259
        // Save the preferred sections in a cookie.
260
        $response->headers->setCookie(
261
            new Cookie('XtoolsEditCounterOptions', implode('|', $this->sections))
262
        );
263
264
        return $response;
265
    }
266
267
    /**
268
     * Display all results.
269
     * @Route(
270
     *     "/ec/{project}/{username}",
271
     *     name="EditCounterResult",
272
     *     requirements={
273
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
274
     *     }
275
     * )
276
     * @return Response|RedirectResponse
277
     * @codeCoverageIgnore
278
     */
279
    public function resultAction()
280
    {
281
        $this->setUpEditCounter();
282
283
        if (1 === count($this->sections)) {
284
            // Redirect to dedicated route.
285
            return $this->redirectToRoute(self::AVAILABLE_SECTIONS[$this->sections[0]], $this->params);
286
        }
287
288
        $ret = [
289
            'xtTitle' => $this->user->getUsername() . ' - ' . $this->project->getTitle(),
290
            'xtPage' => 'EditCounter',
291
            'user' => $this->user,
292
            'project' => $this->project,
293
            'ec' => $this->editCounter,
294
            'sections' => $this->sections,
295
            'isAllSections' => $this->sections === $this->getSectionNames(),
296
        ];
297
298
        // Used when querying for global rights changes.
299
        if ($this->getParameter('app.is_wmf')) {
300
            $ret['metaProject'] = $this->projectRepo->getProject('metawiki');
301
        }
302
303
        return $this->getFormattedResponse('editCounter/result', $ret);
304
    }
305
306
    /**
307
     * Display the general statistics section.
308
     * @Route(
309
     *     "/ec-generalstats/{project}/{username}",
310
     *     name="EditCounterGeneralStats",
311
     *     requirements={
312
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
313
     *     }
314
     * )
315
     * @param GlobalContribsRepository $globalContribsRepo
316
     * @param EditRepository $editRepo
317
     * @return Response
318
     * @codeCoverageIgnore
319
     */
320
    public function generalStatsAction(
321
        GlobalContribsRepository $globalContribsRepo,
322
        EditRepository $editRepo
323
    ): Response {
324
        $this->setUpEditCounter();
325
326
        $globalContribs = new GlobalContribs(
327
            $globalContribsRepo,
328
            $this->pageRepo,
329
            $this->userRepo,
330
            $editRepo,
331
            $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\GlobalContribs::__construct() 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

331
            /** @scrutinizer ignore-type */ $this->user
Loading history...
332
        );
333
        $ret = [
334
            'xtTitle' => $this->user->getUsername(),
335
            'xtPage' => 'EditCounter',
336
            'subtool_msg_key' => 'general-stats',
337
            'is_sub_request' => $this->isSubRequest,
338
            'user' => $this->user,
339
            'project' => $this->project,
340
            'ec' => $this->editCounter,
341
            'gc' => $globalContribs,
342
        ];
343
344
        // Output the relevant format template.
345
        return $this->getFormattedResponse('editCounter/general_stats', $ret);
346
    }
347
348
    /**
349
     * Search form for general stats.
350
     * @Route(
351
     *     "/ec-generalstats",
352
     *     name="EditCounterGeneralStatsIndex",
353
     *     requirements={
354
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
355
     *     }
356
     * )
357
     * @return Response
358
     */
359
    public function generalStatsIndexAction(): Response
360
    {
361
        $this->sections = ['general-stats'];
362
        return $this->indexAction();
363
    }
364
365
    /**
366
     * Display the namespace totals section.
367
     * @Route(
368
     *     "/ec-namespacetotals/{project}/{username}",
369
     *     name="EditCounterNamespaceTotals",
370
     *     requirements={
371
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
372
     *     }
373
     * )
374
     * @return Response
375
     * @codeCoverageIgnore
376
     */
377
    public function namespaceTotalsAction(): Response
378
    {
379
        $this->setUpEditCounter();
380
381
        $ret = [
382
            'xtTitle' => $this->user->getUsername(),
383
            'xtPage' => 'EditCounter',
384
            'subtool_msg_key' => 'namespace-totals',
385
            'is_sub_request' => $this->isSubRequest,
386
            'user' => $this->user,
387
            'project' => $this->project,
388
            'ec' => $this->editCounter,
389
        ];
390
391
        // Output the relevant format template.
392
        return $this->getFormattedResponse('editCounter/namespace_totals', $ret);
393
    }
394
395
    /**
396
     * Search form for namespace totals.
397
     * @Route("/ec-namespacetotals", name="EditCounterNamespaceTotalsIndex")
398
     * @return Response
399
     */
400
    public function namespaceTotalsIndexAction(): Response
401
    {
402
        $this->sections = ['namespace-totals'];
403
        return $this->indexAction();
404
    }
405
406
    /**
407
     * Display the timecard section.
408
     * @Route(
409
     *     "/ec-timecard/{project}/{username}",
410
     *     name="EditCounterTimecard",
411
     *     requirements={
412
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
413
     *     }
414
     * )
415
     * @return Response
416
     * @codeCoverageIgnore
417
     */
418
    public function timecardAction(): Response
419
    {
420
        $this->setUpEditCounter();
421
422
        $ret = [
423
            'xtTitle' => $this->user->getUsername(),
424
            'xtPage' => 'EditCounter',
425
            'subtool_msg_key' => 'timecard',
426
            'is_sub_request' => $this->isSubRequest,
427
            'user' => $this->user,
428
            'project' => $this->project,
429
            'ec' => $this->editCounter,
430
            'opted_in_page' => $this->getOptedInPage(),
431
        ];
432
433
        // Output the relevant format template.
434
        return $this->getFormattedResponse('editCounter/timecard', $ret);
435
    }
436
437
    /**
438
     * Search form for timecard.
439
     * @Route("/ec-timecard", name="EditCounterTimecardIndex")
440
     * @return Response
441
     */
442
    public function timecardIndexAction(): Response
443
    {
444
        $this->sections = ['timecard'];
445
        return $this->indexAction();
446
    }
447
448
    /**
449
     * Display the year counts section.
450
     * @Route(
451
     *     "/ec-yearcounts/{project}/{username}",
452
     *     name="EditCounterYearCounts",
453
     *     requirements={
454
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
455
     *     }
456
     * )
457
     * @return Response
458
     * @codeCoverageIgnore
459
     */
460
    public function yearCountsAction(): Response
461
    {
462
        $this->setUpEditCounter();
463
464
        $ret = [
465
            'xtTitle' => $this->user->getUsername(),
466
            'xtPage' => 'EditCounter',
467
            'subtool_msg_key' => 'year-counts',
468
            'is_sub_request' => $this->isSubRequest,
469
            'user' => $this->user,
470
            'project' => $this->project,
471
            'ec' => $this->editCounter,
472
        ];
473
474
        // Output the relevant format template.
475
        return $this->getFormattedResponse('editCounter/yearcounts', $ret);
476
    }
477
478
    /**
479
     * Search form for year counts.
480
     * @Route("/ec-yearcounts", name="EditCounterYearCountsIndex")
481
     * @return Response
482
     */
483
    public function yearCountsIndexAction(): Response
484
    {
485
        $this->sections = ['year-counts'];
486
        return $this->indexAction();
487
    }
488
489
    /**
490
     * Display the month counts section.
491
     * @Route(
492
     *     "/ec-monthcounts/{project}/{username}",
493
     *     name="EditCounterMonthCounts",
494
     *     requirements={
495
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
496
     *     }
497
     * )
498
     * @return Response
499
     * @codeCoverageIgnore
500
     */
501
    public function monthCountsAction(): Response
502
    {
503
        $this->setUpEditCounter();
504
505
        $ret = [
506
            'xtTitle' => $this->user->getUsername(),
507
            'xtPage' => 'EditCounter',
508
            'subtool_msg_key' => 'month-counts',
509
            'is_sub_request' => $this->isSubRequest,
510
            'user' => $this->user,
511
            'project' => $this->project,
512
            'ec' => $this->editCounter,
513
            'opted_in_page' => $this->getOptedInPage(),
514
        ];
515
516
        // Output the relevant format template.
517
        return $this->getFormattedResponse('editCounter/monthcounts', $ret);
518
    }
519
520
    /**
521
     * Search form for month counts.
522
     * @Route("/ec-monthcounts", name="EditCounterMonthCountsIndex")
523
     * @return Response
524
     */
525
    public function monthCountsIndexAction(): Response
526
    {
527
        $this->sections = ['month-counts'];
528
        return $this->indexAction();
529
    }
530
531
    /**
532
     * Display the user rights changes section.
533
     * @Route(
534
     *     "/ec-rightschanges/{project}/{username}",
535
     *     name="EditCounterRightsChanges",
536
     *     requirements={
537
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
538
     *     }
539
     * )
540
     * @return Response
541
     * @codeCoverageIgnore
542
     */
543
    public function rightsChangesAction(): Response
544
    {
545
        $this->setUpEditCounter();
546
547
        $ret = [
548
            'xtTitle' => $this->user->getUsername(),
549
            'xtPage' => 'EditCounter',
550
            'is_sub_request' => $this->isSubRequest,
551
            'user' => $this->user,
552
            'project' => $this->project,
553
            'ec' => $this->editCounter,
554
        ];
555
556
        if ($this->getParameter('app.is_wmf')) {
557
            $ret['metaProject'] = $this->projectRepo->getProject('metawiki');
558
        }
559
560
        // Output the relevant format template.
561
        return $this->getFormattedResponse('editCounter/rights_changes', $ret);
562
    }
563
564
    /**
565
     * Search form for rights changes.
566
     * @Route("/ec-rightschanges", name="EditCounterRightsChangesIndex")
567
     * @return Response
568
     */
569
    public function rightsChangesIndexAction(): Response
570
    {
571
        $this->sections = ['rights-changes'];
572
        return $this->indexAction();
573
    }
574
575
    /************************ API endpoints ************************/
576
577
    /**
578
     * Get various log counts for the user as JSON.
579
     * @Route(
580
     *     "/api/user/log_counts/{project}/{username}",
581
     *     name="UserApiLogCounts",
582
     *     requirements={
583
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
584
     *     }
585
     * )
586
     * @return JsonResponse
587
     * @codeCoverageIgnore
588
     */
589
    public function logCountsApiAction(): JsonResponse
590
    {
591
        $this->setUpEditCounter();
592
593
        return $this->getFormattedApiResponse([
594
            'log_counts' => $this->editCounter->getLogCounts(),
595
        ]);
596
    }
597
598
    /**
599
     * Get the namespace totals for the user as JSON.
600
     * @Route(
601
     *     "/api/user/namespace_totals/{project}/{username}",
602
     *     name="UserApiNamespaceTotals",
603
     *     requirements={
604
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
605
     *     }
606
     * )
607
     * @return JsonResponse
608
     * @codeCoverageIgnore
609
     */
610
    public function namespaceTotalsApiAction(): JsonResponse
611
    {
612
        $this->setUpEditCounter();
613
614
        return $this->getFormattedApiResponse([
615
            'namespace_totals' => (object)$this->editCounter->namespaceTotals(),
616
        ]);
617
    }
618
619
    /**
620
     * Get the month counts for the user as JSON.
621
     * @Route(
622
     *     "/api/user/month_counts/{project}/{username}",
623
     *     name="UserApiMonthCounts",
624
     *     requirements={
625
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
626
     *     }
627
     * )
628
     * @return JsonResponse
629
     * @codeCoverageIgnore
630
     */
631
    public function monthCountsApiAction(): JsonResponse
632
    {
633
        $this->setUpEditCounter();
634
635
        $ret = $this->editCounter->monthCounts();
636
637
        // Remove labels that are only needed by Twig views, and not consumers of the API.
638
        unset($ret['yearLabels']);
639
        unset($ret['monthLabels']);
640
641
        return $this->getFormattedApiResponse($ret);
642
    }
643
644
    /**
645
     * Get the timecard data as JSON.
646
     * @Route(
647
     *     "/api/user/timecard/{project}/{username}",
648
     *     name="UserApiTimeCard",
649
     *     requirements={
650
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
651
     *     }
652
     * )
653
     * @return JsonResponse
654
     * @codeCoverageIgnore
655
     */
656
    public function timecardApiAction(): JsonResponse
657
    {
658
        $this->setUpEditCounter();
659
660
        return $this->getFormattedApiResponse([
661
            'timecard' => $this->editCounter->timeCard(),
662
        ]);
663
    }
664
}
665