Newer
Older
veriq / backend / main.py
"""VerIQ — FastAPI application (v1 MVP).

Standalone public IQ test with signed, verifiable results.
Run: uvicorn backend.main:app --host 127.0.0.1 --port 8801
"""
import json
import os
import secrets
import time
from pathlib import Path

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
from pydantic import BaseModel, Field

from . import db, scoring, signing

app = FastAPI(title="VerIQ", docs_url=None, redoc_url=None)

FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"

# --- in-memory test sessions (single-process MVP) ---------------------------
# session_token -> {task_ids, started_at, ip}
SESSIONS: dict[str, dict] = {}
SESSION_TTL_S = 60 * 60  # 1 hour to finish after start

# --- simple per-IP rate limiting --------------------------------------------
_RATE: dict[str, list[float]] = {}
RATE_LIMIT_STARTS_PER_HOUR = 10


def _rate_ok(ip: str) -> bool:
    now = time.time()
    window = _RATE.setdefault(ip, [])
    window[:] = [t for t in window if now - t < 3600]
    if len(window) >= RATE_LIMIT_STARTS_PER_HOUR:
        return False
    window.append(now)
    return True


def _client_ip(request: Request) -> str:
    fwd = request.headers.get("x-forwarded-for")
    if fwd:
        return fwd.split(",")[0].strip()
    return request.client.host if request.client else "unknown"


# --- models ------------------------------------------------------------------
class StartResponse(BaseModel):
    session_token: str
    tasks: list[dict]
    time_limit_s: int


class FinishRequest(BaseModel):
    session_token: str
    answers: dict[str, int] = Field(default_factory=dict)
    total_time_s: float = 0.0


class FinishResponse(BaseModel):
    result_id: str
    score: dict


# --- static pages -------------------------------------------------------------
@app.get("/")
def index():
    return FileResponse(FRONTEND_DIR / "index.html")


@app.get("/test")
def test_page():
    return FileResponse(FRONTEND_DIR / "test.html")


@app.get("/v/{result_id}")
def verify_page(result_id: str):
    return FileResponse(FRONTEND_DIR / "verify.html")


@app.get("/assets/{filename}")
def assets(filename: str):
    # simple safe-join guard
    if "/" in filename or ".." in filename:
        raise HTTPException(404)
    path = FRONTEND_DIR / "assets" / filename
    if not path.exists():
        raise HTTPException(404)
    return FileResponse(path)


@app.get("/.well-known/veriq-pubkey.txt")
def pubkey():
    return PlainTextResponse(signing.public_key_b64() + "\n")


# --- API ----------------------------------------------------------------------
@app.post("/api/start", response_model=StartResponse)
def api_start(request: Request):
    ip = _client_ip(request)
    if not _rate_ok(ip):
        raise HTTPException(429, "Too many attempts, try later")

    bank = scoring.load_bank()
    all_tasks = bank["tasks"]
    # v1: use all tasks, shuffled deterministically per session
    ids = [t["id"] for t in all_tasks]
    secrets.SystemRandom().shuffle(ids)
    tasks = [scoring.task_by_id(tid) for tid in ids]

    token = secrets.token_urlsafe(24)
    SESSIONS[token] = {
        "task_ids": ids,
        "started_at": time.time(),
        "ip": ip,
    }
    # prune expired sessions
    now = time.time()
    for k in [k for k, v in SESSIONS.items() if now - v["started_at"] > SESSION_TTL_S]:
        SESSIONS.pop(k, None)

    db.log_event("start", {"ip_hash": secrets.token_hex(4)})
    # strip answers before sending
    public_tasks = [
        {k: t[k] for k in ("id", "category", "difficulty", "time_limit_s",
                           "prompt", "choices")}
        for t in tasks
    ]
    total_limit = sum(t.get("time_limit_s", 60) for t in tasks)
    return StartResponse(session_token=token, tasks=public_tasks,
                         time_limit_s=total_limit)


@app.post("/api/finish", response_model=FinishResponse)
def api_finish(req: FinishRequest, request: Request):
    sess = SESSIONS.get(req.session_token)
    if sess is None:
        raise HTTPException(404, "Session not found or expired")
    if time.time() - sess["started_at"] > SESSION_TTL_S:
        SESSIONS.pop(req.session_token, None)
        raise HTTPException(410, "Session expired")
    SESSIONS.pop(req.session_token, None)

    task_ids = sess["task_ids"]
    # ignore answers for unknown tasks
    answers = {k: v for k, v in req.answers.items() if scoring.task_by_id(k)}

    flagged = scoring.sanity_check(answers, task_ids, req.total_time_s)
    score = scoring.score_test(answers, task_ids, req.total_time_s, flagged)

    result_id = db.new_result_id()
    completed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    payload = signing.canonical_payload(
        result_id=result_id,
        score=score["iq"],
        percentile=score["percentile"],
        bank_version=scoring.bank_version(),
        identity=None,  # phase 2: identity binding
        completed_at=completed_at,
    )
    signature = signing.sign_payload(payload)

    db.save_result(
        result_id=result_id,
        score=score["iq"],
        percentile=score["percentile"],
        bank_version=scoring.bank_version(),
        identity=None,
        completed_at=completed_at,
        payload=payload,
        signature=signature,
        answers=answers,
        flagged=flagged,
    )
    db.log_event("finish", {"flagged": flagged, "iq": score["iq"]})
    return FinishResponse(result_id=result_id, score=score)


@app.get("/api/result/{result_id}")
def api_result(result_id: str):
    row = db.get_result(result_id)
    if row is None:
        raise HTTPException(404, "Result not found")
    valid = signing.verify_payload(row["payload"], row["signature"])
    payload = json.loads(row["payload"])
    return JSONResponse({
        "result_id": result_id,
        "iq": row["score"],
        "percentile": row["percentile"],
        "completed_at": row["completed_at"],
        "identity": row["identity"],
        "bank_version": row["bank_version"],
        "signature_valid": valid,
        "signature": row["signature"],
        "payload": payload,
        "flagged": bool(row["flagged"]),
    })


@app.get("/api/stats")
def api_stats():
    return db.event_stats()


# --- startup --------------------------------------------------------------------
@app.on_event("startup")
def startup():
    db.init_db()
    # fail fast if bank is missing/broken
    scoring.load_bank()


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.0.0.1", port=int(
        os.environ.get("VERIQ_PORT", "8801")))