1: | <?php declare(strict_types=1); |
2: | |
3: | namespace Salient\Http; |
4: | |
5: | use Psr\Http\Message\ResponseInterface; |
6: | use Salient\Contract\Core\Immutable; |
7: | use Salient\Contract\Http\HttpHeader; |
8: | use Salient\Contract\Http\HttpRequestMethod; |
9: | use Salient\Contract\Http\HttpResponseInterface; |
10: | use Salient\Contract\Http\HttpServerRequestInterface; |
11: | use Salient\Core\Concern\ImmutableTrait; |
12: | use Salient\Core\Facade\Console; |
13: | use Salient\Http\Exception\HttpServerException; |
14: | use Salient\Http\Exception\InvalidHeaderException; |
15: | use Salient\Utility\Exception\FilesystemErrorException; |
16: | use Salient\Utility\File; |
17: | use Salient\Utility\Regex; |
18: | use Salient\Utility\Str; |
19: | use InvalidArgumentException; |
20: | |
21: | |
22: | |
23: | |
24: | class HttpServer implements Immutable |
25: | { |
26: | use ImmutableTrait; |
27: | |
28: | protected string $Host; |
29: | protected int $Port; |
30: | protected int $Timeout; |
31: | protected string $ProxyHost; |
32: | protected int $ProxyPort; |
33: | protected bool $ProxyTls; |
34: | protected string $ProxyBasePath; |
35: | private string $Socket; |
36: | |
37: | private $Server; |
38: | |
39: | |
40: | |
41: | |
42: | |
43: | public function __construct(string $host, int $port, int $timeout = -1) |
44: | { |
45: | $this->Host = $host; |
46: | $this->Port = $port; |
47: | $this->Timeout = $timeout; |
48: | } |
49: | |
50: | |
51: | |
52: | |
53: | public function __clone() |
54: | { |
55: | unset($this->Socket); |
56: | $this->Server = null; |
57: | } |
58: | |
59: | |
60: | |
61: | |
62: | public function getHost(): string |
63: | { |
64: | return $this->Host; |
65: | } |
66: | |
67: | |
68: | |
69: | |
70: | public function getPort(): int |
71: | { |
72: | return $this->Port; |
73: | } |
74: | |
75: | |
76: | |
77: | |
78: | public function getTimeout(): int |
79: | { |
80: | return $this->Timeout; |
81: | } |
82: | |
83: | |
84: | |
85: | |
86: | public function hasProxy(): bool |
87: | { |
88: | return isset($this->ProxyHost); |
89: | } |
90: | |
91: | |
92: | |
93: | |
94: | public function getProxyHost(): string |
95: | { |
96: | return $this->ProxyHost; |
97: | } |
98: | |
99: | |
100: | |
101: | |
102: | public function getProxyPort(): int |
103: | { |
104: | return $this->ProxyPort; |
105: | } |
106: | |
107: | |
108: | |
109: | |
110: | public function getProxyTls(): bool |
111: | { |
112: | return $this->ProxyTls; |
113: | } |
114: | |
115: | |
116: | |
117: | |
118: | public function getProxyBasePath(): string |
119: | { |
120: | return $this->ProxyBasePath; |
121: | } |
122: | |
123: | |
124: | |
125: | |
126: | |
127: | |
128: | |
129: | |
130: | |
131: | |
132: | |
133: | |
134: | |
135: | public function withProxy( |
136: | string $host, |
137: | int $port, |
138: | ?bool $tls = null, |
139: | string $basePath = '' |
140: | ): self { |
141: | $basePath = trim($basePath, '/'); |
142: | if ($basePath !== '') { |
143: | $basePath = '/' . $basePath; |
144: | } |
145: | |
146: | return $this |
147: | ->with('ProxyHost', $host) |
148: | ->with('ProxyPort', $port) |
149: | ->with('ProxyTls', $tls ?? ($port === 443)) |
150: | ->with('ProxyBasePath', $basePath); |
151: | } |
152: | |
153: | |
154: | |
155: | |
156: | |
157: | |
158: | public function withoutProxy(): self |
159: | { |
160: | if (!isset($this->ProxyHost)) { |
161: | return $this; |
162: | } |
163: | |
164: | $clone = clone $this; |
165: | unset($clone->ProxyHost); |
166: | unset($clone->ProxyPort); |
167: | unset($clone->ProxyTls); |
168: | unset($clone->ProxyBasePath); |
169: | return $clone; |
170: | } |
171: | |
172: | |
173: | |
174: | |
175: | public function getBaseUri(): string |
176: | { |
177: | if (!isset($this->ProxyHost)) { |
178: | return $this->Port === 80 |
179: | ? sprintf('http://%s', $this->Host) |
180: | : sprintf('http://%s:%d', $this->Host, $this->Port); |
181: | } |
182: | |
183: | return ($this->ProxyTls && $this->ProxyPort === 443) |
184: | || (!$this->ProxyTls && $this->ProxyPort === 80) |
185: | ? sprintf( |
186: | '%s://%s%s', |
187: | $this->ProxyTls ? 'https' : 'http', |
188: | $this->ProxyHost, |
189: | $this->ProxyBasePath, |
190: | ) |
191: | : sprintf( |
192: | '%s://%s:%d%s', |
193: | $this->ProxyTls ? 'https' : 'http', |
194: | $this->ProxyHost, |
195: | $this->ProxyPort, |
196: | $this->ProxyBasePath, |
197: | ); |
198: | } |
199: | |
200: | |
201: | |
202: | |
203: | public function getScheme(): string |
204: | { |
205: | return isset($this->ProxyHost) |
206: | ? ($this->ProxyTls ? 'https' : 'http') |
207: | : 'http'; |
208: | } |
209: | |
210: | |
211: | |
212: | |
213: | public function isRunning(): bool |
214: | { |
215: | return $this->Server !== null; |
216: | } |
217: | |
218: | |
219: | |
220: | |
221: | |
222: | |
223: | public function start(): self |
224: | { |
225: | $this->assertIsNotRunning(); |
226: | |
227: | $this->Socket ??= sprintf('tcp://%s:%d', $this->Host, $this->Port); |
228: | |
229: | $errorCode = null; |
230: | $errorMessage = null; |
231: | $server = @stream_socket_server($this->Socket, $errorCode, $errorMessage); |
232: | |
233: | if ($server === false) { |
234: | throw new HttpServerException(sprintf( |
235: | 'Error starting server at %s (%d: %s)', |
236: | $this->Socket, |
237: | $errorCode, |
238: | $errorMessage, |
239: | )); |
240: | } |
241: | |
242: | $this->Server = $server; |
243: | |
244: | return $this; |
245: | } |
246: | |
247: | |
248: | |
249: | |
250: | |
251: | |
252: | public function stop(): self |
253: | { |
254: | $this->assertIsRunning(); |
255: | |
256: | File::close($this->Server); |
257: | |
258: | $this->Server = null; |
259: | |
260: | return $this; |
261: | } |
262: | |
263: | |
264: | |
265: | |
266: | |
267: | |
268: | |
269: | |
270: | |
271: | |
272: | |
273: | |
274: | |
275: | public function listen(callable $callback, ?int $timeout = null) |
276: | { |
277: | $this->assertIsRunning(); |
278: | |
279: | $timeout ??= $this->Timeout; |
280: | do { |
281: | $socket = @stream_socket_accept($this->Server, $timeout, $peer); |
282: | $this->maybeThrow( |
283: | $socket, |
284: | 'Error accepting connection at %s', |
285: | $this->Socket, |
286: | ); |
287: | |
288: | if ($peer === null) { |
289: | throw new HttpServerException('No client address'); |
290: | } |
291: | |
292: | Regex::match('/(?<addr>.*?)(?::(?<port>[0-9]+))?$/', $peer, $matches); |
293: | |
294: | |
295: | $peer = $matches['addr']; |
296: | $serverParams = [ |
297: | 'REMOTE_ADDR' => $matches['addr'], |
298: | 'REMOTE_PORT' => $matches['port'] ?? '', |
299: | ]; |
300: | |
301: | $method = null; |
302: | $target = ''; |
303: | $targetUri = null; |
304: | $version = ''; |
305: | $headers = new HttpHeaders(); |
306: | $body = null; |
307: | do { |
308: | $line = @fgets($socket); |
309: | if ($line === false) { |
310: | try { |
311: | File::checkEof($socket); |
312: | } catch (FilesystemErrorException $ex) { |
313: | |
314: | throw new HttpServerException(sprintf( |
315: | 'Error reading request from %s', |
316: | $peer, |
317: | ), $ex); |
318: | |
319: | } |
320: | throw new HttpServerException(sprintf( |
321: | 'Incomplete request from %s', |
322: | $peer, |
323: | )); |
324: | } |
325: | |
326: | if ($method === null) { |
327: | if (substr($line, -2) !== "\r\n") { |
328: | |
329: | throw new HttpServerException(sprintf( |
330: | 'Request line from %s does not end with CRLF', |
331: | $peer, |
332: | )); |
333: | |
334: | } |
335: | |
336: | $startLine = explode(' ', substr($line, 0, -2)); |
337: | |
338: | if ( |
339: | count($startLine) !== 3 |
340: | || !HttpUtil::isRequestMethod($startLine[0]) |
341: | || !Regex::match('/^HTTP\/([0-9](?:\.[0-9])?)$/D', $startLine[2], $matches) |
342: | ) { |
343: | throw new HttpServerException(sprintf( |
344: | 'Invalid request line from %s: %s', |
345: | $peer, |
346: | $line, |
347: | )); |
348: | } |
349: | |
350: | $method = $startLine[0]; |
351: | $target = $startLine[1]; |
352: | $version = $matches[1]; |
353: | |
354: | if ($target === '*') { |
355: | if ($method !== HttpRequestMethod::OPTIONS) { |
356: | throw new HttpServerException(sprintf( |
357: | 'Invalid request from %s for target %s: %s', |
358: | $peer, |
359: | $target, |
360: | $method, |
361: | )); |
362: | } |
363: | continue; |
364: | } |
365: | |
366: | if ($method === HttpRequestMethod::CONNECT) { |
367: | if (!Uri::isAuthorityForm($target)) { |
368: | throw new HttpServerException(sprintf( |
369: | 'Invalid request target for %s from %s: %s', |
370: | $method, |
371: | $peer, |
372: | $target, |
373: | )); |
374: | } |
375: | $targetUri = new Uri('//' . $target, true); |
376: | continue; |
377: | } |
378: | |
379: | try { |
380: | $targetUri = new Uri($target, true); |
381: | } catch (InvalidArgumentException $ex) { |
382: | throw new HttpServerException(sprintf( |
383: | 'Invalid request target for %s from %s: %s', |
384: | $method, |
385: | $peer, |
386: | $target, |
387: | ), $ex); |
388: | } |
389: | continue; |
390: | } |
391: | |
392: | $headers = $headers->addLine($line, true); |
393: | if ($headers->hasLastLine()) { |
394: | break; |
395: | } |
396: | } while (true); |
397: | |
398: | |
399: | $uri = implode('', [ |
400: | $this->getScheme(), |
401: | '://', |
402: | Str::coalesce($headers->getOneHeaderLine(HttpHeader::HOST), $this->ProxyHost ?? $this->Host), |
403: | ]); |
404: | if (!Regex::match('/:[0-9]++$/', $uri)) { |
405: | $uri .= ':' . ($this->ProxyPort ?? $this->Port); |
406: | } |
407: | try { |
408: | $uri = new Uri($uri, true); |
409: | } catch (InvalidArgumentException $ex) { |
410: | throw new HttpServerException(sprintf( |
411: | 'Invalid request URI from %s: %s', |
412: | $peer, |
413: | $uri, |
414: | ), $ex); |
415: | } |
416: | if ($targetUri !== null) { |
417: | $uri = $uri->follow($targetUri); |
418: | } |
419: | |
420: | |
421: | |
422: | try { |
423: | $length = $headers->getContentLength(); |
424: | } catch (InvalidHeaderException $ex) { |
425: | throw new HttpServerException(sprintf( |
426: | 'Invalid %s in request from %s', |
427: | HttpHeader::CONTENT_LENGTH, |
428: | $peer, |
429: | ), $ex); |
430: | } |
431: | if ($length === 0) { |
432: | $body = ''; |
433: | } elseif ($length !== null) { |
434: | $body = @fread($socket, $length); |
435: | if ($body === false) { |
436: | throw new HttpServerException(sprintf( |
437: | 'Error reading request body from %s', |
438: | $peer, |
439: | )); |
440: | } |
441: | if (strlen($body) < $length) { |
442: | throw new HttpServerException(sprintf( |
443: | 'Incomplete request body from %s', |
444: | $peer, |
445: | )); |
446: | } |
447: | } |
448: | |
449: | $request = new HttpServerRequest( |
450: | $method, |
451: | $uri, |
452: | $serverParams, |
453: | $body, |
454: | $headers, |
455: | $target, |
456: | $version, |
457: | ); |
458: | |
459: | Console::debug(sprintf('%s %s received from %s', $method, (string) $uri, $peer)); |
460: | |
461: | $continue = false; |
462: | $return = null; |
463: | $response = null; |
464: | |
465: | try { |
466: | $response = $callback($request, $continue, $return); |
467: | } finally { |
468: | $response = $response instanceof ResponseInterface |
469: | ? ($response instanceof HttpResponseInterface |
470: | ? $response |
471: | : HttpResponse::fromPsr7($response)) |
472: | : new HttpResponse(500, 'Internal server error'); |
473: | File::write($socket, (string) $response); |
474: | File::close($socket); |
475: | } |
476: | } while ($continue); |
477: | |
478: | return $return; |
479: | } |
480: | |
481: | |
482: | |
483: | |
484: | private function assertIsNotRunning(): void |
485: | { |
486: | if ($this->Server !== null) { |
487: | throw new HttpServerException('Server is running'); |
488: | } |
489: | } |
490: | |
491: | |
492: | |
493: | |
494: | private function assertIsRunning(): void |
495: | { |
496: | if ($this->Server === null) { |
497: | throw new HttpServerException('Server is not running'); |
498: | } |
499: | } |
500: | |
501: | |
502: | |
503: | |
504: | |
505: | |
506: | |
507: | |
508: | |
509: | |
510: | private function maybeThrow($result, string $message, ...$args) |
511: | { |
512: | if ($result === false) { |
513: | $error = error_get_last(); |
514: | if ($error) { |
515: | throw new HttpServerException($error['message']); |
516: | } |
517: | throw new HttpServerException(sprintf($message, ...$args)); |
518: | } |
519: | return $result; |
520: | } |
521: | } |
522: | |