diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc index 290075e..e68e6b6 100644 Binary files a/backend/app/__pycache__/config.cpython-312.pyc and b/backend/app/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/__pycache__/theme_export.cpython-312.pyc b/backend/app/__pycache__/theme_export.cpython-312.pyc index 280ef73..06d0e08 100644 Binary files a/backend/app/__pycache__/theme_export.cpython-312.pyc and b/backend/app/__pycache__/theme_export.cpython-312.pyc differ diff --git a/backend/app/config.py b/backend/app/config.py index 01d6180..2755cc8 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -35,6 +35,11 @@ class Settings(BaseSettings): # Theme export WEBSITE_URL: str = "https://kmountainflower.org" THEME_JSON_PATH: str = "/app/theme.json" + STATIC_FILES_DIR: str = "/usr/share/nginx/html" + # Internal URL for fetching static files when they're not on the local + # filesystem (e.g. separate Docker/K8s containers). Falls back to + # WEBSITE_URL if not set. + STATIC_UPSTREAM_URL: str = "" model_config = {"env_prefix": "KMTN_"} diff --git a/backend/app/theme_export.py b/backend/app/theme_export.py index 4c615fc..2bcace6 100644 --- a/backend/app/theme_export.py +++ b/backend/app/theme_export.py @@ -2,19 +2,22 @@ This module provides functions to: - Build a clean, consumer-friendly JSON structure from StationConfig. +- Embed actual image bytes as base64 data URIs for offline build use. - Write it atomically to disk (for scp by build machines). - Check whether the file exists and return its metadata. """ +import base64 import json import logging +import re from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict from app.config import settings from app.database import async_session -from app.models import StationConfig +from app.models import StationConfig, StorageBlob from sqlalchemy import select logger = logging.getLogger(__name__) @@ -35,8 +38,107 @@ def resolve_image_url(base_url: str, relative_url: str) -> str | None: return f"{base}/{path}" +def _detect_mime_from_extension(path: str) -> str: + """Guess MIME type from file extension.""" + ext = Path(path).suffix.lower() + return { + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".avif": "image/avif", + }.get(ext, "application/octet-stream") + + +async def fetch_image_bytes(url: str) -> tuple[bytes, str] | None: + """Fetch image bytes from any source the station config uses. + + Resolves three URL patterns: + - /api/storage/{blob_id} → read from StorageBlob in DB + - Relative paths (e.g. svg/logo.svg) → read from STATIC_FILES_DIR on disk + - Absolute HTTP(S) URLs → fetch via httpx + + Returns (raw_bytes, mime_type) or None on failure. + """ + if not url: + return None + + # Database blob + blob_match = re.match(r"^/api/storage/([a-f0-9]+)$", url) + if blob_match: + blob_id = blob_match.group(1) + async with async_session() as blob_session: + result = await blob_session.execute( + select(StorageBlob).where(StorageBlob.blob_id == blob_id) + ) + blob = result.scalar_one_or_none() + if blob: + return (blob.data, blob.content_type) + return None + + # Static file on disk or internal upstream HTTP + if not url.startswith(("http://", "https://")): + # Try local filesystem first + candidates: list[Path] = [Path(settings.STATIC_FILES_DIR)] + # Dev fallback: if the configured path (production nginx root) doesn't + # contain the file, try common Angular dist/public locations. + if not (Path(settings.STATIC_FILES_DIR) / url.lstrip("/")).is_file(): + candidates.extend([ + Path(__file__).resolve().parents[2] / "dist" / "radio-station" / "browser", + Path(__file__).resolve().parents[2] / "public", + ]) + + for static_dir in candidates: + if not static_dir.is_dir(): + continue + file_path = (static_dir / url.lstrip("/")).resolve() + # Safety: must be inside static_dir + try: + file_path.relative_to(static_dir.resolve()) + except ValueError: + continue + if file_path.is_file(): + data = file_path.read_bytes() + mime = _detect_mime_from_extension(url) + return (data, mime) + + # Not on local disk — try internal upstream (Docker/K8s) or public URL + upstream_base = settings.STATIC_UPSTREAM_URL or settings.WEBSITE_URL + fetch_url = f"{upstream_base.rstrip('/')}/{url.lstrip('/')}" + + try: + import httpx + async with httpx.AsyncClient() as client: + resp = await client.get(fetch_url, follow_redirects=True, timeout=10) + if resp.status_code == 200: + content_type = resp.headers.get("content-type", "application/octet-stream") + return (resp.content, content_type) + except Exception as e: + logger.warning("Failed to fetch image %s from %s: %s", url, fetch_url, e) + return None + + # Remote HTTP fetch + try: + import httpx + async with httpx.AsyncClient() as client: + resp = await client.get(url, follow_redirects=True, timeout=10) + if resp.status_code == 200: + content_type = resp.headers.get("content-type", "application/octet-stream") + return (resp.content, content_type) + except Exception as e: + logger.warning("Failed to fetch remote image %s: %s", url, e) + return None + + def build_theme_dict(config: StationConfig) -> Dict[str, Any]: - """Build the theme.json dict from a StationConfig ORM object.""" + """Build the theme.json dict from a StationConfig ORM object. + + Returns the theme structure with URL-based images. For embedded + (base64) images, use build_theme_with_embedded_images() instead. + """ base_url = settings.WEBSITE_URL images: Dict[str, str] = {} @@ -60,6 +162,10 @@ def build_theme_dict(config: StationConfig) -> Dict[str, Any]: "tagline": config.tagline, "frequency": config.frequency, }, + "streams": { + "url": config.stream_url or None, + "metadata_url": config.stream_metadata_url or None, + }, "colors": { "primary": config.color_primary, "primary_light": config.color_primary_light, @@ -83,9 +189,48 @@ def build_theme_dict(config: StationConfig) -> Dict[str, Any]: } +async def build_theme_with_embedded_images( + config: StationConfig, +) -> Dict[str, Any]: + """Build theme dict with images embedded as base64 data URIs. + + Fetches actual image bytes from static files, database blobs, or + remote URLs. Failed fetches are logged and omitted (graceful). + The original URL-based `images` section is kept for reference. + """ + theme = build_theme_dict(config) + + images_embedded: Dict[str, Dict[str, str]] = {} + image_fields = [ + ("logo", config.logo_url), + ("hero_background", config.hero_background_url), + ("hero_icon", config.hero_icon_url), + ("hero_divider", config.hero_divider_url), + ] + + for key, url in image_fields: + result = await fetch_image_bytes(url) + if result is None: + logger.warning("Could not fetch image for %s (%s) — skipping embed", key, url) + continue + data, mime_type = result + b64 = base64.b64encode(data).decode("ascii") + images_embedded[key] = { + "content_type": mime_type, + "size": len(data), + "data": b64, + } + + theme["images_embedded"] = images_embedded + return theme + + async def write_theme_json() -> Dict[str, Any]: """Read the current station config and write theme.json to disk. + Fetches actual image bytes and embeds them as base64 data URIs + in an `images_embedded` section for offline build use. + Uses atomic write (write to .tmp, then os.replace) to prevent reading a partially-written file. @@ -99,7 +244,7 @@ async def write_theme_json() -> Dict[str, Any]: if config is None: raise RuntimeError("Station config not found in database") - theme = build_theme_dict(config) + theme = await build_theme_with_embedded_images(config) # Atomic write path = Path(settings.THEME_JSON_PATH) diff --git a/docker-compose.yml b/docker-compose.yml index 378a548..354f7cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]' KMTN_ADMIN_USERNAME : "admin" KMTN_ADMIN_PASSWORD : "123" + KMTN_STATIC_UPSTREAM_URL: http://frontend:80 ports: - "8000:8000" volumes: