Passed
Push — master ( 8d51f9...a61152 )
by MusikAnimal
04:42
created

XtoolsController::getParams()   B

Complexity

Conditions 5
Paths 3

Size

Total Lines 38
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 5

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 24
c 1
b 0
f 0
nc 3
nop 1
dl 0
loc 38
ccs 8
cts 8
cp 1
crap 5
rs 8.439
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\HttpFoundation\Request;
11
use Symfony\Component\HttpFoundation\RedirectResponse;
12
use Xtools\ProjectRepository;
13
use Xtools\UserRepository;
14
use Xtools\Project;
15
use Xtools\Page;
16
use Xtools\PageRepository;
17
18
/**
19
 * XtoolsController supplies a variety of methods around parsing and validing
20
 * parameters, and initializing Project/User instances. These are used in
21
 * other controllers in the AppBundle\Controller namespace.
22
 */
23
abstract class XtoolsController extends Controller
24
{
25
    /**
26
     * Given the request object, parse out common parameters. These include the
27
     * 'project', 'username', 'namespace' and 'article', along with their legacy
28
     * counterparts (e.g. 'lang' and 'wiki').
29
     * @param  Request $request
30
     * @return string[] Normalized parameters (no legacy params).
31
     */
32 9
    public function parseQueryParams(Request $request)
33
    {
34
        /** @var string[] Each parameter and value that was detected. */
35 9
        $params = $this->getParams($request);
36
37
        // Covert any legacy parameters, if present.
38 9
        $params = $this->convertLegacyParams($params);
39
40
        // Remove blank values.
41 9
        return array_filter($params, function ($param) {
42
            // 'namespace' or 'username' could be '0'.
43 2
            return $param !== null && $param !== '';
44 9
        });
45
    }
46
47
    /**
48
     * Get a Project instance from the project string, using defaults if the
49
     * given project string is invalid.
50
     * @param  string[] $params Query params.
51
     * @return Project
52
     */
53 7
    public function getProjectFromQuery($params)
54
    {
55
        // Set default project so we can populate the namespace selector
56
        // on index pages.
57 7
        if (empty($params['project'])) {
58 5
            $project = $this->container->getParameter('default_project');
59
        } else {
60 2
            $project = $params['project'];
61
        }
62
63 7
        $projectData = ProjectRepository::getProject($project, $this->container);
64
65
        // Revert back to defaults if we've established the given project was invalid.
66 7
        if (!$projectData->exists()) {
67
            $projectData = ProjectRepository::getProject(
68
                $this->container->getParameter('default_project'),
69
                $this->container
70
            );
71
        }
72
73 7
        return $projectData;
74
    }
75
76
    /**
77
     * If the project and username in the given params hash are valid, Project and User instances
78
     * are returned. User validation only occurs if 'username' is in the params.
79
     * Otherwise a redirect is returned that goes back to the index page.
80
     * @param Request $request The HTTP request.
81
     * @param string $tooHighEditCountAction If the requested user has more than the configured
82
     *   max edit count, they will be redirect to this route, passing in available params.
83
     * @return RedirectResponse|array Array contains [Project|null, User|null]
84
     */
85
    public function validateProjectAndUser(Request $request, $tooHighEditCountAction = null)
86
    {
87
        $params = $this->getParams($request);
88
89
        $projectData = $this->validateProject($params);
90
        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...
91
            return $projectData;
92
        }
93
94
        $userData = null;
95
96
        if (isset($params['username'])) {
97
            $userData = $this->validateUser($params, $projectData, $tooHighEditCountAction);
98
            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...
99
                return $userData;
100
            }
101
        }
102
103
        return [$projectData, $userData];
104
    }
105
106
    /**
107
     * Validate the given project, returning a Project if it is valid or false otherwise.
108
     * @param string|string[] $params Project domain or database name, or params hash as
109
     *   retrieved by self::getParams().
110
     * @return Project|false
111
     */
112
    public function validateProject($params)
113
    {
114
        if (is_string($params)) {
115
            $params = ['project' => $params];
116
        }
117
118
        $projectData = ProjectRepository::getProject($params['project'], $this->container);
119
120
        if (!$projectData->exists()) {
121
            $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

121
            $this->addFlash('danger', /** @scrutinizer ignore-type */ ['invalid-project', $params['project']]);
Loading history...
122
            unset($params['project']); // Remove invalid parameter.
123
            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

123
            return $this->redirectToRoute($this->/** @scrutinizer ignore-call */ getToolShortname(), $params);
Loading history...
Bug introduced by
It seems like $params can also be of type string; however, parameter $parameters of Symfony\Bundle\Framework...ller::redirectToRoute() does only seem to accept array, 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

123
            return $this->redirectToRoute($this->getToolShortname(), /** @scrutinizer ignore-type */ $params);
Loading history...
124
        }
125
126
        return $projectData;
127
    }
