velxio/backend/app/api/routes/news.py

92 lines
2.8 KiB
Python

"""Product-news feed proxy for self-hosted installs.
The editor shows a once-per-browser "What's New" modal (see
frontend/src/lib/newsSource.ts). Rather than every user's browser
fetching velxio.dev directly, the backend proxies the public feed:
- users' browsers only ever talk to this server;
- one cached fetch per instance instead of one per visitor;
- a single, documented opt-out: VELXIO_NEWS=off (also the automatic
behavior when the host has no internet — errors degrade to []).
The upstream feed is plain public JSON (no auth, no identifiers sent).
"""
from __future__ import annotations
import logging
import os
import time
import httpx
from fastapi import APIRouter
logger = logging.getLogger(__name__)
router = APIRouter()
DEFAULT_FEED_URL = "https://velxio.dev/api/pro/news/feed"
_CACHE_TTL_SECONDS = 6 * 60 * 60
_cache: dict = {"at": 0.0, "posts": None}
def _news_enabled() -> bool:
return os.environ.get("VELXIO_NEWS", "on").strip().lower() not in (
"off", "0", "false", "disabled", "no",
)
def _sanitize(raw: object) -> list[dict]:
"""Keep only the expected shape/fields — the payload gets rendered
client-side, so an upstream surprise must not leak arbitrary junk."""
if not isinstance(raw, list):
return []
posts: list[dict] = []
for item in raw[:50]:
if not isinstance(item, dict):
continue
try:
posts.append({
"id": str(item["id"])[:64],
"title": str(item["title"])[:200],
"body_md": str(item["body_md"])[:20_000],
"publish_date": str(item["publish_date"])[:10],
"expires_date": (
str(item["expires_date"])[:10]
if item.get("expires_date") else None
),
})
except (KeyError, TypeError):
continue
return posts
@router.get("/feed")
async def news_feed() -> list[dict]:
"""Cached proxy of the velxio.dev product-news feed.
Returns [] when news is disabled, the host is offline, or upstream
misbehaves — the modal simply never appears.
"""
if not _news_enabled():
return []
now = time.monotonic()
if _cache["posts"] is not None and now - _cache["at"] < _CACHE_TTL_SECONDS:
return _cache["posts"]
url = os.environ.get("VELXIO_NEWS_URL", DEFAULT_FEED_URL)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(url)
resp.raise_for_status()
posts = _sanitize(resp.json())
except Exception as exc: # offline / DNS / upstream down — all fine
logger.debug("[news] feed fetch failed (%s); serving stale/empty", exc)
return _cache["posts"] or []
_cache["posts"] = posts
_cache["at"] = now
return posts