<?php

class fuckwebsockets{
	
	public const op_continue = 0x00; // 0
	public const op_text = 0x01; // 1
	public const op_binary = 0x02; // 2
	public const op_disconnect = 0x08; // 8
	public const op_ping = 0x09; // 9
	public const op_pong = 0x0a; // 10

	public const close_normal = 1000; // 1000
	public const close_going_away = 1001; // 1001
	public const close_protocol = 1002; // 1002
	public const close_bad_data = 1003; // 1003
	public const close_no_status = 1005; // 1005
	public const close_abnormal = 1006; // 1006
	public const close_bad_payload = 1007; // 1007
	public const close_policy = 1008; // 1008
	public const close_too_big = 1009; // 1009
	public const close_mis_ext = 1010; // 1010
	public const close_srv_error = 1011; // 1011
	public const close_tls = 1015; // 1015
	
	public function create_server(array $options){
		
		$this->options =
			array_merge(
				[
					"ip" => "localhost", // ip.
					"port" => 8080, // port. anything under 1000 requires root
					"ip_source" => null, // "X-Forwarded-For" to get IP from that header, null for raw IP
					"origin_whitelist" => [], // leave empty to allow any origins. Eg: ["http://localhost"]
					"max_header_size" => 1024, // in bytes
					"max_header_rcvtime" => 10, // in seconds
					"max_tcp_sndtime" => 30, // in seconds
					"tcp_write_size" => 4096, // in bytes
					"tcp_keepalive" => true,
					"ws_ping_interval" => 0, // int for ping interval, 0 for disabled, in seconds
					"max_ws_rcv_size" => 4096, // in bytes
					"http_headers" => [], // Eg ["Host" => "localhost"]
					"debug_log" => true
				],
				$options
			);
		
		if($this->options["ip_source"] !== null){
		
			$this->options["ip_source"] = strtolower($this->options["ip_source"]);
		}
		unset($options);
		
		// define socket on all processes
		$this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
		
		if(
			// make it so we can reuse the address on forced close
			!socket_set_option($this->socket, SOL_SOCKET, SO_REUSEADDR, 1) ||
			!socket_set_option($this->socket, SOL_SOCKET, SO_SNDTIMEO, ["sec" => $this->options["max_tcp_sndtime"], "usec" => 0]) ||
			!socket_set_option($this->socket, SOL_SOCKET, SO_KEEPALIVE, $this->options === true ? 1 : 0) ||
			!@socket_bind($this->socket, $this->options["ip"], $this->options["port"]) ||
			!@socket_listen($this->socket, 5)
		){
			
			throw new Exception("Could not start websocket server: " . socket_strerror(socket_last_error($this->socket)));
			socket_close($this->socket);
			die();
		}
		
		$pid = pcntl_fork();
		
		if($pid === -1){
			
			throw new Exception("Failed to create incoming connection handler process");
		}
		elseif($pid){
			
			//
			// parent:
			// wait for incoming unix signals that correspond to application events
			//
			while(true){
				
				//echo "a\n";
				sleep(1);
			}
			
		}else{
						
			//
			// child:
			// subprocess for handling incoming connections
			//
			
			// reap dead children
			pcntl_signal(SIGCHLD, SIG_IGN);
			
			while(true){
				
				$this->client = new StdClass();
				if(($this->client->socket = @socket_accept($this->socket)) === false){
					
					// socket accept failed
					continue;
				}
				
				$pid = pcntl_fork();
				if($pid === -1){
					
					echo "Warn: failed to create connection handler\n";
					continue;
					
				}elseif($pid){
					
					// parent: loop over to socket_accept call
					continue;
				}else{
					
					//
					// connection handler
					//
					$this->client->request = new StdClass();
					$this->client->request->handshake = false;
					$this->client->request->headers = [];
					
					if($this->options["ip_source"] === null){

						socket_getpeername(
							$this->client->socket,
							$this->client->ip,
							$this->client->port
						);
					}else{
						
						$this->client->ip = "x.x.x.x";
						$this->client->port = -1;
					}
					
					$this->log($this->client, "TCP connect");
					
					$buffer = null;
					$len = 0;
					$time_start = microtime(true) + $this->options["max_header_rcvtime"];
					$newline = false;
					$firstline = true;
					
					// Parse incoming handshake init
					while(true){
						
						$timeout = $time_start - microtime(true);
						
						if($timeout <= 0){
							
							$this->http_disconnect($this->client, 408, "Read timeout");
						}
						
						$times = explode(".", $timeout, 2);
						
						if(count($times) === 2){
							
							$usec = (int)substr($times[1], 0, 6);
						}else{
							
							$usec = 0;
						}
						
						// timeout after configured amount
						socket_set_option(
							$this->client->socket,
							SOL_SOCKET,
							SO_RCVTIMEO,
							[
								"sec" => (int)$times[0],
								"usec" => $usec
							]
						);
						
						// hang till we receive data
						$this->socket_recv($this->client, $bytes, 1, MSG_WAITALL);
						
						$len++;
						
						if($len >= $this->options["max_header_size"]){
							
							$this->http_disconnect($this->client, 413, "Headers exceeded configured size");
						}
						
						if($bytes == "\r"){
							
							continue;
						}
						
						if($bytes == "\n"){
							
							if($newline === true){
								
								// received all headers
								break;
							}
							
							if($firstline){
								
								$firstline = false;
								
								if(
									preg_match(
										'/^([A-Z]+) ([^ ]+) HTTP\/([0-9.]+)$/i',
										$buffer,
										$header
									)
									=== 0
								){
									
									$this->http_disconnect($this->client, 400, "Received malformed HTTP method");
								}
								
								$header[1] = strtoupper($header[1]);
								
								if($header[1] !== "GET"){
									
									$this->http_disconnect($this->client, 405, "Received invalid HTTP method " . $header[1]);
								}
								
								$this->client->request->path = $header[2];
								$this->client->request->http_version = (float)$header[3];
								$buffer = null;
								continue;
							}
							
							$newline = true;
							$buffer = explode(":", $buffer, 2);
							
							if(count($buffer) !== 2){
								
								$this->http_disconnect($this->client, 400, "Received invalid header line");
							}
							
							$buffer[0] = strtolower(trim($buffer[0]));
							$buffer[1] = trim($buffer[1]);
							
							if(
								strlen($buffer[0]) === 0 ||
								strlen($buffer[1]) === 0
							){
								
								$this->http_disconnect($this->client, 400, "Received 0-length header name or value");
							}
							
							$this->client->request->headers[$buffer[0]] = $buffer[1];
								
							$buffer = null;
							continue;
						}
						
						$newline = false;
						$buffer .= $bytes;
					}
					
					// sanitize headers
					if(
						!isset($this->client->request->headers["host"]) ||
						
						!isset($this->client->request->headers["connection"]) ||
						!$this->check_header_value("upgrade", $this->client->request->headers["connection"]) ||
						
						!isset($this->client->request->headers["upgrade"]) ||
						!$this->check_header_value("websocket", $this->client->request->headers["upgrade"]) ||
						
						!isset($this->client->request->headers["sec-websocket-version"]) ||
						!$this->check_header_value("13", $this->client->request->headers["sec-websocket-version"]) ||
						
						!isset($this->client->request->headers["sec-websocket-key"]) ||
						strlen(base64_decode($this->client->request->headers["sec-websocket-key"])) !== 16
					){
						
						$this->http_disconnect(
							$this->client,
							405,
							"Not a WebSocket client"
						);
					}
					
					// get IP
					if($this->options["ip_source"] !== null){
						
						if(!isset($this->client->request->headers[$this->options["ip_source"]])){
							
							$this->http_disconnect(
								$this->client,
								405,
								"Missing " . $this->options["ip_source"] . " header"
							);
						}
						
						$parts =
							explode(
								":",
								$this->client->request->headers[$this->options["ip_source"]],
								2
							);
						
						$this->client->ip = $parts[0];
						
						if(count($parts) === 2){
							
							$this->client->port = $parts[1];
						}
					}
					
					// check origin
					if(
						count($this->options["origin_whitelist"]) !== 0 &&
						(
							!isset($this->client->request->headers["origin"]) ||
							!in_array($this->client->request->headers["origin"], $this->options["origin_whitelist"])
						)
					){
						
						$this->http_disconnect(
							$this->client,
							403,
							"Origin not allowed, got " . $this->client->request->headers["origin"]
						);
					}
					
					// send handshake confirmation
					$this->http_respond(
						$this->client,
						101,
						array_merge(
							$this->options["http_headers"],
							[
								"Connection" => "Upgrade",
								"Upgrade" => "websocket",
								"Sec-WebSocket-Accept" =>
									base64_encode(
										sha1(
											$this->client->request->headers["sec-websocket-key"] .
											"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",
											true
										)
									)
							]
						)
					);
					
					$this->client->request->handshake = true;
					$this->log($this->client, "Handshake done");
					
					// handle incoming messages
					$buffer = null;
					$bufferlength = 0;
					
					// remove socket timeout
					socket_set_option(
						$this->client->socket,
						SOL_SOCKET,
						SO_RCVTIMEO,
						[
							"sec" => 0,
							"usec" => 0
						]
					);
					
					while(true){
						
						// decode message
						$this->socket_recv($this->client, $header, 2, MSG_WAITALL);
						
						$finalmessage = (ord($header[0]) & 0x80) === 128;
						$opcode = ord($header[0]) & 0x0F;
						$masked = (ord($header[1]) & 0x80) === 128;
						$messagelength = ord($header[1]) & 0x7F;
						
						switch($messagelength){
							
							case 126:
								$this->socket_recv($this->client, $messagelength, 2, MSG_WAITALL);
								$messagelength = unpack("n", $messagelength)[1];
								break;
							
							case 127:
								$this->socket_recv($this->client, $messagelength, 8, MSG_WAITALL);
								$messagelength = unpack("J", $messagelength)[1];
								break;
						}
						
						if($messagelength > $this->options["max_ws_rcv_size"]){
							
							$this->ws_disconnect($this->client, self::close_too_big, "Frame exceeds " . $this->options["max_ws_rcv_size"] . "b limit");
						}
						
						if($masked){
							
							// get mask and message payload
							$this->socket_recv($this->client, $frame_data, $messagelength + 4, MSG_WAITALL);
							
							for($i=4; $i<$messagelength; $i++){
								
								$frame_data[$i] = $frame_data[$i] ^ $frame_data[$i % 4];
							}
							
							$frame_data = substr($frame_data, 4);
						}else{
							
							// get message payload
							$this->socket_recv($this->client, $frame_data, $messagelength, MSG_WAITALL);
						}
						
						// call user functions
						switch($opcode){
							
							case self::op_text:
							case self::op_binary:
								if($finalmessage === false){
									// partial message, come back later
									
									$buffer = $frame_data;
									$bufferlength = $messagelength;
									break;
								}
								
								// final message
								$this->log($this->client, "Text frame: {$frame_data}");
								break;
							
							
							case self::op_continue:
								$buffer .= $frame_data;
								$bufferlength += $messagelength;
								
								if($bufferlength > $this->options["max_ws_rcv_size"]){
									
									$this->ws_disconnect($this->client, self::close_too_big, "Message buffer exceeds " . $this->options["max_ws_rcv_size"] . "b limit");
								}
								
								if($finalmessage){
									
									// we received the entire thing, trigger event
									$this->log($this->client, "Text frame (buffer): {$buffer}");
									$buffer = null;
									$bufferlength = 0;
								}
								break;
							
							
							case self::op_ping:
								if($messagelength > 126){
									
									$this->ws_disconnect($this->client, self::close_too_big, "Ping frame exceeded 128 bytes");
								}
								
								$this->log($this->client, "Received ping: " . $frame_data);
								$this->ws_send($this->client, self::op_pong, $frame_data);
								break;
							
							
							case self::op_pong:
								$this->log($this->client, "Received pong: " . $frame_data);
								break;
							
							
							case self::op_disconnect:
								$code = unpack("n", $frame_data)[1];
								$frame_data = substr($frame_data, 2);
								
								$this->log($this->client, $code . ": Client disconnected. " . $frame_data);
								die();
								break;
							
							default:
								// unknown frame
								$this->ws_disconnect($this->client, self::close_bad_data, "Unsupported opcode");
						}
					}
				}
			}
		}
	}
	
	private function socket_recv(object $client, &$bytes, int $len, int $const){
		
		$state = socket_recv($client->socket, $bytes, $len, $const);
		
		if($state === 0){
			
			$this->log($client, "Remote TCP close");
			exit(0);
		}elseif($state === false){
			
			$error = socket_last_error();
			if($error === 0){
				
				return;
			}
			
			if($client->request->handshake === false){
				
				$this->http_disconnect($client, 408, socket_strerror($error));
			}else{
				
				$this->ws_disconnect($client, self::close_no_status, socket_strerror($error));
			}
		}
	}
	
	private function socket_write(object $client, string $bytes){
		
		$read_ptr = 0;
		$len = strlen($bytes);
		
		while(true){
			
			$sent =
				socket_write(
					$client->socket,
					substr(
						$bytes,
						$read_ptr,
						$this->options["tcp_write_size"]
					)
				);
			
			if($sent === false){
				
				$this->log("Message send failure at byte " . $read_ptr . " (closed)");
				exit(0);
				break;
			}
			
			$read_ptr += $sent;
			
			if($read_ptr === $len){
				
				break;
			}
		}
	}
	
	public function ws_send(object $client, int $opcode, string $message){
		
		$this->socket_write(
			$client,
			$this->ws_encode(
				$opcode,
				$message
			)
		);
	}
	
