320 lines
6.7 KiB
Python
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}'
|