#!/usr/bin/env python3 """Pit Log sync server. Phones push their log and pull everyone else's.""" import hashlib import json import os import sqlite3 import sys import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer MAX_BODY = 8_000_000 NIGHT_FIELDS = [ "dateEpochDay", "trackName", "carName", "raceClass", "discipline", "airTempF", "notes", "createdAt", "crossPercent", "springLf", "springRf", "springLr", "springRr", "camberLf", "camberRf", "casterLf", "casterRf", "swayBarHole", "blockLr", "blockRr", ] RUN_FIELDS = [ "session", "tireSet", "laps", "finish", "staggerInches", "coldLf", "coldRf", "coldLr", "coldRr", "hotLf", "hotRf", "hotLr", "hotRr", "lfInside", "lfMiddle", "lfOutside", "rfInside", "rfMiddle", "rfOutside", "lrInside", "lrMiddle", "lrOutside", "rrInside", "rrMiddle", "rrOutside", "handling", "handlingWhere", "notes", "recordedAt", ] SHEET_FIELDS = [ "recordedAt", "note", "crossPercent", "springLf", "springRf", "springLr", "springRr", "camberLf", "camberRf", "casterLf", "casterRf", "swayBarHole", "blockLr", "blockRr", "toeFront", "toeRear", ] _lock = threading.Lock() _db = None _spaces = {} def db_path(): configured = os.environ.get("PITLOG_DB") if configured: return configured home = os.environ.get("HOME", "") if home and os.path.isdir(home) and os.access(home, os.W_OK): return os.path.join(home, "sync.db") return "/var/lib/pitlog/sync.db" def listen_port(): for name in ("PITLOG_PORT", "SERVER_PORT"): value = os.environ.get(name, "").strip() if value: return int(value) return 8787 def token_ok(bearer): if len(bearer) == 8 and bearer.isdigit(): return True return len(bearer) >= 16 and all(character in "0123456789abcdef" for character in bearer) def legacy_token(): inline = os.environ.get("PITLOG_TOKEN", "").strip().lower() if inline: return inline path = os.environ.get("PITLOG_TOKEN_FILE", "/etc/pitlog/token") try: with open(path, "r", encoding="utf-8") as handle: return handle.read().strip().lower() except FileNotFoundError: return "" def space_path(bearer): root = db_path() legacy = legacy_token() if legacy and bearer == legacy: return root folder = os.path.join(os.path.dirname(root) or ".", "cars") os.makedirs(folder, exist_ok=True) digest = hashlib.sha256(bearer.encode("utf-8")).hexdigest() return os.path.join(folder, digest + ".db") def open_db(path): folder = os.path.dirname(path) if folder: os.makedirs(folder, exist_ok=True) db = sqlite3.connect(path, check_same_thread=False) db.row_factory = sqlite3.Row db.execute("PRAGMA journal_mode=WAL") db.executescript(SCHEMA) db.commit() return db def space(bearer): path = space_path(bearer) with _lock: db = _spaces.get(path) if db is None: db = open_db(path) _spaces[path] = db return db SCHEMA = """ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); INSERT OR IGNORE INTO meta(key, value) VALUES ('rev', '0'); CREATE TABLE IF NOT EXISTS profile ( id INTEGER PRIMARY KEY CHECK (id = 1), carName TEXT NOT NULL, raceClass TEXT NOT NULL, discipline TEXT NOT NULL, updatedAt INTEGER NOT NULL, rev INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS nights ( syncId TEXT PRIMARY KEY, updatedAt INTEGER NOT NULL, deleted INTEGER NOT NULL, rev INTEGER NOT NULL, dateEpochDay INTEGER, trackName TEXT, carName TEXT, raceClass TEXT, discipline TEXT, airTempF INTEGER, notes TEXT, createdAt INTEGER, crossPercent REAL, springLf INTEGER, springRf INTEGER, springLr INTEGER, springRr INTEGER, camberLf REAL, camberRf REAL, casterLf REAL, casterRf REAL, swayBarHole INTEGER, blockLr REAL, blockRr REAL ); CREATE TABLE IF NOT EXISTS runs ( syncId TEXT PRIMARY KEY, nightSyncId TEXT NOT NULL, updatedAt INTEGER NOT NULL, deleted INTEGER NOT NULL, rev INTEGER NOT NULL, session TEXT, tireSet TEXT, laps INTEGER, finish INTEGER, staggerInches REAL, coldLf REAL, coldRf REAL, coldLr REAL, coldRr REAL, hotLf REAL, hotRf REAL, hotLr REAL, hotRr REAL, lfInside INTEGER, lfMiddle INTEGER, lfOutside INTEGER, rfInside INTEGER, rfMiddle INTEGER, rfOutside INTEGER, lrInside INTEGER, lrMiddle INTEGER, lrOutside INTEGER, rrInside INTEGER, rrMiddle INTEGER, rrOutside INTEGER, handling TEXT, handlingWhere TEXT, notes TEXT, recordedAt INTEGER ); CREATE TABLE IF NOT EXISTS sheets ( syncId TEXT PRIMARY KEY, updatedAt INTEGER NOT NULL, deleted INTEGER NOT NULL, rev INTEGER NOT NULL, recordedAt INTEGER, note TEXT, crossPercent REAL, springLf INTEGER, springRf INTEGER, springLr INTEGER, springRr INTEGER, camberLf REAL, camberRf REAL, casterLf REAL, casterRf REAL, swayBarHole INTEGER, blockLr REAL, blockRr REAL, toeFront REAL, toeRear REAL ); """ def connect(): global _db path = db_path() _db = open_db(path) _spaces[path] = _db def current_rev(db): return int(db.execute("SELECT value FROM meta WHERE key = 'rev'").fetchone()["value"]) def next_rev(db): db.execute("UPDATE meta SET value = CAST(value AS INTEGER) + 1 WHERE key = 'rev'") return current_rev(db) def row_dict(row, fields): if row is None: return None data = {field: row[field] for field in fields} data["syncId"] = row["syncId"] data["updatedAt"] = row["updatedAt"] data["deleted"] = bool(row["deleted"]) if "nightSyncId" in row.keys(): data["nightSyncId"] = row["nightSyncId"] return data def values_match(existing, incoming, fields): for field in fields: if existing[field] != incoming.get(field): return False return bool(existing["deleted"]) == bool(incoming.get("deleted")) def store_record(db, table, incoming, fields, extra_columns=""): sync_id = str(incoming.get("syncId") or "").strip() if not sync_id or len(sync_id) > 80: raise ValueError("A record is missing its id.") updated = int(incoming.get("updatedAt") or 0) deleted = 1 if incoming.get("deleted") else 0 existing = db.execute(f"SELECT * FROM {table} WHERE syncId = ?", (sync_id,)).fetchone() if existing is not None and int(existing["updatedAt"]) > updated: return False, row_dict(existing, fields) if ( existing is not None and int(existing["updatedAt"]) == updated and values_match(existing, incoming, fields) and (extra_columns == "" or existing["nightSyncId"] == incoming.get("nightSyncId")) ): return True, None rev = next_rev(db) columns = ["syncId", "updatedAt", "deleted", "rev"] values = [sync_id, updated, deleted, rev] if extra_columns: night_sync = str(incoming.get("nightSyncId") or "").strip() if not night_sync: raise ValueError("A run is missing its race night.") columns.append("nightSyncId") values.append(night_sync) for field in fields: columns.append(field) values.append(incoming.get(field)) placeholders = ", ".join("?" for _ in columns) updates = ", ".join(f"{column} = excluded.{column}" for column in columns if column != "syncId") db.execute( f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({placeholders}) " f"ON CONFLICT(syncId) DO UPDATE SET {updates}", values, ) return True, None def profile_row(row): if row is None: return None return { "carName": row["carName"], "raceClass": row["raceClass"], "discipline": row["discipline"], "updatedAt": row["updatedAt"], } def names_differ(existing, incoming): return ( existing["carName"] != incoming.get("carName") or existing["raceClass"] != incoming.get("raceClass") or existing["discipline"] != incoming.get("discipline") ) def store_profile(db, incoming): if not isinstance(incoming, dict): return None updated = int(incoming.get("updatedAt") or 0) car_name = str(incoming.get("carName") or "")[:80] race_class = str(incoming.get("raceClass") or "")[:80] discipline = str(incoming.get("discipline") or "")[:40] existing = db.execute("SELECT * FROM profile WHERE id = 1").fetchone() if existing is not None and int(existing["updatedAt"]) > updated: return profile_row(existing) if existing is not None and int(existing["updatedAt"]) == updated and not names_differ(existing, incoming): return None if existing is not None and int(existing["updatedAt"]) == updated and names_differ(existing, incoming): return profile_row(existing) rev = next_rev(db) db.execute( """ INSERT INTO profile (id, carName, raceClass, discipline, updatedAt, rev) VALUES (1, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET carName = excluded.carName, raceClass = excluded.raceClass, discipline = excluded.discipline, updatedAt = excluded.updatedAt, rev = excluded.rev """, (car_name, race_class, discipline, updated, rev), ) return None def changed_since(db, table, since, fields): rows = db.execute(f"SELECT * FROM {table} WHERE rev > ?", (since,)).fetchall() return [row_dict(row, fields) for row in rows] def sync_body(payload, db=None): if not isinstance(payload, dict): raise ValueError("The sync body has to be an object.") since = int(payload.get("rev") or 0) forced = {"nights": [], "runs": [], "sheets": []} accepted = [] with _lock: db = db or _db profile_reply = store_profile(db, payload.get("profile")) for table, key, fields, extra in ( ("nights", "nights", NIGHT_FIELDS, ""), ("runs", "runs", RUN_FIELDS, "nightSyncId"), ("sheets", "sheets", SHEET_FIELDS, ""), ): for item in payload.get(key) or []: if not isinstance(item, dict): raise ValueError("A sync record was not an object.") ok, winner = store_record(db, table, item, fields, extra) if ok: accepted.append(item.get("syncId")) elif winner is not None: forced[key].append(winner) since_nights = changed_since(db, "nights", since, NIGHT_FIELDS) since_runs = changed_since(db, "runs", since, RUN_FIELDS) since_sheets = changed_since(db, "sheets", since, SHEET_FIELDS) db.commit() rev = current_rev(db) seen = {item["syncId"] for item in since_nights} nights = since_nights + [item for item in forced["nights"] if item["syncId"] not in seen] seen = {item["syncId"] for item in since_runs} runs = since_runs + [item for item in forced["runs"] if item["syncId"] not in seen] seen = {item["syncId"] for item in since_sheets} sheets = since_sheets + [item for item in forced["sheets"] if item["syncId"] not in seen] return { "rev": rev, "accepted": accepted, "profile": profile_reply, "nights": nights, "runs": runs, "sheets": sheets, } class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def handle(self): try: super().handle() except (ConnectionError, TimeoutError): return def send_error(self, code, message=None, explain=None): try: self.close_connection = True self.send_response_only(code) self.send_header("Content-Length", "0") self.send_header("Connection", "close") self.end_headers() except (ConnectionError, TimeoutError): self.close_connection = True def log_message(self, fmt, *args): sys.stdout.write("%s - %s\n" % (self.address_string(), fmt % args)) def do_GET(self): if self.path.split("?", 1)[0] != "/health": self._send(404, {"error": "Not found."}) return self._send(200, {"ok": True}) def do_POST(self): if self.path.split("?", 1)[0] != "/v1/sync": self._send(404, {"error": "Not found."}) return header = self.headers.get("Authorization", "") bearer = header[7:].strip().lower() if header.startswith("Bearer ") else "" if not token_ok(bearer): self._send(401, {"error": "The token was refused."}) return length = int(self.headers.get("Content-Length", "0")) if length <= 0 or length > MAX_BODY: self._send(400, {"error": "The sync body is empty or too large."}) return try: payload = json.loads(self.rfile.read(length).decode("utf-8")) self._send(200, sync_body(payload, space(bearer))) except ValueError as error: self._send(400, {"error": str(error)}) except Exception: self._send(500, {"error": "The server couldn't save that sync."}) def _send(self, status, payload): body = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def serve(): connect() port = listen_port() print(f"Pit Log sync listening on {port}", flush=True) print("Each car in the app has its own token.", flush=True) server = ThreadingHTTPServer(("0.0.0.0", port), Handler) server.serve_forever() def selftest(): import tempfile handle = tempfile.NamedTemporaryFile(suffix=".db", delete=False) handle.close() os.environ["PITLOG_DB"] = handle.name os.environ["PITLOG_TOKEN"] = "test-token" global _db _db = None connect() first = sync_body( { "rev": 0, "profile": {"carName": "1976 Chevy Camaro", "raceClass": "Hobby Stock", "discipline": "Oval", "updatedAt": 1}, "nights": [ { "syncId": "night-a", "updatedAt": 10, "deleted": False, "dateEpochDay": 20000, "trackName": "Tri-City Raceway", "carName": "1976 Chevy Camaro", "raceClass": "Hobby Stock", "discipline": "Oval", "airTempF": 70, "notes": "", "createdAt": 10, "crossPercent": 53.5, "springLf": 950, "springRf": 1000, "springLr": 200, "springRr": 175, "camberLf": 2.0, "camberRf": -7.0, "casterLf": 2.0, "casterRf": 4.0, "swayBarHole": 4, "blockLr": 2.0, "blockRr": 2.0, } ], "runs": [ { "syncId": "run-a", "nightSyncId": "night-a", "updatedAt": 11, "deleted": False, "session": "Heat", "tireSet": "Scuffs", "laps": 15, "finish": 4, "staggerInches": 1.25, "coldLf": 16, "coldRf": 22, "coldLr": 15, "coldRr": 18, "hotLf": 20.5, "hotRf": 28, "hotLr": 18.5, "hotRr": 23.5, "lfInside": 168, "lfMiddle": 158, "lfOutside": 149, "rfInside": 214, "rfMiddle": 198, "rfOutside": 182, "lrInside": 162, "lrMiddle": 154, "lrOutside": 146, "rrInside": 196, "rrMiddle": 180, "rrOutside": 168, "handling": "Loose", "handlingWhere": "Exit", "notes": "", "recordedAt": 11, } ], "sheets": [], } ) second = sync_body({"rev": 0, "profile": {"carName": "1976 Chevy Camaro", "raceClass": "Hobby Stock", "discipline": "Oval", "updatedAt": 0}, "nights": [], "runs": [], "sheets": []}) if second["nights"][0]["trackName"] != "Tri-City Raceway": raise SystemExit("second phone did not receive the night") if second["runs"][0]["nightSyncId"] != "night-a": raise SystemExit("second phone did not receive the run") older = sync_body( { "rev": first["rev"], "profile": None, "nights": [{"syncId": "night-a", "updatedAt": 5, "deleted": False, "dateEpochDay": 1, "trackName": "Old", "carName": "x", "raceClass": "x", "discipline": "x", "airTempF": None, "notes": "", "createdAt": 5, "crossPercent": None, "springLf": None, "springRf": None, "springLr": None, "springRr": None, "camberLf": None, "camberRf": None, "casterLf": None, "casterRf": None, "swayBarHole": None, "blockLr": None, "blockRr": None}], "runs": [], "sheets": [], } ) if older["nights"][0]["trackName"] != "Tri-City Raceway": raise SystemExit("an older edit replaced the night") print("selftest ok") return 0 if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1] == "selftest": raise SystemExit(selftest()) serve()