"""Storing uploaded images.
Only model avatars use this today. Files are written under the data directory
and served back by a dedicated route, never from a URL supplied by a user --
a remote image URL would turn every page render into a request to a third
party, which is both a privacy leak and a way to make the UI depend on someone
else's uptime.
"""
from __future__ import annotations
import logging
import secrets
from pathlib import Path
from lembas.config import settings
log = logging.getLogger(__name__)
# Raster and vector formats a browser will render inline. Deliberately narrow:
# every entry here is something that cannot execute in an tag.
ALLOWED_TYPES: dict[str, str] = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
}
MAX_BYTES = 2 * 1024 * 1024 # 2 MB; these are 64px avatars
# Magic numbers, checked against the declared content type. A browser sniffs
# content, so trusting the client's Content-Type alone would let a file claim
# to be a PNG and be served as something else.
_SIGNATURES: tuple[tuple[bytes, str], ...] = (
(b"\x89PNG\r\n\x1a\n", "image/png"),
(b"\xff\xd8\xff", "image/jpeg"),
(b"GIF87a", "image/gif"),
(b"GIF89a", "image/gif"),
)
class UploadError(Exception):
"""A rejected upload, with a message fit to show the user."""
def _detect(payload: bytes) -> str | None:
for signature, media_type in _SIGNATURES:
if payload.startswith(signature):
return media_type
# WEBP is "RIFF" + 4 size bytes + "WEBP".
if payload[:4] == b"RIFF" and payload[8:12] == b"WEBP":
return "image/webp"
return None
def models_dir() -> Path:
path = settings.uploads_dir / "models"
path.mkdir(parents=True, exist_ok=True)
return path
def save_model_image(payload: bytes, declared_type: str) -> str:
"""Validate and store a model avatar. Returns the stored filename."""
if not payload:
raise UploadError("The file was empty.")
if len(payload) > MAX_BYTES:
raise UploadError(f"Images must be under {MAX_BYTES // (1024 * 1024)} MB.")
actual = _detect(payload)
if actual is None:
raise UploadError("That does not look like a PNG, JPEG, WEBP or GIF image.")
if declared_type and declared_type.split(";")[0].strip() != actual:
# Not fatal on its own, but worth knowing about.
log.info("upload declared %s but is actually %s", declared_type, actual)
# Random name rather than the client's: no path traversal, no collisions,
# and no leaking whatever the uploader called the file.
filename = f"{secrets.token_hex(16)}{ALLOWED_TYPES[actual]}"
(models_dir() / filename).write_bytes(payload)
return filename
def model_image_path(filename: str) -> Path | None:
"""Resolve a stored filename to a path, refusing anything outside the dir."""
if not filename or "/" in filename or "\\" in filename or filename.startswith("."):
return None
path = (models_dir() / filename).resolve()
try:
path.relative_to(models_dir().resolve())
except ValueError:
return None
return path if path.is_file() else None
def delete_model_image(filename: str) -> None:
path = model_image_path(filename)
if path is not None:
path.unlink(missing_ok=True)
# --- Branding assets -----------------------------------------------------------
# The logo, the favicon and the launcher icons derived from them. A separate
# directory from the model avatars because the *route* differs: these are served
# unauthenticated, since the sign-in page and the web app manifest both need
# them and neither has a session to check.
#
# SVG stays excluded, and this is where somebody will most want it. Every entry
# in ALLOWED_TYPES is a format that cannot execute in an ``; an SVG can, and
# these are the one set of files served to somebody who is not signed in.
# What a launcher asks for, and what `` wants.
# Generated from the uploaded logo rather than asked for separately: an
# administrator who has a logo has said everything they need to say, and five
# upload fields to fill in by hand is how three of them end up wrong.
ICON_SIZES: dict[str, int] = {
"icon-192": 192,
"icon-512": 512,
"apple-touch": 180,
"favicon": 32,
}
# The maskable icon has to survive being cropped to a circle, so the artwork
# sits inside the safe zone with the background showing around it. 80% is the
# standard's own guidance and is what the shipped icon already uses.
MASKABLE_SIZE = 512
MASKABLE_INSET = 0.8
def branding_dir() -> Path:
path = settings.uploads_dir / "branding"
path.mkdir(parents=True, exist_ok=True)
return path
def save_branding_image(payload: bytes, declared_type: str) -> str:
"""Validate and store a logo or favicon. Returns the stored filename."""
if not payload:
raise UploadError("The file was empty.")
if len(payload) > MAX_BYTES:
raise UploadError(f"Images must be under {MAX_BYTES // (1024 * 1024)} MB.")
actual = _detect(payload)
if actual is None:
raise UploadError(
"That does not look like a PNG, JPEG, WEBP or GIF image. "
"SVG is deliberately not accepted: these files are served to people "
"who are not signed in, and an SVG can carry a script."
)
if declared_type and declared_type.split(";")[0].strip() != actual:
log.info("upload declared %s but is actually %s", declared_type, actual)
filename = f"{secrets.token_hex(16)}{ALLOWED_TYPES[actual]}"
(branding_dir() / filename).write_bytes(payload)
return filename
def derive_icons(payload: bytes) -> dict[str, str]:
"""Launcher icons from an uploaded logo, at the sizes a browser asks for.
Best-effort: an instance whose logo cannot be resized keeps the shipped
icons, which is a worse launcher tile and not a broken install. Pillow is
already a dependency (`services/files.py` uses it for attachments), so this
adds nothing to install.
Every output is PNG regardless of what came in, because that is what a
manifest icon has to be, and RGBA so a logo with a transparent background
stays one.
"""
try:
import io
from PIL import Image
except Exception: # noqa: BLE001 - Pillow missing is not a failed save
log.info("Pillow unavailable; keeping the shipped launcher icons")
return {}
try:
with Image.open(io.BytesIO(payload)) as source:
source.load()
image = source.convert("RGBA")
except Exception: # noqa: BLE001 - a file we stored but cannot read
log.info("could not read the uploaded logo for icons", exc_info=True)
return {}
paths: dict[str, str] = {}
for name, size in ICON_SIZES.items():
paths[name] = _write_png(_fitted(image, size, size), f"{name}")
# Cropped to a circle on Android, so the artwork is inset and the corners
# are filled rather than transparent -- a transparent maskable icon is
# rendered as a black square by some launchers.
canvas = _new_canvas(MASKABLE_SIZE, image)
inner = _fitted(image, int(MASKABLE_SIZE * MASKABLE_INSET), int(MASKABLE_SIZE * MASKABLE_INSET))
offset = (MASKABLE_SIZE - inner.width) // 2, (MASKABLE_SIZE - inner.height) // 2
canvas.alpha_composite(inner, offset)
paths["maskable"] = _write_png(canvas, "maskable")
return paths
def _fitted(image, width: int, height: int):
from PIL import Image
return image.copy().resize((width, height), Image.LANCZOS)
def _new_canvas(size: int, source):
"""A square the colour of the logo's top-left pixel, or transparent.
Sampling one pixel rather than averaging: a logo on a flat background gets
that background, which is what the inset needs, and a logo on a transparent
one gets transparency, which the composite below then fills.
"""
from PIL import Image
corner = source.getpixel((0, 0))
fill = corner if isinstance(corner, tuple) and len(corner) == 4 else (0, 0, 0, 0)
return Image.new("RGBA", (size, size), fill)
def _write_png(image, label: str) -> str:
import io
buffer = io.BytesIO()
image.save(buffer, format="PNG", optimize=True)
filename = f"{label}-{secrets.token_hex(8)}.png"
(branding_dir() / filename).write_bytes(buffer.getvalue())
return filename
def branding_image_path(filename: str) -> Path | None:
"""Resolve a stored branding filename, refusing anything outside the dir."""
if not filename or "/" in filename or "\\" in filename or filename.startswith("."):
return None
path = (branding_dir() / filename).resolve()
try:
path.relative_to(branding_dir().resolve())
except ValueError:
return None
return path if path.is_file() else None
def delete_branding_image(filename: str) -> None:
path = branding_image_path(filename)
if path is not None:
path.unlink(missing_ok=True)
def media_type_for(filename: str) -> str:
suffix = Path(filename).suffix.lower()
for media_type, extension in ALLOWED_TYPES.items():
if extension == suffix:
return media_type
return "application/octet-stream"