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: * A simple HTTP server
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: /** @var resource|null */
36: private $Server;
37:
38: /**
39: * Creates a new HttpServer object
40: *
41: * @param int $timeout The default number of seconds to wait for a request
42: * before timing out. Use a negative value to wait indefinitely.
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: * @internal
53: */
54: public function __clone()
55: {
56: unset($this->Socket);
57: $this->Server = null;
58: }
59:
60: /**
61: * Get the server's hostname or IP address
62: */
63: public function getHost(): string
64: {
65: return $this->Host;
66: }
67:
68: /**
69: * Get the server's TCP port
70: */
71: public function getPort(): int
72: {
73: return $this->Port;
74: }
75:
76: /**
77: * Get the default number of seconds to wait for a request before timing out
78: */
79: public function getTimeout(): int
80: {
81: return $this->Timeout;
82: }
83:
84: /**
85: * Check if the server is configured to run behind a proxy server
86: */
87: public function hasProxy(): bool
88: {
89: return isset($this->ProxyHost);
90: }
91:
92: /**
93: * Get the hostname or IP address of the proxy server
94: */
95: public function getProxyHost(): string
96: {
97: return $this->ProxyHost;
98: }
99:
100: /**
101: * Get the TCP port of the proxy server
102: */
103: public function getProxyPort(): int
104: {
105: return $this->ProxyPort;
106: }
107:
108: /**
109: * Check if connections to the proxy server are encrypted
110: */
111: public function getProxyTls(): bool
112: {
113: return $this->ProxyTls;
114: }
115:
116: /**
117: * Get the base path at which the server can be reached via the proxy server
118: */
119: public function getProxyBasePath(): string
120: {
121: return $this->ProxyBasePath;
122: }
123:
124: /**
125: * Get an instance configured to run behind a proxy server
126: *
127: * Returns a server that listens at the same host and port, but refers to
128: * itself in client-facing URIs as:
129: *
130: * ```
131: * http[s]://<proxy_host>[:<proxy_port>][<proxy_base_path>]
132: * ```
133: *
134: * @return static
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: * Get an instance that is not configured to run behind a proxy server
156: *
157: * @return static
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: * Get the server's client-facing base URI with no trailing slash
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: * Get the server's client-facing URI scheme
204: */
205: public function getScheme(): string
206: {
207: return isset($this->ProxyHost)
208: ? ($this->ProxyTls ? 'https' : 'http')
209: : 'http';
210: }
211:
212: /**
213: * Check if the server is running
214: */
215: public function isRunning(): bool
216: {
217: return $this->Server !== null;
218: }
219:
220: /**
221: * Start the server
222: *
223: * @return $this
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: * Stop the server
251: *
252: * @return $this
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: * Wait for a request and return a response
267: *
268: * @template T
269: *
270: * @param callable(HttpServerRequestInterface $request, bool &$continue, T &$return): ResponseInterface $callback Receives
271: * an {@see HttpServerRequestInterface} and returns a
272: * {@see ResponseInterface}. May also set `$continue = true` to make
273: * {@see HttpServer::listen()} wait for another request, or pass a value
274: * back to the caller by assigning it to `$return`.
275: * @return T|null
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: /** @var array{addr:string,port?:string} $matches */
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: // @codeCoverageIgnoreStart
316: throw new HttpServerException(sprintf(
317: 'Error reading request from %s',
318: $peer,
319: ), $ex);
320: // @codeCoverageIgnoreEnd
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: // @codeCoverageIgnoreStart
331: throw new HttpServerException(sprintf(
332: 'Request line from %s does not end with CRLF',
333: $peer,
334: ));
335: // @codeCoverageIgnoreEnd
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: // As per [RFC7230], Section 5.5 ("Effective Request URI")
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: /** @todo Handle requests without Content-Length */
423: /** @todo Add support for Transfer-Encoding */
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: * @phpstan-assert null $this->Server
485: */
486: private function assertIsNotRunning(): void
487: {
488: if ($this->Server !== null) {
489: throw new HttpServerException('Server is running');
490: }
491: }
492:
493: /**
494: * @phpstan-assert !null $this->Server
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: * @template T
505: *
506: * @param T $result
507: * @param string|int|float ...$args
508: * @return (T is false ? never : T)
509: * @phpstan-param T|false $result
510: * @phpstan-return ($result is false ? never : T)
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: