RequestValidationHelper   B
last analyzed

Complexity

Total Complexity 47

Size/Duplication

Total Lines 426
Duplicated Lines 0 %

Test Coverage

Coverage 47.62%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
wmc 47
eloc 181
dl 0
loc 426
ccs 50
cts 105
cp 0.4762
rs 8.64
c 3
b 0
f 0

12 Methods

Rating   Name   Duplication   Size   Complexity  
B checkAntiSpoof() 0 33 6
A formOverride() 0 11 3
A validateOther() 0 20 3
C validateName() 0 47 9
A __construct() 0 23 1
A postSaveValidations() 0 46 5
A deferRequest() 0 17 1
A userSulExists() 0 30 3
A checkTitleBlacklist() 0 34 4
A userExists() 0 28 4
A nameRequestExists() 0 11 2
A validateEmail() 0 29 6

How to fix   Complexity   

Complex Class

Complex classes like RequestValidationHelper often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use RequestValidationHelper, and based on these observations, apply Extract Interface, too.

1
<?php
2
/******************************************************************************
3
 * Wikipedia Account Creation Assistance tool                                 *
4
 * ACC Development Team. Please see team.json for a list of contributors.     *
5
 *                                                                            *
6
 * This is free and unencumbered software released into the public domain.    *
7
 * Please see LICENSE.md for the full licencing statement.                    *
8
 ******************************************************************************/
9
10
namespace Waca\Validation;
11
12
use Exception;
13
use Waca\DataObjects\Ban;
14
use Waca\DataObjects\Comment;
15
use Waca\DataObjects\Domain;
16
use Waca\DataObjects\Request;
17
use Waca\DataObjects\RequestQueue;
18
use Waca\ExceptionHandler;
19
use Waca\Exceptions\CurlException;
20
use Waca\Helpers\HttpHelper;
21
use Waca\Helpers\Interfaces\IBanHelper;
22
use Waca\Helpers\Logger;
23
use Waca\PdoDatabase;
24
use Waca\Providers\Interfaces\IAntiSpoofProvider;
25
use Waca\Providers\Interfaces\IXffTrustProvider;
26
use Waca\Providers\TorExitProvider;
27
use Waca\RequestStatus;
28
use Waca\SiteConfiguration;
29
30
/**
31
 * Performs the validation of an incoming request.
32
 */
