tflori/verja

View on GitHub
src/Validator/Url.php

Summary

Maintainability
B
6 hrs
Test Coverage
A
100%
<?php

namespace Verja\Validator;

use Verja\Error;
use Verja\Validator;

class Url extends Validator
{
    const COVERAGE_VALID     = 'valid'; // check only that the url is valid (parse_url !== false)
    const COVERAGE_COMPLETE  = 'complete'; // check that host and scheme is given
    const COVERAGE_ACTIVE    = 'active'; // check that the host exists (active dns record)
    const COVERAGE_LISTENING = 'listening'; // check for an open http(s) or ftp port

    /** @var int[] */
    protected static $ports = [
        'https' => 443,
        'http' => 80,
        'ftp' => 21,
        'ssh' => 22,
    ];

    /** @var callable */
    protected static $socketTester;

    /** @var callable */
    protected static $dnsTester;

    /** @var string */
    protected $mode;

    /** @var array */
    protected $schemes = ['https', 'http', 'ftp'];

    /**
     * Url constructor.
     *
     * @param string       $coverage
     * @param array|string $schemes
     */
    public function __construct(string $coverage = self::COVERAGE_COMPLETE, $schemes = ['https', 'http', 'ftp'])
    {
        $this->mode    = $coverage;
        $this->schemes = is_string($schemes) ? array_slice(func_get_args(), 1) : $schemes;
    }

    /**
     * Validate $value
     *
     * @param mixed $value
     * @param array $context
     * @return bool
     */
    public function validate($value, array $context = []): bool
    {
        if (!is_string($value) || !$url = parse_url($value)) {
            // not a string or not a url
            $this->error = new Error(
                'NO_URL',
                $value,
                'value should be a valid url'
            );
            return false;
        }

        if ($this->mode !== self::COVERAGE_VALID) {
            // for scheme file we don't need a host
            if (!isset($url['scheme']) || !isset($url['host']) && $url['scheme'] !== 'file') {
                // no full url
                $this->error = new Error(
                    'NOT_FULL_URL',
                    $value,
                    'value should be a full url including host and scheme',
                    ['schemes' => $this->schemes]
                );
                return false;
            }

            if (!in_array($url['scheme'], $this->schemes)) {
                // scheme not allowed
                $this->error = new Error(
                    'SCHEME_NOT_ALLOWED',
                    $value,
                    'value should contain an allowed scheme',
                    ['schemes' => $this->schemes]
                );
                return false;
            }

            if (($this->mode === self::COVERAGE_ACTIVE || $this->mode === self::COVERAGE_LISTENING) &&
                !static::testDns($url['host'])
            ) {
                // no dns entry
                $this->error = new Error(
                    'NO_DNS_RECORD',
                    $value,
                    'value should contain a hostname with an active dns record'
                );
                return false;
            }

            if ($this->mode === self::COVERAGE_LISTENING) {
                if (!isset($url['port']) && !isset(self::$ports[$url['scheme']])) {
                    // no port defined
                    $this->error = new Error(
                        'NO_PORT',
                        $value,
                        'value should contain a port'
                    );
                    return false;
                }

                if (!static::testSocket($url['host'], $url['port'] ?? self::$ports[$url['scheme']])) {
                    // not listening to connections
                    $this->error = new Error(
                        'NOT_LISTENING',
                        $value,
                        'value should point to a server that is currently listening'
                    );
                    return false;
                }
            }
        }

        return true;
    }

    protected static function testSocket(string $host, int $port)
    {
        if (static::$socketTester !== null) {
            return call_user_func(static::$socketTester, $host, $port);
        }

        // @codeCoverageIgnoreStart
        // we can not test this during unit tests - so we mock this through $socketTester
        try {
            $socket = fsockopen($host, $port, $errno, $errstr, 0.5);
            fclose($socket);
            return true;
        } catch (\Throwable $e) {
            // ignore any errors (socket timeout for example)
        }
        return false;
        // @codeCoverageIgnoreEnd
    }

    protected static function testDns(string $host)
    {
        if (static::$dnsTester !== null) {
            return call_user_func(static::$dnsTester, $host);
        }

        // @codeCoverageIgnoreStart
        // we can not test this during unit tests - so we mock this through $dnsTester
        return checkdnsrr($host, 'A') ||
               checkdnsrr($host, 'AAAA') ||
               checkdnsrr($host, 'CNAME');
        // @codeCoverageIgnoreEnd
    }

    public static function setSocketTester(callable $socketTester)
    {
        static::$socketTester = $socketTester;
    }

    public static function setDnsTester(callable $dnsTester)
    {
        static::$dnsTester = $dnsTester;
    }
}