Files
interactive-story/backend/app/images.py
T
Parth e7d75c3b05 Rewrite Python comments in Google developer documentation style (#12)
* Rewrite comments in Google developer documentation style

Rewrite the comments and docstrings across the backend core modules so they
read plainly. The previous prose was accurate but dense and figurative, which
made it slow to skim.

Applies the Google developer documentation style guide: short sentences, active
voice, present tense, American spelling, and no metaphors, idioms, or
rhetorical asides. Replaces em-dash chains with separate sentences.
2026-08-26 15:37:25 +05:30

68 lines
2.8 KiB
Python

"""Scenario cover art: inline data URIs in, cacheable URLs out.
A scenario's `image` column holds either an `https://` URL or a base64
`data:image/…` URI (the editor downscales uploads before storing one). Sending
those data URIs inside list responses would balloon them, so lists advertise a
`image_url` pointing at `GET /api/scenarios/{id}/image` instead, and the bytes
are fetched once and cached by the browser.
"""
import base64
import binascii
import re
# Only raster formats a browser renders in an <img>. SVG is deliberately absent:
# it can carry script, and these bytes are served from our own origin.
DATA_URI_RE = re.compile(
r"^data:(image/(?:png|jpeg|webp|gif|avif));base64,([A-Za-z0-9+/=\s]+)$",
re.IGNORECASE,
)
def public_url(scenario_id: int, image: str, version: object) -> str:
"""The URL a client should load for this scenario's art ("" if none).
`version` is any object with a stable repr, and is normally the row's
`updated_at`. It becomes a cache-buster, so the image response can be marked
immutable and still refresh as soon as the author replaces the picture.
"""
if not image:
return ""
if DATA_URI_RE.match(image):
stamp = int(version.timestamp()) if hasattr(version, "timestamp") else 0
return f"/api/scenarios/{scenario_id}/image?v={stamp}"
# Anything else must be an absolute https URL. http:// is rejected rather
# than passed through: the deployed app is https, so a browser would block
# it as mixed content and the author would just see a broken image.
return image if image.startswith("https://") else ""
def sanitize(value: object, max_length: int) -> str:
"""Coerce an untrusted `image` value from an import bundle to a safe one.
A value that is not a supported data URI or an https URL, or that is too
large to store, becomes "". A hostile or merely unfamiliar bundle therefore
cannot pass in a `javascript:` URI or exceed the column cap.
"""
if not isinstance(value, str) or not value or len(value) > max_length:
return ""
if DATA_URI_RE.match(value):
return value if decode(value) is not None else ""
return value if value.startswith("https://") else ""
def decode(image: str) -> tuple[bytes, str] | None:
"""`(bytes, content_type)` for a stored data URI, or None if it isn't one."""
match = DATA_URI_RE.match(image or "")
if not match:
return None
try:
# validate=True rejects anything outside the base64 alphabet, so strip
# the newlines a hand-pasted or line-wrapped URI may carry first.
payload = re.sub(r"\s+", "", match.group(2))
return base64.b64decode(payload, validate=True), match.group(1).lower()
except (binascii.Error, ValueError):
# The base64 is truncated or hand-edited. Treat it as no image rather
# than return a 500.
return None