Coverage for app/utils/app_image.py: 100.00%
25 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1"""Helpers for the App-image data-URL ↔ bytes round-trip.
3The API exposes ``apps.image`` as a single ``data:<mime>;base64,<...>``
4string. Bytes are stored in ``apps.image`` and the mime in
5``apps.image_mime``.
7* ``parse_image_data_url`` decodes an incoming data-URL into
8 ``(bytes, mime)``, validates size and mime, and raises an
9 ``HTTPException`` on failure.
10* ``build_image_data_url`` does the reverse for the response payload.
11"""
13from __future__ import annotations
15import base64
16import re
18from fastapi import HTTPException, status
20# 2 MiB on the decoded byte payload; enforced on the decoded length so
21# the limit is independent of base64 encoding overhead.
22MAX_IMAGE_BYTES = 2 * 1024 * 1024
24# Permissive on the mime side — anything an HTML5 ``<img>`` can render
25# is accepted. Only the obviously-not-an-image case is rejected.
26_DATA_URL_RE = re.compile(
27 r"^data:(?P<mime>image/[a-zA-Z0-9.+-]+);base64,(?P<payload>[A-Za-z0-9+/=\s]+)$"
28)
31def parse_image_data_url(data_url: str | None) -> tuple[bytes | None, str | None]:
32 """Decode a data-URL into ``(bytes, mime)``.
34 Returns ``(None, None)`` for ``None`` or empty string — the empty
35 string is a useful sentinel from the update endpoint meaning
36 "clear the image". Otherwise raises 422 if the input doesn't
37 parse, or 413 if the decoded payload is larger than ``MAX_IMAGE_BYTES``.
38 """
39 if data_url is None or data_url == "":
40 return None, None
41 match = _DATA_URL_RE.match(data_url)
42 if not match:
43 raise HTTPException(
44 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
45 detail={
46 "reason": "invalid_image_format",
47 "message": (
48 "image must be a data-URL like "
49 "'data:image/png;base64,<...>'"
50 ),
51 },
52 )
53 mime = match.group("mime").lower()
54 try:
55 payload = base64.b64decode(match.group("payload"), validate=True)
56 except Exception:
57 raise HTTPException(
58 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
59 detail={"reason": "invalid_base64", "message": "image payload is not valid base64"},
60 )
61 if len(payload) > MAX_IMAGE_BYTES:
62 raise HTTPException(
63 status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
64 detail={
65 "reason": "image_too_large",
66 "max_bytes": MAX_IMAGE_BYTES,
67 "actual_bytes": len(payload),
68 },
69 )
70 return payload, mime
73def build_image_data_url(image_bytes: bytes | None, image_mime: str | None) -> str | None:
74 """Build a data-URL for the API response.
76 Returns ``None`` if either side is missing. The mime is trusted
77 from the DB (validated at write time); it is not re-validated here.
78 """
79 if not image_bytes or not image_mime:
80 return None
81 encoded = base64.b64encode(image_bytes).decode("ascii")
82 return f"data:{image_mime};base64,{encoded}"