Issues (196)

Security Analysis    6 potential vulnerabilities

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  SQL Injection (4)
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Variable Injection (1)
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

src/Controller/EditCounterController.php (4 issues)

Labels
1
<?php
2
3
declare(strict_types=1);
4
5
namespace App\Controller;
6
7
use App\Model\EditCounter;
8
use App\Model\GlobalContribs;
9
use App\Model\UserRights;
10
use App\Repository\EditCounterRepository;
11
use App\Repository\EditRepository;
12
use App\Repository\GlobalContribsRepository;
13
use App\Repository\UserRightsRepository;
14
use OpenApi\Annotations as OA;
15
use Symfony\Component\HttpFoundation\Cookie;
16
use Symfony\Component\HttpFoundation\JsonResponse;
17
use Symfony\Component\HttpFoundation\RedirectResponse;
18
use Symfony\Component\HttpFoundation\RequestStack;
19
use Symfony\Component\HttpFoundation\Response;
20
use Symfony\Component\Routing\Annotation\Route;
21
22
/**
23
 * Class EditCounterController
24
 */
25
class EditCounterController extends XtoolsController
26
{
27
    /**
28
     * Available statistic sections. These can be hand-picked on the index form so that you only get the data you
29
     * want and hence speed up the tool. Keys are the i18n messages (and DOM IDs), values are the action names.
30
     */
31
    private const AVAILABLE_SECTIONS = [
32
        'general-stats' => 'EditCounterGeneralStats',
33
        'namespace-totals' => 'EditCounterNamespaceTotals',
34
        'year-counts' => 'EditCounterYearCounts',
35
        'month-counts' => 'EditCounterMonthCounts',
36
        'timecard' => 'EditCounterTimecard',
37
        'top-edited-pages' => 'TopEditsResultNamespace',
38
        'rights-changes' => 'EditCounterRightsChanges',
39
    ];
40
41
    protected EditCounter $editCounter;
42
    protected UserRights $userRights;
43
44
    /** @var string[] Which sections to show. */
45
    protected array $sections;
46
47
    /**
48
     * @inheritDoc
49
     * @codeCoverageIgnore
50
     */
51
    public function getIndexRoute(): string
52
    {
53
        return 'EditCounter';
54
    }
55
56
    /**
57
     * Causes the tool to redirect to the Simple Edit Counter if the user has too high of an edit count.
58
     * @inheritDoc
59
     * @codeCoverageIgnore
60
     */
61
    public function tooHighEditCountRoute(): string
62
    {
63
        return 'SimpleEditCounterResult';
64
    }
65
66
    /**
67
     * @inheritDoc
68
     * @codeCoverageIgnore
69
     */
70
    public function tooHighEditCountActionAllowlist(): array
71
    {
72
        return ['rightsChanges'];
73
    }
74
75
    /**
76
     * @inheritDoc
77
     * @codeCoverageIgnore
78
     */
79
    public function restrictedApiActions(): array
80
    {
81
        return ['monthCountsApi', 'timecardApi'];
82
    }
83
84
    /**
85
     * Every action in this controller (other than 'index') calls this first.
86
     * If a response is returned, the calling action is expected to return it.
87
     * @param EditCounterRepository $editCounterRepo
88
     * @param UserRightsRepository $userRightsRepo
89
     * @param RequestStack $requestStack
90
     * @codeCoverageIgnore
91
     */
92
    protected function setUpEditCounter(
93
        EditCounterRepository $editCounterRepo,
94
        UserRightsRepository $userRightsRepo,
95
        RequestStack $requestStack
96
    ): void {
97
        // Whether we're making a subrequest (the view makes a request to another action).
98
        // Subrequests to the same controller do not re-instantiate a new controller, and hence
99
        // this flag would not be set in XtoolsController::__construct(), so we must do it here as well.
100
        $this->isSubRequest = $this->request->get('htmlonly')
101
            || null !== $requestStack->getParentRequest();
102
103
        // Return the EditCounter if we already have one.
104
        if (isset($this->editCounter)) {
105
            return;
106
        }
107
108
        // Will redirect to Simple Edit Counter if they have too many edits, as defined self::construct.
109
        $this->validateUser($this->user->getUsername());
0 ignored issues
show
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

109
        $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...
110
111
        // Store which sections of the Edit Counter they requested.
112
        $this->sections = $this->getRequestedSections();
113
114
        $this->userRights = new UserRights($userRightsRepo, $this->project, $this->user, $this->i18n);
0 ignored issues
show
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

114
        $this->userRights = new UserRights($userRightsRepo, $this->project, /** @scrutinizer ignore-type */ $this->user, $this->i18n);
Loading history...
115
116
        // Instantiate EditCounter.
117
        $this->editCounter = new EditCounter(
118
            $editCounterRepo,
119
            $this->i18n,
120
            $this->userRights,
121
            $this->project,
122
            $this->user
0 ignored issues
show
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

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

309
            /** @scrutinizer ignore-type */ $this->user
Loading history...
310
        );
311
        $ret = [
312
            'xtTitle' => $this->user->getUsername(),
313
            'xtPage' => 'EditCounter',
314
            'subtool_msg_key' => 'general-stats',
315
            'is_sub_request' => $this->isSubRequest,
316
            'user' => $this->user,
317
            'project' => $this->project,
318
            'ec' => $this->editCounter,
319
            'gc' => $globalContribs,
320
        ];
321
322
        // Output the relevant format template.
323
        return $this->getFormattedResponse('editCounter/general_stats', $ret);
324
    }
325
326
    /**
327
     * Search form for general stats.
328
     * @Route(
329
     *     "/ec-generalstats",
330
     *     name="EditCounterGeneralStatsIndex",
331
     *     requirements={
332
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
333
     *     }
334
     * )
335
     * @return Response
336
     */
337
    public function generalStatsIndexAction(): Response
338
    {
339
        $this->sections = ['general-stats'];
340
        return $this->indexAction();
341
    }
342
343
    /**
344
     * Display the namespace totals section.
345
     * @Route(
346
     *     "/ec-namespacetotals/{project}/{username}",
347
     *     name="EditCounterNamespaceTotals",
348
     *     requirements={
349
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
350
     *     }
351
     * )
352
     * @param EditCounterRepository $editCounterRepo
353
     * @param UserRightsRepository $userRightsRepo
354
     * @param RequestStack $requestStack
355
     * @return Response
356
     * @codeCoverageIgnore
357
     */
358
    public function namespaceTotalsAction(
359
        EditCounterRepository $editCounterRepo,
360
        UserRightsRepository $userRightsRepo,
361
        RequestStack $requestStack
362
    ): Response {
363
        $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack);
364
365
        $ret = [
366
            'xtTitle' => $this->user->getUsername(),
367
            'xtPage' => 'EditCounter',
368
            'subtool_msg_key' => 'namespace-totals',
369
            'is_sub_request' => $this->isSubRequest,
370
            'user' => $this->user,
371
            'project' => $this->project,
372
            'ec' => $this->editCounter,
373
        ];
374
375
        // Output the relevant format template.
376
        return $this->getFormattedResponse('editCounter/namespace_totals', $ret);
377
    }
