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
|
# Theme export
|
||||||
WEBSITE_URL: str = "https://kmountainflower.org"
|
WEBSITE_URL: str = "https://kmountainflower.org"
|
||||||
THEME_JSON_PATH: str = "/app/theme.json"
|
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_"}
|
model_config = {"env_prefix": "KMTN_"}
|
||||||
|
|
||||||
|
|||||||
+148
-3
@@ -2,19 +2,22 @@
|
|||||||
|
|
||||||
This module provides functions to:
|
This module provides functions to:
|
||||||
- Build a clean, consumer-friendly JSON structure from StationConfig.
|
- 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).
|
- Write it atomically to disk (for scp by build machines).
|
||||||
- Check whether the file exists and return its metadata.
|
- Check whether the file exists and return its metadata.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import async_session
|
from app.database import async_session
|
||||||
from app.models import StationConfig
|
from app.models import StationConfig, StorageBlob
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -35,8 +38,107 @@ def resolve_image_url(base_url: str, relative_url: str) -> str | None:
|
|||||||
return f"{base}/{path}"
|
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]:
|
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
|
base_url = settings.WEBSITE_URL
|
||||||
|
|
||||||
images: Dict[str, str] = {}
|
images: Dict[str, str] = {}
|
||||||
@@ -60,6 +162,10 @@ def build_theme_dict(config: StationConfig) -> Dict[str, Any]:
|
|||||||
"tagline": config.tagline,
|
"tagline": config.tagline,
|
||||||
"frequency": config.frequency,
|
"frequency": config.frequency,
|
||||||
},
|
},
|
||||||
|
"streams": {
|
||||||
|
"url": config.stream_url or None,
|
||||||
|
"metadata_url": config.stream_metadata_url or None,
|
||||||
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"primary": config.color_primary,
|
"primary": config.color_primary,
|
||||||
"primary_light": config.color_primary_light,
|
"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]:
|
async def write_theme_json() -> Dict[str, Any]:
|
||||||
"""Read the current station config and write theme.json to disk.
|
"""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
|
Uses atomic write (write to .tmp, then os.replace) to prevent
|
||||||
reading a partially-written file.
|
reading a partially-written file.
|
||||||
|
|
||||||
@@ -99,7 +244,7 @@ async def write_theme_json() -> Dict[str, Any]:
|
|||||||
if config is None:
|
if config is None:
|
||||||
raise RuntimeError("Station config not found in database")
|
raise RuntimeError("Station config not found in database")
|
||||||
|
|
||||||
theme = build_theme_dict(config)
|
theme = await build_theme_with_embedded_images(config)
|
||||||
|
|
||||||
# Atomic write
|
# Atomic write
|
||||||
path = Path(settings.THEME_JSON_PATH)
|
path = Path(settings.THEME_JSON_PATH)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]'
|
KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]'
|
||||||
KMTN_ADMIN_USERNAME : "admin"
|
KMTN_ADMIN_USERNAME : "admin"
|
||||||
KMTN_ADMIN_PASSWORD : "123"
|
KMTN_ADMIN_PASSWORD : "123"
|
||||||
|
KMTN_STATIC_UPSTREAM_URL: http://frontend:80
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Reference in New Issue
Block a user