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