128
129
    /**
130
     * Validate the given user, returning a User or Redirect if they don't exist.
131
     * @param string|string[] $params Username or params hash as retrieved by self::getParams().
132
     * @param Project $project Project to get check against.
133
     * @param string $tooHighEditCountAction If the requested user has more than the configured
134
     *   max edit count, they will be redirect to this route, passing in available params.
135
     * @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...
136
     */
137
    public function validateUser($params, Project $project, $tooHighEditCountAction = null)
138
    {
139
        if (is_string($params)) {
140
            $params = ['username' => $params];
141
        }
142
143
        $userData = UserRepository::getUser($params['username'], $this->container);
144
145
        // Don't continue if the user doesn't exist.
146
        if (!$userData->existsOnProject($project)) {
147
            $this->addFlash('danger', 'user-not-found');
148
            unset($params['username']);
149
            return $this->redirectToRoute($this->getToolShortname(), $params);
0 ignored issues
show
Bug introduced by
It seems like $params can also be of type string; however, parameter $parameters of Symfony\Bundle\Framework...ller::redirectToRoute() does only seem to accept array, 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

149
            return $this->redirectToRoute($this->getToolShortname(), /** @scrutinizer ignore-type */ $params);
Loading history...
150
        }
151
152
        // Reject users with a crazy high edit count.
153
        if ($tooHighEditCountAction && $userData->hasTooManyEdits($project)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $tooHighEditCountAction of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
154
            $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

154
            $this->addFlash('danger', /** @scrutinizer ignore-type */ ['too-many-edits', number_format($userData->maxEdits())]);
Loading history...
155
156
            // If redirecting to a different controller, show an informative message accordingly.
157
            if ($tooHighEditCountAction !== $this->getToolShortname()) {
158
                // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter,
159
                // so this bit is hardcoded. We need to instead give the i18n key of the route.
160
                $this->addFlash('info', ['too-many-edits-redir', 'Simple Counter']);
161
            } else {
162
                // Redirecting back to index, so remove username (otherwise we'd get a redirect loop).
163
                unset($params['username']);
164
            }
165
166
            return $this->redirectToRoute($tooHighEditCountAction, $params);
167
        }
168
169
        return $userData;
170
    }
171
172
    /**
173
     * Get a Page instance from the given page title, and validate that it exists.
174
     * @param  Project $project
175
     * @param  string $pageTitle
176
     * @return Page|RedirectResponse Page or redirect back to index if page doesn't exist.
177
     */
178
    public function getAndValidatePage($project, $pageTitle)
179
    {
180
        $page = new Page($project, $pageTitle);
181
        $pageRepo = new PageRepository();
182
        $pageRepo->setContainer($this->container);
183
        $page->setRepository($pageRepo);
184
185
        if (!$page->exists()) {
186
            // Redirect if the page doesn't exist.
187
            $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

187
            $this->addFlash('notice', /** @scrutinizer ignore-type */ ['no-result', $pageTitle]);
Loading history...
188
            return $this->redirectToRoute($this->getToolShortname());
189
        }
190
191
        return $page;
192
    }
193
194
    /**
195
     * Get all standardized parameters from the Request, either via URL query string or routing.
196
     * @param Request $request
197
     * @return string[]
198
     */
199 10
    public function getParams(Request $request)
200
    {
201
        $paramsToCheck = [
202 10
            'project',
203
            'username',
204
            'namespace',
205
            'article',
206
            'redirects',
207
            'deleted',
208
            'start',
209
            'end',
210
            'offset',
211
212
            // Legacy parameters.
213
            'user',
214
            'name',
215
            'page',
216
            'wiki',
217
            'wikifam',
218
            'lang',
219
            'wikilang',
220
            'begin',
221
        ];
222
223
        /** @var string[] Each parameter that was detected along with its value. */
224 10
        $params = [];
225
226 10
        foreach ($paramsToCheck as $param) {
227
            // Pull in either from URL query string or route.
228 10
            $value = $request->query->get($param) ?: $request->get($param);
229
230
            // Only store if value is given ('namespace' or 'username' could be '0').
231 10
            if ($value !== null && $value !== '') {
232 10
                $params[$param] = rawurldecode($value);
233
            }
234
        }
235
236 10
        return $params;
237
    }
238
239
    /**
240
     * Get UTC timestamps from given start and end string parameters.
241
     * This also makes $start on month before $end if not present,
242
     * and makes $end the current time if not present.
243
     * @param  string $start
244
     * @param  string $end
245
     * @param  bool   $useDefaults Whether to use defaults if the values
246
     *   are blank. The start date is set to one month before the end date,
247
     *   and the end date is set to the present.
248
     * @return mixed[] Start and end date as UTC timestamps or 'false' if empty.
249
     */
