dmitry-kulikov/yii2-domain-validator

View on GitHub
src/DomainValidator.php

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
<?php

namespace kdn\yii2\validators;

use Yii;
use yii\base\ErrorException;
use yii\base\InvalidConfigException;
use yii\validators\Validator;

/**
 * Class DomainValidator.
 * @package kdn\yii2\validators
 */
class DomainValidator extends Validator
{
    /**
     * @var bool whether to allow underscores in domain name;
     * defaults to false
     */
    public $allowUnderscore = false;

    /**
     * @var bool whether to allow the URL address along with domain name;
     * defaults to true, meaning that validator should try to parse URL address and then validate domain name
     */
    public $allowURL = true;

    /**
     * @var bool|callable whether to check whether domain name exists;
     * be aware that this check can fail due to temporary DNS problems even if domain name exists;
     * do not use it to check domain name availability;
     * defaults to false;
     * this field can be specified as a PHP callback, for example:
     * ```php
     * function (string $domain) {
     *     $records = @dns_get_record("$domain.", DNS_MX); // @ is just for simplicity of example, avoid using it
     *     if (empty($records)) {
     *         return ['Cannot find Mail Exchanger record for "{value}".', ['value' => $domain]];
     *     }
     *
     *     return null; // the data is valid
     * }
     * ```
     * note that alternatively you can override method `checkDNS`
     * @see checkDNS
     */
    public $checkDNS = false;

    /**
     * @var bool whether validation process should take into account IDN (internationalized domain names);
     * defaults to false, meaning that validation of domain names containing IDN will always fail;
     * note that in order to use IDN validation you have to install and enable `intl` PHP extension,
     * otherwise an exception would be thrown
     */
    public $enableIDN = false;

    /**
     * @var string the encoding of the string value to be validated (e.g. 'UTF-8');
     * if this property is not set, `\yii\base\Application::charset` will be used
     */
    public $encoding;

    /**
     * @var string the base path for all translated messages; specify it if you want to use custom translated messages
     */
    public $i18nBasePath;

    /**
     * @var int minimum number of domain name labels;
     * defaults to 2, meaning that domain name should contain at least 2 labels
     * @see messageLabelNumberMin for the customized message for domain name with too small number of labels
     */
    public $labelNumberMin = 2;

    /**
     * @var string user-defined error message used when domain name is invalid but
     * reason is too complicated for explanation to end-user or details are not needed at all;
     * you may use the following placeholders in the message:
     * - `{attribute}`: the label of the attribute being validated
     * - `{value}`: the value of the attribute being validated
     * @see simpleErrorMessage to use this message for all possible errors
     */
    public $message;

    /**
     * @var string user-defined error message used when DNS record corresponding to domain name not found;
     * you may use the following placeholders in the message:
     * - `{attribute}`: the label of the attribute being validated
     * - `{value}`: the value of the attribute being validated
     */
    public $messageDNS;

    /**
     * @var string user-defined error message used when domain name contains an invalid character;
     * you may use the following placeholders in the message:
     * - `{attribute}`: the label of the attribute being validated
     * - `{value}`: the value of the attribute being validated
     */
    public $messageInvalidCharacter;

    /**
     * @var string user-defined error message used when number of domain name labels is smaller than `labelNumberMin`;
     * you may use the following placeholders in the message:
     * - `{attribute}`: the label of the attribute being validated
     * - `{labelNumberMin}`: the value of `labelNumberMin`
     * - `{value}`: the value of the attribute being validated
     */
    public $messageLabelNumberMin;

    /**
     * @var string user-defined error message used when domain name label starts or ends with an invalid character;
     * you may use the following placeholders in the message:
     * - `{attribute}`: the label of the attribute being validated
     * - `{value}`: the value of the attribute being validated
     */
    public $messageLabelStartEnd;

    /**
     * @var string user-defined error message used when domain name label is too long;
     * you may use the following placeholders in the message:
     * - `{attribute}`: the label of the attribute being validated
     * - `{value}`: the value of the attribute being validated
     */
    public $messageLabelTooLong;

    /**
     * @var string user-defined error message used when domain name label is too short;
     * you may use the following placeholders in the message:
     * - `{attribute}`: the label of the attribute being validated
     * - `{value}`: the value of the attribute being validated
     */
    public $messageLabelTooShort;

    /**
     * @var string user-defined error message used when domain name is not a string;
     * you may use the following placeholders in the message:
     * - `{attribute}`: the label of the attribute being validated
     * - `{value}`: the value of the attribute being validated
     */
    public $messageNotString;

    /**
     * @var string user-defined error message used when domain name is too long;
     * you may use the following placeholders in the message:
     * - `{attribute}`: the label of the attribute being validated
     * - `{value}`: the value of the attribute being validated
     */
    public $messageTooLong;

    /**
     * @var string user-defined error message used when domain name is too short;
     * you may use the following placeholders in the message:
     * - `{attribute}`: the label of the attribute being validated
     * - `{value}`: the value of the attribute being validated
     */
    public $messageTooShort;

    /**
     * @var bool whether to always use simple error message;
     * defaults to false, meaning that validator should use specialized error messages for different errors,
     * it should help end-user to understand reason of error; set it to true if detailed error messages don't fit
     * for your application then `message` will be used in all cases
     */
    public $simpleErrorMessage = false;

    /**
     * {@inheritdoc}
     */
    public function init()
    {
        parent::init();
        if ($this->enableIDN && !function_exists('idn_to_ascii')) {
            throw new InvalidConfigException(
                'In order to use IDN validation intl extension must be installed and enabled.'
            );
        }
        if (!isset($this->encoding)) {
            $this->encoding = Yii::$app->charset;
        }
        if (!isset($this->i18nBasePath)) {
            $this->i18nBasePath = dirname(__DIR__) . '/messages';
        }
        Yii::$app->i18n->translations['kdn/yii2/validators/domain'] = [
            'class' => 'yii\i18n\PhpMessageSource',
            'basePath' => $this->i18nBasePath,
            'fileMap' => ['kdn/yii2/validators/domain' => 'domain.php'],
        ];
    }

    /**
     * {@inheritdoc}
     */
    protected function validateValue($value)
    {
        if (!is_string($value)) {
            return $this->getErrorMessage('messageNotString');
        }

        if (empty($value)) {
            return $this->getErrorMessage('messageTooShort');
        }

        if ($this->allowURL) {
            $host = parse_url($value, PHP_URL_HOST);
            if (isset($host) && $host !== false) {
                $value = $host;
            }
        }

        if ($this->enableIDN) {
            $idnaInfo = [];
            $options = IDNA_CHECK_BIDI | IDNA_CHECK_CONTEXTJ;
            $asciiValue = idn_to_ascii($value, $options, INTL_IDNA_VARIANT_UTS46, $idnaInfo);
            if ($asciiValue !== false) {
                $value = $asciiValue;
            } else {
                $idnaErrors = null;
                if (is_array($idnaInfo) && array_key_exists('errors', $idnaInfo)) {
                    $idnaErrors = $idnaInfo['errors'];
                }
                if ($idnaErrors & IDNA_ERROR_DOMAIN_NAME_TOO_LONG) {
                    $errorMessageName = 'messageTooLong';
                } elseif ($idnaErrors & IDNA_ERROR_EMPTY_LABEL) {
                    $errorMessageName = 'messageLabelTooShort';
                } elseif ($idnaErrors & IDNA_ERROR_LABEL_TOO_LONG) {
                    $errorMessageName = 'messageLabelTooLong';
                } elseif ($idnaErrors & IDNA_ERROR_DISALLOWED) {
                    $errorMessageName = 'messageInvalidCharacter';
                } elseif ($idnaErrors & IDNA_ERROR_LEADING_HYPHEN || $idnaErrors & IDNA_ERROR_TRAILING_HYPHEN) {
                    $errorMessageName = 'messageLabelStartEnd';
                } elseif (empty($idnaInfo)) {
                    // too long domain name caused buffer overflow
                    $errorMessageName = 'messageTooLong';
                } else {
                    $errorMessageName = 'message';
                }

                return $this->getErrorMessage($errorMessageName);
            }
        }

        // ignore trailing dot
        if (mb_substr($value, -1, 1, $this->encoding) == '.') {
            $value = substr_replace($value, '', -1);
        }

        // 253 characters limit is same as 127 levels,
        // domain name with 127 levels with 1 character per label will be 253 characters long
        if (mb_strlen($value, $this->encoding) > 253) {
            return $this->getErrorMessage('messageTooLong');
        }

        $labels = explode('.', $value);
        $labelsCount = count($labels);

        if ($labelsCount < $this->labelNumberMin) {
            return $this->getErrorMessage('messageLabelNumberMin', ['labelNumberMin' => $this->labelNumberMin]);
        }

        for ($i = 0; $i < $labelsCount; $i++) {
            $label = $labels[$i];
            $labelLength = mb_strlen($label, $this->encoding);

            if (empty($label)) {
                return $this->getErrorMessage('messageLabelTooShort');
            }

            if ($labelLength > 63) {
                return $this->getErrorMessage('messageLabelTooLong');
            }

            if ($this->allowUnderscore) {
                $pattern = '/^[a-z\d\-_]+$/i';
            } else {
                $pattern = '/^[a-z\d\-]+$/i';
            }
            if (!preg_match($pattern, $label)) {
                return $this->getErrorMessage('messageInvalidCharacter');
            }

            if (
                $i == $labelsCount - 1 && !ctype_alpha($label[0])
                || !ctype_alnum($label[0])
                || !ctype_alnum($label[$labelLength - 1])
            ) {
                return $this->getErrorMessage('messageLabelStartEnd');
            }
        }

        if ($this->checkDNS) {
            if (is_callable($this->checkDNS)) {
                return call_user_func($this->checkDNS, $value);
            }

            if (!$this->checkDNS($value)) {
                return $this->getErrorMessage('messageDNS');
            }
        }

        return null;
    }