378
379
    /**
380
     * Search form for namespace totals.
381
     * @Route("/ec-namespacetotals", name="EditCounterNamespaceTotalsIndex")
382
     * @return Response
383
     */
384
    public function namespaceTotalsIndexAction(): Response
385
    {
386
        $this->sections = ['namespace-totals'];
387
        return $this->indexAction();
388
    }
389
390
    /**
391
     * Display the timecard section.
392
     * @Route(
393
     *     "/ec-timecard/{project}/{username}",
394
     *     name="EditCounterTimecard",
395
     *     requirements={
396
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
397
     *     }
398
     * )
399
     * @param EditCounterRepository $editCounterRepo
400
     * @param UserRightsRepository $userRightsRepo
401
     * @param RequestStack $requestStack
402
     * @return Response
403
     * @codeCoverageIgnore
404
     */
405
    public function timecardAction(
406
        EditCounterRepository $editCounterRepo,
407
        UserRightsRepository $userRightsRepo,
408
        RequestStack $requestStack
409
    ): Response {
410
        $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack);
411
412
        $ret = [
413
            'xtTitle' => $this->user->getUsername(),
414
            'xtPage' => 'EditCounter',
415
            'subtool_msg_key' => 'timecard',
416
            'is_sub_request' => $this->isSubRequest,
417
            'user' => $this->user,
418
            'project' => $this->project,
419
            'ec' => $this->editCounter,
420
            'opted_in_page' => $this->getOptedInPage(),
421
        ];
422
423
        // Output the relevant format template.
424
        return $this->getFormattedResponse('editCounter/timecard', $ret);
425
    }
426
427
    /**
428
     * Search form for timecard.
429
     * @Route("/ec-timecard", name="EditCounterTimecardIndex")
430
     * @return Response
431
     */
432
    public function timecardIndexAction(): Response
433
    {
434
        $this->sections = ['timecard'];
435
        return $this->indexAction();
436
    }
