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