bl2mods/Mods/WebPanel/server.py
2023-03-22 12:57:56 -04:00

320 lines
6.7 KiB
Python

import json
import sys
import threading
import time
import traceback
from Mods.ModUtils.misc import clsname
from http import HTTPStatus
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
ROUTES = {}
def convert_to_bytes(value, encoding='utf-8'):
if isinstance(value, bytes):
return value
try:
return convert_to_string(value).encode(encoding)
except RuntimeError:
raise RuntimeError(f'Cannot convert "{clsname(value)}" into bytes')
def convert_to_string(value, encoding='utf-8'):
if isinstance(value, bytes):
return value.decode(encoding)
elif isinstance(value, bool):
return str(value)
elif isinstance(value, str):
return value
elif isinstance(value, (dict, list, tuple, set)):
return json.dumps(value)
elif isinstance(value, (int, float)):
return str(value)
elif value == None:
return ''
raise RuntimeError(f'Cannot convert "{clsname(value)}" into a string')
def get_route(path, method):
try: rpath = ROUTES[path]
except KeyError: raise HttpError(404)
try: return rpath[method.upper()]
except KeyError: raise HttpError(405)
def route(path, method='GET'):
def wrapper(handler):
ROUTES[path] = {method: handler}
return wrapper
class Request(BaseHTTPRequestHandler):
default_request_version = 'HTTP/1.1'
protocol_version = 'HTTP/1.1'
responded = False
app = None
def __init__(self, app, request, client_addr, server_obj):
self.app = app
BaseHTTPRequestHandler.__init__(self, request, client_addr, server_obj)
@property
def length(self):
return int(self.headers.get('Length', 0))
@property
def method(self):
return self.command
@property
def mod(self):
return self.app.mod
@property
def remote(self):
return self.headers.get('X-Real-Ip', self.client_address[0])
@property
def useragent(self):
return self.headers.get('User-Agent', 'n/a')
def log_request(self, code='-', size='-'):
message = f'{self.remote} "{self.method} {self.path}" {code} {size} "{self.useragent}"'
self.app.mod.log(message + '\n')
def read(self, length=None):
return self.rfile.read(length or self.length)
def respond(self, body=b'', status=HTTPStatus.OK, headers=None, content_type='text/plain', new_line=True):
if self.responded:
raise RuntimeError('Response already sent')
if not isinstance(status, HTTPStatus):
try:
status = HTTPStatus(status)
except ValueError:
return self.respond('Server Error', 500)
body = convert_to_bytes(body)
resp_headers = self.app.default_headers.copy()
resp_headers.update(headers or {})
resp_headers = {key.title(): value for key, value in resp_headers.items()}
resp_headers.pop('Server', None)
self.send_response_only(status)
self.send_header('Server', 'BL2Panel')
self.send_header('Date', resp_headers.pop('Date', self.date_time_string()))
self.send_header('Content-Type', resp_headers.pop('Content-Type', content_type))
self.send_header('Length', resp_headers.pop('Length', len(body)))
for key, value in resp_headers.items():
self.send_header(key.title(), value)
self.end_headers()
if self.app.mod.access_log:
self.log_request(status, len(body))
self.write(body)
if new_line and not body.endswith(b'\n'):
self.write(b'\n')
self.wfile.flush()
self.responded = True
def write(self, data, encoding='utf-8'):
if self.wfile.closed:
raise IOError('Client connection closed')
self.wfile.write(convert_to_bytes(data, encoding))
def handle(self):
try:
self.raw_requestline = self.rfile.readline(65537)
if len(self.raw_requestline) > 65536:
self.requestline = ''
self.request_version = ''
self.command = ''
self.send_error(HTTPStatus.REQUEST_URI_TOO_LONG)
return
if not self.raw_requestline:
self.close_connection = True
return
if not self.parse_request():
# An error code has been sent, just exit
return
self.handle_request()
self.wfile.flush() #actually send the response if not already done.
except TimeoutError as e:
self.log_error("Request timed out: %r", e)
self.close_connection = True
return
def handle_request(self):
try:
handler = get_route(self.path, self.method)
response = handler(self)
if not response:
raise HttpError(500, 'Empty response')
elif not isinstance(response, Response):
raise HttpError(500, 'Invalid response')
return self.respond(
response.body,
response.status,
response.headers,
response.content_type
)
except HttpError as e:
return self.respond(e.body, e.status, e.headers, e.content_type)
except Exception:
return self.respond(traceback.format_exc(), status=500)
class Server:
def __init__(self, mod):
self.mod = mod
self.default_headers = {}
self._server = None
self._stop = threading.Event()
def __enter__(self):
self.start()
return self
def __exit__(self, *args):
self.stop()
@property
def running(self):
return self._server != None
def run(self):
with self:
while not self._stop.is_set():
time.sleep(0.25)
def start(self):
if self._server:
return
self._stop.clear()
self._server = ThreadingHTTPServer((self.mod.host, self.mod.port), self.handle_request)
self._server.allow_reuse_address = True
thread = threading.Thread(target=self._server.serve_forever)
thread.start()
self.mod.log(f'Started server @ {self.mod.host}:{self.mod.port}')
def stop(self):
if not self._server:
return
self._stop.set()
self._server.shutdown()
self._server = None
self.mod.log('Stopped server')
def handle_request(self, request, client_addr, server):
return Request(self, request, client_addr, server)
class Response:
def __init__(self, body=b'', status=200, headers=None, content_type='text/html'):
self.status = HTTPStatus(status)
self.body = body
self.headers = headers or {}
self.content_type = content_type
def __repr__(self):
props = []
for key, value in self.to_dict().items():
if key == 'status':
value = value.value
props.append(f'{key}={repr(value)}')
propstr = ', '.join(props)
return f'{self.__class__.__name__}({propstr})'
def __str__(self):
return f'{self.status.value} {self.status.phrase}'
@property
def reason(self):
return self.status.phrase
@property
def statuscode(self):
return self.status.value
def to_dict(self):
return dict(
status = self.status,
body = self.body,
headers = self.headers,
content_type = self.content_type
)
class HttpError(Response, Exception):
def __init__(self, status=500, body=b'', headers=None, content_type='text/html'):
Response.__init__(self, body, status, headers, content_type)
Exception.__init__(self, str(self))
if not self.body:
self.body = str(self)
def __str__(self):
return f'{self.statuscode} {self.reason}'