Completed
Push — master ( e3e8c6...010637 )
by MusikAnimal
12s
created

XtoolsController::validateUser()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 34
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 34
rs 8.439
c 0
b 0
f 0
cc 6
eloc 16
nc 8
nop 3
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\PagesRepository;
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
    public function parseQueryParams(Request $request)
33
    {
34
        /** @var string[] Each parameter and value that was detected. */
35
        $params = $this->getParams($request);
36
37
        // Covert any legacy parameters, if present.
38
        $params = $this->convertLegacyParams($params);
39
40
        // Remove blank values.
41
        return array_filter($params, function ($param) {
42
            // 'namespace' or 'username' could be '0'.
43
            return $param !== null && $param !== '';
44
        });
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
    public function getProjectFromQuery($params)
54
    {
55
        // Set default project so we can populate the namespace selector
56
        // on index pages.
57
        if (empty($params['project'])) {
58
            $project = $this->container->getParameter('default_project');
59
        } else {
60
            $project = $params['project'];
61
        }
62
63
        $projectData = ProjectRepository::getProject($project, $this->container);
64
65
        // Revert back to defaults if we've established the given project was invalid.
66
        if (!$projectData->exists()) {
67
            $projectData = ProjectRepository::getProject(
68
                $this->container->getParameter('default_project'),
69
                $this->container
70
            );
71
        }
72
73
        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
Documentation introduced by
array('invalid-project', $params['project']) is of type array<integer,string,{"0":"string","1":"string"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
122
            unset($params['project']); // Remove invalid parameter.
123
            return $this->redirectToRoute($this->getToolShortname(), $params);
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class AppBundle\Controller\XtoolsController as the method getToolShortname() does only exist in the following sub-classes of AppBundle\Controller\XtoolsController: AppBundle\Controller\AdminScoreController, AppBundle\Controller\AdminStatsController, AppBundle\Controller\ArticleInfoController, AppBundle\Controller\AutomatedEditsController, AppBundle\Controller\EditCounterController, AppBundle\Controller\EditSummaryController, AppBundle\Controller\PagesController, AppBundle\Controller\SimpleEditCounterController, AppBundle\Controller\TopEditsController. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
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
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);
1 ignored issue
show
Compatibility introduced by
$this->container of type object<Symfony\Component...ion\ContainerInterface> is not a sub-type of object<Symfony\Component...ncyInjection\Container>. It seems like you assume a concrete implementation of the interface Symfony\Component\Depend...tion\ContainerInterface to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
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 you code against a specific sub-type and not the parent class AppBundle\Controller\XtoolsController as the method getToolShortname() does only exist in the following sub-classes of AppBundle\Controller\XtoolsController: AppBundle\Controller\AdminScoreController, AppBundle\Controller\AdminStatsController, AppBundle\Controller\ArticleInfoController, AppBundle\Controller\AutomatedEditsController, AppBundle\Controller\EditCounterController, AppBundle\Controller\EditSummaryController, AppBundle\Controller\PagesController, AppBundle\Controller\SimpleEditCounterController, AppBundle\Controller\TopEditsController. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
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 string|null 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
Documentation introduced by
array('too-many-edits', ...$userData->maxEdits())) is of type array<integer,string,{"0":"string","1":"string"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
155
156
            // If redirecting to a different controller, show an informative message accordingly.
157
            if ($tooHighEditCountAction !== $this->getToolShortname()) {
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class AppBundle\Controller\XtoolsController as the method getToolShortname() does only exist in the following sub-classes of AppBundle\Controller\XtoolsController: AppBundle\Controller\AdminScoreController, AppBundle\Controller\AdminStatsController, AppBundle\Controller\ArticleInfoController, AppBundle\Controller\AutomatedEditsController, AppBundle\Controller\EditCounterController, AppBundle\Controller\EditSummaryController, AppBundle\Controller\PagesController, AppBundle\Controller\SimpleEditCounterController, AppBundle\Controller\TopEditsController. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
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']);
0 ignored issues
show
Documentation introduced by
array('too-many-edits-redir', 'Simple Counter') is of type array<integer,string,{"0":"string","1":"string"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
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 PagesRepository();
182
        $pageRepo->setContainer($this->container);
1 ignored issue
show
Compatibility introduced by
$this->container of type object<Symfony\Component...ion\ContainerInterface> is not a sub-type of object<Symfony\Component...ncyInjection\Container>. It seems like you assume a concrete implementation of the interface Symfony\Component\Depend...tion\ContainerInterface to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
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
Documentation introduced by
array('no-result', $pageTitle) is of type array<integer,string,{"0":"string","1":"string"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
188
            return $this->redirectToRoute($this->getToolShortname());
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class AppBundle\Controller\XtoolsController as the method getToolShortname() does only exist in the following sub-classes of AppBundle\Controller\XtoolsController: AppBundle\Controller\AdminScoreController, AppBundle\Controller\AdminStatsController, AppBundle\Controller\ArticleInfoController, AppBundle\Controller\AutomatedEditsController, AppBundle\Controller\EditCounterController, AppBundle\Controller\EditSummaryController, AppBundle\Controller\PagesController, AppBundle\Controller\SimpleEditCounterController, AppBundle\Controller\TopEditsController. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
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
    public function getParams(Request $request)
200
    {
201
        $paramsToCheck = [
202
            'project',
203
            'username',
204
            'namespace',
205
            'article',
206
            'redirects',
207
            'start',
208
            'end',
209
210
            // Legacy parameters.
211
            'user',
212
            'name',
213
            'page',
214
            'wiki',
215
            'wikifam',
216
            'lang',
217
            'wikilang',
218
            'begin',
219
        ];
220
221
        /** @var string[] Each parameter that was detected along with its value. */
222
        $params = [];
223
224
        foreach ($paramsToCheck as $param) {
225
            // Pull in either from URL query string or route.
226
            $value = $request->query->get($param) ?: $request->get($param);
227
228
            // Only store if value is given ('namespace' or 'username' could be '0').
229
            if ($value !== null && $value !== '') {
230
                $params[$param] = urldecode($value);
231
            }
232
        }
233
234
        return $params;
235
    }
236
237
    /**
238
     * Get UTC timestamps from given start and end string parameters.
239
     * This also makes $start on month before $end if not present,
240
     * and makes $end the current time if not present.
241
     * @param  string $start
242
     * @param  string $end
243
     * @param  bool   $useDefaults Whether to use defaults if the values
244
     *   are blank. The start date is set to one month before the end date,
245
     *   and the end date is set to the present.
246
     * @return mixed[] Start and end date as UTC timestamps or 'false' if empty.
247
     */
248
    public function getUTCFromDateParams($start, $end, $useDefaults = true)
249
    {
250
        $start = strtotime($start);
251
        $end = strtotime($end);
252
253
        // Use current time if end is not present (and is required),
254
        // or if it exceeds the current time.
255
        if (($useDefaults && $end === false) || $end > time()) {
256
            $end = time();
257
        }
258
259
        // Default to one month before end time if start is not present,
260
        // as is not optional.
261
        if ($useDefaults && $start === false) {
262
            $start = strtotime('-1 month', $end);
263
        }
264
265
        // Reverse if start date is after end date.
266
        if ($start > $end && $start !== false && $end !== false) {
267
            $newEnd = $start;
268
            $start = $end;
269
            $end = $newEnd;
270
        }
271
272
        return [$start, $end];
273
    }
274
275
    /**
276
     * Given the params hash, normalize any legacy parameters to thier modern equivalent.
277
     * @param  string[] $params
278
     * @return string[]
279
     */
280
    private function convertLegacyParams($params)
281
    {
282
        $paramMap = [
283
            'user' => 'username',
284
            'name' => 'username',
285
            'page' => 'article',
286
            'begin' => 'start',
287
288
            // Copy super legacy project params to legacy so we can concatenate below.
289
            'wikifam' => 'wiki',
290
            'wikilang' => 'lang',
291
        ];
292
293
        // Copy legacy parameters to modern equivalent.
294
        foreach ($paramMap as $legacy => $modern) {
295
            if (isset($params[$legacy])) {
296
                $params[$modern] = $params[$legacy];
297
                unset($params[$legacy]);
298
            }
299
        }
300
301
        // Separate parameters for language and wiki.
302
        if (isset($params['wiki']) && isset($params['lang'])) {
303
            // 'wikifam' will be like '.wikipedia.org', vs just 'wikipedia',
304
            // so we must remove leading periods and trailing .org's.
305
            $params['project'] = rtrim(ltrim($params['wiki'], '.'), '.org').'.org';
306
307
            /** @var string[] Projects for which there is no specific language association. */
308
            $languagelessProjects = $this->container->getParameter('languageless_wikis');
309
310
            // Prepend language if applicable.
311
            if (isset($params['lang']) && !in_array($params['wiki'], $languagelessProjects)) {
312
                $params['project'] = $params['lang'].'.'.$params['project'];
313
            }
314
315
            unset($params['wiki']);
316
            unset($params['lang']);
317
        }
318
319
        return $params;
320
    }
321
}
322