overlay_branding.py 3.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. """Server-stored streaming overlay logo. Mutation requires settings permission."""
  2. import io
  3. import os
  4. import tempfile
  5. import warnings
  6. from fastapi import APIRouter, File, HTTPException, UploadFile
  7. from fastapi.responses import Response
  8. from PIL import Image, UnidentifiedImageError
  9. from starlette.concurrency import run_in_threadpool
  10. from backend.app.core.auth import RequireOverlayTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
  11. from backend.app.core.config import settings
  12. from backend.app.core.permissions import Permission
  13. from backend.app.models.user import User
  14. router = APIRouter(tags=["overlay-branding"])
  15. MAX_BYTES = 2 * 1024 * 1024
  16. MAX_PIXELS = 4_000_000
  17. def _read_logo() -> Response:
  18. try:
  19. content = (settings.base_dir / "overlay-branding" / "logo.png").read_bytes()
  20. except FileNotFoundError:
  21. raise HTTPException(404, "No overlay logo saved") from None
  22. return Response(content, media_type="image/png", headers={"Cache-Control": "no-store"})
  23. def _save_logo(content: bytes) -> None:
  24. try:
  25. with warnings.catch_warnings():
  26. warnings.simplefilter("error", Image.DecompressionBombWarning)
  27. with Image.open(io.BytesIO(content)) as image:
  28. if image.format not in ("PNG", "WEBP") or image.width * image.height > MAX_PIXELS:
  29. raise HTTPException(400, "Use a PNG or WebP image with at most 4 million pixels")
  30. if getattr(image, "is_animated", False):
  31. raise HTTPException(400, "Animated logos are not supported")
  32. image.load()
  33. image = image.convert("RGBA")
  34. image.thumbnail((512, 512))
  35. output = io.BytesIO()
  36. image.save(output, format="PNG")
  37. except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError, Image.DecompressionBombWarning):
  38. raise HTTPException(400, "Invalid PNG or WebP image") from None
  39. directory = settings.base_dir / "overlay-branding"
  40. directory.mkdir(parents=True, exist_ok=True)
  41. # Replace atomically so concurrent readers never see a partially written PNG.
  42. with tempfile.NamedTemporaryFile(dir=directory, delete=False) as temporary:
  43. temporary_path = temporary.name
  44. try:
  45. temporary.write(output.getvalue())
  46. temporary.close()
  47. os.replace(temporary_path, directory / "logo.png")
  48. finally:
  49. if os.path.exists(temporary_path):
  50. os.unlink(temporary_path)
  51. @router.get("/settings/overlay-logo")
  52. def get_logo(_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ)):
  53. return _read_logo()
  54. @router.get("/overlay-branding/logo")
  55. def get_stream_logo(_: None = RequireOverlayTokenIfAuthEnabled):
  56. return _read_logo()
  57. @router.post("/settings/overlay-logo")
  58. async def upload_logo(
  59. file: UploadFile = File(...),
  60. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  61. ):
  62. content = await file.read(MAX_BYTES + 1)
  63. if len(content) > MAX_BYTES:
  64. raise HTTPException(413, "Logo must be 2 MiB or smaller")
  65. await run_in_threadpool(_save_logo, content)
  66. return {"status": "ok"}
  67. @router.delete("/settings/overlay-logo")
  68. def delete_logo(_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE)):
  69. (settings.base_dir / "overlay-branding" / "logo.png").unlink(missing_ok=True)
  70. return {"status": "ok"}