diff --git a/server/main.py b/server/main.py index 582cadb..db51777 100644 --- a/server/main.py +++ b/server/main.py @@ -3,15 +3,18 @@ GCP Server — collects random trials from distributed eggs, runs Stouffer Z network variance analysis every 60 seconds, and serves the dot website. """ +import hashlib +import json as _json import os import sqlite3 import threading import time +import urllib.request from collections import defaultdict from contextlib import contextmanager import numpy as np -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from scipy import stats @@ -47,6 +50,15 @@ def init_db(): n_samples INTEGER NOT NULL, window_seconds INTEGER NOT NULL ); + + CREATE TABLE IF NOT EXISTS egg_locations ( + egg_id TEXT PRIMARY KEY, + country TEXT, + country_code TEXT, + lat REAL, + lon REAL, + located_at INTEGER + ); """) @@ -61,6 +73,60 @@ def db(): con.close() +# --------------------------------------------------------------------------- +# Geo-location (server-side, anonymised to 1° grid ≈ 111 km) +# --------------------------------------------------------------------------- + +_geo_pending: set[str] = set() +_geo_lock = threading.Lock() + + +def _jitter(egg_id: str) -> tuple[float, float]: + """Deterministic ±0.35° offset so eggs in the same cell don't stack.""" + h = int(hashlib.md5(egg_id.encode()).hexdigest()[:8], 16) + return ((h & 0xFFFF) / 0xFFFF - 0.5) * 0.7, ((h >> 16 & 0xFFFF) / 0xFFFF - 0.5) * 0.7 + + +def _fetch_geo(egg_id: str, ip: str): + try: + url = f"http://ip-api.com/json/{ip}?fields=status,country,countryCode,lat,lon" + with urllib.request.urlopen(url, timeout=6) as resp: + data = _json.loads(resp.read()) + if data.get("status") != "success": + return + lat = round(float(data["lat"])) + _jitter(egg_id)[0] + lon = round(float(data["lon"])) + _jitter(egg_id)[1] + with db() as con: + con.execute( + "INSERT OR REPLACE INTO egg_locations " + "(egg_id, country, country_code, lat, lon, located_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (egg_id, data["country"], data["countryCode"], + round(lat, 2), round(lon, 2), int(time.time())), + ) + except Exception as exc: + print(f"[geo] {egg_id} {ip}: {exc}") + finally: + with _geo_lock: + _geo_pending.discard(egg_id) + + +def maybe_geolocate(egg_id: str, ip: str): + if not ip or ip in ("127.0.0.1", "::1"): + return + with _geo_lock: + if egg_id in _geo_pending: + return + with db() as con: + if con.execute( + "SELECT 1 FROM egg_locations WHERE egg_id = ?", (egg_id,) + ).fetchone(): + return + with _geo_lock: + _geo_pending.add(egg_id) + threading.Thread(target=_fetch_geo, args=(egg_id, ip), daemon=True).start() + + # --------------------------------------------------------------------------- # Statistical analysis # --------------------------------------------------------------------------- @@ -171,9 +237,8 @@ def startup(): @app.post("/api/data") -def receive_trial(data: TrialData): +def receive_trial(request: Request, data: TrialData): now = int(time.time()) - # Reject data more than 5 minutes out of sync if abs(data.timestamp - now) > 300: raise HTTPException(status_code=422, detail="timestamp out of range") with db() as con: @@ -181,6 +246,11 @@ def receive_trial(data: TrialData): "INSERT INTO trials (egg_id, timestamp, trial) VALUES (?, ?, ?)", (data.egg_id, data.timestamp, data.trial), ) + client_ip = ( + request.headers.get("X-Forwarded-For", "").split(",")[0].strip() + or (request.client.host if request.client else "") + ) + maybe_geolocate(data.egg_id, client_ip) return {"status": "ok"} @@ -214,6 +284,34 @@ def get_history(limit: int = 60): ] +@app.get("/api/map") +def get_map(): + """Anonymised egg positions for the world map (active in last 5 min).""" + cutoff = int(time.time()) - 300 + with db() as con: + rows = con.execute( + """ + SELECT el.egg_id, el.country, el.country_code, el.lat, el.lon, + MAX(t.timestamp) AS last_active + FROM egg_locations el + JOIN trials t ON el.egg_id = t.egg_id + WHERE t.timestamp > ? + GROUP BY el.egg_id + """, + (cutoff,), + ).fetchall() + return [ + { + "country": r["country"], + "country_code": r["country_code"], + "lat": r["lat"], + "lon": r["lon"], + "last_active": r["last_active"], + } + for r in rows + ] + + @app.get("/api/eggs") def get_active_eggs(): """Return eggs that have submitted data in the last 2 minutes.""" diff --git a/server/static/index.html b/server/static/index.html index 4f7e367..e1c048d 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -4,6 +4,7 @@