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