""" captcha.py — Slider CAPTCHA using Bouncypotato.gif as background. The background ships as static/captcha_bg.png: Necyklopedie's Bouncypotato.gif (first frame, pre-sized), saved once from SOURCE_URL instead of fetched on every server start. Flow: GET /api/captcha/challenge → returns {bg, piece, piece_y, piece_w, piece_h, bg_w, bg_h, token} POST /api/captcha/verify → {token, answer} → sets signed session cookie on success """ import base64 import contextlib import hashlib import hmac as _hmac import io import json import os import random import secrets import threading import time import json_storage import stt_budget _DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") _SECRET_FILE = os.path.join(_DATA_DIR, "captcha_secret.bin") _IP_FILE = os.path.join(_DATA_DIR, "captcha_ips.json") def _load_or_create_secret() -> bytes: """The CAPTCHA_SECRET env var, else a persisted secret, else a new one. The Space has no disk that survives a rebuild, so there the file is new on every deploy and logs everyone out; a Space secret keeps sessions valid. """ env = os.environ.get("CAPTCHA_SECRET", "").encode() if len(env) >= 32: return env os.makedirs(_DATA_DIR, exist_ok=True) try: with open(_SECRET_FILE, "rb") as f: secret = f.read() if len(secret) >= 32: return secret except FileNotFoundError: pass secret = os.urandom(32) with open(_SECRET_FILE, "wb") as f: f.write(secret) return secret CAPTCHA_SECRET = _load_or_create_secret() CAPTCHA_COOKIE = "ajaj_v" CAPTCHA_TTL = 86400 * 14 # 14 days COOKIE_MAX_AGE = 86400 * 400 # Chrome's cap; the server checks age, not this CHALLENGE_TTL = 300 # 5 min to solve TOLERANCE = 20 # px # ── IP allowlist (persisted, with 30-day TTL) ──────────────────────────── _IP_TTL = 86400 * 30 # 30 days _ip_lock = threading.Lock() _approved_ips: dict[str, float] = {} # ip -> timestamp # Missing or corrupt file: start with an empty allowlist. with contextlib.suppress(OSError, ValueError), open(_IP_FILE) as f: raw = json.load(f) if isinstance(raw, list): # Migrate old format (list of IPs) → dict with current timestamp _approved_ips = {ip: time.time() for ip in raw} elif isinstance(raw, dict): _approved_ips = raw def _save_ips(): # ponytail: the in-memory dict is authoritative, so the file is replaced # wholesale. One process owns it; per-process merge if that ever changes. def replace(data): data.clear() data.update(_approved_ips) try: json_storage.update_json(_IP_FILE, dict, replace) except OSError: pass def approve_ip(ip: str) -> None: """Record an IP that passed captcha.""" with _ip_lock: _approved_ips[ip] = time.time() _save_ips() def is_ip_approved(ip: str) -> bool: """Check if an IP has previously passed captcha (within 30 days).""" ts = _approved_ips.get(ip) if ts is None: return False if time.time() - ts > _IP_TTL: with _ip_lock: _approved_ips.pop(ip, None) _save_ips() return False return True # ── Pending challenges (server-side) ───────────────────────────────────── # The answer never leaves the process. The client gets an opaque id and echoes # it back; a token that carried the coordinates would be the answer key. _challenge_lock = threading.Lock() _pending: dict[str, tuple[int, float]] = {} # id -> (hole_x, issued_at) def _remember_challenge(hole_x: int) -> str: """Store a challenge answer, drop expired ones, return the client's id.""" challenge_id = secrets.token_urlsafe(16) now = time.time() with _challenge_lock: for stale in [k for k, (_, issued) in _pending.items() if now - issued > CHALLENGE_TTL]: del _pending[stale] _pending[challenge_id] = (hole_x, now) return challenge_id def _take_challenge(challenge_id: str) -> int | None: """Pop a challenge answer. Single use: a solved id is never accepted twice.""" with _challenge_lock: entry = _pending.pop(challenge_id, None) if entry is None: return None hole_x, issued = entry if time.time() - issued > CHALLENGE_TTL: return None return hole_x PIECE_W = 85 PIECE_H = 57 BG_W = 340 BG_H = 170 # Where static/captcha_bg.png came from; never fetched at runtime SOURCE_URL = "https://images.uncyclomedia.co/necyklopedie/cs/4/4a/Bouncypotato.gif" BG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "captcha_bg.png") LOGO_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "logo.png") _img_cache = None _logo_cache = None def _load_base_image(): global _img_cache if _img_cache is not None: return _img_cache.copy() from PIL import Image try: img = Image.open(BG_PATH, formats=["PNG"]).convert("RGBA") except OSError: # the file is missing or unreadable: a generated pattern instead img = _make_fallback_image() img = img.resize((BG_W, BG_H), Image.Resampling.LANCZOS) _img_cache = img return img.copy() def _make_fallback_image(): """Colorful potato-less fallback if network unavailable.""" from PIL import Image, ImageDraw img = Image.new("RGBA", (BG_W, BG_H), (28, 28, 48)) draw = ImageDraw.Draw(img) # diagonal stripes for i in range(-BG_H, BG_W + BG_H, 22): draw.line([(i, 0), (i + BG_H, BG_H)], fill=(50, 80, 110, 180), width=10) # dots for _ in range(40): x, y = random.randint(0, BG_W), random.randint(0, BG_H) r = random.randint(3, 9) draw.ellipse([x - r, y - r, x + r, y + r], fill=(127, 212, 160, 200)) return img def _load_logo_piece(): """Load logo.png scaled to PIECE_W×PIECE_H, transparent background.""" global _logo_cache if _logo_cache is not None: return _logo_cache.copy() from PIL import Image try: logo = Image.open(LOGO_PATH).convert("RGBA") logo.thumbnail((PIECE_W, PIECE_H), Image.Resampling.LANCZOS) # Centre on a transparent canvas exactly PIECE_W×PIECE_H canvas = Image.new("RGBA", (PIECE_W, PIECE_H), (0, 0, 0, 0)) ox = (PIECE_W - logo.width) // 2 oy = (PIECE_H - logo.height) // 2 canvas.paste(logo, (ox, oy), logo) except Exception: # noqa: BLE001 - any load or decode failure falls back to a drawn piece from PIL import ImageDraw canvas = Image.new("RGBA", (PIECE_W, PIECE_H), (0, 0, 0, 0)) ImageDraw.Draw(canvas).ellipse([4, 4, PIECE_W - 4, PIECE_H - 4], fill=(200, 160, 48, 230)) _logo_cache = canvas return canvas.copy() def generate_challenge() -> dict: from PIL import Image bg_img = _load_base_image() logo = _load_logo_piece() # PIECE_W × PIECE_H, transparent bg w, h = bg_img.size # secrets, not random: once the answer is no longer handed out, a # predictable PRNG would be the next way to guess it. hole_x = PIECE_W + 20 + secrets.randbelow(w - 2 * PIECE_W - 39) hole_y = 8 + secrets.randbelow(h - PIECE_H - 15) # ── Piece: logo with drop-shadow ────────────────────────────────────── pw, ph = PIECE_W + 4, PIECE_H + 4 piece_final = Image.new("RGBA", (pw, ph), (0, 0, 0, 0)) # shadow layer (offset 3,3, use logo alpha as mask) shadow_layer = Image.new("RGBA", (PIECE_W, PIECE_H), (0, 0, 0, 140)) piece_final.paste(shadow_layer, (3, 3), logo.split()[3]) piece_final.paste(logo, (0, 0), logo) # ── Background: logo-shaped hole where the piece should land ────────── bg = bg_img.copy() logo_alpha = logo.split()[3] # alpha channel = logo shape # Dark fill in logo shape hole_fill = Image.new("RGBA", (PIECE_W, PIECE_H), (15, 10, 5, 200)) bg.paste(hole_fill, (hole_x, hole_y), logo_alpha) # Gold outline: dilate the alpha mask by 2px, draw only the border from PIL import ImageFilter border_mask = logo_alpha.filter(ImageFilter.MaxFilter(5)) # Border = dilated minus original import numpy as np border_arr = np.array(border_mask).astype(int) - np.array(logo_alpha).astype(int) border_arr = np.clip(border_arr, 0, 255).astype(np.uint8) border_alpha = Image.fromarray(border_arr, mode="L") gold_border = Image.new("RGBA", (PIECE_W, PIECE_H), (200, 160, 48, 200)) bg.paste(gold_border, (hole_x, hole_y), border_alpha) def to_b64(im: Image.Image) -> str: buf = io.BytesIO() im.save(buf, format="PNG") return base64.b64encode(buf.getvalue()).decode() return { "bg": to_b64(bg), "piece": to_b64(piece_final), "piece_y": hole_y, "piece_w": pw, "piece_h": ph, "bg_w": w, "bg_h": h, "token": _remember_challenge(hole_x), } def verify_challenge(token: str, answer_x: int) -> bool: """Check a slider answer against the position held for this challenge id.""" hole_x = _take_challenge(token) if hole_x is None: return False return abs(hole_x - answer_x) <= TOLERANCE def visitor_id(token: str) -> str: """Who a verified token belongs to: its random id, or for a token from before ids (ok:ts:sig) a keyed hash of the whole token, as logged then.""" parts = token.split(":") if len(parts) == 4: return parts[2] return stt_budget.client_id(token, CAPTCHA_SECRET) def issued_at(token: str) -> int: """When a verified token was issued (unix seconds).""" return int(token.split(":")[1]) def make_session_cookie(previous: str = "") -> str: """A fresh token. Given `previous`, a token that once verified (any age), it keeps that visitor id: a re-solve after expiry is the same visitor.""" vid = visitor_id(previous) if previous else secrets.token_hex(8) msg = f"ok:{int(time.time())}:{vid}" sig = _hmac.new(CAPTCHA_SECRET, msg.encode(), hashlib.sha256).hexdigest()[:20] return f"{msg}:{sig}" def verify_session_cookie(cookie: str, ttl: int | None = CAPTCHA_TTL) -> bool: """Check the signature, and the age unless ttl is None (never expires). ok:ts:vid:sig, or ok:ts:sig from before visitor ids.""" try: msg, _, sig = cookie.rpartition(":") parts = msg.split(":") if len(parts) not in (2, 3) or parts[0] != "ok": return False if ttl is not None and time.time() - int(parts[1]) > ttl: return False expected = _hmac.new(CAPTCHA_SECRET, msg.encode(), hashlib.sha256).hexdigest()[:20] return _hmac.compare_digest(expected, sig) # int() rejects a non-number (ValueError) or overflows the float # subtraction (OverflowError); compare_digest rejects non-ASCII (TypeError). except (ValueError, OverflowError, TypeError): return False