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 =="