Passed
Push — master ( 885c2d...cbeb5d )
by MusikAnimal
05:30
created

XtoolsController::getFlashMessage()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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

129
            $this->addFlash('danger', /** @scrutinizer ignore-type */ ['invalid-project', $params['project']]);
Loading history...
130
            unset($params['project']); // Remove invalid parameter.
131
132
            return $this->redirectToRoute($this->getIndexRoute(), $params);
133
        }
134
135
        return $projectData;
136
    }
137
138
    /**
139
     * Validate the given user, returning a User or Redirect if they don't exist.
140
     * @param string|string[] $params Username or params hash as retrieved by self::getParams().
141
     * @param Project $project Project to get check against.
142
     * @param string $tooHighEditCountAction If the requested user has more than the configured
143
     *   max edit count, they will be redirect to this route, passing in available params.
144
     * @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...
145
     */
146
    public function validateUser($params, Project $project, $tooHighEditCountAction = null)
147
    {
148
        if (is_string($params)) {
149
            $params = ['username' => $params];
150
        }
151
152
        $userData = UserRepository::getUser($params['username'], $this->container);
153
154
        // Allow querying for any IP, currently with no edit count limitation...
155
        // Once T188677 is resolved IPs will be affected by the EXPLAIN results.
156
        if ($userData->isAnon()) {
157
            return $userData;
158
        }
159
160
        // Don't continue if the user doesn't exist.
161
        if (!$userData->existsOnProject($project)) {
162
            $this->addFlash('danger', 'user-not-found');
163
            unset($params['username']);
164
            return $this->redirectToRoute($this->getIndexRoute(), $params);
165
        }
166
167
        // Reject users with a crazy high edit count.
168
        if ($tooHighEditCountAction && $userData->hasTooManyEdits($project)) {
169
            // FIXME: i18n!!
170
            $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

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

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