openajaj / captcha.py
Jindrich3's picture
Super-squash branch 'main' using huggingface_hub
5eb8692
"""
captcha.py β€” Slider CAPTCHA using Bouncypotato.gif as background.
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 hashlib
import hmac as _hmac
import io
import json
import os
import random
import threading
import time
_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:
"""Load persisted secret or create one. Survives restarts."""
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 * 7 # 7 days
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
try:
with 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
except Exception:
pass
def _save_ips():
try:
with open(_IP_FILE, "w") as f:
json.dump(_approved_ips, f)
except Exception:
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
PIECE_W = 85
PIECE_H = 57
BG_W = 340
BG_H = 170
SOURCE_URL = "https://images.uncyclomedia.co/necyklopedie/cs/4/4a/Bouncypotato.gif"
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:
import requests as _req
resp = _req.get(SOURCE_URL, timeout=10)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content))
try:
img.seek(0)
except EOFError:
pass
img = img.convert("RGBA")
except Exception:
# Fallback: generate a branded pattern
img = _make_fallback_image()
img = img.resize((BG_W, BG_H), Image.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.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:
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, ImageDraw
bg_img = _load_base_image()
logo = _load_logo_piece() # PIECE_W Γ— PIECE_H, transparent bg
w, h = bg_img.size
hole_x = random.randint(PIECE_W + 20, w - PIECE_W - 20)
hole_y = random.randint(8, h - PIECE_H - 8)
# ── 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()
ts = int(time.time())
msg = f"{hole_x}:{hole_y}:{ts}"
sig = _hmac.new(CAPTCHA_SECRET, msg.encode(), hashlib.sha256).hexdigest()[:20]
token = f"{msg}:{sig}"
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": token,
}
def verify_challenge(token: str, answer_x: int) -> bool:
try:
parts = token.split(":")
if len(parts) != 4:
return False
x_str, y_str, ts_str, sig = parts
if time.time() - int(ts_str) > CHALLENGE_TTL:
return False
msg = f"{x_str}:{y_str}:{ts_str}"
expected = _hmac.new(CAPTCHA_SECRET, msg.encode(), hashlib.sha256).hexdigest()[:20]
if not _hmac.compare_digest(expected, sig):
return False
return abs(int(x_str) - answer_x) <= TOLERANCE
except Exception:
return False
def make_session_cookie() -> str:
ts = int(time.time())
msg = f"ok:{ts}"
sig = _hmac.new(CAPTCHA_SECRET, msg.encode(), hashlib.sha256).hexdigest()[:20]
return f"{msg}:{sig}"
def verify_session_cookie(cookie: str) -> bool:
try:
parts = cookie.split(":")
if len(parts) != 3:
return False
ok, ts_str, sig = parts
if ok != "ok":
return False
if time.time() - int(ts_str) > CAPTCHA_TTL:
return False
msg = f"ok:{ts_str}"
expected = _hmac.new(CAPTCHA_SECRET, msg.encode(), hashlib.sha256).hexdigest()[:20]
return _hmac.compare_digest(expected, sig)
except Exception:
return False