"""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)