diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4ba83f6 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# VerIQ environment (copy to .env on the VPS, fill real values) +VERIQ_DB_PATH=/var/lib/veriq/veriq.db +VERIQ_KEY_PATH=/var/lib/veriq/ed25519.key +VERIQ_OG_CACHE=/var/lib/veriq/og_cache +VERIQ_PORT=8801 + +# Domain for deploy script +VERIQ_DOMAIN=veriq.example.com +VERIQ_LE_EMAIL=you@example.com + +# GitBucket creds for fresh-clone deploy +VERIQ_GIT_PASS= + +# OAuth apps (phase 2, register at developer.twitter.com / facebook developers) +X_CLIENT_ID= +X_CLIENT_SECRET= +IG_APP_ID= +IG_APP_SECRET= +VERIQ_PUBLIC_BASE_URL=https://veriq.example.com diff --git a/backend/main.py b/backend/main.py index 77eeded..a23b6c4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,15 +5,16 @@ """ import json import os +import re import secrets import time from pathlib import Path from fastapi import FastAPI, HTTPException, Request -from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse from pydantic import BaseModel, Field -from . import db, scoring, signing +from . import db, oauth, og, scoring, signing app = FastAPI(title="VerIQ", docs_url=None, redoc_url=None) @@ -46,6 +47,19 @@ return request.client.host if request.client else "unknown" +def _inject_meta(html: str, title: str, description: str, + image_url: str | None = None) -> str: + """Server-side OG meta injection for social scrapers.""" + esc = lambda s: s.replace("&", "&").replace('"', """).replace("<", "<") + meta = f"{esc(title)}\n" + meta += f'\n' + meta += f'\n' + if image_url: + meta += f'\n' + meta += '\n' + return re.sub(r".*?", meta, html, count=1, flags=re.S) + + # --- models ------------------------------------------------------------------ class StartResponse(BaseModel): session_token: str @@ -75,9 +89,48 @@ 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.") + return HTMLResponse(_inject_meta(html, title, desc, f"/og/{result_id}.png")) + + @app.get("/v/{result_id}") def verify_page(result_id: str): - return FileResponse(FRONTEND_DIR / "verify.html") + 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}.") + return HTMLResponse(_inject_meta(html, title, desc, f"/og/{result_id}.png")) + + +@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}") @@ -204,6 +257,54 @@ return db.event_stats() +# --- identity binding (phase 2 scaffolding) ----------------------------------- +@app.get("/auth/x/start") +def auth_x_start(result_id: str): + """Begin X OAuth binding for a result. 503 until apps registered.""" + if not oauth.x_configured(): + raise HTTPException(503, "X binding not configured yet") + url = oauth.x_authorize_url(result_id) + if url is None: + raise HTTPException(503, "X binding not configured yet") + return JSONResponse({"authorize_url": url[0]}) + + +@app.get("/auth/x/callback") +def auth_x_callback(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") + # 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") + + +@app.get("/auth/ig/start") +def auth_ig_start(result_id: str): + if not oauth.ig_configured(): + raise HTTPException(503, "Instagram binding not configured yet") + url = oauth.ig_authorize_url(result_id) + if url is None: + raise HTTPException(503, "Instagram binding not configured yet") + return JSONResponse({"authorize_url": url[0]}) + + +@app.get("/auth/ig/callback") +def auth_ig_callback(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") + raise HTTPException(501, "IG token exchange lands in phase 2 — apps not registered yet") + + # --- startup -------------------------------------------------------------------- @app.on_event("startup") def startup(): diff --git a/backend/oauth.py b/backend/oauth.py new file mode 100644 index 0000000..409af2e --- /dev/null +++ b/backend/oauth.py @@ -0,0 +1,109 @@ +"""VerIQ — identity binding via OAuth (phase 2 scaffolding). + +X (Twitter): OAuth 2.0 Authorization Code with PKCE. + - authorize: https://twitter.com/i/oauth2/authorize + - token: https://api.twitter.com/2/oauth2/token + - userinfo: https://api.twitter.com/2/users/me (user.fields=id,name,username,profile_image_url) + - scopes: users.read tweet.read (read-only; we never post) + +Instagram: Facebook Login for Instagram (business/creator accounts). + - authorize: https://www.facebook.com/v19.0/dialog/oauth + - token: https://graph.facebook.com/v19.0/oauth/access_token + - userinfo: https://graph.facebook.com/me?fields=id,username,name,picture + +Both are wired but return 503 until the owner registers the apps and fills +.env (X_CLIENT_ID/X_CLIENT_SECRET, IG_APP_ID/IG_APP_SECRET). +""" +import base64 +import hashlib +import os +import secrets +import time +from urllib.parse import urlencode + +X_AUTHORIZE = "https://twitter.com/i/oauth2/authorize" +X_TOKEN = "https://api.twitter.com/2/oauth2/token" +X_USERINFO = "https://api.twitter.com/2/users/me" + +IG_AUTHORIZE = "https://www.facebook.com/v19.0/dialog/oauth" +IG_TOKEN = "https://graph.facebook.com/v19.0/oauth/access_token" +IG_USERINFO = "https://graph.facebook.com/me" + +# in-memory PKCE/state store (single-process MVP; fine for v1) +FLOWS: dict[str, dict] = {} +FLOW_TTL_S = 600 + + +def _env(name: str) -> str | None: + v = os.environ.get(name, "").strip() + return v or None + + +def x_configured() -> bool: + return bool(_env("X_CLIENT_ID") and _env("X_CLIENT_SECRET")) + + +def ig_configured() -> bool: + return bool(_env("IG_APP_ID") and _env("IG_APP_SECRET")) + + +def public_base_url() -> str: + return _env("VERIQ_PUBLIC_BASE_URL") or "http://127.0.0.1:8801" + + +def _new_flow(provider: str, result_id: str) -> dict: + verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip("=") + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") + state = secrets.token_urlsafe(24) + FLOWS[state] = { + "provider": provider, + "result_id": result_id, + "verifier": verifier, + "created_at": time.time(), + } + # prune + now = time.time() + for k in [k for k, v in FLOWS.items() if now - v["created_at"] > FLOW_TTL_S]: + FLOWS.pop(k, None) + return {"state": state, "challenge": challenge, "verifier": verifier} + + +def x_authorize_url(result_id: str) -> tuple[str, str] | None: + """Returns (authorize_url, state) or None if not configured.""" + cid = _env("X_CLIENT_ID") + if not cid: + return None + flow = _new_flow("x", result_id) + params = { + "response_type": "code", + "client_id": cid, + "redirect_uri": public_base_url() + "/auth/x/callback", + "scope": "users.read tweet.read", + "state": flow["state"], + "code_challenge": flow["challenge"], + "code_challenge_method": "S256", + } + return X_AUTHORIZE + "?" + urlencode(params), flow["state"] + + +def ig_authorize_url(result_id: str) -> tuple[str, str] | None: + app_id = _env("IG_APP_ID") + if not app_id: + return None + flow = _new_flow("ig", result_id) + params = { + "client_id": app_id, + "redirect_uri": public_base_url() + "/auth/ig/callback", + "state": flow["state"], + "scope": "instagram_basic,instagram_graph_user_profile", + "response_type": "code", + } + return IG_AUTHORIZE + "?" + urlencode(params), flow["state"] + + +def pop_flow(state: str) -> dict | None: + flow = FLOWS.pop(state, None) + if flow and time.time() - flow["created_at"] <= FLOW_TTL_S: + return flow + return None diff --git a/backend/og.py b/backend/og.py new file mode 100644 index 0000000..7901ba6 --- /dev/null +++ b/backend/og.py @@ -0,0 +1,116 @@ +"""VerIQ — OG image generation (1200x630 PNG cards for social scrapers). + +Scrapers (X/Twitter, Telegram, Facebook) don't render SVG and need a real +PNG. We draw the card with Pillow: dark brutalist background, big IQ number, +percentile line, signature badge. Cached on disk per result_id. +""" +import os +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +CACHE_DIR = Path(os.environ.get("VERIQ_OG_CACHE", "/var/lib/veriq/og_cache")) + +W, H = 1200, 630 +BG = (13, 13, 15) +LINE = (42, 42, 51) +TEXT = (232, 232, 234) +MUTED = (138, 138, 148) +ACCENT = (122, 162, 247) +GOOD = (158, 206, 106) +BAD = (247, 118, 142) +WARN = (224, 175, 104) + +_FONT_DIRS = [ + "/usr/share/fonts/truetype/dejavu/", + "/usr/share/fonts/dejavu/", +] + + +def _font(name, size): + for d in _FONT_DIRS: + p = Path(d) / name + if p.exists(): + return ImageFont.truetype(str(p), size) + return ImageFont.load_default() + + +def _fonts(): + return { + "logo": _font("DejaVuSans-Bold.ttf", 44), + "big": _font("DejaVuSans-Bold.ttf", 240), + "sub": _font("DejaVuSans.ttf", 40), + "small": _font("DejaVuSans.ttf", 28), + "badge": _font("DejaVuSans-Bold.ttf", 30), + } + + +def _center(draw, text, y, font, fill): + bbox = draw.textbbox((0, 0), text, font=font) + w = bbox[2] - bbox[0] + draw.text(((W - w) / 2, y), text, font=font, fill=fill) + + +def generate_card(iq, percentile, signature_valid, flagged, result_id): + """Render the OG card, return path to cached PNG.""" + try: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + cache = CACHE_DIR + except PermissionError: + import tempfile + cache = Path(tempfile.gettempdir()) / "veriq_og" + cache.mkdir(parents=True, exist_ok=True) + out = cache / (result_id + ".png") + if out.exists(): + return out + + img = Image.new("RGB", (W, H), BG) + d = ImageDraw.Draw(img) + f = _fonts() + + # frame + d.rectangle([8, 8, W - 9, H - 9], outline=LINE, width=2) + # accent corner blocks (brutalist) + d.rectangle([8, 8, 8 + 56, 8 + 10], fill=ACCENT) + d.rectangle([8, 8, 8 + 10, 8 + 56], fill=ACCENT) + d.rectangle([W - 9 - 56, H - 9 - 10, W - 9, H - 9], fill=ACCENT) + d.rectangle([W - 9 - 10, H - 9 - 56, W - 9, H - 9], fill=ACCENT) + + # logo + d.text((48, 44), "Ver", font=f["logo"], fill=TEXT) + logo_w = d.textbbox((0, 0), "Ver", font=f["logo"])[2] + d.text((48 + logo_w, 44), "IQ", font=f["logo"], fill=ACCENT) + + # big score + _center(d, str(iq), 130, f["big"], ACCENT) + + # percentile line + _center(d, "higher than " + str(percentile) + "% of test takers", 400, + f["sub"], TEXT) + + # badges row + y = 480 + if signature_valid: + badge_text = "SIGNATURE VALID" + badge_color = GOOD + else: + badge_text = "SIGNATURE INVALID" + badge_color = BAD + bbox = d.textbbox((0, 0), badge_text, font=f["badge"]) + bw = bbox[2] - bbox[0] + 48 + bx = (W - bw) / 2 + d.rectangle([bx, y, bx + bw, y + 56], outline=badge_color, width=3) + d.text((bx + 24, y + 10), badge_text, font=f["badge"], fill=badge_color) + if flagged: + fx = bx + bw + 16 + fb = d.textbbox((0, 0), "FLAGGED", font=f["badge"]) + fw = fb[2] - fb[0] + 40 + d.rectangle([fx, y, fx + fw, y + 56], outline=WARN, width=3) + d.text((fx + 20, y + 10), "FLAGGED", font=f["badge"], fill=WARN) + + # footer + _center(d, "veriq · verify at /v/" + result_id + " · signed with Ed25519", + 560, f["small"], MUTED) + + img.save(out, "PNG", optimize=True) + return out diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..d533f6d --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# VerIQ deploy script — run on the VPS as root (or with sudo). +# Usage: sudo ./scripts/deploy.sh +set -euo pipefail + +APP_DIR="/opt/veriq" +APP_USER="veriq" +SERVICE_NAME="veriq" +PORT="8801" +DOMAIN="${VERIQ_DOMAIN:-veriq.example.com}" +REPO="https://git.gnexus.space/git/root/veriq.git" +GIT_USER="root" +GIT_PASS="${VERIQ_GIT_PASS:?set VERIQ_GIT_PASS env var}" +DB_DIR="/var/lib/veriq" + +echo "== VerIQ deploy to $DOMAIN ==" + +# 1. system deps +if ! command -v python3 >/dev/null; then apt-get update && apt-get install -y python3 python3-venv python3-pip; fi +apt-get install -y python3-pil >/dev/null 2>&1 || pip3 install --user pillow 2>/dev/null || true + +# 2. app user + dirs +id -u "$APP_USER" &>/dev/null || useradd -r -s /usr/sbin/nologin "$APP_USER" +mkdir -p "$DB_DIR" "$DB_DIR/og_cache" +chown -R "$APP_USER:$APP_USER" "$DB_DIR" + +# 3. code +if [ -d "$APP_DIR/.git" ]; then + echo "-- updating existing checkout" + cd "$APP_DIR" && sudo -u "$APP_USER" git pull --ff-only +else + echo "-- fresh clone" + git clone "https://${GIT_USER}:${GIT_PASS}@git.gnexus.space/git/root/veriq.git" "$APP_DIR" + chown -R "$APP_USER:$APP_USER" "$APP_DIR" +fi + +# 4. venv + deps +cd "$APP_DIR" +sudo -u "$APP_USER" python3 -m venv .venv +sudo -u "$APP_USER" .venv/bin/pip install -q -r requirements.txt pillow + +# 5. systemd unit +cat > /etc/systemd/system/veriq.service < /etc/nginx/sites-available/veriq </dev/null; then apt-get install -y certbot python3-certbot-nginx; fi +certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos -m "${VERIQ_LE_EMAIL:?set VERIQ_LE_EMAIL}" || echo "WARN: certbot failed, add TLS manually" + +echo "== Done. Check: curl -s http://127.0.0.1:$PORT/ | head -1 =="