RU
apps/caramail-server / src / Net / WsSession.php · commit 070afda
<?phpnamespace App\Net;use App\Chat\ChatServer;use App\Chat\Frame;use App\Chat\Session;/** * A chat client over a WebSocket (port 443 through the web server: it passes every firewall). The client is * php-wasm-network (JumPHP's network library, the same in a browser and on a desktop), so the socket speaks the * little of its relay protocol it needs: "hello", a room ("CHAT") where the server is peer 0 and the client peer 1, * binary messages "[peer byte] + bytes" that carry the applet's TCP stream unchanged, pings. */final class WsSession implements Session{    public const ROOM = 'CHAT';    public const MAX_FRAME = 65536 + 16;    public string $buffer = '';    private string $in = '';    private string $output = '';    private bool $open = false;    private bool $joined = false;    private bool $closed = false;    private string $ip = '';    private float $pinged;    /** @param resource $stream */    public function __construct(public $stream, private readonly string $peer, private readonly ChatServer $chat)    {        $this->pinged = microtime(true);    }    public function address(): string    {        return ($this->ip !== '' ? $this->ip : $this->peer) . ' (ws)';    }    public function ip(): string    {        return $this->ip !== '' ? $this->ip : ClientIp::host($this->peer);    }    // ---- Session: what the chat sends goes as one binary message from peer 0 ---------------------------------    public function send(string $code, array $fields = []): void    {        if ($this->joined && !$this->closed) {            $this->frame(0x2, "\x00" . Frame::encode($code, $fields));        }    }    public function close(): void    {        if ($this->closed) {            return;        }        if ($this->open) {            $this->frame(0x8, '');        }        $this->flush();        $this->closed = true;        if (is_resource($this->stream)) {            @fclose($this->stream);        }    }    public function isClosed(): bool    {        return $this->closed;    }    public function hasOutput(): bool    {        return $this->output !== '' && !$this->closed;    }    public function flush(): void    {        if ($this->closed || $this->output === '') {            return;        }        $written = @fwrite($this->stream, $this->output);        if ($written === false) {            $this->closed = true;            return;        }        $this->output = (string)substr($this->output, $written);    }    /** Keeps proxies (Cloudflare drops a WebSocket silent for 100 s) from closing the line. */    public function keepAlive(float $now): void    {        if ($this->open && $now - $this->pinged > 30) {            $this->pinged = $now;            $this->frame(0x9, '');        }    }    // ---- what comes in --------------------------------------------------------------------------------------    public function read(string $data): void    {        $this->in .= $data;        if (!$this->open) {            $end = strpos($this->in, "\r\n\r\n");            if ($end === false) {                if (strlen($this->in) > 8192) {                    $this->close();                }                return;            }            $head = substr($this->in, 0, $end);            $this->in = (string)substr($this->in, $end + 4);            if (!$this->handshake($head)) {                return;            }        }        while (!$this->closed && ($message = $this->nextFrame()) !== null) {            [$opcode, $payload] = $message;            match ($opcode) {                0x1 => $this->control($payload),                0x2 => $this->binary($payload),                0x8 => $this->close(),                0x9 => $this->frame(0xA, $payload),                default => null,            };        }    }    private function handshake(string $head): bool    {        if (!preg_match('/^Sec-WebSocket-Key:\s*(\S+)/mi', $head, $m) || !preg_match('#^GET #', $head)) {            // a plain HTTP request (a health check, a browser): a short answer            $body = ChatServer::BANNER . "\n";            $this->output .= "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: " . strlen($body) . "\r\nConnection: close\r\n\r\n" . $body;            $this->flush();            $this->closed = true;            @fclose($this->stream);            return false;        }        $headers = [];        foreach (preg_split('/\r\n/', $head) ?: [] as $line) {            if (preg_match('/^([A-Za-z0-9-]+):\s*(.*)$/', $line, $h)) {                $headers[strtolower($h[1])] = trim($h[2]);            }        }        $this->ip = ClientIp::resolve($this->peer, $headers);        $accept = base64_encode(sha1($m[1] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));        $this->output .= "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: $accept\r\n\r\n";        $this->open = true;        $this->text(['t' => 'hello', 'v' => 1]);        $this->flush();        return true;    }    /** create / join / quick: the client enters the chat's "room"; then the chat says its banner. */    private function control(string $json): void    {        $m = json_decode($json, true);        if (!is_array($m)) {            return;        }        switch ($m['t'] ?? '') {            case 'create':            case 'join':            case 'quick':                if ($this->joined) {                    return;                }                $this->text(['t' => 'room', 'room' => self::ROOM, 'peer' => 1, 'host' => 0, 'peers' => [0, 1], 'watchers' => [],                    'watcher' => false, 'meta' => new \stdClass(), 'max' => 2, 'public' => false]);                $this->joined = true;                $this->chat->connect($this);                break;            case 'ping':                $this->text(['t' => 'pong', 'id' => $m['id'] ?? 0]);                break;            case 'leave':                $this->close();                break;        }    }    /** [to] + the applet's bytes: cut at line ends, as over TCP. */    private function binary(string $payload): void    {        if (!$this->joined || $payload === '') {            return;        }        $this->buffer .= substr($payload, 1);        foreach (Frame::decode($this->buffer) as [$code, $fields]) {            if ($this->closed) {                return;            }            $this->chat->receive($this, $code, $fields);        }        if (strlen($this->buffer) > TcpServer::MAX_LINE) {            $this->close();        }    }    private function text(array $message): void    {        $this->frame(0x1, (string)json_encode($message));    }    private function frame(int $opcode, string $payload): void    {        $len = strlen($payload);        $head = chr(0x80 | $opcode);        $head .= $len < 126 ? chr($len) : ($len < 65536 ? chr(126) . pack('n', $len) : chr(127) . pack('J', $len));        $this->output .= $head . $payload;        $this->flush();    }    /** @return array{0: int, 1: string}|null one whole frame (client frames are masked) */    private function nextFrame(): ?array    {        $n = strlen($this->in);        if ($n < 2) {            return null;        }        $b1 = ord($this->in[0]);        $b2 = ord($this->in[1]);        $len = $b2 & 0x7F;        $pos = 2;        if ($len === 126) {            if ($n < 4) {                return null;            }            $len = unpack('n', substr($this->in, 2, 2))[1];            $pos = 4;        } elseif ($len === 127) {            if ($n < 10) {                return null;            }            $len = unpack('J', substr($this->in, 2, 8))[1];            $pos = 10;        }        if ($len > self::MAX_FRAME) {            $this->close();            return null;        }        $masked = ($b2 & 0x80) !== 0;        $mask = '';        if ($masked) {            if ($n < $pos + 4) {                return null;            }            $mask = substr($this->in, $pos, 4);            $pos += 4;        }        if ($n < $pos + $len) {            return null;        }        $payload = substr($this->in, $pos, $len);        $this->in = (string)substr($this->in, $pos + $len);        if ($masked) {            for ($i = 0; $i < $len; $i++) {                $payload[$i] = $payload[$i] ^ $mask[$i % 4];            }        }        // fragments are rare here (small frames): a continuation is joined to nothing and ignored        return ($b1 & 0x80) ? [$b1 & 0x0F, $payload] : [0, ''];    }}