DomainValidator::init()   A
last analyzed

Complexity

Conditions 5
Paths 5

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 5

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 12
c 1
b 0
f 0
nc 5
nop 0
dl 0
loc 18
rs 9.5555
ccs 15
cts 15
cp 1
crap 5
1
<?php
2
3
namespace kdn\yii2\validators;
4
5
use Yii;
6
use yii\base\ErrorException;
7
use yii\base\InvalidConfigException;
8
use yii\validators\Validator;
9
10
/**
11
 * Class DomainValidator.
12
 * @package kdn\yii2\validators
13
 */
14
class DomainValidator extends Validator
15
{
16
    /**
17
     * @var bool whether to allow underscores in domain name;
18
     * defaults to false
19
     */
20
    public $allowUnderscore = false;
21
22
    /**
23
     * @var bool whether to allow the URL address along with domain name;
24
     * defaults to true, meaning that validator should try to parse URL address and then validate domain name
25
     */
26
    public $allowURL = true;
27
28
    /**
29
     * @var bool|callable whether to check whether domain name exists;
30
     * be aware that this check can fail due to temporary DNS problems even if domain name exists;
31
     * do not use it to check domain name availability;
32
     * defaults to false;
33
     * this field can be specified as a PHP callback, for example:
34
     * ```php
35
     * function (string $domain) {
36
     *     $records = @dns_get_record("$domain.", DNS_MX); // @ is just for simplicity of example, avoid using it
37
     *     if (empty($records)) {
38
     *         return ['Cannot find Mail Exchanger record for "{value}".', ['value' => $domain]];
39
     *     }
40
     *
41
     *     return null; // the data is valid
42
     * }
43
     * ```
44
     * note that alternatively you can override method `checkDNS`
45
     * @see checkDNS
46
     */
47
    public $checkDNS = false;
48
49
    /**
50
     * @var bool whether validation process should take into account IDN (internationalized domain names);
51
     * defaults to false, meaning that validation of domain names containing IDN will always fail;
52
     * note that in order to use IDN validation you have to install and enable `intl` PHP extension,
53
     * otherwise an exception would be thrown
54
     */
55
    public $enableIDN = false;
56
57
    /**
58
     * @var string the encoding of the string value to be validated (e.g. 'UTF-8');
59
     * if this property is not set, `\yii\base\Application::charset` will be used
60
     */
61
    public $encoding;
62
63
    /**
64
     * @var string the base path for all translated messages; specify it if you want to use custom translated messages
65
     */
66
    public $i18nBasePath;
67
68
    /**
69
     * @var int minimum number of domain name labels;
70
     * defaults to 2, meaning that domain name should contain at least 2 labels
71
     * @see messageLabelNumberMin for the customized message for domain name with too small number of labels
72
     */
73
    public $labelNumberMin = 2;
74
75
    /**
76
     * @var string user-defined error message used when domain name is invalid but
77
     * reason is too complicated for explanation to end-user or details are not needed at all;
78
     * you may use the following placeholders in the message:
79
     * - `{attribute}`: the label of the attribute being validated
80
     * - `{value}`: the value of the attribute being validated
81
     * @see simpleErrorMessage to use this message for all possible errors
82
     */
83
    public $message;
84
85
    /**
86
     * @var string user-defined error message used when DNS record corresponding to domain name not found;
87
     * you may use the following placeholders in the message:
88
     * - `{attribute}`: the label of the attribute being validated
89
     * - `{value}`: the value of the attribute being validated
90
     */
91
    public $messageDNS;
92
93
    /**
94
     * @var string user-defined error message used when domain name contains an invalid character;
95
     * you may use the following placeholders in the message:
96
     * - `{attribute}`: the label of the attribute being validated
97
     * - `{value}`: the value of the attribute being validated
98
     */
99
    public $messageInvalidCharacter;
100
101
    /**
102
     * @var string user-defined error message used when number of domain name labels is smaller than `labelNumberMin`;
103
     * you may use the following placeholders in the message:
104
     * - `{attribute}`: the label of the attribute being validated
105
     * - `{labelNumberMin}`: the value of `labelNumberMin`
106
     * - `{value}`: the value of the attribute being validated
107
     */
108
    public $messageLabelNumberMin;
109
110
    /**
111
     * @var string user-defined error message used when domain name label starts or ends with an invalid character;
112
     * you may use the following placeholders in the message:
113
     * - `{attribute}`: the label of the attribute being validated
114
     * - `{value}`: the value of the attribute being validated
115
     */
116
    public $messageLabelStartEnd;
117
118
    /**
119
     * @var string user-defined error message used when domain name label is too long;
120
     * you may use the following placeholders in the message:
121
     * - `{attribute}`: the label of the attribute being validated
122
     * - `{value}`: the value of the attribute being validated
123
     */
124
    public $messageLabelTooLong;
125
126
    /**
127
     * @var string user-defined error message used when domain name label is too short;
128
     * you may use the following placeholders in the message:
129
     * - `{attribute}`: the label of the attribute being validated
130
     * - `{value}`: the value of the attribute being validated
131
     */
132
    public $messageLabelTooShort;
133
134
    /**
135
     * @var string user-defined error message used when domain name is not a string;
136
     * you may use the following placeholders in the message:
137
     * - `{attribute}`: the label of the attribute being validated
138
     * - `{value}`: the value of the attribute being validated
139
     */
140
    public $messageNotString;
141
142
    /**
143
     * @var string user-defined error message used when domain name is too long;
144
     * you may use the following placeholders in the message:
145
     * - `{attribute}`: the label of the attribute being validated
146
     * - `{value}`: the value of the attribute being validated
147
     */
148
    public $messageTooLong;
149
150
    /**
151
     * @var string user-defined error message used when domain name is too short;
152
     * you may use the following placeholders in the message:
153
     * - `{attribute}`: the label of the attribute being validated
154
     * - `{value}`: the value of the attribute being validated
155
     */
156
    public $messageTooShort;
157
158
    /**
159
     * @var bool whether to always use simple error message;
160
     * defaults to false, meaning that validator should use specialized error messages for different errors,
161
     * it should help end-user to understand reason of error; set it to true if detailed error messages don't fit
162
     * for your application then `message` will be used in all cases
163
     */
164
    public $simpleErrorMessage = false;
165
166
    /**
167
     * {@inheritdoc}
168
     */
169 202
    public function init()
170
    {
171 202
        parent::init();
172 202
        if ($this->enableIDN && !function_exists('idn_to_ascii')) {
173 1
            throw new InvalidConfigException(
174
                'In order to use IDN validation intl extension must be installed and enabled.'
175 1
            );
176
        }
177 202
        if (!isset($this->encoding)) {
178 202
            $this->encoding = Yii::$app->charset;
179 202
        }
180 202
        if (!isset($this->i18nBasePath)) {
181 202
            $this->i18nBasePath = dirname(__DIR__) . '/messages';
182 202
        }
183 202
        Yii::$app->i18n->translations['kdn/yii2/validators/domain'] = [
184 202
            'class' => 'yii\i18n\PhpMessageSource',
185 202
            'basePath' => $this->i18nBasePath,
186 202
            'fileMap' => ['kdn/yii2/validators/domain' => 'domain.php'],
187
        ];
188 202
    }
189
190
    /**
191
     * {@inheritdoc}
192
     */
193 201
    protected function validateValue($value)
194
    {
195 201
        if (!is_string($value)) {
196 13
            return $this->getErrorMessage('messageNotString');
197
        }
198
199 188
        if (empty($value)) {
200 2
            return $this->getErrorMessage('messageTooShort');
201
        }
202
203 186
        if ($this->allowURL) {
204 138
            $host = parse_url($value, PHP_URL_HOST);
205 138
            if (isset($host) && $host !== false) {
206 47
                $value = $host;
207 47
            }
208 138
        }
209
210 186
        if ($this->enableIDN) {
211 104
            $idnaInfo = [];
212 104
            $options = IDNA_CHECK_BIDI | IDNA_CHECK_CONTEXTJ;
213 104
            $asciiValue = idn_to_ascii($value, $options, INTL_IDNA_VARIANT_UTS46, $idnaInfo);
214 104
            if ($asciiValue !== false) {
215 71
                $value = $asciiValue;
216 71
            } else {
217 33
                $idnaErrors = null;
218 33
                if (is_array($idnaInfo) && array_key_exists('errors', $idnaInfo)) {
219 28
                    $idnaErrors = $idnaInfo['errors'];
220 28
                }
221 33
                if ($idnaErrors & IDNA_ERROR_DOMAIN_NAME_TOO_LONG) {
222 1
                    $errorMessageName = 'messageTooLong';
223 33
                } elseif ($idnaErrors & IDNA_ERROR_EMPTY_LABEL) {
224 4
                    $errorMessageName = 'messageLabelTooShort';
225 32
                } elseif ($idnaErrors & IDNA_ERROR_LABEL_TOO_LONG) {
226 4
                    $errorMessageName = 'messageLabelTooLong';
227 28
                } elseif ($idnaErrors & IDNA_ERROR_DISALLOWED) {
228 1
                    $errorMessageName = 'messageInvalidCharacter';
229 24
                } elseif ($idnaErrors & IDNA_ERROR_LEADING_HYPHEN || $idnaErrors & IDNA_ERROR_TRAILING_HYPHEN) {
230 12
                    $errorMessageName = 'messageLabelStartEnd';
231 23
                } elseif (empty($idnaInfo)) {
232
                    // too long domain name caused buffer overflow
233 5
                    $errorMessageName = 'messageTooLong';
234 5
                } else {
235 6
                    $errorMessageName = 'message';
236
                }
237
238 33
                return $this->getErrorMessage($errorMessageName);
239
            }
240 71
        }
241
242
        // ignore trailing dot
243 153
        if (mb_substr($value, -1, 1, $this->encoding) == '.') {
244 25
            $value = substr_replace($value, '', -1);
245 25
        }
246
247
        // 253 characters limit is same as 127 levels,
248
        // domain name with 127 levels with 1 character per label will be 253 characters long
249 153
        if (mb_strlen($value, $this->encoding) > 253) {
250 2
            return $this->getErrorMessage('messageTooLong');
251
        }
252
253 151
        $labels = explode('.', $value);
254 151
        $labelsCount = count($labels);
255
256 151
        if ($labelsCount < $this->labelNumberMin) {
257 4
            return $this->getErrorMessage('messageLabelNumberMin', ['labelNumberMin' => $this->labelNumberMin]);
258
        }
259
260 151
        for ($i = 0; $i < $labelsCount; $i++) {
261 151
            $label = $labels[$i];
262 151
            $labelLength = mb_strlen($label, $this->encoding);
263
264 151
            if (empty($label)) {
265 4
                return $this->getErrorMessage('messageLabelTooShort');
266
            }
267
268 149
            if ($labelLength > 63) {
269 2
                return $this->getErrorMessage('messageLabelTooLong');
270
            }
271
272 147
            if ($this->allowUnderscore) {
273 2
                $pattern = '/^[a-z\d\-_]+$/i';
274 2
            } else {
275 145
                $pattern = '/^[a-z\d\-]+$/i';
276
            }
277 147
            if (!preg_match($pattern, $label)) {
278 58
                return $this->getErrorMessage('messageInvalidCharacter');
279
            }
280
281
            if (
282 93
                $i == $labelsCount - 1 && !ctype_alpha($label[0])
283 93
                || !ctype_alnum($label[0])
284 92
                || !ctype_alnum($label[$labelLength - 1])
285 93
            ) {
286 7
                return $this->getErrorMessage('messageLabelStartEnd');
287
            }
288 90
        }
289
290 82
        if ($this->checkDNS) {
291 4
            if (is_callable($this->checkDNS)) {
292 1
                return call_user_func($this->checkDNS, $value);
293
            }
294
295 3
            if (!$this->checkDNS($value)) {
296 3
                return $this->getErrorMessage('messageDNS');
297
            }
298 2
        }
299
300 81
        return null;
301
    }
302
303
    /**
304
     * Check whether domain name exists.
305
     * @param string $domain domain name
306
     * @return bool whether domain name exists.
307
     * @see https://github.com/yiisoft/yii2/issues/17083
308
     * @see https://github.com/yiisoft/yii2/issues/17602
309
     */
310 3
    protected function checkDNS($domain)
311
    {
312 3
        $normalizedDomain = "$domain.";
313 3
        if (!checkdnsrr($normalizedDomain, 'ANY')) {
314 2
            return false;
315
        }
316
317
        try {
318
            // dns_get_record can return false and emit Warning that may or may not be converted to ErrorException
319 3
            $records = dns_get_record($normalizedDomain, DNS_ANY);
320 3
        } catch (ErrorException $e) {
321 1
            return false;
322
        }
323
324 2
        return !empty($records);
325
    }
326
327
    /**
328
     * Get error message by name.
329
     * @param string $name error message name
330
     * @param array $params parameters to be inserted into the error message
331
     * @return array error message.
332
     */
333 104
    protected function getErrorMessage($name, $params = [])
334
    {
335 104
        if ($this->simpleErrorMessage) {
336 1
            $name = 'message';
337 1
        }
338 104
        if (isset($this->$name)) {
339 4
            return [$this->$name, $params];
340
        }
341 103
        $this->$name = Yii::t('kdn/yii2/validators/domain', $this->getDefaultErrorMessages()[$name]);
342
343 103
        return [$this->$name, $params];
344
    }
345
346
    /**
347
     * Get default error messages.
348
     * @return array default error messages.
349
     */
350 103
    protected function getDefaultErrorMessages()
351
    {
352
        $messages = [
353 103
            'message' => '{attribute} is invalid.',
354 103
            'messageDNS' => 'DNS record corresponding to {attribute} not found.',
355
            'messageLabelNumberMin' =>
356
                '{attribute} should consist of at least {labelNumberMin, number} labels separated by ' .
357 103
                '{labelNumberMin, plural, =2{dot} other{dots}}.',
358 103
            'messageLabelTooShort' => 'Each label of {attribute} should contain at least 1 character.',
359 103
            'messageNotString' => '{attribute} must be a string.',
360 103
            'messageTooShort' => '{attribute} should contain at least 1 character.',
361 103
        ];
362 103
        if ($this->enableIDN) {
363 43
            $messages['messageLabelStartEnd'] =
364
                'Each label of {attribute} should start and end with letter or number.' .
365 43
                ' The rightmost label of {attribute} should start with letter.';
366 43
            $messages['messageLabelTooLong'] = 'Label of {attribute} is too long.';
367 43
            $messages['messageTooLong'] = '{attribute} is too long.';
368 43
            if ($this->allowUnderscore) {
369 1
                $messages['messageInvalidCharacter'] =
370
                    'Each label of {attribute} can consist of only letters, numbers, hyphens and underscores.';
371 1
            } else {
372 42
                $messages['messageInvalidCharacter'] =
373
                    'Each label of {attribute} can consist of only letters, numbers and hyphens.';
374
            }
375 43
        } else {
376 60
            $messages['messageLabelStartEnd'] =
377
                'Each label of {attribute} should start and end with latin letter or number.' .
378 60
                ' The rightmost label of {attribute} should start with latin letter.';
379 60
            $messages['messageLabelTooLong'] = 'Each label of {attribute} should contain at most 63 characters.';
380 60
            $messages['messageTooLong'] = '{attribute} should contain at most 253 characters.';
381 60
            if ($this->allowUnderscore) {
382 1
                $messages['messageInvalidCharacter'] =
383
                    'Each label of {attribute} can consist of only latin letters, numbers, hyphens and underscores.';
384 1
            } else {
385 59
                $messages['messageInvalidCharacter'] =
386
                    'Each label of {attribute} can consist of only latin letters, numbers and hyphens.';
387
            }
388
        }
389
390 103
        return $messages;
391
    }
392
}
393