Passed
Push — master ( dcfba0...0e0426 )
by MusikAnimal
04:50
created

XtoolsController::getAndValidatePage()   A

Complexity

Conditions 2
Paths 2

Size

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

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

124
            return $this->redirectToRoute($this->/** @scrutinizer ignore-call */ getToolShortname(), $params);
Loading history...
125
        }
126
127
        return $projectData;
128
    }
129
130
    /**
131
     * Validate the given user, returning a User or Redirect if they don't exist.
132
     * @param string|string[] $params Username or params hash as retrieved by self::getParams().
133
     * @param Project $project Project to get check against.
134
     * @param string $tooHighEditCountAction If the requested user has more than the configured
135
     *   max edit count, they will be redirect to this route, passing in available params.
136
     * @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...
137
     */
138
    public function validateUser($params, Project $project, $tooHighEditCountAction = null)
139
    {
140
        if (is_string($params)) {
141
            $params = ['username' => $params];
142
        }
143
144
        $userData = UserRepository::getUser($params['username'], $this->container);
145
146
        // Don't continue if the user doesn't exist.
147
        if (!$userData->existsOnProject($project)) {
148
            $this->addFlash('danger', 'user-not-found');
149
            unset($params['username']);
150
            return $this->redirectToRoute($this->getToolShortname(), $params);
151
        }
152
153
        // Reject users with a crazy high edit count.
154
        if ($tooHighEditCountAction && $userData->hasTooManyEdits($project)) {
155
            $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

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

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