76 lines
2.5 KiB
Python
76 lines
2.5 KiB
Python
import base64
|
|
import io
|
|
import random
|
|
import secrets
|
|
import string
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
CAPTCHA_TTL_SECONDS = 300
|
|
CAPTCHA_WIDTH = 148
|
|
CAPTCHA_HEIGHT = 48
|
|
CAPTCHA_FONT_SIZE = 30
|
|
CAPTCHA_FONT_PATHS = (
|
|
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
|
|
Path("/System/Library/Fonts/SFNS.ttf"),
|
|
Path("/Library/Fonts/Arial Bold.ttf"),
|
|
)
|
|
_captchas: dict[str, tuple[str, float]] = {}
|
|
|
|
|
|
def make_captcha() -> dict[str, str]:
|
|
clean_expired_captchas()
|
|
captcha_id = secrets.token_urlsafe(16)
|
|
code = "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(4))
|
|
_captchas[captcha_id] = (code, time.time() + CAPTCHA_TTL_SECONDS)
|
|
return {"captcha_id": captcha_id, "image": draw_captcha_image(code)}
|
|
|
|
|
|
def verify_captcha(captcha_id: str, captcha_code: str) -> bool:
|
|
clean_expired_captchas()
|
|
record = _captchas.pop(captcha_id, None)
|
|
if not record:
|
|
return False
|
|
expected, expires_at = record
|
|
if expires_at < time.time():
|
|
return False
|
|
return expected.upper() == captcha_code.strip().upper()
|
|
|
|
|
|
def clean_expired_captchas() -> None:
|
|
now = time.time()
|
|
expired = [captcha_id for captcha_id, (_, expires_at) in _captchas.items() if expires_at < now]
|
|
for captcha_id in expired:
|
|
_captchas.pop(captcha_id, None)
|
|
|
|
|
|
def draw_captcha_image(code: str) -> str:
|
|
image = Image.new("RGB", (CAPTCHA_WIDTH, CAPTCHA_HEIGHT), "#f8fafc")
|
|
draw = ImageDraw.Draw(image)
|
|
font = load_captcha_font()
|
|
for i, char in enumerate(code):
|
|
bounds = draw.textbbox((0, 0), char, font=font)
|
|
text_height = bounds[3] - bounds[1]
|
|
y = (CAPTCHA_HEIGHT - text_height) // 2 - bounds[1] + random.randint(-2, 2)
|
|
draw.text((12 + i * 33, y), char, fill="#111827", font=font)
|
|
for _ in range(8):
|
|
x1, y1 = random.randint(0, CAPTCHA_WIDTH), random.randint(0, CAPTCHA_HEIGHT)
|
|
x2, y2 = random.randint(0, CAPTCHA_WIDTH), random.randint(0, CAPTCHA_HEIGHT)
|
|
draw.line((x1, y1, x2, y2), fill="#cbd5e1", width=1)
|
|
|
|
buffer = io.BytesIO()
|
|
image.save(buffer, format="PNG")
|
|
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
|
|
|
|
def load_captcha_font() -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
for path in CAPTCHA_FONT_PATHS:
|
|
if path.exists():
|
|
return ImageFont.truetype(str(path), CAPTCHA_FONT_SIZE)
|
|
try:
|
|
return ImageFont.load_default(size=CAPTCHA_FONT_SIZE)
|
|
except TypeError:
|
|
return ImageFont.load_default()
|