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