33
class RequestValidationHelper
34
{
35
    /** @var IBanHelper */
36
    private $banHelper;
37
    /** @var PdoDatabase */
38
    private $database;
39
    /** @var IAntiSpoofProvider */
40
    private $antiSpoofProvider;
41
    /** @var IXffTrustProvider */
42
    private $xffTrustProvider;
43
    /** @var HttpHelper */
44
    private $httpHelper;
45
    /**
46
     * @var string
47
     */
48
    private $mediawikiApiEndpoint;
49
    private $titleBlacklistEnabled;
50
    /**
51
     * @var TorExitProvider
52
     */
53
    private $torExitProvider;
54
    /**
55
     * @var SiteConfiguration
56
     */
57
    private $siteConfiguration;
58
59
    private $validationRemoteTimeout = 5000;
60
61
    /**
62 1
     * Summary of __construct
63
     *
64
     * @param IBanHelper         $banHelper
65
     * @param PdoDatabase        $database
66
     * @param IAntiSpoofProvider $antiSpoofProvider
67
     * @param IXffTrustProvider  $xffTrustProvider
68
     * @param HttpHelper         $httpHelper
69
     * @param TorExitProvider    $torExitProvider
70
     * @param SiteConfiguration  $siteConfiguration
71
     */
72
    public function __construct(
73
        IBanHelper $banHelper,
74 1
        PdoDatabase $database,
75 1
        IAntiSpoofProvider $antiSpoofProvider,
76 1
        IXffTrustProvider $xffTrustProvider,
77 1
        HttpHelper $httpHelper,
78 1
        TorExitProvider $torExitProvider,
79 1
        SiteConfiguration $siteConfiguration
80 1
    ) {
81 1
        $this->banHelper = $banHelper;
82 1
        $this->database = $database;
83 1
        $this->antiSpoofProvider = $antiSpoofProvider;
84 1
        $this->xffTrustProvider = $xffTrustProvider;
85
        $this->httpHelper = $httpHelper;
86
87
        // FIXME: domains!
88
        /** @var Domain $domain */
89
        $domain = Domain::getById(1, $database);
90 1
91
        $this->mediawikiApiEndpoint = $domain->getWikiApiPath();
92 1
        $this->titleBlacklistEnabled = $siteConfiguration->getTitleBlacklistEnabled();
93
        $this->torExitProvider = $torExitProvider;
94
        $this->siteConfiguration = $siteConfiguration;
95
    }
96 1
97
    /**
98
     * Summary of validateName
99
     *
100
     * @param Request $request
101 1
     *
102 1
     * @return ValidationError[]
103
     */
104
    public function validateName(Request $request)
105
    {
106
        $errorList = array();
107 1
108
        // ERRORS
109
        // name is empty
110
        if (trim($request->getName()) == "") {
111
            $errorList[ValidationError::NAME_EMPTY] = new ValidationError(ValidationError::NAME_EMPTY);
112 1
        }
113
114
        // name is too long
115
        if (mb_strlen(trim($request->getName())) > 500) {
116
            $errorList[ValidationError::NAME_EMPTY] = new ValidationError(ValidationError::NAME_TOO_LONG);
117
        }
118 1
119
        // username already exists
120
        if ($this->userExists($request)) {
121
            $errorList[ValidationError::NAME_EXISTS] = new ValidationError(ValidationError::NAME_EXISTS);
122
        }
123 1
124
        // username part of SUL account
125
        if ($this->userSulExists($request)) {
126
            // using same error slot as name exists - it's the same sort of error, and we probably only want to show one.
127
            $errorList[ValidationError::NAME_EXISTS] = new ValidationError(ValidationError::NAME_EXISTS_SUL);
128 1
        }
129
130
        // username is numbers
131
        if (preg_match("/^[0-9]+$/", $request->getName()) === 1) {
132 1
            $errorList[ValidationError::NAME_NUMONLY] = new ValidationError(ValidationError::NAME_NUMONLY);
133
        }
134
135
        // username can't contain #@/<>[]|{}
136
        if (preg_match("/[" . preg_quote("#@/<>[]|{}", "/") . "]/", $request->getName()) === 1) {
137
            $errorList[ValidationError::NAME_INVALIDCHAR] = new ValidationError(ValidationError::NAME_INVALIDCHAR);
138
        }
139
140
        // username is an IP
141
        if (filter_var($request->getName(), FILTER_VALIDATE_IP)) {
142
            $errorList[ValidationError::NAME_IP] = new ValidationError(ValidationError::NAME_IP);
143
        }
144
145
        // existing non-closed request for this name
146
        if ($this->nameRequestExists($request)) {
147
            $errorList[ValidationError::OPEN_REQUEST_NAME] = new ValidationError(ValidationError::OPEN_REQUEST_NAME);
148
        }
149
150
        return $errorList;
151
    }
152
153
    /**
154
     * Summary of validateEmail
155
     *
156
     * @param Request $request
157
     * @param string  $emailConfirmation
158
     *
159
     * @return ValidationError[]
160
     */
161
    public function validateEmail(Request $request, $emailConfirmation)
162
    {
163
        $errorList = array();
164
165
        // ERRORS
166
167
        // email addresses must match
168
        if ($request->getEmail() != $emailConfirmation) {
169
            $errorList[ValidationError::EMAIL_MISMATCH] = new ValidationError(ValidationError::EMAIL_MISMATCH);
170
        }
171
172
        // email address must be validly formed
173
        if (trim($request->getEmail()) == "") {
174
            $errorList[ValidationError::EMAIL_EMPTY] = new ValidationError(ValidationError::EMAIL_EMPTY);
175
        }
176
177
        // email address must be validly formed
178
        if (!filter_var($request->getEmail(), FILTER_VALIDATE_EMAIL)) {
179
            if (trim($request->getEmail()) != "") {
180
                $errorList[ValidationError::EMAIL_INVALID] = new ValidationError(ValidationError::EMAIL_INVALID);
181
            }
182
        }
183
184
        // email address can't be wikimedia/wikipedia .com/org
185
        if (preg_match('/.*@.*wiki(m.dia|p.dia)\.(org|com)/i', $request->getEmail()) === 1) {
186
            $errorList[ValidationError::EMAIL_WIKIMEDIA] = new ValidationError(ValidationError::EMAIL_WIKIMEDIA);
187
        }
188
189
        return $errorList;
190
    }
191
192
    /**
193
     * Summary of validateOther
194
     *
195
     * @param Request $request
196
     *
197
     * @return ValidationError[]
198
     */
199
    public function validateOther(Request $request)
200
    {
201
        $errorList = array();
202
203
        $trustedIp = $this->xffTrustProvider->getTrustedClientIp($request->getIp(),
204
            $request->getForwardedIp());
205
206
        // ERRORS
207
208
        // TOR nodes
209
        if ($this->torExitProvider->isTorExit($trustedIp)) {
210
            $errorList[ValidationError::BANNED] = new ValidationError(ValidationError::BANNED_TOR);
211
        }
212
213
        // Bans
214
        if ($this->banHelper->isBlockBanned($request)) {
215
            $errorList[ValidationError::BANNED] = new ValidationError(ValidationError::BANNED);
216
        }
217
218
        return $errorList;
219
    }
220
221
    public function postSaveValidations(Request $request)
222
    {
223
        // Antispoof check
224
        $this->checkAntiSpoof($request);
225
226
        // Blacklist check
227
        $this->checkTitleBlacklist($request);
228
229
        // Add comment for form override
230
        $this->formOverride($request);
231
232
        $bans = $this->banHelper->getBans($request);
233
234
        foreach ($bans as $ban) {
235
            if ($ban->getAction() == Ban::ACTION_DROP) {
236
                $request->setStatus(RequestStatus::CLOSED);
237
                $request->save();
238
239
                Logger::closeRequest($request->getDatabase(), $request, 0, null);
240
241
                $comment = new Comment();
242
                $comment->setDatabase($this->database);
243
                $comment->setRequest($request->getId());
244
                $comment->setVisibility('user');
245
                $comment->setUser(null);
246
247
                $comment->setComment('Request dropped automatically due to matching rule.');
248
                $comment->save();
249
            }
250 1
251
            if ($ban->getAction() == Ban::ACTION_DEFER) {
252 1
                /** @var RequestQueue|false $targetQueue */
253 1
                $targetQueue = RequestQueue::getById($ban->getTargetQueue(), $this->database);
254
255 1
                if ($targetQueue === false ) {
256 1
                    $comment = new Comment();
257 1
                    $comment->setDatabase($this->database);
258 1
                    $comment->setRequest($request->getId());
259
                    $comment->setVisibility('user');
260
                    $comment->setUser(null);
261
262 1
                    $comment->setComment("This request would have been deferred automatically due to a matching rule, but the queue to defer to could not be found.");
263 1
                    $comment->save();
264
                }
265
                else {
266
                    $this->deferRequest($request, $targetQueue, 'Request deferred automatically due to matching rule.');
267 1
                }
268
            }
269
        }
270 1
    }
271
272 1
    private function checkAntiSpoof(Request $request)
273
    {
274 1
        try {
275 1
            if (count($this->antiSpoofProvider->getSpoofs($request->getName())) > 0) {
276
                // If there were spoofs an Admin should handle the request.
277 1
                // FIXME: domains!
278 1
                $defaultQueue = RequestQueue::getDefaultQueue($this->database, 1, RequestQueue::DEFAULT_ANTISPOOF);
279 1
                $this->deferRequest($request, $defaultQueue,
0 ignored issues
show
Bug introduced by
It seems like $defaultQueue can also be of type false; however, parameter $targetQueue of Waca\Validation\RequestV...nHelper::deferRequest() does only seem to accept Waca\DataObjects\RequestQueue, 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

279
                $this->deferRequest($request, /** @scrutinizer ignore-type */ $defaultQueue,
Loading history...
280 1
                    'Request automatically deferred due to AntiSpoof hit');
281
            }
282
        }
283
        catch (Exception $ex) {
284 1
            $skippable = [
285 1
                'Contains unassigned character',
286
                'Contains incompatible mixed scripts',
287
                'Does not contain any letters',
288
                'Usernames must contain one or more characters',
289 1
                'Usernames cannot contain characters from different writing systems',
290
                'Usernames cannot contain the character'
291
            ];
292
293
            $skip = false;
294
295
            foreach ($skippable as $s) {
296
                if (strpos($ex->getMessage(), 'Encountered error while getting result: ' . $s) !== false) {
297 1
                    $skip = true;
298
                    break;
299 1
                }
300 1
            }
301 1
302
            // Only log to disk if this *isn't* a "skippable" error.
303 1
            if (!$skip) {
304
                ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
305
            }
306
        }
307 1
    }
308
309
    private function checkTitleBlacklist(Request $request)
310
    {
311
        if ($this->titleBlacklistEnabled == 1) {
312
            try {
313
                $apiResult = $this->httpHelper->get(
314
                    $this->mediawikiApiEndpoint,
315
                    array(
316
                        'action'       => 'titleblacklist',
317
                        'tbtitle'      => $request->getName(),
318
                        'tbaction'     => 'new-account',
319
                        'tbnooverride' => true,
320
                        'format'       => 'php',
321
                    ),
322
                    [],
323
                    $this->validationRemoteTimeout
324
                );
325
326
                $data = unserialize($apiResult);
327
328
                $requestIsOk = $data['titleblacklist']['result'] == "ok";
329
            }
330
            catch (CurlException $ex) {
331
                ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
332
333
                // Don't kill the request, just assume it's fine. Humans can deal with it later.
334
                return;
335
            }
336
337
            if (!$requestIsOk) {
338
                // FIXME: domains!
339
                $defaultQueue = RequestQueue::getDefaultQueue($this->database, 1, RequestQueue::DEFAULT_TITLEBLACKLIST);
340
341
                $this->deferRequest($request, $defaultQueue,
0 ignored issues
show
Bug introduced by
It seems like $defaultQueue can also be of type false; however, parameter $targetQueue of Waca\Validation\RequestV...nHelper::deferRequest() does only seem to accept Waca\DataObjects\RequestQueue, 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

341
                $this->deferRequest($request, /** @scrutinizer ignore-type */ $defaultQueue,
Loading history...
342
                    'Request automatically deferred due to title blacklist hit');
343
            }
344
        }
345
    }
346
347
    private function userExists(Request $request)
348
    {
349
        try {
350
            $userExists = $this->httpHelper->get(
351
                $this->mediawikiApiEndpoint,
352
                array(
353
                    'action'  => 'query',
354
                    'list'    => 'users',
355
                    'ususers' => $request->getName(),
356
                    'format'  => 'php',
357
                ),
358
                [],
359
                $this->validationRemoteTimeout
360
            );
361
362
            $ue = unserialize($userExists);
363
            if (!isset ($ue['query']['users']['0']['missing']) && isset ($ue['query']['users']['0']['userid'])) {
364
                return true;
365
            }
366
        }
367
        catch (CurlException $ex) {
368
            ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
369
370
            // Don't kill the request, just assume it's fine. Humans can deal with it later.
371
            return false;
372
        }
373
374
        return false;
375
    }
376
377
    private function userSulExists(Request $request)
378
    {
379
        $requestName = $request->getName();
380
381
        try {
382
            $userExists = $this->httpHelper->get(
383
                $this->mediawikiApiEndpoint,
384
                array(
385
                    'action'  => 'query',
386
                    'meta'    => 'globaluserinfo',
387
                    'guiuser' => $requestName,
388
                    'format'  => 'php',
389
                ),
390
                [],
391
                $this->validationRemoteTimeout
392
            );
393
394
            $ue = unserialize($userExists);
395
            if (isset ($ue['query']['globaluserinfo']['id'])) {
396
                return true;
397
            }
398
        }
399
        catch (CurlException $ex) {
400
            ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
401
402
            // Don't kill the request, just assume it's fine. Humans can deal with it later.
403
            return false;
404
        }
405
406
        return false;
407
    }
408
409
    /**
410
     * Checks if a request with this name is currently open
411
     *
412
     * @param Request $request
413
     *
414
     * @return bool
415
     */
416
    private function nameRequestExists(Request $request)
417
    {
418
        $query = "SELECT COUNT(id) FROM request WHERE status != 'Closed' AND name = :name;";
419
        $statement = $this->database->prepare($query);
420
        $statement->execute(array(':name' => $request->getName()));
421
422
        if (!$statement) {
0 ignored issues
show
introduced by
$statement is of type PDOStatement, thus it always evaluated to true.
Loading history...
423
            return false;
424
        }
425
426
        return $statement->fetchColumn() > 0;
427
    }
428
429
    private function deferRequest(Request $request, RequestQueue $targetQueue, $deferComment): void
430
    {
431
        $request->setQueue($targetQueue->getId());
432
        $request->save();
433
434
        $logTarget = $targetQueue->getLogName();
0 ignored issues
show
Deprecated Code introduced by
The function Waca\DataObjects\RequestQueue::getLogName() has been deprecated. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

434
        $logTarget = /** @scrutinizer ignore-deprecated */ $targetQueue->getLogName();
Loading history...
435
436
        Logger::deferRequest($this->database, $request, $logTarget);
437
438
        $comment = new Comment();
439
        $comment->setDatabase($this->database);
440
        $comment->setRequest($request->getId());
441
        $comment->setVisibility('user');
442
        $comment->setUser(null);
443
444
        $comment->setComment($deferComment);
445
        $comment->save();
446
    }
447
448
    private function formOverride(Request $request)
449
    {
450
        $form = $request->getOriginFormObject();
451
        if($form === null || $form->getOverrideQueue() === null) {
452
            return;
453
        }
454
455
        /** @var RequestQueue $targetQueue */
456
        $targetQueue = RequestQueue::getById($form->getOverrideQueue(), $request->getDatabase());
457
458
        $this->deferRequest($request, $targetQueue, 'Request deferred automatically due to request submission through a request form with a default queue set.');
459
    }
460
}
461