Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
Url
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
6 / 6
26
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 validate
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
15
 testSocket
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 testDns
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 setSocketTester
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDnsTester
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Verja\Validator;
4
5use Verja\Error;
6use Verja\Validator;
7
8class Url extends Validator
9{
10    const COVERAGE_VALID     = 'valid'; // check only that the url is valid (parse_url !== false)
11    const COVERAGE_COMPLETE  = 'complete'; // check that host and scheme is given
12    const COVERAGE_ACTIVE    = 'active'; // check that the host exists (active dns record)
13    const COVERAGE_LISTENING = 'listening'; // check for an open http(s) or ftp port
14
15    /** @var int[] */
16    protected static $ports = [
17        'https' => 443,
18        'http' => 80,
19        'ftp' => 21,
20        'ssh' => 22,
21    ];
22
23    /** @var callable */
24    protected static $socketTester;
25
26    /** @var callable */
27    protected static $dnsTester;
28
29    /** @var string */
30    protected $mode;
31
32    /** @var array */
33    protected $schemes = ['https', 'http', 'ftp'];
34
35    /**
36     * Url constructor.
37     *
38     * @param string       $coverage
39     * @param array|string $schemes
40     */
41    public function __construct(string $coverage = self::COVERAGE_COMPLETE, $schemes = ['https', 'http', 'ftp'])
42    {
43        $this->mode    = $coverage;
44        $this->schemes = is_string($schemes) ? array_slice(func_get_args(), 1) : $schemes;
45    }
46
47    /**
48     * Validate $value
49     *
50     * @param mixed $value
51     * @param array $context
52     * @return bool
53     */
54    public function validate($value, array $context = []): bool
55    {
56        if (!is_string($value) || !$url = parse_url($value)) {
57            // not a string or not a url
58            $this->error = new Error(
59                'NO_URL',
60                $value,
61                'value should be a valid url'
62            );
63            return false;
64        }
65
66        if ($this->mode !== self::COVERAGE_VALID) {
67            // for scheme file we don't need a host
68            if (!isset($url['scheme']) || !isset($url['host']) && $url['scheme'] !== 'file') {
69                // no full url
70                $this->error = new Error(
71                    'NOT_FULL_URL',
72                    $value,
73                    'value should be a full url including host and scheme',
74                    ['schemes' => $this->schemes]
75                );
76                return false;
77            }
78
79            if (!in_array($url['scheme'], $this->schemes)) {
80                // scheme not allowed
81                $this->error = new Error(
82                    'SCHEME_NOT_ALLOWED',
83                    $value,
84                    'value should contain an allowed scheme',
85                    ['schemes' => $this->schemes]
86                );
87                return false;
88            }
89
90            if (($this->mode === self::COVERAGE_ACTIVE || $this->mode === self::COVERAGE_LISTENING) &&
91                !static::testDns($url['host'])
92            ) {
93                // no dns entry
94                $this->error = new Error(
95                    'NO_DNS_RECORD',
96                    $value,
97                    'value should contain a hostname with an active dns record'
98                );
99                return false;
100            }
101
102            if ($this->mode === self::COVERAGE_LISTENING) {
103                if (!isset($url['port']) && !isset(self::$ports[$url['scheme']])) {
104                    // no port defined
105                    $this->error = new Error(
106                        'NO_PORT',
107                        $value,
108                        'value should contain a port'
109                    );
110                    return false;
111                }
112
113                if (!static::testSocket($url['host'], $url['port'] ?? self::$ports[$url['scheme']])) {
114                    // not listening to connections
115                    $this->error = new Error(
116                        'NOT_LISTENING',
117                        $value,
118                        'value should point to a server that is currently listening'
119                    );
120                    return false;
121                }
122            }
123        }
124
125        return true;
126    }
127
128    protected static function testSocket(string $host, int $port)
129    {
130        if (static::$socketTester !== null) {
131            return call_user_func(static::$socketTester, $host, $port);
132        }
133
134        // @codeCoverageIgnoreStart
135        // we can not test this during unit tests - so we mock this through $socketTester
136        try {
137            $socket = fsockopen($host, $port, $errno, $errstr, 0.5);
138            fclose($socket);
139            return true;
140        } catch (\Throwable $e) {
141            // ignore any errors (socket timeout for example)
142        }
143        return false;
144        // @codeCoverageIgnoreEnd
145    }
146
147    protected static function testDns(string $host)
148    {
149        if (static::$dnsTester !== null) {
150            return call_user_func(static::$dnsTester, $host);
151        }
152
153        // @codeCoverageIgnoreStart
154        // we can not test this during unit tests - so we mock this through $dnsTester
155        return checkdnsrr($host, 'A') ||
156               checkdnsrr($host, 'AAAA') ||
157               checkdnsrr($host, 'CNAME');
158        // @codeCoverageIgnoreEnd
159    }
160
161    public static function setSocketTester(callable $socketTester)
162    {
163        static::$socketTester = $socketTester;
164    }
165
166    public static function setDnsTester(callable $dnsTester)
167    {
168        static::$dnsTester = $dnsTester;
169    }
170}