    /**
     * Check whether domain name exists.
     * @param string $domain domain name
     * @return bool whether domain name exists.
     * @see https://github.com/yiisoft/yii2/issues/17083
     * @see https://github.com/yiisoft/yii2/issues/17602
     */
    protected function checkDNS($domain)
    {
        $normalizedDomain = "$domain.";
        if (!checkdnsrr($normalizedDomain, 'ANY')) {
            return false;
        }

        try {
            // dns_get_record can return false and emit Warning that may or may not be converted to ErrorException
            $records = dns_get_record($normalizedDomain, DNS_ANY);
        } catch (ErrorException $e) {
            return false;
        }

        return !empty($records);
    }

    /**
     * Get error message by name.
     * @param string $name error message name
     * @param array $params parameters to be inserted into the error message
     * @return array error message.
     */
    protected function getErrorMessage($name, $params = [])
    {
        if ($this->simpleErrorMessage) {
            $name = 'message';
        }
        if (isset($this->$name)) {
            return [$this->$name, $params];
        }
        $this->$name = Yii::t('kdn/yii2/validators/domain', $this->getDefaultErrorMessages()[$name]);

        return [$this->$name, $params];
    }

    /**
     * Get default error messages.
     * @return array default error messages.
     */
    protected function getDefaultErrorMessages()
    {
        $messages = [
            'message' => '{attribute} is invalid.',
            'messageDNS' => 'DNS record corresponding to {attribute} not found.',
            'messageLabelNumberMin' =>
                '{attribute} should consist of at least {labelNumberMin, number} labels separated by ' .
                '{labelNumberMin, plural, =2{dot} other{dots}}.',
            'messageLabelTooShort' => 'Each label of {attribute} should contain at least 1 character.',
            'messageNotString' => '{attribute} must be a string.',
            'messageTooShort' => '{attribute} should contain at least 1 character.',
        ];
        if ($this->enableIDN) {
            $messages['messageLabelStartEnd'] =
                'Each label of {attribute} should start and end with letter or number.' .
                ' The rightmost label of {attribute} should start with letter.';
            $messages['messageLabelTooLong'] = 'Label of {attribute} is too long.';
            $messages['messageTooLong'] = '{attribute} is too long.';
            if ($this->allowUnderscore) {
                $messages['messageInvalidCharacter'] =
                    'Each label of {attribute} can consist of only letters, numbers, hyphens and underscores.';
            } else {
                $messages['messageInvalidCharacter'] =
                    'Each label of {attribute} can consist of only letters, numbers and hyphens.';
            }
        } else {
            $messages['messageLabelStartEnd'] =
                'Each label of {attribute} should start and end with latin letter or number.' .
                ' The rightmost label of {attribute} should start with latin letter.';
            $messages['messageLabelTooLong'] = 'Each label of {attribute} should contain at most 63 characters.';
            $messages['messageTooLong'] = '{attribute} should contain at most 253 characters.';
            if ($this->allowUnderscore) {
                $messages['messageInvalidCharacter'] =
                    'Each label of {attribute} can consist of only latin letters, numbers, hyphens and underscores.';
            } else {
                $messages['messageInvalidCharacter'] =
                    'Each label of {attribute} can consist of only latin letters, numbers and hyphens.';
            }
        }

        return $messages;
    }
}