Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
56 / 56 |
|
100.00% |
6 / 6 |
CRAP | |
100.00% |
1 / 1 |
Url | |
100.00% |
56 / 56 |
|
100.00% |
6 / 6 |
26 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
validate | |
100.00% |
48 / 48 |
|
100.00% |
1 / 1 |
15 | |||
testSocket | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
testDns | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
4 | |||
setSocketTester | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setDnsTester | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace Verja\Validator; |
4 | |
5 | use Verja\Error; |
6 | use Verja\Validator; |
7 | |
8 | class 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 | } |