527 lines
18 KiB
Python
527 lines
18 KiB
Python
#!/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 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 len(bearer) < 16 or any(character not in "0123456789abcdef" for character in 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()
|