#!/usr/bin/env python3 """Cairn's server: the board app and its sync API, on the Pi, for the tailnet only. GET / the app (app/): one page, a service worker, a manifest. It installs as an app. GET /api/board {"version", "board": {columns, categories}} POST /api/sync {"ops": [{opid, op, ...}]} -> {"version", "board", "applied", "dropped"} It listens on 127.0.0.1 only. `tailscale serve --bg --https=8443 http://127.0.0.1:8770` puts it on the tailnet with a real certificate, which a service worker needs. There is no login: the tailnet is the lock. A POST must be JSON from this page's own origin, so a web page open in another tab can't send changes here. Every change goes through board.sync(), the same locked code path the cairn command uses. A thread runs the phone pushes (notify.py) and the Google Calendar sync (gcal.py). """ import json, mimetypes, sys, threading, time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import board as B import gcal import notify APP = Path(__file__).resolve().parent / "app" PORT = 8770 # the page's own addresses: through tailscale serve, and straight to the port on the Pi itself ORIGINS = {"https://board.your-tailnet.ts.net:8443", f"http://127.0.0.1:{PORT}", f"http://localhost:{PORT}"} mimetypes.add_type("application/manifest+json", ".webmanifest") mimetypes.add_type("font/ttf", ".ttf") class Handler(BaseHTTPRequestHandler): server_version = "cairn" def send(self, code, body, ctype="application/json", cache="no-store"): data = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode() self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", cache) self.send_header("X-Content-Type-Options", "nosniff") self.end_headers() self.wfile.write(data) def board(self, board, **extra): self.send(200, {"version": B.version(board), "board": B.as_json(board), **extra}) def do_GET(self): path = self.path.split("?")[0] if path == "/api/board": try: return self.board(B.load()) except ValueError as e: # a hand edit that isn't a card: say so, don't guess return self.send(500, {"error": str(e)}) name = "index.html" if path in ("/", "") else path.lstrip("/") file = (APP / name).resolve() if APP.resolve() not in file.parents or not file.is_file(): return self.send(404, {"error": "not found"}) ctype = mimetypes.guess_type(file.name)[0] or "application/octet-stream" # the service worker keeps its own copy; the browser's cache must not hold an old sw.js or page self.send(200, file.read_bytes(), ctype, "no-cache") def do_POST(self): if self.path != "/api/sync": return self.send(404, {"error": "not found"}) origin = self.headers.get("Origin") if origin and origin not in ORIGINS: return self.send(403, {"error": "wrong origin"}) if not self.headers.get("Content-Type", "").startswith("application/json"): return self.send(415, {"error": "JSON only"}) try: body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0)))) ops = body.get("ops", []) who = str(body.get("who", "app"))[:20] board, applied, dropped = B.sync(ops, who) except ValueError as e: return self.send(400, {"error": str(e)}) if ops: WAKE.set() self.board(board, applied=applied, dropped=dropped) def log_message(self, fmt, *args): # one line per change, nothing per page load if self.command == "POST": sys.stderr.write("%s %s\n" % (self.command, fmt % args)) WAKE = threading.Event() # set after a change from the app: the calendar follows at once, not in 5 minutes def clock(): """What runs on time: the phone pushes (notify.py) every 30 s, and the calendar (gcal.py) after a change from the app and every 5 minutes, which also catches changes made with the cairn command.""" last = 0 while True: notify.check() if WAKE.is_set() or time.time() - last > 300: WAKE.clear() gcal.sync_quietly() last = time.time() WAKE.wait(30) if __name__ == "__main__": threading.Thread(target=clock, daemon=True).start() ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()