fixes for double nginx forward scenarios
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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_"}
|
||||
|
||||
|
||||
+148
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user