1: | <?php declare(strict_types=1); |
2: | |
3: | namespace Salient\Http\Server; |
4: | |
5: | use Salient\Contract\Core\Immutable; |
6: | use Salient\Contract\Http\Exception\InvalidHeaderException as InvalidHeaderExceptionInterface; |
7: | use Salient\Contract\Http\Message\ServerRequestInterface; |
8: | use Salient\Contract\Http\HasHttpHeader; |
9: | use Salient\Contract\Http\HasRequestMethod; |
10: | use Salient\Core\Concern\ImmutableTrait; |
11: | use Salient\Http\Exception\InvalidHeaderException; |
12: | use Salient\Http\Exception\ServerException; |
13: | use Salient\Http\Message\Response; |
14: | use Salient\Http\Message\ServerRequest; |
15: | use Salient\Http\Headers; |
16: | use Salient\Http\HttpUtil; |
17: | use Salient\Http\Uri; |
18: | use Salient\Utility\File; |
19: | use Salient\Utility\Regex; |
20: | use Salient\Utility\Str; |
21: | use InvalidArgumentException; |
22: | use LogicException; |
23: | use Throwable; |
24: | |
25: | |
26: | |
27: | |
28: | |
29: | |
30: | |
31: | |
32: | class Server implements Immutable, HasHttpHeader, HasRequestMethod |
33: | { |
34: | use ImmutableTrait; |
35: | |
36: | private string $Host; |
37: | private int $Port; |
38: | private int $Timeout; |
39: | private string $ProxyHost; |
40: | private int $ProxyPort; |
41: | private bool $ProxyHasTls; |
42: | private string $ProxyPath; |
43: | |
44: | |
45: | |
46: | private string $Address; |
47: | |
48: | private $Server = null; |
49: | private string $LocalIpAddress; |
50: | private int $LocalPort; |
51: | |
52: | |
53: | |
54: | |
55: | |
56: | |
57: | |
58: | |
59: | public function __construct(string $host, int $port, int $timeout = -1) |
60: | { |
61: | $this->Host = $host; |
62: | $this->Port = $port; |
63: | $this->Timeout = $timeout; |
64: | } |
65: | |
66: | |
67: | |
68: | |
69: | protected function __clone() |
70: | { |
71: | unset($this->Address); |
72: | $this->Server = null; |
73: | unset($this->LocalIpAddress); |
74: | unset($this->LocalPort); |
75: | } |
76: | |
77: | |
78: | |
79: | |
80: | public function getHost(): string |
81: | { |
82: | return $this->Host; |
83: | } |
84: | |
85: | |
86: | |
87: | |
88: | public function getPort(): int |
89: | { |
90: | return $this->Port; |
91: | } |
92: | |
93: | |
94: | |
95: | |
96: | |
97: | |
98: | |
99: | public function getTimeout(): int |
100: | { |
101: | return $this->Timeout; |
102: | } |
103: | |
104: | |
105: | |
106: | |
107: | public function hasProxy(): bool |
108: | { |
109: | return isset($this->ProxyHost); |
110: | } |
111: | |
112: | |
113: | |
114: | |
115: | |
116: | |
117: | |
118: | public function getProxyHost(): string |
119: | { |
120: | $this->assertHasProxy(); |
121: | return $this->ProxyHost; |
122: | } |
123: | |
124: | |
125: | |
126: | |
127: | |
128: | |
129: | |
130: | public function getProxyPort(): int |
131: | { |
132: | $this->assertHasProxy(); |
133: | return $this->ProxyPort; |
134: | } |
135: | |
136: | |
137: | |
138: | |
139: | |
140: | |
141: | |
142: | public function proxyHasTls(): bool |
143: | { |
144: | $this->assertHasProxy(); |
145: | return $this->ProxyHasTls; |
146: | } |
147: | |
148: | |
149: | |
150: | |
151: | |
152: | |
153: | |
154: | public function getProxyPath(): string |
155: | { |
156: | $this->assertHasProxy(); |
157: | return $this->ProxyPath; |
158: | } |
159: | |
160: | |
161: | |
162: | |
163: | |
164: | |
165: | |
166: | |
167: | |
168: | |
169: | |
170: | |
171: | |
172: | public function withProxy( |
173: | string $host, |
174: | int $port, |
175: | ?bool $hasTls = null, |
176: | string $path = '' |
177: | ): self { |
178: | $hasTls ??= $port === 443; |
179: | $path = trim($path, '/'); |
180: | if ($path !== '') { |
181: | $path = '/' . $path; |
182: | } |
183: | |
184: | return $this |
185: | ->with('ProxyHost', $host) |
186: | ->with('ProxyPort', $port) |
187: | ->with('ProxyHasTls', $hasTls) |
188: | ->with('ProxyPath', $path); |
189: | } |
190: | |
191: | |
192: | |
193: | |
194: | |
195: | |
196: | public function withoutProxy(): self |
197: | { |
198: | return $this |
199: | ->without('ProxyHost') |
200: | ->without('ProxyPort') |
201: | ->without('ProxyHasTls') |
202: | ->without('ProxyPath'); |
203: | } |
204: | |
205: | |
206: | |
207: | |
208: | |
209: | |
210: | public function getUri(): Uri |
211: | { |
212: | return isset($this->ProxyHost) |
213: | ? (new Uri()) |
214: | ->withScheme($this->ProxyHasTls ? 'https' : 'http') |
215: | ->withHost($this->ProxyHost) |
216: | ->withPort($this->ProxyPort) |
217: | ->withPath($this->ProxyPath) |
218: | : (new Uri()) |
219: | ->withScheme('http') |
220: | ->withHost($this->Host) |
221: | ->withPort($this->LocalPort ?? $this->Port); |
222: | } |
223: | |
224: | |
225: | |
226: | |
227: | public function isRunning(): bool |
228: | { |
229: | return (bool) $this->Server; |
230: | } |
231: | |
232: | |
233: | |
234: | |
235: | |
236: | |
237: | public function getLocalIpAddress(): string |
238: | { |
239: | $this->assertIsRunning(); |
240: | return $this->LocalIpAddress; |
241: | } |
242: | |
243: | |
244: | |
245: | |
246: | |
247: | |
248: | public function getLocalPort(): int |
249: | { |
250: | $this->assertIsRunning(); |
251: | return $this->LocalPort; |
252: | } |
253: | |
254: | |
255: | |
256: | |
257: | |
258: | |
259: | |
260: | public function start(): self |
261: | { |
262: | $this->assertIsNotRunning(); |
263: | |
264: | $address = $this->Address ??= sprintf( |
265: | 'tcp://%s:%d', |
266: | $this->Host, |
267: | $this->Port, |
268: | ); |
269: | |
270: | $errorCode = null; |
271: | $errorMessage = null; |
272: | $server = @stream_socket_server($address, $errorCode, $errorMessage); |
273: | |
274: | if ($server === false) { |
275: | throw new ServerException(sprintf( |
276: | 'Error starting server at %s (%d: %s)', |
277: | $address, |
278: | $errorCode, |
279: | $errorMessage, |
280: | )); |
281: | } |
282: | |
283: | $address = @stream_socket_get_name($server, false); |
284: | |
285: | if ($address === false || ($pos = strrpos($address, ':')) === false) { |
286: | throw new ServerException('Error getting server address'); |
287: | } |
288: | |
289: | $this->Server = $server; |
290: | $this->LocalIpAddress = substr($address, 0, $pos); |
291: | $this->LocalPort = (int) substr($address, $pos + 1); |
292: | |
293: | return $this; |
294: | } |
295: | |
296: | |
297: | |
298: | |
299: | |
300: | |
301: | public function stop(): self |
302: | { |
303: | if ($this->Server) { |
304: | File::close($this->Server, $this->Address); |
305: | $this->Server = null; |
306: | unset($this->LocalIpAddress); |
307: | unset($this->LocalPort); |
308: | } |
309: | |
310: | return $this; |
311: | } |
312: | |
313: | |
314: | |
315: | |
316: | |
317: | |
318: | |
319: | |
320: | |
321: | |
322: | |
323: | |
324: | |
325: | |
326: | |
327: | |
328: | |
329: | |
330: | |
331: | |
332: | |
333: | |
334: | |
335: | public function listen( |
336: | callable $listener, |
337: | int $limit = -1, |
338: | bool $catchBadRequests = true, |
339: | ?int $timeout = null, |
340: | bool $strict = false |
341: | ) { |
342: | $this->assertIsRunning(); |
343: | |
344: | while ($limit) { |
345: | $stream = null; |
346: | $response = null; |
347: | try { |
348: | $request = $this->getRequest($stream, $timeout, $strict); |
349: | $response = $listener($request); |
350: | if ($response->hasReturnValue()) { |
351: | return $response->getReturnValue(); |
352: | } |
353: | } catch (InvalidHeaderExceptionInterface $ex) { |
354: | $response = new Response( |
355: | $ex->getStatusCode() ?? 400, |
356: | $ex->getMessage(), |
357: | ); |
358: | if (!$catchBadRequests) { |
359: | throw $ex; |
360: | } |
361: | } catch (LogicException|ServerException $ex) { |
362: | throw $ex; |
363: | } catch (Throwable $ex) { |
364: | throw new ServerException($ex->getMessage(), $ex); |
365: | } finally { |
366: | if ($stream) { |
367: | if ($response) { |
368: | File::writeAll($stream, (string) $response); |
369: | } |
370: | File::close($stream); |
371: | } |
372: | } |
373: | $limit--; |
374: | } |
375: | |
376: | return null; |
377: | } |
378: | |
379: | |
380: | |
381: | |
382: | |
383: | private function getRequest(&$stream, ?int $timeout, bool $strict): ServerRequest |
384: | { |
385: | $this->assertIsRunning(); |
386: | |
387: | $handle = @stream_socket_accept( |
388: | $this->Server, |
389: | $timeout ?? $this->Timeout, |
390: | $client, |
391: | ); |
392: | |
393: | if ($handle === false) { |
394: | $error = error_get_last(); |
395: | throw new ServerException($error['message'] ?? sprintf( |
396: | 'Error accepting connection at %s', |
397: | $this->Address, |
398: | )); |
399: | } |
400: | |
401: | if ($client === null) { |
402: | throw new ServerException('No client address'); |
403: | } |
404: | |
405: | Regex::match('/(?<addr>.*?)(?::(?<port>[0-9]++))?$/D', $client, $matches); |
406: | |
407: | |
408: | $client = $matches['addr']; |
409: | $serverParams = [ |
410: | 'REMOTE_ADDR' => $matches['addr'], |
411: | 'REMOTE_PORT' => $matches['port'] ?? '', |
412: | ]; |
413: | |
414: | |
415: | $stream = $handle; |
416: | $line = File::readLine($stream); |
417: | |
418: | if ($strict && substr($line, -2) !== "\r\n") { |
419: | throw new InvalidHeaderException( |
420: | 'HTTP request line must end with CRLF', |
421: | ); |
422: | } |
423: | |
424: | $line = $strict |
425: | ? substr($line, 0, -2) |
426: | : rtrim($line, "\r\n"); |
427: | $startLine = $strict |
428: | ? explode(' ', $line, 4) |
429: | : Regex::split('/\s++/', trim($line), 3); |
430: | |
431: | $invalidMethod = false; |
432: | if ( |
433: | count($startLine) !== 3 |
434: | || !Regex::match('/^HTTP\/([0-9](?:\.[0-9])?)$/D', $startLine[2], $matches) |
435: | || ($invalidMethod = !HttpUtil::isRequestMethod($startLine[0])) |
436: | ) { |
437: | throw new InvalidHeaderException(sprintf( |
438: | 'Invalid HTTP request line: %s', |
439: | $line, |
440: | ), null, $invalidMethod ? 501 : null); |
441: | } |
442: | |
443: | $method = $startLine[0]; |
444: | $requestTarget = $startLine[1]; |
445: | $version = $matches[1]; |
446: | $requestTargetUri = null; |
447: | |
448: | |
449: | if (( |
450: | ($isAsteriskForm = $requestTarget === '*') |
451: | && $method !== self::METHOD_OPTIONS |
452: | ) || ( |
453: | ($isAuthorityForm = HttpUtil::isAuthorityForm($requestTarget)) |
454: | && $method !== self::METHOD_CONNECT |
455: | )) { |
456: | throw new InvalidHeaderException(sprintf( |
457: | "Invalid HTTP method for request target '%s': %s", |
458: | $requestTarget, |
459: | $method, |
460: | )); |
461: | } elseif ( |
462: | $method === self::METHOD_CONNECT |
463: | && !$isAuthorityForm |
464: | ) { |
465: | throw new InvalidHeaderException(sprintf( |
466: | "Invalid request target for HTTP method '%s': %s", |
467: | $method, |
468: | $requestTarget, |
469: | )); |
470: | } elseif ($isAuthorityForm) { |
471: | $requestTargetUri = new Uri('//' . $requestTarget); |
472: | } elseif (!$isAsteriskForm) { |
473: | try { |
474: | $requestTargetUri = new Uri($requestTarget, $strict); |
475: | } catch (InvalidArgumentException $ex) { |
476: | throw new InvalidHeaderException(sprintf( |
477: | 'Invalid HTTP request target: %s', |
478: | $requestTarget, |
479: | ), $ex); |
480: | } |
481: | $isAbsoluteForm = !$requestTargetUri->isRelativeReference(); |
482: | $isOriginForm = !$isAbsoluteForm |
483: | && $requestTargetUri->getPath() !== '' |
484: | && !array_diff_key( |
485: | $requestTargetUri->getComponents(), |
486: | ['path' => null, 'query' => null], |
487: | ); |
488: | if (!$isAbsoluteForm && !$isOriginForm) { |
489: | throw new InvalidHeaderException(sprintf( |
490: | 'Invalid HTTP request target: %s', |
491: | $requestTarget, |
492: | )); |
493: | } |
494: | } |
495: | |
496: | |
497: | $headers = new Headers(); |
498: | do { |
499: | $line = File::readLine($stream); |
500: | if ($line === '') { |
501: | throw new InvalidHeaderException('Invalid HTTP field lines'); |
502: | } |
503: | $headers = $headers->addLine($line, $strict); |
504: | } while (!$headers->hasEmptyLine()); |
505: | |
506: | |
507: | $host = $headers->getOnlyHeaderValue(self::HEADER_HOST); |
508: | $uri = implode('', [ |
509: | $this->getUri()->getScheme(), |
510: | '://', |
511: | Str::coalesce($host, $this->ProxyHost ?? $this->Host), |
512: | ]); |
513: | if (!Regex::match('/:[0-9]++$/', $uri)) { |
514: | $uri .= ':' . ($this->ProxyPort ?? $this->LocalPort); |
515: | } |
516: | try { |
517: | $uri = new Uri($uri, $strict); |
518: | } catch (InvalidArgumentException $ex) { |
519: | throw new InvalidHeaderException(sprintf( |
520: | 'Invalid HTTP request target: %s', |
521: | $uri, |
522: | ), $ex); |
523: | } |
524: | if ($requestTargetUri) { |
525: | $uri = $uri->follow($requestTargetUri); |
526: | } |
527: | |
528: | $length = HttpUtil::getContentLength($headers); |
529: | if ($length === 0) { |
530: | $body = ''; |
531: | } elseif ($length !== null) { |
532: | $body = File::readAll($stream, $length); |
533: | } else { |
534: | $body = null; |
535: | } |
536: | |
537: | return new ServerRequest( |
538: | $method, |
539: | $uri, |
540: | $serverParams, |
541: | $body, |
542: | $headers, |
543: | $requestTarget, |
544: | $version, |
545: | ); |
546: | } |
547: | |
548: | |
549: | |
550: | |
551: | private function assertIsNotRunning(): void |
552: | { |
553: | if ($this->Server) { |
554: | throw new LogicException('Server is running'); |
555: | } |
556: | } |
557: | |
558: | |
559: | |
560: | |
561: | private function assertIsRunning(): void |
562: | { |
563: | if (!$this->Server) { |
564: | throw new LogicException('Server is not running'); |
565: | } |
566: | } |
567: | |
568: | private function assertHasProxy(): void |
569: | { |
570: | if (!isset($this->ProxyHost)) { |
571: | throw new LogicException('Server is not configured to run behind a proxy server'); |
572: | } |
573: | } |
574: | } |
575: | |