diff --git a/backend/app/services/captcha.py b/backend/app/services/captcha.py index 07df890..c1f0bf4 100644 --- a/backend/app/services/captcha.py +++ b/backend/app/services/captcha.py @@ -4,10 +4,19 @@ 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]] = {} @@ -38,17 +47,29 @@ def clean_expired_captchas() -> None: def draw_captcha_image(code: str) -> str: - width, height = 132, 44 - image = Image.new("RGB", (width, height), "#f8fafc") + image = Image.new("RGB", (CAPTCHA_WIDTH, CAPTCHA_HEIGHT), "#f8fafc") draw = ImageDraw.Draw(image) - font = ImageFont.load_default() + font = load_captcha_font() for i, char in enumerate(code): - draw.text((18 + i * 26, 13 + random.randint(-2, 2)), char, fill="#1f2937", font=font) + 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, width), random.randint(0, height) - x2, y2 = random.randint(0, width), random.randint(0, height) + 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() diff --git a/backend/tests/test_captcha.py b/backend/tests/test_captcha.py index 88901c1..5ed8d7c 100644 --- a/backend/tests/test_captcha.py +++ b/backend/tests/test_captcha.py @@ -1,4 +1,16 @@ -from app.services.captcha import make_captcha, verify_captcha +import base64 +import io + +from PIL import Image + +from app.services.captcha import ( + CAPTCHA_FONT_SIZE, + CAPTCHA_HEIGHT, + CAPTCHA_WIDTH, + load_captcha_font, + make_captcha, + verify_captcha, +) def test_captcha_can_only_be_used_once(): @@ -7,3 +19,12 @@ def test_captcha_can_only_be_used_once(): # The real code is intentionally not exposed. This test locks in one-time consumption behavior. assert not verify_captcha(captcha_id, "bad") assert not verify_captcha(captcha_id, "bad") + + +def test_captcha_uses_large_font_and_expected_image_size(): + captcha = make_captcha() + encoded_image = captcha["image"].split(",", 1)[1] + image = Image.open(io.BytesIO(base64.b64decode(encoded_image))) + + assert image.size == (CAPTCHA_WIDTH, CAPTCHA_HEIGHT) + assert getattr(load_captcha_font(), "size", CAPTCHA_FONT_SIZE) == CAPTCHA_FONT_SIZE diff --git a/frontend/src/views/CertificateQuery.vue b/frontend/src/views/CertificateQuery.vue index 869d44b..47dd2cd 100644 --- a/frontend/src/views/CertificateQuery.vue +++ b/frontend/src/views/CertificateQuery.vue @@ -409,7 +409,7 @@ h1 { .captcha-row { display: grid; - grid-template-columns: 1fr 132px; + grid-template-columns: minmax(0, 1fr) 148px; gap: 10px; width: 100%; } @@ -422,7 +422,7 @@ h1 { } .captcha-button { - height: 34px; + height: 42px; padding: 0; border: 1px solid #dbe6ea; border-radius: 8px;