Newer
Older
veriq / backend / main.py
from typing import Dict, List, Optional
"""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 re
import secrets
import time
from pathlib import Path

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

from . import db, oauth, og, 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"


def _inject_meta(html: str, title: str, description: str,
                 image_url: Optional[str] = None,
                 page_url: Optional[str] = None) -> str:
    """Server-side OG meta injection for social scrapers."""
    esc = lambda s: s.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;")
    meta = f"<title>{esc(title)}</title>\n"
    meta += f'<meta property="og:title" content="{esc(title)}">\n'
    meta += f'<meta property="og:description" content="{esc(description)}">\n'
    if page_url:
        meta += f'<meta property="og:url" content="{esc(page_url)}">\n'
    meta += '<meta property="og:site_name" content="VerIQ">\n'
    if image_url:
        meta += f'<meta property="og:image" content="{esc(image_url)}">\n'
        meta += '<meta name="twitter:card" content="summary_large_image">\n'
    return re.sub(r"<title>.*?</title>", meta, html, count=1, flags=re.S)


# --- 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")


def _identity_from(request: Request) -> Optional[dict]:
    return oauth.verify_identity(request.cookies.get(oauth.IDENTITY_COOKIE, ""))


def _session_from(request: Request) -> Optional[dict]:
    return oauth.verify_session(request.cookies.get(oauth.SESSION_COOKIE, ""))


@app.get("/login")
def login_page():
    """Explicit provider choice: X or Instagram (button disabled until configured)."""
    return FileResponse(FRONTEND_DIR / "login.html")


@app.get("/test")
def test_page(request: Request):
    """Auth-before-test: no valid session cookie -> explicit provider choice."""
    if _session_from(request) is None:
        return RedirectResponse("/login", status_code=303)
    return FileResponse(FRONTEND_DIR / "test.html")


def _result_context(result_id: str):
    row = db.get_result(result_id)
    if row is None:
        return None, None
    valid = signing.verify_payload(row["payload"], row["signature"])
    return row, valid


@app.get("/result/{result_id}")
def result_page(result_id: str):
    row, valid = _result_context(result_id)
    if row is None:
        raise HTTPException(404, "Result not found")
    html = (FRONTEND_DIR / "result.html").read_text(encoding="utf-8")
    title = f"My IQ is {row['score']} — VerIQ"
    sig = "valid" if valid else "INVALID"
    desc = (f"Higher than {row['percentile']}% of test takers. "
            f"Signature {sig}. Verify it yourself.")
    base = oauth.public_base_url()
    return HTMLResponse(_inject_meta(html, title, desc,
                                     f"{base}/og/{result_id}.png",
                                     f"{base}/v/{result_id}"))


@app.get("/v/{result_id}")
def verify_page(result_id: str):
    row, valid = _result_context(result_id)
    if row is None:
        raise HTTPException(404, "Result not found")
    html = (FRONTEND_DIR / "verify.html").read_text(encoding="utf-8")
    title = f"Verified IQ {row['score']} — VerIQ"
    sig = "valid" if valid else "INVALID"
    desc = (f"Publicly verified result: IQ {row['score']}, higher than "
            f"{row['percentile']}% of test takers. Signature {sig}.")
    base = oauth.public_base_url()
    return HTMLResponse(_inject_meta(html, title, desc,
                                     f"{base}/og/{result_id}.png",
                                     f"{base}/v/{result_id}"))


@app.get("/og/{result_id}.png")
def og_image(result_id: str):
    row, valid = _result_context(result_id)
    if row is None:
        raise HTTPException(404, "Result not found")
    path = og.generate_card(row["score"], row["percentile"], valid,
                            bool(row["flagged"]), result_id)
    return FileResponse(path, media_type="image/png")


@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):
    auth = _session_from(request)
    if auth is None:
        raise HTTPException(401, "Login required")
    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)

    # identity comes from the session cookie (auth-before-test); a new result
    # supersedes all previous active results of the same ACCOUNT (all its
    # linked identities) — anti-cherry-pick
    identity = None
    account_id = None
    if auth:
        identity = auth["provider"] + ":@" + auth["username"]
        account_id = auth["account_id"]
        db.supersede_account(account_id)

    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=identity,
        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=identity,
        completed_at=completed_at,
        payload=payload,
        signature=signature,
        answers=answers,
        flagged=flagged,
        score_json=json.dumps(score),
        account_id=account_id,
    )
    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"])
    snapshot = {}
    if row.get("score_json"):
        try:
            snapshot = json.loads(row["score_json"])
        except Exception:
            snapshot = {}
    return JSONResponse({
        "result_id": result_id,
        "iq": row["score"],
        "percentile": row["percentile"],
        "completed_at": row["completed_at"],
        "identity": row["identity"],
        "account_id": row.get("account_id"),
        "bank_version": row["bank_version"],
        "signature_valid": valid,
        "signature": row["signature"],
        "payload": payload,
        "flagged": bool(row["flagged"]),
        "superseded": bool(row.get("superseded", 0)),
        "categories": snapshot.get("categories", {}),
        "calibrated": snapshot.get("calibrated"),
        "total_time_s": snapshot.get("total_time_s"),
    })


@app.get("/stats")
def stats_page():
    return FileResponse(FRONTEND_DIR / "stats.html")


@app.get("/how")
def how_page():
    return FileResponse(FRONTEND_DIR / "how.html")


@app.get("/api/me")
def api_me(request: Request):
    sess = _session_from(request)
    if not sess:
        return JSONResponse({"identity": None, "account_id": None,
                             "identities": [], "active_result": None})
    idents = db.account_identities(sess["account_id"])
    active = db.active_result_for_account(sess["account_id"])
    return JSONResponse({
        "identity": sess["provider"] + ":@" + sess["username"],
        "username": sess["username"],
        "provider": sess["provider"],
        "account_id": sess["account_id"],
        "identities": [{"provider": i["provider"], "username": i["username"]}
                       for i in idents],
        "x_configured": oauth.x_configured(),
        "ig_configured": oauth.ig_configured(),
        "active_result": ({
            "result_id": active["result_id"],
            "iq": active["score"],
            "percentile": active["percentile"],
            "completed_at": active["completed_at"],
            "identity": active["identity"],
            "flagged": bool(active["flagged"]),
        } if active else None),
    })


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


# --- cabinet, logout, account linking -----------------------------------------

@app.get("/me")
def me_page(request: Request):
    """Personal cabinet: active result + connected accounts."""
    if _session_from(request) is None:
        return RedirectResponse("/login", status_code=303)
    return FileResponse(FRONTEND_DIR / "me.html")


@app.get("/logout")
def logout():
    resp = RedirectResponse("/", status_code=303)
    resp.delete_cookie(oauth.SESSION_COOKIE)
    return resp


@app.get("/link/x/start")
def link_x_start(request: Request):
    """Add an X identity to the current account (requires session)."""
    sess = _session_from(request)
    if sess is None:
        return RedirectResponse("/login", status_code=303)
    if not oauth.x_configured():
        raise HTTPException(503, "X binding not configured yet")
    url = oauth.x_authorize_url(mode="link")
    if url is None:
        raise HTTPException(503, "X binding not configured yet")
    return RedirectResponse(url[0], status_code=303)


@app.get("/link/ig/start")
def link_ig_start(request: Request):
    """Add an Instagram identity to the current account (requires session)."""
    sess = _session_from(request)
    if sess is None:
        return RedirectResponse("/login", status_code=303)
    if not oauth.ig_configured():
        raise HTTPException(503, "Instagram binding not configured yet")
    url = oauth.ig_authorize_url(mode="link")
    if url is None:
        raise HTTPException(503, "Instagram binding not configured yet")
    return RedirectResponse(url[0], status_code=303)


def _finish_auth(provider: str, user: dict, flow: dict,
                 request: Request) -> Response:
    """Login-or-link: route the OAuth identity to the right account."""
    pid = str(user.get("id", ""))
    username = user["username"]
    existing = db.get_identity(provider, pid)
    if existing:
        account_id = existing["account_id"]
        if existing["username"] != username:
            db.update_identity_username(provider, pid, username)
        db.log_event("login", {"provider": provider, "username": username})
    else:
        sess = _session_from(request)
        if flow.get("mode") == "link" and sess:
            account_id = sess["account_id"]
            db.create_identity(provider, pid, username, account_id)
            db.log_event("link", {"provider": provider, "username": username,
                                  "account_id": account_id})
        else:
            account_id = db.create_account()
            db.create_identity(provider, pid, username, account_id)
            db.log_event("login", {"provider": provider, "username": username})
    token = oauth.sign_session(account_id, provider, username)
    resp = RedirectResponse("/me", status_code=303)
    resp.set_cookie(oauth.SESSION_COOKIE, token, max_age=oauth.SESSION_TTL_S,
                    httponly=True, samesite="lax", secure=True)
    return resp


# --- identity binding (phase 2 scaffolding) -----------------------------------
def _login_error_page(provider: str) -> HTMLResponse:
    """Standalone error page: login failed, no identity cookie is set."""
    body = (
        '<!doctype html><html><head><meta charset="utf-8">'
        '<title>VerIQ - login failed</title></head>'
        '<body style="font-family:system-ui,sans-serif;background:#111;color:#eee;'
        'margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh">'
        '<div style="max-width:520px;padding:28px;border:1px solid #444;border-radius:10px">'
        '<h2 style="margin-top:0">Login failed</h2>'
        '<p>' + provider + ' did not return a usable profile. No result was '
        'affected — you simply are not logged in. You can try again.</p>'
        '<p><a href="/login" style="color:#7ab">Back to login</a> · '
        '<a href="/" style="color:#7ab">Back to home</a></p>'
        '</div></body></html>'
    )
    return HTMLResponse(body, status_code=502)


@app.get("/auth/x/start")
def auth_x_start():
    """Begin X OAuth login. 503 until apps registered."""
    if not oauth.x_configured():
        raise HTTPException(503, "X binding not configured yet")
    url = oauth.x_authorize_url()
    if url is None:
        raise HTTPException(503, "X binding not configured yet")
    return RedirectResponse(url[0], status_code=303)


@app.get("/auth/x/callback")
def auth_x_callback(request: Request, code: str = None, state: str = None):
    if not oauth.x_configured():
        raise HTTPException(503, "X binding not configured yet")
    if not code or not state:
        raise HTTPException(400, "missing code/state")
    flow = oauth.pop_flow(state)
    if flow is None or flow["provider"] != "x":
        raise HTTPException(400, "invalid or expired state")
    tok = oauth.x_exchange(code, flow["verifier"])
    user = oauth.x_userinfo(tok.get("access_token")) if tok else None
    if not user or not user.get("username"):
        return _login_error_page("X")
    return _finish_auth("x", user, flow, request)


@app.get("/auth/ig/start")
def auth_ig_start():
    """Begin Instagram OAuth login (same identity-cookie flow as X)."""
    if not oauth.ig_configured():
        raise HTTPException(503, "Instagram binding not configured yet")
    url = oauth.ig_authorize_url("")
    if url is None:
        raise HTTPException(503, "Instagram binding not configured yet")
    return RedirectResponse(url[0], status_code=303)


@app.get("/auth/ig/callback")
def auth_ig_callback(request: Request, code: str = None, state: str = None):
    if not oauth.ig_configured():
        raise HTTPException(503, "Instagram binding not configured yet")
    if not code or not state:
        raise HTTPException(400, "missing code/state")
    flow = oauth.pop_flow(state)
    if flow is None or flow["provider"] != "ig":
        raise HTTPException(400, "invalid or expired state")
    tok = oauth.ig_exchange(code)
    user = oauth.ig_userinfo(tok.get("access_token")) if tok else None
    if not user or not user.get("username"):
        return _login_error_page("Instagram")
    return _finish_auth("ig", user, flow, request)


# --- 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")))