放大图形验证码文字
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user