"""Character portraits: a small square picture stored inside the character as a data URL (so it travels with saving, sharing and loading). The browser crops it to a square; the server checks and re-encodes it (256 px, WebP) so only real images get stored.""" import base64 import binascii import hashlib import io from fastapi import HTTPException from PIL import Image, ImageOps SIZE = 256 MAX_RAW = 400_000 # bytes of the uploaded data before re-encoding def clean(ch: dict) -> None: """Validates and re-encodes ch["portrait"] in place (an empty value removes it).""" p = ch.get("portrait") if not p: ch["portrait"] = "" return if not isinstance(p, str) or not p.startswith("data:image/") or ";base64," not in p: raise HTTPException(422, "The portrait must be an image") try: raw = base64.b64decode(p.split(";base64,", 1)[1], validate=True) if len(raw) > MAX_RAW: raise HTTPException(413, "The portrait is too large") im = Image.open(io.BytesIO(raw)) if im.format not in ("PNG", "JPEG", "WEBP"): raise ValueError im.load() except HTTPException: raise except (binascii.Error, ValueError, OSError, Image.DecompressionBombError): raise HTTPException(422, "The portrait must be a PNG, JPEG or WebP image") im = ImageOps.fit(ImageOps.exif_transpose(im).convert("RGB"), (SIZE, SIZE)) out = io.BytesIO() im.save(out, "WEBP", quality=85) ch["portrait"] = "data:image/webp;base64," + base64.b64encode(out.getvalue()).decode() def tag(portrait) -> str: """Short fingerprint, used as cache key in URLs ("" if there is no portrait).""" return hashlib.sha1(portrait.encode()).hexdigest()[:10] if portrait else "" def image(portrait) -> bytes | None: if not portrait or ";base64," not in portrait: return None try: return base64.b64decode(portrait.split(";base64,", 1)[1]) except (binascii.Error, ValueError): return None