437
438
    /**
439
     * Display the year counts section.
440
     * @Route(
441
     *     "/ec-yearcounts/{project}/{username}",
442
     *     name="EditCounterYearCounts",
443
     *     requirements={
444
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
445
     *     }
446
     * )
447
     * @param EditCounterRepository $editCounterRepo
448
     * @param UserRightsRepository $userRightsRepo
449
     * @param RequestStack $requestStack
450
     * @return Response
451
     * @codeCoverageIgnore
452
     */
453
    public function yearCountsAction(
454
        EditCounterRepository $editCounterRepo,
455
        UserRightsRepository $userRightsRepo,
456
        RequestStack $requestStack
457
    ): Response {
458
        $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack);
459
460
        $ret = [
461
            'xtTitle' => $this->user->getUsername(),
462
            'xtPage' => 'EditCounter',
463
            'subtool_msg_key' => 'year-counts',
464
            'is_sub_request' => $this->isSubRequest,
465
            'user' => $this->user,
466
            'project' => $this->project,
467
            'ec' => $this->editCounter,
468
        ];
469
470
        // Output the relevant format template.
471
        return $this->getFormattedResponse('editCounter/yearcounts', $ret);
472
    }
473
474
    /**
475
     * Search form for year counts.
476
     * @Route("/ec-yearcounts", name="EditCounterYearCountsIndex")
477
     * @return Response
478
     */
479
    public function yearCountsIndexAction(): Response
480
    {
481
        $this->sections = ['year-counts'];
482
        return $this->indexAction();
483
    }
484
485
    /**
486
     * Display the month counts section.
487
     * @Route(
488
     *     "/ec-monthcounts/{project}/{username}",
489
     *     name="EditCounterMonthCounts",
490
     *     requirements={
491
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
492
     *     }
493
     * )
494
     * @param EditCounterRepository $editCounterRepo
495
     * @param UserRightsRepository $userRightsRepo
496
     * @param RequestStack $requestStack
497
     * @return Response
498
     * @codeCoverageIgnore
499
     */
500
    public function monthCountsAction(
501
        EditCounterRepository $editCounterRepo,
502
        UserRightsRepository $userRightsRepo,
503
        RequestStack $requestStack
504
    ): Response {
505
        $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack);
506
507
        $ret = [
508
            'xtTitle' => $this->user->getUsername(),
509
            'xtPage' => 'EditCounter',
510
            'subtool_msg_key' => 'month-counts',
511
            'is_sub_request' => $this->isSubRequest,
512
            'user' => $this->user,
513
            'project' => $this->project,
514
            'ec' => $this->editCounter,
515
            'opted_in_page' => $this->getOptedInPage(),
516
        ];
517
518
        // Output the relevant format template.
519
        return $this->getFormattedResponse('editCounter/monthcounts', $ret);
520
    }
521
522
    /**
523
     * Search form for month counts.
524
     * @Route("/ec-monthcounts", name="EditCounterMonthCountsIndex")
525
     * @return Response
526
     */
527
    public function monthCountsIndexAction(): Response
528
    {
529
        $this->sections = ['month-counts'];
530
        return $this->indexAction();
531
    }
532
533
    /**
534
     * Display the user rights changes section.
535
     * @Route(
536
     *     "/ec-rightschanges/{project}/{username}",
537
     *     name="EditCounterRightsChanges",
538
     *     requirements={
539
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
540
     *     }
541
     * )
542
     * @param EditCounterRepository $editCounterRepo
543
     * @param UserRightsRepository $userRightsRepo
544
     * @param RequestStack $requestStack
545
     * @return Response
546
     * @codeCoverageIgnore
547
     */
548
    public function rightsChangesAction(
549
        EditCounterRepository $editCounterRepo,
550
        UserRightsRepository $userRightsRepo,
551
        RequestStack $requestStack
552
    ): Response {
553
        $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack);
554
555
        $ret = [
556
            'xtTitle' => $this->user->getUsername(),
557
            'xtPage' => 'EditCounter',
558
            'is_sub_request' => $this->isSubRequest,
559
            'user' => $this->user,
560
            'project' => $this->project,
561
            'ec' => $this->editCounter,
562
        ];
563
564
        if ($this->isWMF) {
565
            $ret['metaProject'] = $this->projectRepo->getProject('metawiki');
566
        }
567
568
        // Output the relevant format template.
569
        return $this->getFormattedResponse('editCounter/rights_changes', $ret);
570
    }
571
572
    /**
573
     * Search form for rights changes.
574
     * @Route("/ec-rightschanges", name="EditCounterRightsChangesIndex")
575
     * @return Response
576
     */
577
    public function rightsChangesIndexAction(): Response
578
    {
579
        $this->sections = ['rights-changes'];
580
        return $this->indexAction();
581
    }
582
583
    /************************ API endpoints ************************/
584
585
    /**
586
     * Get counts of various log actions made by the user.
587
     * @Route(
588
     *     "/api/user/log_counts/{project}/{username}",
589
     *     name="UserApiLogCounts",
590
     *     requirements={
591
     *         "username"="(ipr-.+\/\d+[^\/])|([^\/]+)",
592
     *     },
593
     *     methods={"GET"}
594
     * )
595
     * @OA\Tag(name="User API")
596
     * @OA\Get(description="Get counts of various logged actions made by a user. The keys of the returned `log_counts`
597
           property describe the log type and log action in the form of _type-action_.
598
           See also the [logevents API](https://www.mediawiki.org/wiki/Special:MyLanguage/API:Logevents).")
599
     * @OA\ExternalDocumentation(url="https://www.mediawiki.org/wiki/Manual:Log_actions")
600
     * @OA\Parameter(ref="#/components/parameters/Project")
601
     * @OA\Parameter(ref="#/components/parameters/UsernameOrIp")
602
     * @OA\Response(
603
     *     response=200,
604
     *     description="Counts of logged actions",
605
     *     @OA\JsonContent(
606
     *         @OA\Property(property="project", ref="#/components/parameters/Project/schema"),
607
     *         @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"),
608
     *         @OA\Property(property="log_counts", type="object", example={
609
     *             "block-block": 0,
610
     *             "block-unblock": 0,
611
     *             "protect-protect": 0,
612
     *             "protect-unprotect": 0,
613
     *             "move-move": 0,
614
     *             "move-move_redir": 0
615
     *         })
616
     *     )
617
     * )
618
     * @OA\Response(response=404, ref="#/components/responses/404")
619
     * @OA\Response(response=501, ref="#/components/responses/501")
620
     * @OA\Response(response=503, ref="#/components/responses/503")
621
     * @OA\Response(response=504, ref="#/components/responses/504")
622
     * @param EditCounterRepository $editCounterRepo
623
     * @param UserRightsRepository $userRightsRepo
624
     * @param RequestStack $requestStack
625
     * @return JsonResponse
626
     * @codeCoverageIgnore
627
     */
628
    public function logCountsApiAction(
629
        EditCounterRepository $editCounterRepo,
630
        UserRightsRepository $userRightsRepo,
631
        RequestStack $requestStack
632
    ): JsonResponse {
633
        $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack);
634
635
        return $this->getFormattedApiResponse([
636
            'log_counts' => $this->editCounter->getLogCounts(),
637
        ]);
638
    }
639
640
    /**
641
     * Get the number of edits made by the user to each namespace.
642
     * @Route(
643
     *     "/api/user/namespace_totals/{project}/{username}",
644
     *     name="UserApiNamespaceTotals",
645
     *     requirements={
646
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
647
     *     },
648
     *     methods={"GET"}
649
     * )
650
     * @OA\Tag(name="User API")
651
     * @OA\Get(description="Get edit counts of a user broken down by [namespace](https://w.wiki/6oKq).")
652
     * @OA\Parameter(ref="#/components/parameters/Project")
653
     * @OA\Parameter(ref="#/components/parameters/UsernameOrIp")
654
     * @OA\Response(
655
     *     response=200,
656
     *     description="Namepsace totals",
657
     *     @OA\JsonContent(
658
     *         @OA\Property(property="project", ref="#/components/parameters/Project/schema"),
659
     *         @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"),
660
     *         @OA\Property(property="namespace_totals", type="object", example={"0": 50, "2": 10, "3": 100},
661
     *             description="Keys are namespace IDs, values are edit counts.")
662
     *     )
663
     * )
664
     * @OA\Response(response=404, ref="#/components/responses/404")
665
     * @OA\Response(response=501, ref="#/components/responses/501")
666
     * @OA\Response(response=503, ref="#/components/responses/503")
667
     * @OA\Response(response=504, ref="#/components/responses/504")
668
     * @param EditCounterRepository $editCounterRepo
669
     * @param UserRightsRepository $userRightsRepo
670
     * @param RequestStack $requestStack
671
     * @return JsonResponse
672
     * @codeCoverageIgnore
673
     */
674
    public function namespaceTotalsApiAction(
675
        EditCounterRepository $editCounterRepo,
676
        UserRightsRepository $userRightsRepo,
677
        RequestStack $requestStack
678
    ): JsonResponse {
679
        $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack);
680
681
        return $this->getFormattedApiResponse([
682
            'namespace_totals' => (object)$this->editCounter->namespaceTotals(),
683
        ]);
684
    }