	public function ws_disconnect(object $client, int $disconnect_opcode, string $message){
		
		$this->ws_send(
			$client,
			self::op_disconnect,
			pack("n", $disconnect_opcode) .
			$message
		);
		
		socket_close($client->socket);
		$this->log($client, $disconnect_opcode . ": " . $message . " (closed)");
		exit(0);
	}
	
	public function ws_encode(int $opcode, string $message){
		
		// 0x80 = hardcoded end msg bit=true & mask=false (8bit)
		// $opcode = 1 to 4 bits
		$header = 0x80 | $opcode;
		$len = strlen($message);
		
		if($len < 126){
			
			$header = pack("CC", $header, $len);
		}elseif($len < 65536){
			
			$header = pack("CCn", $header, 126, $len); // 16bit len
		}else{
			
			$header = pack("CCJ", $header, 127, $len); // 64bit len
		}
		
		return $header . $message;
	}
	
	public function log(object $client, string $message){
		
		if($this->options["debug_log"]){
			
			echo $client->ip . ":" . $client->port . "] " . $message . "\n";
		}
	}
	
	public function http_disconnect(object $client, int $errcode, string $message){
		
		$this->http_respond(
			$client,
			$errcode,
			array_merge(
				$this->options["http_headers"],
				[
					"Content-Type" => "text/plain",
					"Sec-WebSocket-Version" => "13",
					"Content-Length" => strlen($message),
					"Connection" => "close"
				]
			),
			$message
		);
		
		socket_close($client->socket);
		$this->log($client, $errcode . ": " . $message . " (closed)");
		exit(0);
	}
	
