# deck-it - the HTTP server, auth and module routing # Copyright (C) 2026 Danilo M. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """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 re import secrets import socketserver import subprocess import threading import time 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" ICON = re.compile(r"[A-Za-z0-9._-]{1,64}") ICON_BASES = (Path.home() / ".local/share/icons", Path("/usr/share/icons"), Path.home() / ".icons") CFG = config.load_config() FALLBACK_ICON_THEME = CFG.get("icons", {}).get("theme", "candy-icons") _theme_cache = {"t": 0.0, "v": None} 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 icon_theme() -> str: """The icon theme Keystone's appearance drawer sets via gsettings.""" now = time.monotonic() if _theme_cache["v"] and now - _theme_cache["t"] < 5: return _theme_cache["v"] out = _run(["gsettings", "get", "org.gnome.desktop.interface", "icon-theme"]) theme = out.strip().strip("'\"") or FALLBACK_ICON_THEME _theme_cache.update(t=now, v=theme) return theme def icon_path(name: str, theme: str | None = None, bases=ICON_BASES): """An app icon from the installed icon theme, so launchers follow the theme.""" if not ICON.fullmatch(name): return None # hicolor last: the spec's fallback every theme inherits, where apps # install their own icon when the theme has none for them. for th in (theme or icon_theme(), "hicolor"): for base in bases: # Themes lay out category/size (candy-icons) or size/category (hicolor). for sub in ("apps/scalable", "apps/64", "apps/48", "apps/32", "scalable/apps"): path = Path(base) / th / sub / f"{name}.svg" if path.is_file(): return path return None 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("/icons/"): name = path[len("/icons/"):].removesuffix(".svg") icon = icon_path(name) return self._send(200, icon.read_bytes(), "image/svg+xml") if icon else self._send(404) 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()