"""Small non-blocking password-protected HTTP configuration interface.""" import gc import socket import time import ubinascii CLIENT_TIMEOUT_MS = 5000 MAX_REQUEST_BYTES = 4096 class WebServer: def __init__(self, settings, on_save, on_volume): self.settings = settings self.on_save = on_save self.on_volume = on_volume self._client = None self._buffer = b"" self._last_activity = None self._socket = socket.socket() self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self._socket.bind(("0.0.0.0", 80)) self._socket.listen(1) self._socket.settimeout(0) def poll(self, now=None): if now is None: now = time.ticks_ms() if self._client is None: try: self._client, _ = self._socket.accept() self._client.settimeout(0) except OSError: if self._client is not None: self._close() return self._buffer = b"" self._last_activity = now try: data = self._client.recv(512) except OSError: data = None if data == b"": self._close() return if data: self._last_activity = now if len(self._buffer) + len(data) > MAX_REQUEST_BYTES: self._reply(413, "Request too large") return self._buffer += data elif time.ticks_diff(now, self._last_activity) >= CLIENT_TIMEOUT_MS: self._close() return else: return marker = self._buffer.find(b"\r\n\r\n") if marker < 0: return headers = self._buffer[:marker].decode("utf-8", "replace") length = self._content_length(headers) if len(self._buffer) < marker + 4 + length: return body = self._buffer[marker + 4:marker + 4 + length].decode("utf-8", "replace") self._handle(headers, body) def _handle(self, headers, body): lines = headers.split("\r\n") request = lines[0].split(" ") if len(request) < 2: self._reply(400, "Malformed request") return if not self._authorized(lines): self._reply(401, "Authentication required", {"WWW-Authenticate": 'Basic realm="PolterHID"'}) return if request[0] == "POST" and request[1] == "/": try: from settings import apply_form form = _parse_form(body) apply_form(self.settings, form) action = form.get("action") if action in ("volume_up", "volume_down"): self.on_volume(action) self.on_save() self._reply(303, "", {"Location": "/"}) except ValueError as error: self._reply(400, str(error)) return self._reply(200, _page(self.settings), {"Content-Type": "text/html; charset=utf-8"}) def _authorized(self, lines): expected = "Basic " + ubinascii.b2a_base64( ("admin:" + self.settings["web_password"]).encode() ).strip().decode() for line in lines[1:]: if line.lower().startswith("authorization:"): return line.split(":", 1)[1].strip() == expected return False @staticmethod def _content_length(headers): for line in headers.split("\r\n")[1:]: if line.lower().startswith("content-length:"): try: return int(line.split(":", 1)[1].strip()) except ValueError: return 0 return 0 def _reply(self, status, body, extra=None): reasons = {200: "OK", 303: "See Other", 400: "Bad Request", 401: "Unauthorized", 413: "Payload Too Large"} encoded = body.encode() headers = {"Content-Length": str(len(encoded)), "Connection": "close"} if extra: headers.update(extra) response = "HTTP/1.1 %d %s\r\n" % (status, reasons[status]) for key, value in headers.items(): response += "%s: %s\r\n" % (key, value) try: self._client.send(response.encode() + b"\r\n" + encoded) except OSError: pass self._close() def _close(self): client = self._client self._client = None self._buffer = b"" self._last_activity = None if client is not None: try: client.close() except OSError: pass gc.collect() def _parse_form(body): result = {} for pair in body.split("&"): if "=" in pair: key, value = pair.split("=", 1) result[_unquote(key)] = _unquote(value) return result def _unquote(value): value = value.replace("+", " ") parts = value.split("%") result = parts[0] for part in parts[1:]: try: result += chr(int(part[:2], 16)) + part[2:] except ValueError: result += "%" + part return result def _checked(settings, name): return " checked" if settings[name] else "" def _page(settings): return """ PolterHID

PolterHID

Awareness-training HID configuration

Changes take effect immediately. This HTTP interface has no TLS; use only a trusted training network.

""" % ( _checked(settings, "enabled"), _checked(settings, "return_enabled"), settings["return_min_seconds"], settings["return_max_seconds"], _checked(settings, "mouse_enabled"), settings["mouse_min_seconds"], settings["mouse_max_seconds"], settings["mouse_distance"], )