Passed
Push — master ( 9a481f...a0a42c )
by MusikAnimal
05:51
created

XtoolsController::recordApiUsage()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 22
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 16
nc 2
nop 1
dl 0
loc 22
ccs 0
cts 0
cp 0
crap 6
rs 9.2
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains the abstract XtoolsController,
4
 * which all other controllers will extend.
5
 */
6
7
namespace AppBundle\Controller;
8
9
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
10
use Symfony\Component\DependencyInjection\ContainerInterface;
11
use Symfony\Component\HttpFoundation\Request;
12
use Symfony\Component\HttpFoundation\RedirectResponse;
13
use Symfony\Component\HttpFoundation\RequestStack;
14
use Xtools\ProjectRepository;
15
use Xtools\UserRepository;
16
use Xtools\Project;
17
use Xtools\Page;
18
use Xtools\PageRepository;
19
20
/**
21
 * XtoolsController supplies a variety of methods around parsing and validing
22
 * parameters, and initializing Project/User instances. These are used in
23
 * other controllers in the AppBundle\Controller namespace.
24
 * @abstract
25
 */
26
abstract class XtoolsController extends Controller
27
{
28
    /** @var Request The request object. */
29
    protected $request;
30
31
    /** @var ContainerInterface Symfony's container interface. */
32
    protected $container;
33
34
    /** @var string[] Params and their values, parsed from the Request. */
35
    protected $params;
36
37
    /** @var bool Whether this is a subrequest (e.g. called from Twig). */
38
    protected $isSubRequest;
39
40
    /** @var string Name of the action that is being executed. */
41
    private $actionName;
42
43
    /**
44
     * Constructor for the abstract XtoolsController.
45
     * @param RequestStack $requestStack
46
     * @param ContainerInterface $container
47
     */
48 26
    public function __construct(RequestStack $requestStack, ContainerInterface $container)
49
    {
50 26
        $this->request = $requestStack->getCurrentRequest();
51 26
        $this->container = $container;
52
53 26
        if (null !== $this->request) {
54 26
            $this->actionName = $this->getActionName();
55 26
            $this->isSubRequest = $this->request->get('htmlonly') || $this->actionName === 'result';
56 26
            $this->processParams();
57
        }
58 26
    }
59
60
    /**
61
     * Shared method call across all controller actions to process parameters
62
     * and set class properties accordingly.
63
     */
64 26
    private function processParams()
65
    {
66 26
        if ($this->actionName === 'index') {
67 23
            $this->params = $this->parseQueryParams();
68
        } else {
69 3
            $this->params = $this->getParams();
70
        }
71 26
    }
72
73
    /**
74
     * Given the request object, parse out common parameters. These include the
75
     * 'project', 'username', 'namespace' and 'article', along with their legacy
76
     * counterparts (e.g. 'lang' and 'wiki').
77
     * @return string[] Normalized parameters (no legacy params).
78
     */
79 23
    public function parseQueryParams()
80
    {
81
        /** @var string[] Each parameter and value that was detected. */
82 23
        $params = $this->getParams();
83
84
        // Covert any legacy parameters, if present.
85 23
        $params = $this->convertLegacyParams($params);
86
87
        // Remove blank values.
88 23
        return array_filter($params, function ($param) {
89
            // 'namespace' or 'username' could be '0'.
90 15
            return $param !== null && $param !== '';
91 23
        });
92
    }
93
94
    /**
95
     * Get a Project instance from the project string, using defaults if the
96
     * given project string is invalid.
97
     * @param  string[] $params Query params.
98
     * @return Project
99
     */
100 7
    public function getProjectFromQuery($params)
101
    {
102
        // Set default project so we can populate the namespace selector
103
        // on index pages.
104 7
        if (empty($params['project'])) {
105 5
            $project = $this->container->getParameter('default_project');
106
        } else {
107 2
            $project = $params['project'];
108
        }
109
110 7
        $projectData = ProjectRepository::getProject($project, $this->container);
111
112
        // Revert back to defaults if we've established the given project was invalid.
113 7
        if (!$projectData->exists()) {
114
            $projectData = ProjectRepository::getProject(
115
                $this->container->getParameter('default_project'),
116
                $this->container
117
            );
118
        }
119
120 7
        return $projectData;
121
    }
122
123
    /**
124
     * If the project and username in the given params hash are valid, Project and User instances
125
     * are returned. User validation only occurs if 'username' is in the params.
126
     * Otherwise a redirect is returned that goes back to the index page.
127
     * @param string $tooHighEditCountAction If the requested user has more than the configured
128
     *   max edit count, they will be redirect to this route, passing in available params.
129
     * @return RedirectResponse|array Array contains [Project|null, User|null]
130
     */
131
    public function validateProjectAndUser($tooHighEditCountAction = null)
132
    {
133
        $params = $this->getParams();
134
135
        $projectData = $this->validateProject($params);
136
        if ($projectData instanceof RedirectResponse) {
0 ignored issues
show
introduced by
The condition $projectData instanceof ...dation\RedirectResponse can never be true since $projectData is never a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
137
            return $projectData;
138
        }
139
140
        $userData = null;
141
142
        if (isset($params['username'])) {
143
            $userData = $this->validateUser($params, $projectData, $tooHighEditCountAction);
144
            if ($userData instanceof RedirectResponse) {
0 ignored issues
show
introduced by
The condition $userData instanceof Sym...dation\RedirectResponse can never be false since $userData is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
145
                return $userData;
146
            }
147
        }
148
149
        return [$projectData, $userData];
150
    }
151
152
    /**
153
     * Validate the given project, returning a Project if it is valid or false otherwise.
154
     * @param string|string[] $params Project domain or database name, or params hash as
155
     *   retrieved by self::getParams().
156
     * @return Project|false
157
     */
158
    public function validateProject($params)
159
    {
160
        if (is_string($params)) {
161
            $params = ['project' => $params];
162
        }
163
164
        $projectData = ProjectRepository::getProject($params['project'], $this->container);
165
166
        if (!$projectData->exists()) {
167
            $this->addFlash('danger', ['invalid-project', $params['project']]);
0 ignored issues
show
Bug introduced by
array('invalid-project', $params['project']) of type array<integer,mixed|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

167
            $this->addFlash('danger', /** @scrutinizer ignore-type */ ['invalid-project', $params['project']]);
Loading history...
168
            unset($params['project']); // Remove invalid parameter.
169
            return $this->redirectToRoute($this->getToolShortname(), $params);
0 ignored issues
show
Bug introduced by
The method getToolShortname() does not exist on AppBundle\Controller\XtoolsController. It seems like you code against a sub-type of said class. However, the method does not exist in AppBundle\Controller\DefaultController or AppBundle\Controller\MetaController. Are you sure you never get one of those? ( Ignorable by Annotation )

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

169
            return $this->redirectToRoute($this->/** @scrutinizer ignore-call */ getToolShortname(), $params);
Loading history...
170
        }
171
172
        return $projectData;
173
    }
174
175
    /**
176
     * Validate the given user, returning a User or Redirect if they don't exist.
177
     * @param string|string[] $params Username or params hash as retrieved by self::getParams().
178
     * @param Project $project Project to get check against.
179
     * @param string $tooHighEditCountAction If the requested user has more than the configured
180
     *   max edit count, they will be redirect to this route, passing in available params.
181
     * @return RedirectResponse|User
0 ignored issues
show
Bug introduced by
The type AppBundle\Controller\User was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
182
     */
183
    public function validateUser($params, Project $project, $tooHighEditCountAction = null)
184
    {
185
        if (is_string($params)) {
186
            $params = ['username' => $params];
187
        }
188
189
        $userData = UserRepository::getUser($params['username'], $this->container);
190
191
        // Don't continue if the user doesn't exist.
192
        if (!$userData->existsOnProject($project)) {
193
            $this->addFlash('danger', 'user-not-found');
194
            unset($params['username']);
195
            return $this->redirectToRoute($this->getToolShortname(), $params);
196
        }
197
198
        // Reject users with a crazy high edit count.
199
        if ($tooHighEditCountAction && $userData->hasTooManyEdits($project)) {
200
            $this->addFlash('danger', ['too-many-edits', number_format($userData->maxEdits())]);
0 ignored issues
show
Bug introduced by
array('too-many-edits', ...$userData->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

200
            $this->addFlash('danger', /** @scrutinizer ignore-type */ ['too-many-edits', number_format($userData->maxEdits())]);
Loading history...
201
202
            // If redirecting to a different controller, show an informative message accordingly.
203
            if ($tooHighEditCountAction !== $this->getToolShortname()) {
204
                // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,
205
                // so this bit is hardcoded. We need to instead give the i18n key of the route.
206
                $this->addFlash('info', ['too-many-edits-redir', 'Simple Counter']);
207
            } else {
208
                // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).
209
                unset($params['username']);
210
            }
211
212
            return $this->redirectToRoute($tooHighEditCountAction, $params);
213
        }
214
215
        return $userData;
216
    }
217
218
    /**
219
     * Get a Page instance from the given page title, and validate that it exists.
220
     * @param  Project $project
221
     * @param  string $pageTitle
222
     * @return Page|RedirectResponse Page or redirect back to index if page doesn't exist.
223
     */
224
    public function getAndValidatePage($project, $pageTitle)
225
    {
226
        $page = new Page($project, $pageTitle);
227
        $pageRepo = new PageRepository();
228
        $pageRepo->setContainer($this->container);
229
        $page->setRepository($pageRepo);
230
231
        if (!$page->exists()) {
232
            // Redirect if the page doesn't exist.
233
            $this->addFlash('notice', ['no-result', $pageTitle]);
0 ignored issues
show
Bug introduced by
array('no-result', $pageTitle) 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

233
            $this->addFlash('notice', /** @scrutinizer ignore-type */ ['no-result', $pageTitle]);
Loading history...
234
            return $this->redirectToRoute($this->getToolShortname());
235
        }
236
237
        return $page;
238
    }
239
240
    /**
241
     * Get all standardized parameters from the Request, either via URL query string or routing.
242
     * @return string[]
243
     */
244 26
    public function getParams()
245
    {
246
        $paramsToCheck = [
247 26
            'project',
248
            'username',
249
            'namespace',
250
            'article',
251
            'redirects',
252
            'deleted',
253
            'start',
254
            'end',
255
            'offset',
256
            'format',
257
258
            // Legacy parameters.
259
            'user',
260
            'name',
261
            'page',
262
            'wiki',
263
            'wikifam',
264
            'lang',
265
            'wikilang',
266
            'begin',
267
        ];
268
269
        /** @var string[] Each parameter that was detected along with its value. */
270 26
        $params = [];
271
272 26
        foreach ($paramsToCheck as $param) {
273
            // Pull in either from URL query string or route.
274 26
            $value = $this->request->query->get($param) ?: $this->request->get($param);
275
276
            // Only store if value is given ('namespace' or 'username' could be '0').
277 26
            if ($value !== null && $value !== '') {
278 26
                $params[$param] = rawurldecode($value);
279
            }
280
        }
281
282 26
        return $params;
283
    }
284
285
    /**
286
     * Get UTC timestamps from given start and end string parameters.
287
     * This also makes $start on month before $end if not present,
288
     * and makes $end the current time if not present.
289
     * @param  string $start
290
     * @param  string $end
291
     * @param  bool   $useDefaults Whether to use defaults if the values
292
     *   are blank. The start date is set to one month before the end date,
293
     *   and the end date is set to the present.
294
     * @return mixed[] Start and end date as UTC timestamps or 'false' if empty.
295
     */
296 1
    public function getUTCFromDateParams($start, $end, $useDefaults = true)
297
    {
298 1
        $start = strtotime($start);
299 1
        $end = strtotime($end);
300
301
        // Use current time if end is not present (and is required),
302
        // or if it exceeds the current time.
303 1
        if (($useDefaults && $end === false) || $end > time()) {
304
            $end = time();
305
        }
306
307
        // Default to one month before end time if start is not present,
308
        // as is not optional.
309 1
        if ($useDefaults && $start === false) {
0 ignored issues
show
introduced by
The condition $useDefaults && $start === false can never be true.
Loading history...
310 1
            $start = strtotime('-1 month', $end);
311
        }
312
313
        // Reverse if start date is after end date.
314 1
        if ($start > $end && $start !== false && $end !== false) {
315 1
            $newEnd = $start;
316 1
            $start = $end;
317 1
            $end = $newEnd;
318
        }
319
320 1
        return [$start, $end];
321
    }
322
323
    /**
324
     * Given the params hash, normalize any legacy parameters to thier modern equivalent.
325
     * @param  string[] $params
326
     * @return string[]
327
     */
328 23
    private function convertLegacyParams($params)
329
    {
330
        $paramMap = [
331 23
            'user' => 'username',
332
            'name' => 'username',
333
            'page' => 'article',
334
            'begin' => 'start',
335
336
            // Copy super legacy project params to legacy so we can concatenate below.
337
            'wikifam' => 'wiki',
338
            'wikilang' => 'lang',
339
        ];
340
341
        // Copy legacy parameters to modern equivalent.
342 23
        foreach ($paramMap as $legacy => $modern) {
343 23
            if (isset($params[$legacy])) {
344
                $params[$modern] = $params[$legacy];
345 23
                unset($params[$legacy]);
346
            }
347
        }
348
349
        // Separate parameters for language and wiki.
350 23
        if (isset($params['wiki']) && isset($params['lang'])) {
351
            // 'wikifam' will be like '.wikipedia.org', vs just 'wikipedia',
352
            // so we must remove leading periods and trailing .org's.
353
            $params['project'] = rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
354
355
            /** @var string[] Projects for which there is no specific language association. */
356
            $languagelessProjects = $this->container->getParameter('languageless_wikis');
357
358
            // Prepend language if applicable.
359
            if (isset($params['lang']) && !in_array($params['wiki'], $languagelessProjects)) {
360
                $params['project'] = $params['lang'].'.'.$params['project'];
361
            }
362
363
            unset($params['wiki']);
364
            unset($params['lang']);
365
        }
366
367 23
        return $params;
368
    }
369
370
    /**
371
     * Record usage of an API endpoint.
372
     * @param string $endpoint
373
     * @codeCoverageIgnore
374
     */
375
    public function recordApiUsage($endpoint)
376
    {
377
        $conn = $this->container->get('doctrine')
378
            ->getManager('default')
379
            ->getConnection();
380
        $date =  date('Y-m-d');
381
382
        // Increment count in timeline
383
        $existsSql = "SELECT 1 FROM usage_api_timeline
384
                      WHERE date = '$date'
385
                      AND endpoint = '$endpoint'";
386
387
        if (count($conn->query($existsSql)->fetchAll()) === 0) {
388
            $createSql = "INSERT INTO usage_api_timeline
389
                          VALUES(NULL, '$date', '$endpoint', 1)";
390
            $conn->query($createSql);
391
        } else {
392
            $updateSql = "UPDATE usage_api_timeline
393
                          SET count = count + 1
394
                          WHERE endpoint = '$endpoint'
395
                          AND date = '$date'";
396
            $conn->query($updateSql);
397
        }
398
    }
399
400
    /**
401
     * Get the rendered template for the requested format.
402
     * @param  string  $templatePath Path to template without format,
403
     *   such as '/editCounter/latest_global'.
404
     * @param  array $ret Data that should be passed to the view.
405
     * @return array
406
     * @codeCoverageIgnore
407
     */
408
    public function getFormattedReponse($templatePath, $ret)
409
    {
410
        $format = $this->request->query->get('format', 'html');
411
        if ($format == '') {
412
            // The default above doesn't work when the 'format' parameter is blank.
413
            $format = 'html';
414
        }
415
416
        $formatMap = [
417
            'wikitext' => 'text/plain',
418
            'csv' => 'text/csv',
419
            'json' => 'application/json',
420
        ];
421
422
        $response = $this->render("$templatePath.$format.twig", $ret);
423
424
        $contentType = isset($formatMap[$format]) ? $formatMap[$format] : 'text/html';
425
        $response->headers->set('Content-Type', $contentType);
426
427
        return $response;
428
    }
429
430
    /**
431
     * Get current action name.
432
     * @return string
433
     * There is no request stack in unit tests.
434
     * @codeCoverageIgnore
435
     */
436
    private function getActionName()
437
    {
438
        $pattern = "#::([a-zA-Z]*)Action#";
439
        $matches = [];
440
        preg_match($pattern, $this->request->get('_controller'), $matches);
441
442
        return $matches[1];
443
    }
444
}
445