	public function http_respond(object $client, int $errcode, array $headers, string $message = null){
		
		switch($errcode){
			
			case 100: $errtext = "Continue"; break;
			case 101: $errtext = "Switching Protocols"; break;
			case 200: $errtext = "OK"; break;
			case 201: $errtext = "Created"; break;
			case 202: $errtext = "Accepted"; break;
			case 203: $errtext = "Non-Authoritative Information"; break;
			case 204: $errtext = "No Content"; break;
			case 205: $errtext = "Reset Content"; break;
			case 206: $errtext = "Partial Content"; break;
			case 300: $errtext = "Multiple Choices"; break;
			case 301: $errtext = "Moved Permanently"; break;
			case 302: $errtext = "Moved Temporarily"; break;
			case 303: $errtext = "See Other"; break;
			case 304: $errtext = "Not Modified"; break;
			case 305: $errtext = "Use Proxy"; break;
			case 400: $errtext = "Bad Request"; break;
			case 401: $errtext = "Unauthorized"; break;
			case 402: $errtext = "Payment Required"; break;
			case 403: $errtext = "Forbidden"; break;
			case 404: $errtext = "Not Found"; break;
			case 405: $errtext = "Method Not Allowed"; break;
			case 406: $errtext = "Not Acceptable"; break;
			case 407: $errtext = "Proxy Authentication Required"; break;
			case 408: $errtext = "Request Time-out"; break;
			case 409: $errtext = "Conflict"; break;
			case 410: $errtext = "Gone"; break;
			case 411: $errtext = "Length Required"; break;
			case 412: $errtext = "Precondition Failed"; break;
			case 413: $errtext = "Request Entity Too Large"; break;
			case 414: $errtext = "Request-URI Too Large"; break;
			case 415: $errtext = "Unsupported Media Type"; break;
			case 500: $errtext = "Internal Server Error"; break;
			case 501: $errtext = "Not Implemented"; break;
			case 502: $errtext = "Bad Gateway"; break;
			case 503: $errtext = "Service Unavailable"; break;
			case 504: $errtext = "Gateway Time-out"; break;
			case 505: $errtext = "HTTP Version not supported"; break;
		}
		
		$headers_write = null;
		foreach($headers as $name => $value){
			
			$headers_write .= $name . ": " . $value . "\r\n";
		}
		
		$this->socket_write(
			$client,
			"HTTP/1.1 " . $errcode . " " . $errtext . "\r\n" .
			$headers_write .
			"\r\n" .
			$message
		);
	}
	
	private function check_header_value(string $value, string $header){
		
		$headers = explode(",", $header);
		
		foreach($headers as $part){
			
			if(trim(strtolower($part)) == $value){
				
				return true;
			}
		}
		
		return false;
	}
}