Completed
Push — master ( c229e0...35b95e )
by Dmitry
08:27 queued 10s
created

DomainValidator::checkDNS()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

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