685
686
    /**
687
     * Get the number of edits made by the user for each month, grouped by namespace.
688
     * @Route(
689
     *     "/api/user/month_counts/{project}/{username}",
690
     *     name="UserApiMonthCounts",
691
     *     requirements={
692
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
693
     *     },
694
     *     methods={"GET"}
695
     * )
696
     * @OA\Tag(name="User API")
697
     * @OA\Get(description="Get the number of edits a user has made grouped by namespace and month.")
698
     * @OA\Parameter(ref="#/components/parameters/Project")
699
     * @OA\Parameter(ref="#/components/parameters/UsernameOrIp")
700
     * @OA\Response(
701
     *     response=200,
702
     *     description="Month counts",
703
     *     @OA\JsonContent(
704
     *         @OA\Property(property="project", ref="#/components/parameters/Project/schema"),
705
     *         @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"),
706
     *         @OA\Property(property="totals", type="object", example={
707
     *             "0": {
708
     *                 "2020-11": 40,
709
     *                 "2020-12": 50,
710
     *                 "2021-01": 5
711
     *             },
712
     *             "3": {
713
     *                 "2020-11": 0,
714
     *                 "2020-12": 10,
715
     *                 "2021-01": 0
716
     *             }
717
     *         })
718
     *     )
719
     * )
720
     * @OA\Response(response=404, ref="#/components/responses/404")
721
     * @OA\Response(response=501, ref="#/components/responses/501")
722
     * @OA\Response(response=503, ref="#/components/responses/503")
723
     * @OA\Response(response=504, ref="#/components/responses/504")
724
     * @param EditCounterRepository $editCounterRepo
725
     * @param UserRightsRepository $userRightsRepo
726
     * @param RequestStack $requestStack
727
     * @return JsonResponse
728
     * @codeCoverageIgnore
729
     */
730
    public function monthCountsApiAction(
731
        EditCounterRepository $editCounterRepo,
732
        UserRightsRepository $userRightsRepo,
733
        RequestStack $requestStack
734
    ): JsonResponse {
735
        $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack);
736
737
        $ret = $this->editCounter->monthCounts();
738
739
        // Remove labels that are only needed by Twig views, and not consumers of the API.
740
        unset($ret['yearLabels']);
741
        unset($ret['monthLabels']);
742
743
        // Ensure 'totals' keys are strings, see T292031.
744
        $ret['totals'] = (object)$ret['totals'];
745
746
        return $this->getFormattedApiResponse($ret);
747
    }
748
749
    /**
750
     * Get the total number of edits made by a user during each hour of day and day of week.
751
     * @Route(
752
     *     "/api/user/timecard/{project}/{username}",
753
     *     name="UserApiTimeCard",
754
     *     requirements={
755
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
756
     *     },
757
     *     methods={"GET"}
758
     * )
759
     * @OA\Tag(name="User API")
760
     * @OA\Get(description="Get the raw number of edits made by a user during each hour of day and day of week. The
761
            `scale` is a value that indicates the number of edits made relative to other hours and days of the week.")
762
     * @OA\Parameter(ref="#/components/parameters/Project")
763
     * @OA\Parameter(ref="#/components/parameters/UsernameOrIp")
764
     * @OA\Response(
765
     *     response=200,
766
     *     description="Timecard",
767
     *     @OA\JsonContent(
768
     *         @OA\Property(property="project", ref="#/components/parameters/Project/schema"),
769
     *         @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"),
770
     *         @OA\Property(property="timecard", type="array", @OA\Items(type="object"), example={
771
     *             {
772
     *                 "day_of_week": 1,
773
     *                 "hour": 0,
774
     *                 "value": 50,
775
     *                 "scale": 5
776
     *             }
777
     *         })
778
     *     )
779
     * )
780
     * @OA\Response(response=404, ref="#/components/responses/404")
781
     * @OA\Response(response=501, ref="#/components/responses/501")
782
     * @OA\Response(response=503, ref="#/components/responses/503")
783
     * @OA\Response(response=504, ref="#/components/responses/504")
784
     * @param EditCounterRepository $editCounterRepo
785
     * @param UserRightsRepository $userRightsRepo
786
     * @param RequestStack $requestStack
787
     * @return JsonResponse
788
     * @codeCoverageIgnore
789
     */
790
    public function timecardApiAction(
791
        EditCounterRepository $editCounterRepo,
792
        UserRightsRepository $userRightsRepo,
793
        RequestStack $requestStack
794
    ): JsonResponse {
795
        $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack);
796
797
        return $this->getFormattedApiResponse([
798
            'timecard' => $this->editCounter->timeCard(),
799
        ]);
800
    }
801
}
802