Completed
Push — master ( 8d2667...a78947 )
by MusikAnimal
04:28
created

XtoolsController::recordApiUsage()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 22
Code Lines 16

Duplication

Lines 10
Ratio 45.45 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 16
nc 2
nop 1
dl 10
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\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) {
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) {
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...
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);
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
            'start',
208
            'end',
209
            'offset',
210
211
            // Legacy parameters.
212
            'user',
213
            'name',
214
            'page',
215
            'wiki',
216
            'wikifam',
217
            'lang',
218
            'wikilang',
219
            'begin',
220
        ];
221
222
        /** @var string[] Each parameter that was detected along with its value. */
223 10
        $params = [];
224
225 10
        foreach ($paramsToCheck as $param) {
226
            // Pull in either from URL query string or route.
227 10
            $value = $request->query->get($param) ?: $request->get($param);
228
229
            // Only store if value is given ('namespace' or 'username' could be '0').
230 10
            if ($value !== null && $value !== '') {
231 10
                $params[$param] = urldecode($value);
232
            }
233
        }
234
235 10
        return $params;
236
    }
237
238
    /**
239
     * Get UTC timestamps from given start and end string parameters.
240
     * This also makes $start on month before $end if not present,
241
     * and makes $end the current time if not present.
242
     * @param  string $start
243
     * @param  string $end
244
     * @param  bool   $useDefaults Whether to use defaults if the values
245
     *   are blank. The start date is set to one month before the end date,
246
     *   and the end date is set to the present.
247
     * @return mixed[] Start and end date as UTC timestamps or 'false' if empty.
248
     */
249 1
    public function getUTCFromDateParams($start, $end, $useDefaults = true)
250
    {
251 1
        $start = strtotime($start);
252 1
        $end = strtotime($end);
253
254
        // Use current time if end is not present (and is required),
255
        // or if it exceeds the current time.
256 1
        if (($useDefaults && $end === false) || $end > time()) {
257
            $end = time();
258
        }
259
260
        // Default to one month before end time if start is not present,
261
        // as is not optional.
262 1
        if ($useDefaults && $start === false) {
263 1
            $start = strtotime('-1 month', $end);
0 ignored issues
show
Bug introduced by
It seems like $end can also be of type false; however, parameter $now of strtotime() 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

263
            $start = strtotime('-1 month', /** @scrutinizer ignore-type */ $end);
Loading history...
264
        }
265
266
        // Reverse if start date is after end date.
267 1
        if ($start > $end && $start !== false && $end !== false) {
268 1
            $newEnd = $start;
269 1
            $start = $end;
270 1
            $end = $newEnd;
271
        }
272
273 1
        return [$start, $end];
274
    }
275
276
    /**
277
     * Given the params hash, normalize any legacy parameters to thier modern equivalent.
278
     * @param  string[] $params
279
     * @return string[]
280
     */
281 9
    private function convertLegacyParams($params)
282
    {
283
        $paramMap = [
284 9
            'user' => 'username',
285
            'name' => 'username',
286
            'page' => 'article',
287
            'begin' => 'start',
288
289
            // Copy super legacy project params to legacy so we can concatenate below.
290
            'wikifam' => 'wiki',
291
            'wikilang' => 'lang',
292
        ];
293
294
        // Copy legacy parameters to modern equivalent.
295 9
        foreach ($paramMap as $legacy => $modern) {
296 9
            if (isset($params[$legacy])) {
297
                $params[$modern] = $params[$legacy];
298 9
                unset($params[$legacy]);
299
            }
300
        }
301
302
        // Separate parameters for language and wiki.
303 9
        if (isset($params['wiki']) && isset($params['lang'])) {
304
            // 'wikifam' will be like '.wikipedia.org', vs just 'wikipedia',
305
            // so we must remove leading periods and trailing .org's.
306
            $params['project'] = rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
307
308
            /** @var string[] Projects for which there is no specific language association. */
309
            $languagelessProjects = $this->container->getParameter('languageless_wikis');
310
311
            // Prepend language if applicable.
312
            if (isset($params['lang']) && !in_array($params['wiki'], $languagelessProjects)) {
313
                $params['project'] = $params['lang'].'.'.$params['project'];
314
            }
315
316
            unset($params['wiki']);
317
            unset($params['lang']);
318
        }
319
320 9
        return $params;
321
    }
322
323
    /**
324
     * Record usage of an API endpoint.
325
     * @param  string $endpoint
326
     * @codeCoverageIgnore
327
     */
328
    public function recordApiUsage($endpoint)
329
    {
330
        $conn = $this->container->get('doctrine')
331
            ->getManager('default')
332
            ->getConnection();
333
        $date =  date('Y-m-d');
334
335
        // Increment count in timeline
336
        $existsSql = "SELECT 1 FROM usage_api_timeline
337
                      WHERE date = '$date'
338
                      AND endpoint = '$endpoint'";
339
340 View Code Duplication
        if (count($conn->query($existsSql)->fetchAll()) === 0) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
341
            $createSql = "INSERT INTO usage_api_timeline
342
                          VALUES(NULL, '$date', '$endpoint', 1)";
343
            $conn->query($createSql);
344
        } else {
345
            $updateSql = "UPDATE usage_api_timeline
346
                          SET count = count + 1
347
                          WHERE endpoint = '$endpoint'
348
                          AND date = '$date'";
349
            $conn->query($updateSql);
350
        }
351
    }
352
}
353