Passed
Push — master ( e6ce91...429907 )
by MusikAnimal
04:46
created

XtoolsController   F

Complexity

Total Complexity 70

Size/Duplication

Total Lines 560
Duplicated Lines 0 %

Test Coverage

Coverage 50.31%

Importance

Changes 0
Metric Value
eloc 213
dl 0
loc 560
ccs 81
cts 161
cp 0.5031
rs 2.8
c 0
b 0
f 0
wmc 70

17 Methods

Rating   Name   Duplication   Size   Complexity  
A recordApiUsage() 0 23 2
A getFormattedResponse() 0 21 3
A getPageFromNsAndTitle() 0 10 4
A validateProject() 0 14 2
B convertLegacyParams() 0 40 7
B validateUser() 0 46 7
A getProjectFromQuery() 0 21 3
A validatePage() 0 16 3
A parseQueryParams() 0 12 2
A getFormattedApiResponse() 0 12 1
A __construct() 0 23 4
A getParams() 0 41 5
A setDates() 0 6 5
A setProperties() 0 20 6
A getFlashMessage() 0 13 2
A throwXtoolsException() 0 26 3
B getUTCFromDateParams() 0 23 11

How to fix   Complexity   

Complex Class

Complex classes like XtoolsController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use XtoolsController, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * This file contains the abstract XtoolsController, which all other controllers will extend.
4
 */
5
6
namespace AppBundle\Controller;
7
8
use AppBundle\Exception\XtoolsHttpException;
9
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
10
use Symfony\Component\DependencyInjection\ContainerInterface;
11
use Symfony\Component\HttpFoundation\JsonResponse;
12
use Symfony\Component\HttpFoundation\Response;
13
use Symfony\Component\HttpFoundation\Request;
14
use Symfony\Component\HttpFoundation\RequestStack;
15
use Xtools\ProjectRepository;
16
use Xtools\UserRepository;
17
use Xtools\Project;
18
use Xtools\Page;
19
use Xtools\PageRepository;
20
use Xtools\User;
21
22
/**
23
 * XtoolsController supplies a variety of methods around parsing and validating parameters, and initializing
24
 * Project/User instances. These are used in other controllers in the AppBundle\Controller namespace.
25
 * @abstract
26
 */
