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

1"""Helpers for the App-image data-URL ↔ bytes round-trip. 

2 

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``. 

6 

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""" 

12 

13from __future__ import annotations 

14 

15import base64 

16import re 

17 

18from fastapi import HTTPException, status 

19 

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 

23 

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) 

29 

30 

31def parse_image_data_url(data_url: str | None) -> tuple[bytes | None, str | None]: 

32 """Decode a data-URL into ``(bytes, mime)``. 

33 

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 

71 

72 

73def build_image_data_url(image_bytes: bytes | None, image_mime: str | None) -> str | None: 

74 """Build a data-URL for the API response. 

75 

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}"