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