92 lines
2.8 KiB
Python
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
|