| """Guardrails on everything that reaches the model. |
| |
| We never trust the client. The browser already shrinks images, but a direct |
| POST to /api/interpret could carry anything — so we decode, validate, and |
| re-encode every image server-side to a known-safe size and format, and we cap |
| and clean the fragment. Bad input raises InputError, which the API turns into a |
| clean 4xx instead of letting it reach (or crash) the model. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import base64 |
| import binascii |
| import io |
| import re |
|
|
| from PIL import Image, UnidentifiedImageError |
|
|
| MAX_IMAGES = 4 |
| |
| MAX_SOURCE_BYTES = 12 * 1024 * 1024 |
| |
| TARGET_MAX_DIM = 896 |
| JPEG_QUALITY = 72 |
| |
| MAX_FRAGMENT_CHARS = 600 |
| |
| MAX_EXCLUDE = 24 |
| MAX_TITLE_CHARS = 120 |
|
|
| _DATA_URL_RE = re.compile(r"^data:(?P<mime>[\w.+-]+/[\w.+-]+)?;base64,(?P<data>.+)$", re.DOTALL) |
| |
| _CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") |
|
|
|
|
| class InputError(Exception): |
| """User-supplied input that we refuse to forward to the model.""" |
|
|
|
|
| def sanitize_fragment(text: str) -> str: |
| """Trim, strip control characters, collapse runaway whitespace, and cap length.""" |
| if not text: |
| return "" |
| if not isinstance(text, str): |
| raise InputError("Fragment must be text.") |
| cleaned = _CONTROL_RE.sub("", text).strip() |
| |
| cleaned = re.sub(r"[ \t]{2,}", " ", cleaned) |
| cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) |
| if len(cleaned) > MAX_FRAGMENT_CHARS: |
| cleaned = cleaned[:MAX_FRAGMENT_CHARS].rstrip() + "…" |
| return cleaned |
|
|
|
|
| def sanitize_titles(titles: list[str]) -> list[str]: |
| """Clean and de-duplicate the 'already seen' titles, capped in count and length.""" |
| if not titles: |
| return [] |
| if not isinstance(titles, list): |
| raise InputError("Exclude must be a list of titles.") |
| out: list[str] = [] |
| seen: set[str] = set() |
| for t in titles: |
| if not isinstance(t, str): |
| continue |
| cleaned = _CONTROL_RE.sub("", t).strip()[:MAX_TITLE_CHARS] |
| key = cleaned.lower() |
| if cleaned and key not in seen: |
| seen.add(key) |
| out.append(cleaned) |
| if len(out) >= MAX_EXCLUDE: |
| break |
| return out |
|
|
|
|
| def _decode(one: str) -> bytes: |
| """Accept a data URL or raw base64 and return the decoded bytes.""" |
| if not isinstance(one, str) or not one.strip(): |
| raise InputError("Empty image entry.") |
| m = _DATA_URL_RE.match(one.strip()) |
| payload = m.group("data") if m else one.strip() |
| try: |
| raw = base64.b64decode(payload, validate=True) |
| except (binascii.Error, ValueError) as exc: |
| raise InputError("An image wasn't valid base64.") from exc |
| if not raw: |
| raise InputError("An image decoded to nothing.") |
| if len(raw) > MAX_SOURCE_BYTES: |
| raise InputError("An image is too large (max 12 MB).") |
| return raw |
|
|
|
|
| def _reencode(raw: bytes) -> str: |
| """Open, normalize, downscale, and re-encode to a JPEG data URL we control.""" |
| try: |
| img = Image.open(io.BytesIO(raw)) |
| img.load() |
| except (UnidentifiedImageError, OSError) as exc: |
| raise InputError("An image couldn't be read.") from exc |
|
|
| |
| if img.mode not in ("RGB", "L"): |
| img = img.convert("RGBA") if "A" in img.mode else img.convert("RGB") |
| if img.mode == "RGBA": |
| bg = Image.new("RGB", img.size, (12, 9, 7)) |
| bg.paste(img, mask=img.split()[-1]) |
| img = bg |
| if img.mode != "RGB": |
| img = img.convert("RGB") |
|
|
| w, h = img.size |
| if max(w, h) > TARGET_MAX_DIM: |
| if w >= h: |
| img = img.resize((TARGET_MAX_DIM, round(h * TARGET_MAX_DIM / w))) |
| else: |
| img = img.resize((round(w * TARGET_MAX_DIM / h), TARGET_MAX_DIM)) |
|
|
| buf = io.BytesIO() |
| img.save(buf, format="JPEG", quality=JPEG_QUALITY) |
| b64 = base64.b64encode(buf.getvalue()).decode() |
| return f"data:image/jpeg;base64,{b64}" |
|
|
|
|
| def sanitize_images(images: list[str]) -> list[str]: |
| """Validate, downscale, and re-encode each image. Returns safe JPEG data URLs.""" |
| if not isinstance(images, list) or not images: |
| raise InputError("Bring at least one image.") |
| if len(images) > MAX_IMAGES: |
| raise InputError(f"Up to {MAX_IMAGES} images, please.") |
| return [_reencode(_decode(one)) for one in images] |
|
|