diff --git a/backend/db.py b/backend/db.py index 709ebdb..3a4c9a4 100644 --- a/backend/db.py +++ b/backend/db.py @@ -112,4 +112,11 @@ "results_total": total, "results_flagged": flagged, "avg_iq": round(avg_iq, 1) if avg_iq is not None else None, - } \ No newline at end of file + } + +def claim_result(result_id: str, identity: str) -> bool: + """Bind identity to an existing result. False if result_id unknown.""" + with _conn() as c: + cur = c.execute("UPDATE results SET identity=? WHERE result_id=?", + (identity, result_id)) + return cur.rowcount > 0 diff --git a/backend/main.py b/backend/main.py index 1d50c2d..e2eaec3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from pathlib import Path from fastapi import FastAPI, HTTPException, Request -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse from pydantic import BaseModel, Field from . import db, oauth, og, scoring, signing @@ -279,6 +279,23 @@ # --- identity binding (phase 2 scaffolding) ----------------------------------- +def _claim_error_page(result_id: str, provider: str) -> HTMLResponse: + """Standalone error page: claim failed, result untouched and still verifiable.""" + body = ( + '' + 'VerIQ - claim failed' + '' + '
' + '

Claim failed

' + '

' + provider + ' did not return a usable profile. Your result is unchanged ' + 'and still verifiable. You can try again from the result page.

' + '

Back to result

' + '
' + ) + return HTMLResponse(body, status_code=502) + + @app.get("/auth/x/start") def auth_x_start(result_id: str): """Begin X OAuth binding for a result. 503 until apps registered.""" @@ -299,9 +316,15 @@ flow = oauth.pop_flow(state) if flow is None or flow["provider"] != "x": raise HTTPException(400, "invalid or expired state") - # token exchange + userinfo fetch is implemented in phase 2 - # (needs httpx; kept out of MVP deps) - raise HTTPException(501, "X token exchange lands in phase 2 — apps not registered yet") + 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 _claim_error_page(flow["result_id"], "X") + identity = "x:@" + user["username"] + db.claim_result(flow["result_id"], identity) + db.log_event("claim", {"result_id": flow["result_id"], "provider": "x", + "user_id": user.get("id"), "username": user["username"]}) + return RedirectResponse("/v/" + flow["result_id"], status_code=303) @app.get("/auth/ig/start") @@ -323,7 +346,15 @@ flow = oauth.pop_flow(state) if flow is None or flow["provider"] != "ig": raise HTTPException(400, "invalid or expired state") - raise HTTPException(501, "IG token exchange lands in phase 2 — apps not registered yet") + 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 _claim_error_page(flow["result_id"], "Instagram") + identity = "ig:@" + user["username"] + db.claim_result(flow["result_id"], identity) + db.log_event("claim", {"result_id": flow["result_id"], "provider": "ig", + "user_id": user.get("id"), "username": user["username"]}) + return RedirectResponse("/v/" + flow["result_id"], status_code=303) # --- startup -------------------------------------------------------------------- diff --git a/backend/oauth.py b/backend/oauth.py index e7d9ec2..6e77c9b 100644 --- a/backend/oauth.py +++ b/backend/oauth.py @@ -108,3 +108,87 @@ if flow and time.time() - flow["created_at"] <= FLOW_TTL_S: return flow return None + + +# --- phase 2: token exchange + userinfo (httpx) -------------------------------- + + +def x_exchange(code: str, verifier: str) -> Optional[dict]: + """OAuth 2.0 PKCE: authorization code -> access token. None on any failure.""" + cid, csec = _env("X_CLIENT_ID"), _env("X_CLIENT_SECRET") + if not (cid and csec): + return None + import httpx + try: + r = httpx.post( + X_TOKEN, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": public_base_url() + "/auth/x/callback", + "client_id": cid, + "code_verifier": verifier, + }, + auth=(cid, csec), + timeout=15, + ) + except Exception: + return None + if r.status_code != 200: + return None + return r.json() + + +def x_userinfo(access_token: str) -> Optional[dict]: + import httpx + try: + r = httpx.get( + X_USERINFO, + params={"user.fields": "id,name,username,profile_image_url"}, + headers={"Authorization": "Bearer " + access_token}, + timeout=15, + ) + except Exception: + return None + if r.status_code != 200: + return None + return (r.json() or {}).get("data") + + +def ig_exchange(code: str) -> Optional[dict]: + app_id, app_secret = _env("IG_APP_ID"), _env("IG_APP_SECRET") + if not (app_id and app_secret): + return None + import httpx + try: + r = httpx.get( + IG_TOKEN, + params={ + "client_id": app_id, + "client_secret": app_secret, + "redirect_uri": public_base_url() + "/auth/ig/callback", + "code": code, + }, + timeout=15, + ) + except Exception: + return None + if r.status_code != 200: + return None + return r.json() + + +def ig_userinfo(access_token: str) -> Optional[dict]: + import httpx + try: + r = httpx.get( + IG_USERINFO, + params={"fields": "id,username,name,picture", + "access_token": access_token}, + timeout=15, + ) + except Exception: + return None + if r.status_code != 200: + return None + return r.json() diff --git a/frontend/assets/result.js b/frontend/assets/result.js index e8d85fc..b09f8d7 100644 --- a/frontend/assets/result.js +++ b/frontend/assets/result.js @@ -47,6 +47,13 @@ b3.textContent = d.completed_at; badges.appendChild(b3); + if (d.identity) { + $("claimHint").textContent = "This result is already claimed by " + + d.identity + "."; + $("claimX").style.display = "none"; + $("claimIg").style.display = "none"; + } + var cats = $("cats"); cats.innerHTML = ""; var order = ["numeric", "matrix", "verbal", "spatial"]; diff --git a/requirements.txt b/requirements.txt index 23e13e0..c4cec90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ fastapi>=0.110 uvicorn[standard]>=0.29 pydantic>=2.6 -cryptography>=42.0 \ No newline at end of file +cryptography>=42.0 +httpx>=0.27