27
abstract class XtoolsController extends Controller
28
{
29
    /** @var Request The request object. */
30
    protected $request;
31
32
    /** @var string Name of the action within the child controller that is being executed. */
33
    protected $controllerAction;
34
35
    /** @var array Hash of params parsed from the Request. */
36
    protected $params;
37
38
    /** @var bool Whether this is a request to an API action. */
39
    protected $isApi;
40
41
    /** @var Project Relevant Project parsed from the Request. */
42
    protected $project;
43
44
    /** @var User Relevant User parsed from the Request. */
45
    protected $user;
46
47
    /** @var Page Relevant Page parsed from the Request. */
48
    protected $page;
49
50
    /** @var int|false Start date parsed from the Request. */
51
    protected $start = false;
52
53
    /** @var int|false End date parsed from the Request. */
54
    protected $end = false;
55
56
    /** @var int|string Namespace parsed from the Request, ID as int or 'all' for all namespaces. */
57
    protected $namespace;
58
59
    /** @var int Pagination offset parsed from the Request. */
60
    protected $offset = 0;
61
62
    /** @var int Number of results to return. */
63
    protected $limit;
64
65
    /** @var bool Is the current request a subrequest? */
66
    protected $isSubRequest;
67
68
    /**
69
     * @var string This activates the 'too high edit count' functionality. This property represents the
70
     *   action that should be redirected to if the user has too high of an edit count.
71
     */
72
    protected $tooHighEditCountAction;
73
74
    /**
75
     * @var array Actions that are exempt from edit count limitations.
76
     */
77
    protected $tooHighEditCountActionBlacklist = [];
78
79
    /**
80
     * Require the tool's index route (initial form) be defined here. This should also
81
     * be the name of the associated model, if present.
82
     * @return string
83
     */
84
    abstract protected function getIndexRoute();
85
86
    /**
87
     * XtoolsController constructor.
88
     * @param RequestStack $requestStack
89
     * @param ContainerInterface $container
90
     */
91 16
    public function __construct(RequestStack $requestStack, ContainerInterface $container)
92
    {
93 16
        $this->request = $requestStack->getCurrentRequest();
94 16
        $this->container = $container;
95 16
        $this->params = $this->parseQueryParams();
96
97 16
        $pattern = "#::([a-zA-Z]*)Action#";
98 16
        $matches = [];
99 16
        preg_match($pattern, $this->request->get('_controller'), $matches);
0 ignored issues
show
Bug introduced by
The method get() 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

99
        preg_match($pattern, $this->request->/** @scrutinizer ignore-call */ get('_controller'), $matches);

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...
100
        // The blank string here only happens in the unit tests, where the request may not be made to an action.
101 16
        $this->controllerAction = isset($matches[1]) ? $matches[1] : '';
102
103
        // Whether the action is an API action.
104 16
        $this->isApi = substr($this->controllerAction, -3) === 'Api';
105
106
        // Whether we're making a subrequest (the view makes a request to another action).
107 16
        $this->isSubRequest = $this->request->get('htmlonly')
108 16
            || $this->get('request_stack')->getParentRequest() !== null;
109
110 16
        if ($this->controllerAction === 'index') {
111 10
            $this->project = $this->getProjectFromQuery();
112
        } else {
113 6
            $this->setProperties();
114
        }
115 16
    }
116
117
    /**
118
     * Normalize all common parameters used by the controllers and set class properties.
119
     */
120 6
    private function setProperties()
121
    {
122
        // No normalization needed for these params.
123 6
        foreach (['namespace', 'offset', 'limit'] as $param) {
124 6
            if (isset($this->params[$param])) {
125 6
                $this->{$param} = $this->params[$param];
126
            }
127
        }
128
129 6
        if (isset($this->params['project'])) {
130 2
            $this->project = $this->validateProject($this->params['project']);
131
        }
132 6
        if (isset($this->params['username'])) {
133
            $this->user = $this->validateUser($this->params['username']);
134
        }
135 6
        if (isset($this->params['page'])) {
136
            $this->page = $this->getPageFromNsAndTitle($this->namespace, $this->params['page']);
0 ignored issues
show
Bug introduced by
It seems like $this->namespace can also be of type string; however, parameter $ns of AppBundle\Controller\Xto...getPageFromNsAndTitle() does only seem to accept integer, 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

136
            $this->page = $this->getPageFromNsAndTitle(/** @scrutinizer ignore-type */ $this->namespace, $this->params['page']);
Loading history...
137
        }
138
139 6
        $this->setDates();
140 6
    }
141
142
    /**
143
     * Set class properties for dates, if such params were passed in.
144
     */
145 6
    private function setDates()
146
    {
147 6
        $start = isset($this->params['start']) ? $this->params['start'] : false;
148 6
        $end = isset($this->params['end']) ? $this->params['end'] : false;
149 6
        if ($start || $end) {
150
            list($this->start, $this->end) = $this->getUTCFromDateParams($start, $end);
151
        }
152 6
    }
153
154
    /**
155
     * Construct a fully qualified page title given the namespace and title.
156
     * @param int $ns Namespace ID.
157
     * @param string $title Page title.
158
     * @param bool $rawTitle Return only the title (and not a Page).
159
     * @return Page|string
160
     */
161
    protected function getPageFromNsAndTitle($ns, $title, $rawTitle = false)
162
    {
163
        if ((int)$ns === 0) {
164
            return $rawTitle ? $title : $this->validatePage($title);
165
        }
166
167
        // Prepend namespace and strip out duplicates.
168
        $nsName = $this->project->getNamespaces()[$ns];
169
        $title = $nsName.':'.ltrim($title, $nsName.':');
170
        return $rawTitle ? $title : $this->validatePage($title);
171
    }
172
173
    /**
174
     * Validate the given project, returning a Project if it is valid or false otherwise.
175
     * @param string $projectQuery Project domain or database name.
176
     * @return Project
177
     * @throws XtoolsHttpException
178
     */
179 2
    public function validateProject($projectQuery)
180
    {
181
        /** @var Project $project */
182 2
        $project = ProjectRepository::getProject($projectQuery, $this->container);
183
184 2
        if ($project->exists()) {
185 2
            return $project;
186
        }
187
188
        $this->throwXtoolsException(
189
            $this->getIndexRoute(),
190
            'Invalid project',
191
            ['invalid-project', $this->params['project']],
192
            'project'
193
        );
194
    }
195
196
    /**
197
     * Parse out common parameters from the request. These include the
198
     * 'project', 'username', 'namespace' and 'page', along with their legacy
199
     * counterparts (e.g. 'lang' and 'wiki').
200
     * @return string[] Normalized parameters (no legacy params).
201
     */
202 16
    public function parseQueryParams()
203
    {
204
        /** @var string[] Each parameter and value that was detected. */
205 16
        $params = $this->getParams();
206
207
        // Covert any legacy parameters, if present.
208 16
        $params = $this->convertLegacyParams($params);
209
210
        // Remove blank values.
211 16
        return array_filter($params, function ($param) {
212
            // 'namespace' or 'username' could be '0'.
213 4
            return $param !== null && $param !== '';
214 16
        });
215
    }
216
217
    /**
218
     * Get a Project instance from the project string, using defaults if the given project string is invalid.
219
     * @return Project
220
     */
221 10
    public function getProjectFromQuery()
222
    {
223
        // Set default project so we can populate the namespace selector
224
        // on index pages.
225 10
        if (empty($this->params['project'])) {
226 8
            $project = $this->container->getParameter('default_project');
227
        } else {
228 2
            $project = $this->params['project'];
229
        }
230
231 10
        $projectData = ProjectRepository::getProject($project, $this->container);
232
233
        // Revert back to defaults if we've established the given project was invalid.
234 10
        if (!$projectData->exists()) {
235
            $projectData = ProjectRepository::getProject(
236
                $this->container->getParameter('default_project'),
237
                $this->container
238
            );
239
        }
240
241 10
        return $projectData;
242
    }
243
244
    /**
245
     * Validate the given user, returning a User or Redirect if they don't exist.
246
     * @param string $username
247
     * @return User
248
     * @throws XtoolsHttpException
249
     */
250
    public function validateUser($username)
251
    {
252
        $user = UserRepository::getUser($username, $this->container);
253
254
        // Allow querying for any IP, currently with no edit count limitation...
255
        // Once T188677 is resolved IPs will be affected by the EXPLAIN results.
256
        if ($user->isAnon()) {
257
            return $user;
258
        }
259
260
        $originalParams = $this->params;
261
262
        // Don't continue if the user doesn't exist.
263
        if (!$user->existsOnProject($this->project)) {
264
            $this->throwXtoolsException($this->getIndexRoute(), 'User not found', 'user-not-found', 'username');
265
        }
266
267
        // Reject users with a crazy high edit count.
268
        if (isset($this->tooHighEditCountAction) &&
269
            !in_array($this->controllerAction, $this->tooHighEditCountActionBlacklist) &&
270
            $user->hasTooManyEdits($this->project)
271
        ) {
272
            /** TODO: Somehow get this to use self::throwXtoolsException */
273
274
            // FIXME: i18n!!
275
            $this->addFlash('danger', ['too-many-edits', number_format($user->maxEdits())]);
0 ignored issues
show
Bug introduced by
array('too-many-edits', ...mat($user->maxEdits())) of type array<integer,string> is incompatible with the type string expected by parameter $message of Symfony\Bundle\Framework...\Controller::addFlash(). ( Ignorable by Annotation )

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

275
            $this->addFlash('danger', /** @scrutinizer ignore-type */ ['too-many-edits', number_format($user->maxEdits())]);
Loading history...
276
277
            // If redirecting to a different controller, show an informative message accordingly.
278
            if ($this->tooHighEditCountAction !== $this->getIndexRoute()) {
279
                // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,
280
                // so this bit is hardcoded. We need to instead give the i18n key of the route.
281
                $this->addFlash('info', ['too-many-edits-redir', 'Simple Counter']);
282
            } else {
283
                // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).
284
                unset($this->params['username']);
285
            }
286
287
            throw new XtoolsHttpException(
288
                'User has made too many edits! Maximum '.$user->maxEdits(),
289
                $this->generateUrl($this->tooHighEditCountAction, $this->params),
290
                $originalParams,
291
                $this->isApi
292
            );
293
        }
294
295
        return $user;
296
    }
297
298
    /**
299
     * Get a Page instance from the given page title, and validate that it exists.
300
     * @param string $pageTitle
301
     * @return Page
302
     * @throws XtoolsHttpException
303
     */
304
    public function validatePage($pageTitle)
305
    {
306
        $page = new Page($this->project, $pageTitle);
307
        $pageRepo = new PageRepository();
308
        $pageRepo->setContainer($this->container);
309
        $page->setRepository($pageRepo);
310
311
        if ($page->exists()) { // Page is valid.
312
            return $page;
313
        }
314
315
        $this->throwXtoolsException(
316
            $this->getIndexRoute(),
317
            'Page not found',
318
            isset($this->params['page']) ? ['no-result', $this->params['page']] : null,
319
            'page'
320
        );
321
    }
322
323
    /**
324
     * Throw an XtoolsHttpException, which the given error message and redirects to specified action.
325
     * @param string $redirectAction Name of action to redirect to.
326
     * @param string $message Shown in API responses (TODO: this should be i18n'd too?)
327
     * @param array|string|null $flashParams
328
     * @param string $invalidParam This will be removed from $this->params. Omit if you don't want this to happen.
329
     * @throws XtoolsHttpException
330
     */
331
    public function throwXtoolsException($redirectAction, $message, $flashParams = null, $invalidParam = null)
332
    {
333
        if (null !== $flashParams) {
334
            $this->addFlash('danger', $flashParams);
0 ignored issues
show
Bug introduced by
It seems like $flashParams can also be of type array; however, parameter $message of Symfony\Bundle\Framework...\Controller::addFlash() 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

334
            $this->addFlash('danger', /** @scrutinizer ignore-type */ $flashParams);
Loading history...
335
        }
336
        $originalParams = $this->params;
337
338
        // Remove invalid parameter if it was given.
339
        if (is_string($invalidParam)) {
340
            unset($this->params[$invalidParam]);
341
        }
342
343
        // We sometimes are redirecting to the index page, so also remove project (otherwise we'd get a redirect loop).
344
        /**
345
         * FIXME: Index pages should have a 'nosubmit' parameter to prevent submission.
346
         * Then we don't even need to remove $invalidParam.
347
         * Better, we should show the error on the results page, with no results.
348
         */
349
        unset($this->params['project']);
350
351
        // Throw exception which will redirect to $redirectAction.
352
        throw new XtoolsHttpException(
353
            $message,
354
            $this->generateUrl($redirectAction, $this->params),
355
            $originalParams,
356
            $this->isApi
357
        );
358
    }
359
360
    /**
361
     * Get the first error message stored in the session's FlashBag.
362
     * @return string
363
     */
364
    public function getFlashMessage()
365
    {
366
        $key = $this->get('session')->getFlashBag()->get('danger')[0];
367
        $param = null;
368
369
        if (is_array($key)) {
370
            list($key, $param) = $key;
371
        }
372
373
        return $this->render('message.twig', [
374
            'key' => $key,
375
            'params' => [$param]
376
        ])->getContent();
377
    }
378
379
    /**
380
     * Get all standardized parameters from the Request, either via URL query string or routing.
381
     * @return string[]
382
     */
383 16
    public function getParams()
384
    {
385
        $paramsToCheck = [
386 16
            'project',
387
            'username',
388
            'namespace',
389
            'page',
390
            'categories',
391
            'redirects',
392
            'deleted',
393
            'start',
394
            'end',
395
            'offset',
396
            'limit',
397
            'format',
398
399
            // Legacy parameters.
400
            'user',
401
            'name',
402
            'article',
403
            'wiki',
404
            'wikifam',
405
            'lang',
406
            'wikilang',
407
            'begin',
408
        ];
409
410
        /** @var string[] Each parameter that was detected along with its value. */
411 16
        $params = [];
412
413 16
        foreach ($paramsToCheck as $param) {
414
            // Pull in either from URL query string or route.
415 16
            $value = $this->request->query->get($param) ?: $this->request->get($param);
416
417
            // Only store if value is given ('namespace' or 'username' could be '0').
418 16
            if ($value !== null && $value !== '') {
419 16
                $params[$param] = rawurldecode($value);
420
            }
421
        }
422
423 16
        return $params;
424
    }
425
426
    /**
427
     * Get UTC timestamps from given start and end string parameters. This also makes $start on month before
428
     * $end if not present, and makes $end the current time if not present.
429
     * @param int|string $start Unix timestamp or string accepted by strtotime.
430
     * @param int|string $end Unix timestamp or string accepted by strtotime.
431
     * @param bool $useDefaults Whether to use defaults if the values are blank. The start date is set to one month
432
     *   before the end date, and the end date is set to the present.
433
     * @return mixed[] Start and end date as UTC timestamps or 'false' if empty.
434
     */
435 1
    public function getUTCFromDateParams($start, $end, $useDefaults = false)
436
    {
437 1
        $start = is_int($start) ? $start : strtotime($start);
438 1
        $end = is_int($end) ? $end : strtotime($end);
439
440
        // Use current time if end is not present (and is required), or if it exceeds the current time.
441 1
        if (($useDefaults && $end === false) || $end > time()) {
0 ignored issues
show
introduced by
The condition $end === false is always false.
Loading history...
442
            $end = time();
443
        }
444
445
        // Default to one month before end time if start is not present, as is not optional.
446 1
        if ($useDefaults && $start === false) {
0 ignored issues
show
introduced by
The condition $start === false is always false.
Loading history...
447 1
            $start = strtotime('-1 month', $end);
448
        }
449
450
        // Reverse if start date is after end date.
451 1
        if ($start > $end && $start !== false && $end !== false) {
452 1
            $newEnd = $start;
453 1
            $start = $end;
454 1
            $end = $newEnd;
455
        }
456
457 1
        return [$start, $end];
458
    }
459
460
    /**
461
     * Given the params hash, normalize any legacy parameters to thier modern equivalent.
462
     * @param string[] $params
463
     * @return string[]
464
     */
465 16
    private function convertLegacyParams($params)
466
    {
467
        $paramMap = [
468 16
            'user' => 'username',
469
            'name' => 'username',
470
            'article' => 'page',
471
            'begin' => 'start',
472
473
            // Copy super legacy project params to legacy so we can concatenate below.
474
            'wikifam' => 'wiki',
475
            'wikilang' => 'lang',
476
        ];
477
478
        // Copy legacy parameters to modern equivalent.
479 16
        foreach ($paramMap as $legacy => $modern) {
480 16
            if (isset($params[$legacy])) {
481
                $params[$modern] = $params[$legacy];
482 16
                unset($params[$legacy]);
483
            }
484
        }
485
486
        // Separate parameters for language and wiki.
487 16
        if (isset($params['wiki']) && isset($params['lang'])) {
488
            // 'wikifam' will be like '.wikipedia.org', vs just 'wikipedia',
489
            // so we must remove leading periods and trailing .org's.
490
            $params['project'] = rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
491
492
            /** @var string[] Projects for which there is no specific language association. */
493
            $languagelessProjects = $this->container->getParameter('languageless_wikis');
494
495
            // Prepend language if applicable.
496
            if (isset($params['lang']) && !in_array($params['wiki'], $languagelessProjects)) {
497
                $params['project'] = $params['lang'].'.'.$params['project'];
498
            }
499
500
            unset($params['wiki']);
501
            unset($params['lang']);
502
        }
503
504 16
        return $params;
505
    }
506
507
    /**
508
     * Record usage of an API endpoint.
509
     * @param string $endpoint
510
     * @codeCoverageIgnore
511
     */
512
    public function recordApiUsage($endpoint)
513
    {
514
        /** @var \Doctrine\DBAL\Connection $conn */
515
        $conn = $this->container->get('doctrine')
516
            ->getManager('default')
517
            ->getConnection();
518
        $date =  date('Y-m-d');
519
520
        // Increment count in timeline
521
        $existsSql = "SELECT 1 FROM usage_api_timeline
522
                      WHERE date = '$date'
523
                      AND endpoint = '$endpoint'";
524
525
        if (count($conn->query($existsSql)->fetchAll()) === 0) {
526
            $createSql = "INSERT INTO usage_api_timeline
527
                          VALUES(NULL, '$date', '$endpoint', 1)";
528
            $conn->query($createSql);
529
        } else {
530
            $updateSql = "UPDATE usage_api_timeline
531
                          SET count = count + 1
532
                          WHERE endpoint = '$endpoint'
533
                          AND date = '$date'";
534
            $conn->query($updateSql);
535
        }
536
    }
537
538
    /**
539
     * Get the rendered template for the requested format.
540
     * @param Request $request
541
     * @param string $templatePath Path to template without format,
542
     *   such as '/editCounter/latest_global'.
543
     * @param array $ret Data that should be passed to the view.
544
     * @return \Symfony\Component\HttpFoundation\Response
545
     * @codeCoverageIgnore
546
     */
547
    public function getFormattedResponse(Request $request, $templatePath, $ret)
548
    {
549
        $format = $request->query->get('format', 'html');
550
        if ($format == '') {
551
            // The default above doesn't work when the 'format' parameter is blank.
552
            $format = 'html';
553
        }
554
555
        $formatMap = [
556
            'wikitext' => 'text/plain',
557
            'csv' => 'text/csv',
558
            'tsv' => 'text/tab-separated-values',
559
            'json' => 'application/json',
560
        ];
561
562
        $response = $this->render("$templatePath.$format.twig", $ret);
563
564
        $contentType = isset($formatMap[$format]) ? $formatMap[$format] : 'text/html';
565
        $response->headers->set('Content-Type', $contentType);
566
567
        return $response;
568
    }
569
570
    /**
571
     * Return a JsonResponse object pre-supplied with the requested params.
572
     * @param $data
573
     * @return JsonResponse
574
     */
575 2
    public function getFormattedApiResponse($data)
576
    {
577 2
        $response = new JsonResponse();
578 2
        $response->setEncodingOptions(JSON_NUMERIC_CHECK);
579 2
        $response->setStatusCode(Response::HTTP_OK);
580
581 2
        $response->setData(array_merge($this->params, [
582
            // In some controllers, $this->params['project'] may be overriden with a Project object.
583 2
            'project' => $this->project->getDomain(),
584 2
        ], $data));
585
586 2
        return $response;
587
    }
588
}
589