250 1
    public function getUTCFromDateParams($start, $end, $useDefaults = true)
251
    {
252 1
        $start = strtotime($start);
253 1
        $end = strtotime($end);
254
255
        // Use current time if end is not present (and is required),
256
        // or if it exceeds the current time.
257 1
        if (($useDefaults && $end === false) || $end > time()) {
258
            $end = time();
259
        }
260
261
        // Default to one month before end time if start is not present,
262
        // as is not optional.
263 1
        if ($useDefaults && $start === false) {
0 ignored issues
show
introduced by
The condition $useDefaults && $start === false can never be true.
Loading history...
264 1
            $start = strtotime('-1 month', $end);
265
        }
266
267
        // Reverse if start date is after end date.
268 1
        if ($start > $end && $start !== false && $end !== false) {
269 1
            $newEnd = $start;
270 1
            $start = $end;
271 1
            $end = $newEnd;
272
        }
273
274 1
        return [$start, $end];
275
    }
276
277
    /**
278
     * Given the params hash, normalize any legacy parameters to thier modern equivalent.
279
     * @param  string[] $params
280
     * @return string[]
281
     */
282 9
    private function convertLegacyParams($params)
283
    {
284
        $paramMap = [
285 9
            'user' => 'username',
286
            'name' => 'username',
287
            'page' => 'article',
288
            'begin' => 'start',
289
290
            // Copy super legacy project params to legacy so we can concatenate below.
291
            'wikifam' => 'wiki',
292
            'wikilang' => 'lang',
293
        ];
294
295
        // Copy legacy parameters to modern equivalent.
296 9
        foreach ($paramMap as $legacy => $modern) {
297 9
            if (isset($params[$legacy])) {
298
                $params[$modern] = $params[$legacy];
299 9
                unset($params[$legacy]);
300
            }
301
        }
302
303
        // Separate parameters for language and wiki.
304 9
        if (isset($params['wiki']) && isset($params['lang'])) {
305
            // 'wikifam' will be like '.wikipedia.org', vs just 'wikipedia',
306
            // so we must remove leading periods and trailing .org's.
307
            $params['project'] = rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
308
309
            /** @var string[] Projects for which there is no specific language association. */
310
            $languagelessProjects = $this->container->getParameter('languageless_wikis');
311
312
            // Prepend language if applicable.
313
            if (isset($params['lang']) && !in_array($params['wiki'], $languagelessProjects)) {
314
                $params['project'] = $params['lang'].'.'.$params['project'];
315
            }
316
317
            unset($params['wiki']);
318
            unset($params['lang']);
319
        }
320
321 9
        return $params;
322
    }
323
324
    /**
325
     * Record usage of an API endpoint.
326
     * @param  string $endpoint
327
     * @codeCoverageIgnore
328
     */
329
    public function recordApiUsage($endpoint)
330
    {
331
        $conn = $this->container->get('doctrine')
332
            ->getManager('default')
333
            ->getConnection();
334
        $date =  date('Y-m-d');
335
336
        // Increment count in timeline
337
        $existsSql = "SELECT 1 FROM usage_api_timeline
338
                      WHERE date = '$date'
339
                      AND endpoint = '$endpoint'";
340
341
        if (count($conn->query($existsSql)->fetchAll()) === 0) {
342
            $createSql = "INSERT INTO usage_api_timeline
343
                          VALUES(NULL, '$date', '$endpoint', 1)";
344
            $conn->query($createSql);
345
        } else {
346
            $updateSql = "UPDATE usage_api_timeline
347
                          SET count = count + 1
348
                          WHERE endpoint = '$endpoint'
349
                          AND date = '$date'";
350
            $conn->query($updateSql);
351
        }
352
    }
353
354
    /**
355
     * Get the rendered template for the requested format.
356
     * @param  Request $request
357
     * @param  string  $templatePath Path to template without format,
358
     *   such as '/editCounter/latest_global'.
359
     * @param  array   $ret Data that should be passed to the views.
360
     * @return array
361
     * @codeCoverageIgnore
362
     */
363
    public function getFormattedReponse(Request $request, $templatePath, $ret)
364
    {
365
        $format = $request->query->get('format', 'html');
366
        if ($format == '') {
367
            // The default above doesn't work when the 'format' parameter is blank.
368
            $format = 'html';
369
        }
370
371
        $formatMap = [
372
            'wikitext' => 'text/plain',
373
            'csv' => 'text/csv',
374
            'json' => 'application/json',
375
        ];
376
377
        $response = $this->render("$templatePath.$format.twig", $ret);
378
379
        $contentType = isset($formatMap[$format]) ? $formatMap[$format] : 'text/html';
380
        $response->headers->set('Content-Type', $contentType);
381
382
        return $response;
383
    }
384
}
385