Files
pristontale-eu-linux/ptlinux/news.py

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)