367 lines
10 KiB
Python
367 lines
10 KiB
Python
"""Official news feed support for the native Linux dashboard."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import UTC, datetime
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Callable, Protocol
|
|
from urllib.parse import urljoin, urlparse
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
NEWS_ENDPOINT = "https://pristontale.eu/api/news/articles"
|
|
NEWS_ARTICLE_URL = "https://pristontale.eu/news/"
|
|
NEWS_ORIGIN = "https://pristontale.eu"
|
|
OFFICIAL_LOGO_URL = "https://pristontale.eu/public/img/pristontale/logo/logo_small.png"
|
|
BROADCAST_ENDPOINT = "http://5.135.35.188:90/broadcasts"
|
|
MAX_NEWS_BYTES = 2 * 1024 * 1024
|
|
MAX_NEWS_IMAGE_BYTES = 12 * 1024 * 1024
|
|
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
|
|
|
|
class NewsError(RuntimeError):
|
|
"""Raised when the official news feed cannot be parsed safely."""
|
|
|
|
|
|
class NewsResponse(Protocol):
|
|
"""The minimal response interface used by the news client."""
|
|
|
|
def read(self, size: int = -1) -> bytes: ...
|
|
|
|
def __enter__(self) -> NewsResponse: ...
|
|
|
|
def __exit__(
|
|
self,
|
|
exception_type: type[BaseException] | None,
|
|
exception: BaseException | None,
|
|
traceback: object | None,
|
|
) -> bool | None: ...
|
|
|
|
|
|
NewsOpener = Callable[..., NewsResponse]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NewsArticle:
|
|
id: int
|
|
slug: str
|
|
title: str
|
|
summary: str
|
|
author: str | None
|
|
published_at: str | None
|
|
image_url: str | None = None
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
return f"{NEWS_ARTICLE_URL}{self.slug}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Broadcast:
|
|
"""The latest announcement from the official broadcast API."""
|
|
|
|
author: str
|
|
content: str
|
|
published_at: str | None
|
|
url: str
|
|
|
|
|
|
def fetch_news_image(
|
|
article: NewsArticle,
|
|
cache_directory: Path,
|
|
*,
|
|
timeout_seconds: float = 15.0,
|
|
opener: NewsOpener = urlopen,
|
|
) -> bytes | None:
|
|
"""Return one verified PNG thumbnail, preferring the local image cache.
|
|
|
|
The official feed currently serves PNG article art. Deliberately accepting
|
|
only PNG keeps the Tk UI dependency-free and avoids treating arbitrary remote
|
|
data as a displayable image format.
|
|
"""
|
|
|
|
if article.image_url is None:
|
|
return None
|
|
cache_path = _image_cache_path(cache_directory, article)
|
|
cached = _read_png(cache_path)
|
|
if cached is not None:
|
|
return cached
|
|
return _fetch_png_image(article.image_url, cache_path, timeout_seconds, opener)
|
|
|
|
|
|
def fetch_official_logo(
|
|
cache_path: Path,
|
|
*,
|
|
timeout_seconds: float = 15.0,
|
|
opener: NewsOpener = urlopen,
|
|
) -> bytes | None:
|
|
"""Return the official PristonTale EU PNG logo, using its local cache first."""
|
|
|
|
return _fetch_png_image(OFFICIAL_LOGO_URL, cache_path, timeout_seconds, opener)
|
|
|
|
|
|
def _fetch_png_image(
|
|
url: str,
|
|
cache_path: Path,
|
|
timeout_seconds: float,
|
|
opener: NewsOpener,
|
|
) -> bytes | None:
|
|
cached = _read_png(cache_path)
|
|
if cached is not None:
|
|
return cached
|
|
request = Request(
|
|
url,
|
|
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
|
|
method="GET",
|
|
)
|
|
try:
|
|
response = opener(request, timeout=timeout_seconds)
|
|
with response:
|
|
payload = response.read(MAX_NEWS_IMAGE_BYTES + 1)
|
|
except OSError:
|
|
return None
|
|
if len(payload) > MAX_NEWS_IMAGE_BYTES or not _is_png(payload):
|
|
return None
|
|
_write_image_cache(cache_path, payload)
|
|
return payload
|
|
|
|
|
|
def fetch_news(
|
|
cache_path: Path,
|
|
*,
|
|
timeout_seconds: float = 15.0,
|
|
opener: NewsOpener = urlopen,
|
|
) -> tuple[NewsArticle, ...]:
|
|
"""Fetch fresh official news, falling back to the last valid local cache."""
|
|
|
|
request = Request(
|
|
NEWS_ENDPOINT,
|
|
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
|
|
method="GET",
|
|
)
|
|
try:
|
|
response = opener(request, timeout=timeout_seconds)
|
|
with response:
|
|
payload = response.read(MAX_NEWS_BYTES + 1)
|
|
if len(payload) > MAX_NEWS_BYTES:
|
|
raise NewsError("official news response exceeds the 2 MiB limit")
|
|
articles = _parse_news(payload)
|
|
_write_cache(cache_path, articles)
|
|
return articles
|
|
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
|
|
return read_cached_news(cache_path)
|
|
|
|
|
|
def read_cached_news(cache_path: Path) -> tuple[NewsArticle, ...]:
|
|
"""Read the last valid news feed without performing network I/O."""
|
|
|
|
return _read_cache(cache_path)
|
|
|
|
|
|
def fetch_latest_broadcast(
|
|
cache_path: Path,
|
|
*,
|
|
timeout_seconds: float = 15.0,
|
|
opener: NewsOpener = urlopen,
|
|
) -> Broadcast | None:
|
|
"""Fetch the first broadcast supplied by the official broadcast service."""
|
|
|
|
request = Request(
|
|
BROADCAST_ENDPOINT,
|
|
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
|
|
method="GET",
|
|
)
|
|
try:
|
|
response = opener(request, timeout=timeout_seconds)
|
|
with response:
|
|
payload = response.read(MAX_NEWS_BYTES + 1)
|
|
if len(payload) > MAX_NEWS_BYTES:
|
|
raise NewsError("broadcast response exceeds the 2 MiB limit")
|
|
broadcast = _parse_latest_broadcast(payload)
|
|
_write_broadcast_cache(cache_path, broadcast)
|
|
return broadcast
|
|
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
|
|
return read_cached_broadcast(cache_path)
|
|
|
|
|
|
def read_cached_broadcast(cache_path: Path) -> Broadcast | None:
|
|
"""Read the last valid broadcast without performing network I/O."""
|
|
|
|
return _read_broadcast_cache(cache_path)
|
|
|
|
|
|
def _parse_news(payload: bytes) -> tuple[NewsArticle, ...]:
|
|
value = json.loads(payload.decode("utf-8-sig"))
|
|
if isinstance(value, dict):
|
|
value = value.get("data")
|
|
if not isinstance(value, list):
|
|
raise NewsError("official news response is not an article list")
|
|
articles: list[NewsArticle] = []
|
|
for item in value[:20]:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
article_id = item.get("id")
|
|
slug = item.get("slug")
|
|
title = item.get("title")
|
|
summary = item.get("summary")
|
|
if (
|
|
isinstance(article_id, bool)
|
|
or not isinstance(article_id, int)
|
|
or not isinstance(slug, str)
|
|
or not slug.strip()
|
|
or "/" in slug
|
|
or not isinstance(title, str)
|
|
or not title.strip()
|
|
or not isinstance(summary, str)
|
|
):
|
|
continue
|
|
author = item.get("author")
|
|
date = item.get("date")
|
|
articles.append(
|
|
NewsArticle(
|
|
id=article_id,
|
|
slug=slug.strip(),
|
|
title=title.strip(),
|
|
summary=summary.strip(),
|
|
author=author.strip() if isinstance(author, str) and author.strip() else None,
|
|
published_at=_format_timestamp(date),
|
|
image_url=_image_url(item.get("image")),
|
|
)
|
|
)
|
|
if not articles:
|
|
raise NewsError("official news response contains no usable articles")
|
|
return tuple(articles)
|
|
|
|
|
|
def _parse_latest_broadcast(payload: bytes) -> Broadcast:
|
|
value = json.loads(payload.decode("utf-8-sig"))
|
|
if not isinstance(value, list) or not value or not isinstance(value[0], dict):
|
|
raise NewsError("broadcast response does not contain a latest broadcast")
|
|
latest = value[0]
|
|
author = latest.get("author")
|
|
content = latest.get("content")
|
|
url = _broadcast_url(latest.get("url"))
|
|
if (
|
|
not isinstance(author, str)
|
|
or not author.strip()
|
|
or not isinstance(content, str)
|
|
or not content.strip()
|
|
or url is None
|
|
):
|
|
raise NewsError("latest broadcast is incomplete")
|
|
return Broadcast(
|
|
author=author.strip(),
|
|
content=content.strip(),
|
|
published_at=_format_timestamp(latest.get("timestamp")),
|
|
url=url,
|
|
)
|
|
|
|
|
|
def _format_timestamp(value: object) -> str | None:
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
return None
|
|
try:
|
|
return datetime.fromtimestamp(value, UTC).astimezone().strftime("%d %b %Y")
|
|
except (OverflowError, OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def _image_url(value: object) -> str | None:
|
|
"""Accept only an official, relative article-art path from the feed."""
|
|
|
|
if not isinstance(value, str) or not value:
|
|
return None
|
|
parsed = urlparse(value)
|
|
if parsed.scheme or parsed.netloc or not value.startswith("/"):
|
|
return None
|
|
if not parsed.path.startswith("/storage/media/images/"):
|
|
return None
|
|
if any(part in ("", ".", "..") for part in parsed.path.split("/")[1:]):
|
|
return None
|
|
return urljoin(NEWS_ORIGIN, value)
|
|
|
|
|
|
def _broadcast_url(value: object) -> str | None:
|
|
if not isinstance(value, str) or not value.strip():
|
|
return None
|
|
parsed = urlparse(value)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
return None
|
|
return value.strip()
|
|
|
|
|
|
def _read_cache(path: Path) -> tuple[NewsArticle, ...]:
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(value, list):
|
|
return ()
|
|
return tuple(NewsArticle(**item) for item in value if isinstance(item, dict))
|
|
except (OSError, UnicodeError, TypeError, json.JSONDecodeError):
|
|
return ()
|
|
|
|
|
|
def _write_cache(path: Path, articles: tuple[NewsArticle, ...]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
try:
|
|
temporary.write_text(
|
|
json.dumps([asdict(article) for article in articles], indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
temporary.replace(path)
|
|
except OSError:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def _read_broadcast_cache(path: Path) -> Broadcast | None:
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
return Broadcast(**value) if isinstance(value, dict) else None
|
|
except (OSError, UnicodeError, TypeError, json.JSONDecodeError):
|
|
return None
|
|
|
|
|
|
def _write_broadcast_cache(path: Path, broadcast: Broadcast) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
try:
|
|
temporary.write_text(
|
|
json.dumps(asdict(broadcast), indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
temporary.replace(path)
|
|
except OSError:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def _image_cache_path(cache_directory: Path, article: NewsArticle) -> Path:
|
|
assert article.image_url is not None
|
|
digest = hashlib.sha256(article.image_url.encode("utf-8")).hexdigest()[:16]
|
|
return cache_directory / f"{article.id}-{digest}.png"
|
|
|
|
|
|
def _read_png(path: Path) -> bytes | None:
|
|
try:
|
|
payload = path.read_bytes()
|
|
except OSError:
|
|
return None
|
|
return payload if _is_png(payload) else None
|
|
|
|
|
|
def _is_png(payload: bytes) -> bool:
|
|
return PNG_SIGNATURE == payload[:len(PNG_SIGNATURE)] and len(payload) <= MAX_NEWS_IMAGE_BYTES
|
|
|
|
|
|
def _write_image_cache(path: Path, payload: bytes) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
try:
|
|
temporary.write_bytes(payload)
|
|
temporary.replace(path)
|
|
except OSError:
|
|
temporary.unlink(missing_ok=True)
|