"""The HTTP server: static assets, the theme, and the module API. It listens only on a Unix socket in a 0700 directory, which `adb reverse` exposes on the tablet's localhost. A token cookie unlocks the API; a Host and Origin check keeps arbitrary web pages and DNS rebinding out. """ import hmac import json import mimetypes import os import secrets import socketserver import subprocess import threading from http.cookies import CookieError, SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, unquote, urlparse, urlsplit from . import config, module, theme ROOT = Path(__file__).resolve().parent.parent STATIC = ROOT / "static" RUNDIR = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "deck-it" SOCKET = RUNDIR / "http.sock" CFG = config.load_config() MONITOR = CFG.get("display", {}).get("name", "TAB") PORT = CFG.get("ports", {}).get("command_center", 8765) VNC_PORT = CFG.get("ports", {}).get("display", 5900) ORIGINS = {f"http://127.0.0.1:{PORT}", f"http://localhost:{PORT}"} HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"} TOKEN = None REGISTRY = None def token_path() -> Path: return config.user_config_path().parent / "token" def _run(cmd, timeout=2): try: return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout.strip() except (OSError, subprocess.TimeoutExpired): return "" def art(): """The current track's cover art, if the player exposes a local file.""" url = _run(["playerctl", "metadata", "mpris:artUrl"]) if not url.startswith("file://"): return None path = Path(unquote(urlparse(url).path)) try: return path.read_bytes(), mimetypes.guess_type(path)[0] or "image/png" except OSError: return None def load_token() -> str: """A per-machine secret the tablet's browser holds as a cookie.""" path = token_path() if not path.is_file(): path.parent.mkdir(parents=True, exist_ok=True) fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w") as f: f.write(secrets.token_urlsafe(32)) return path.read_text().strip() class Handler(BaseHTTPRequestHandler): def _authed(self): try: c = SimpleCookie(self.headers.get("Cookie", "")).get("deckit") except CookieError: return False return bool(c and TOKEN and hmac.compare_digest(c.value, TOKEN)) def _trusted(self, write): if self.headers.get("Host") not in HOSTS: return False origin = self.headers.get("Origin") return origin in ORIGINS if write else origin in (None, *ORIGINS) def _send(self, code, body=b"", ctype="text/plain", cache=False): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "max-age=86400" if cache else "no-store") self.end_headers() self.wfile.write(body) def _json(self, data): self._send(200, json.dumps(data).encode(), "application/json") def _unlock(self, given): if not (TOKEN and hmac.compare_digest(given, TOKEN)): return False self.send_response(303) self.send_header("Location", "/") self.send_header("Set-Cookie", f"deckit={TOKEN}; Path=/; Max-Age=34560000; HttpOnly; SameSite=Strict") self.send_header("Content-Length", "0") self.end_headers() return True def do_GET(self): if not self._trusted(write=False): return self._send(403) url = urlsplit(self.path) path = url.path if path == "/" and (t := parse_qs(url.query).get("t")) and self._unlock(t[0]): return if path == "/theme.css": return self._send(200, theme.load().encode(), "text/css") if path.startswith("/api/"): if not self._authed(): return self._send(401) parts = path.strip("/").split("/") if len(parts) == 2 and parts[1] == "art": image = art() return self._send(200, image[0], image[1], cache=True) if image else self._send(404) if len(parts) == 2 and parts[1] == "modules": return self._json(REGISTRY.names()) if len(parts) == 2: try: return self._json(REGISTRY.state(parts[1])) except module.ModuleError as e: return self._send(404, str(e).encode()) return self._send(404) if path == "/": path = "/index.html" file = (STATIC / path.lstrip("/")).resolve() if file.is_file() and file.is_relative_to(STATIC.resolve()): ctype = mimetypes.guess_type(file)[0] or "application/octet-stream" return self._send(200, file.read_bytes(), ctype, cache=file.suffix in (".woff2", ".svg")) self._send(404) def do_POST(self): if not self._trusted(write=True): return self._send(403) if not self._authed(): return self._send(401) parts = urlsplit(self.path).path.strip("/").split("/") if len(parts) != 3 or parts[0] != "api": return self._send(404) _, name, action = parts try: fn = REGISTRY.get(name).actions.get(action) except module.ModuleError as e: return self._send(404, str(e).encode()) if fn is None: return self._send(404, b"unknown action") try: length = int(self.headers.get("Content-Length", 0) or 0) except ValueError: return self._send(400, b"Invalid Content-Length") if self.headers.get("Content-Type") != "application/json" or not 0 < length <= 4096: return self._send(400, b"Expected a small JSON body") try: result = fn(json.loads(self.rfile.read(length))) except module.ModuleError as e: return self._send(400, str(e).encode()) except (ValueError, AttributeError, TypeError, KeyError): return self._send(400, b"Invalid request") if result is None: return self._send(204) return self._json(result) def log_message(self, *args): pass class UnixHTTPServer(socketserver.ThreadingUnixStreamServer): daemon_threads = True def serve(): """Serve the page and API on the Unix socket until killed.""" global TOKEN, REGISTRY from .modules import build TOKEN = load_token() REGISTRY = build(CFG) REGISTRY.start_all() RUNDIR.mkdir(mode=0o700, parents=True, exist_ok=True) RUNDIR.chmod(0o700) SOCKET.unlink(missing_ok=True) UnixHTTPServer(str(SOCKET), Handler).serve_forever()