Files
bambuddy/backend/app/services/announcements.py
T
maziggy 3fae2b402b Announcements: history, collapsed list, instant refresh, footer icon
- Expired messages stay readable under a collapsed "Earlier" section, as
  long as the feed keeps them (12 months, at most 50). They never count as
  unread or raise a banner; withdrawn ones are gone everywhere.
- Each message is one row (level, date, title) that opens in place.
  Unread ones carry a dot and a New chip, and opening one is what marks
  it read; the banner's Read more opens the panel on its message.
- A fetch that brings a newer feed broadcasts an empty
  announcements_changed event, so open pages show the new dot and banner
  without a reload.
- The sidebar entry is a megaphone icon with an unread badge, in the
  footer row left of System. Footer icons are 32px with no gap so seven
  fit an expanded sidebar; with authentication on, logout used to wrap
  onto a line of its own.
2026-10-01 11:37:06 +02:00

477 lines
18 KiB
Python

"""Announcements from the Bambuddy maintainers: fetch, verify, keep what applies.
Bambuddy fetches one file, ``feed.json``, from the public
``maziggy/bambuddy-notifications`` repo on raw.githubusercontent.com -- the host
the update check already talks to. No Bambuddy server is contacted and nothing
about this install is sent: whether a message applies here (version range,
channel, install type) is decided below, locally.
The file is written by the maintainers' registrar and signed with Ed25519:
{"format": 1, "key_id": "...", "signature": "<base64>", "payload": {...}}
The signature covers ``canonical(payload)``. A file that does not verify against
a key in ``TRUSTED_KEYS`` is ignored, so neither a copy of the repo nor anyone in
the middle can make Bambuddy show a message. The payload's ``serial`` only goes
up; a feed older than one already accepted is refused, so an old signed file
cannot be re-served to bring back a withdrawn message.
The feed is the full current list. On every accepted fetch the stored set is
replaced: a message withdrawn upstream disappears here with its read markers.
Any failure keeps the last good list.
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import random
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from urllib.parse import urlsplit
import httpx
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import APP_VERSION
from backend.app.models.announcement import Announcement, AnnouncementRead
from backend.app.models.settings import Settings
logger = logging.getLogger(__name__)
FEED_URL = "https://raw.githubusercontent.com/maziggy/bambuddy-notifications/main/feed.json"
FEED_FORMAT = 1
# key_id (first 16 hex of sha256 of the raw public key) -> base64 raw public key.
# A list so the key can be rotated: ship the new key next to the old one first.
TRUSTED_KEYS: dict[str, str] = {
"d70b3bf207fdfd59": "uQqUAYrInmXQ1nIwOIY/95L7tD5o/HZMmZfhAlULJ/w=",
}
# Where a message may link to. The registrar refuses anything else too; this is
# the side that counts.
LINK_HOSTS = ("github.com", "bambuddy.cool")
LEVELS = ("info", "important", "critical")
MAX_FEED_BYTES = 512 * 1024
MAX_TITLE = 120
MAX_BODY = 2000
MAX_LINK_LABEL = 40
FETCH_INTERVAL_SECONDS = 6 * 3600
FETCH_JITTER_SECONDS = 30 * 60
# Let startup settle before the first fetch.
FIRST_FETCH_DELAY_SECONDS = 60
ENABLED_KEY = "announcements_enabled"
ALL_USERS_KEY = "announcements_all_users"
# Internal state, never part of the settings API.
SERIAL_KEY = "announcements_feed_serial"
LAST_FETCH_KEY = "announcements_last_fetch"
class FeedRejected(Exception):
"""The fetched file is not a feed this install accepts. Nothing changes."""
# ---- verification -------------------------------------------------------------------
def canonical(payload: dict) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
def verify_feed(content: bytes, trusted_keys: dict[str, str] | None = None) -> dict:
"""The payload of a correctly signed feed, or FeedRejected."""
keys = TRUSTED_KEYS if trusted_keys is None else trusted_keys
try:
envelope = json.loads(content)
except (ValueError, UnicodeDecodeError) as exc:
raise FeedRejected("not JSON") from exc
if not isinstance(envelope, dict) or envelope.get("format") != FEED_FORMAT:
raise FeedRejected("unknown feed format")
public = keys.get(str(envelope.get("key_id")))
if public is None:
raise FeedRejected(f"signed with an unknown key ({envelope.get('key_id')!r})")
payload = envelope.get("payload")
if not isinstance(payload, dict):
raise FeedRejected("no payload")
try:
signature = base64.b64decode(str(envelope.get("signature")), validate=True)
Ed25519PublicKey.from_public_bytes(base64.b64decode(public)).verify(signature, canonical(payload))
except (ValueError, InvalidSignature) as exc:
raise FeedRejected("signature does not verify") from exc
if payload.get("format") != FEED_FORMAT:
raise FeedRejected("unknown payload format")
serial = payload.get("serial")
if not isinstance(serial, int) or isinstance(serial, bool) or serial < 1:
raise FeedRejected("no serial")
if not isinstance(payload.get("announcements"), list):
raise FeedRejected("no announcement list")
return payload
# ---- what this install is ------------------------------------------------------------
@dataclass(frozen=True)
class InstallFacts:
version: str
channel: str # stable | beta
install_type: str # docker | native | ha_addon | windows
def _version_key(version: str) -> tuple:
"""Sortable form that puts a release above its own betas (1.2.6 > 1.2.6b3)."""
from backend.app.api.routes.updates import parse_version
parsed = parse_version(version)
major, minor, patch, micro = (parsed + (0, 0, 0, 0))[:4]
is_prerelease = parsed[4] if len(parsed) > 4 else 0
prerelease_num = parsed[5] if len(parsed) > 5 else 0
return (major, minor, patch, micro, 1 - is_prerelease, prerelease_num)
def _install_type() -> str:
from backend.app.api.routes import updates
if updates._is_windows_installer_install():
return "windows"
if updates._is_ha_addon():
return "ha_addon"
if updates._is_docker_environment():
return "docker"
return "native"
async def install_facts(db: AsyncSession) -> InstallFacts:
beta_setting = (
await db.execute(select(Settings.value).where(Settings.key == "include_beta_updates"))
).scalar_one_or_none()
# Beta testers are the installs that asked for betas, and the ones running one.
prerelease = bool(re.search(r"[a-zA-Z]", APP_VERSION.lstrip("v")))
beta = prerelease or (beta_setting or "").lower() == "true"
return InstallFacts(version=APP_VERSION, channel="beta" if beta else "stable", install_type=_install_type())
def targets(entry: dict, facts: InstallFacts) -> bool:
target = entry.get("target") or {}
if not isinstance(target, dict):
return False
try:
here = _version_key(facts.version)
low, high = target.get("min_version"), target.get("max_version")
if low and here < _version_key(str(low)):
return False
if high and here > _version_key(str(high)):
return False
except (TypeError, ValueError):
return False
channels = target.get("channels") or []
if channels and facts.channel not in channels:
return False
install_types = target.get("install_types") or []
return not (install_types and facts.install_type not in install_types)
# ---- one entry, defensively ---------------------------------------------------------
def link_allowed(url: str) -> bool:
try:
parts = urlsplit(url)
port = parts.port
except ValueError:
return False
host = (parts.hostname or "").lower()
if parts.scheme != "https" or not host or parts.username or parts.password:
return False
if port not in (None, 443):
return False
return any(host == allowed or host.endswith("." + allowed) for allowed in LINK_HOSTS)
def _parse_time(value: object) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _clean_texts(texts: object) -> dict[str, dict[str, str]] | None:
"""Plain strings, cut to length. None when there is no usable English text."""
if not isinstance(texts, dict):
return None
cleaned: dict[str, dict[str, str]] = {}
for lang, text in texts.items():
if not isinstance(lang, str) or len(lang) > 10 or not isinstance(text, dict):
continue
title, body = text.get("title"), text.get("body")
if not isinstance(title, str) or not isinstance(body, str) or not title.strip() or not body.strip():
continue
entry = {"title": title.strip()[:MAX_TITLE], "body": body.strip()[:MAX_BODY]}
label = text.get("link_label")
if isinstance(label, str) and label.strip():
entry["link_label"] = label.strip()[:MAX_LINK_LABEL]
cleaned[lang] = entry
return cleaned if "en" in cleaned else None
@dataclass
class Entry:
public_id: str
level: str
texts: dict[str, dict[str, str]]
link_url: str | None
published_at: datetime | None
expires_at: datetime | None
def parse_entry(raw: object) -> Entry | None:
"""One feed entry, or None if it can't be shown safely. Never raises."""
if not isinstance(raw, dict):
return None
public_id = raw.get("id")
if not isinstance(public_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", public_id):
return None
level = raw.get("level") if raw.get("level") in LEVELS else "info"
texts = _clean_texts(raw.get("texts"))
if texts is None:
return None
link = raw.get("link_url")
link = link if isinstance(link, str) and link_allowed(link) else None
published_at = _parse_time(raw.get("published_at"))
expires_at = _parse_time(raw.get("expires_at"))
# History is whatever has expired; the feed's ``archived`` flag says the same
# thing for messages the registrar kept after their expiry. One flagged but
# still in date by this install's clock is history anyway: expire it now.
now = datetime.now(timezone.utc).replace(tzinfo=None)
if raw.get("archived") is True and (expires_at is None or expires_at > now):
expires_at = now
return Entry(
public_id=public_id,
level=level,
texts=texts,
link_url=link,
published_at=published_at,
expires_at=expires_at,
)
# ---- store ---------------------------------------------------------------------------
async def _get(db: AsyncSession, key: str) -> str | None:
return (await db.execute(select(Settings.value).where(Settings.key == key))).scalar_one_or_none()
async def _set(db: AsyncSession, key: str, value: str) -> None:
from backend.app.core.db_dialect import upsert_setting
await upsert_setting(db, Settings, key, value)
async def is_enabled(db: AsyncSession) -> bool:
return (await _get(db, ENABLED_KEY) or "true").lower() != "false"
async def apply_payload(db: AsyncSession, payload: dict, facts: InstallFacts) -> int:
"""Replace the stored announcements with the ones in ``payload`` that apply here.
Refuses (FeedRejected) a serial below the highest already accepted. The same
serial again is the same feed fetched twice, and is fine. Returns how many are
stored. The caller commits.
"""
serial = payload["serial"]
seen = int(await _get(db, SERIAL_KEY) or 0)
if serial < seen:
raise FeedRejected(f"serial {serial} is older than {seen}, already accepted")
wanted: dict[str, Entry] = {}
for raw in payload["announcements"]:
entry = parse_entry(raw)
if entry is not None and targets(raw, facts):
wanted[entry.public_id] = entry
existing = {a.public_id: a for a in (await db.execute(select(Announcement))).scalars().all()}
gone = [a.id for pid, a in existing.items() if pid not in wanted]
if gone:
# Read markers first: SQLite enforces no foreign keys unless asked to, so
# ON DELETE CASCADE alone would leave them behind.
await db.execute(delete(AnnouncementRead).where(AnnouncementRead.announcement_id.in_(gone)))
await db.execute(delete(Announcement).where(Announcement.id.in_(gone)))
for pid, entry in wanted.items():
row = existing.get(pid) or Announcement(public_id=pid)
row.level = entry.level
row.texts = json.dumps(entry.texts, ensure_ascii=False)
row.link_url = entry.link_url
row.published_at = entry.published_at
row.expires_at = entry.expires_at
if pid not in existing:
db.add(row)
await _set(db, SERIAL_KEY, str(max(serial, seen)))
return len(wanted)
async def _download() -> bytes:
# No version and no install identity in the request: the generic agent says
# what is asking, nothing about which install.
headers = {"User-Agent": "Bambuddy-Announcements", "Cache-Control": "no-cache"}
async with (
httpx.AsyncClient(timeout=15, follow_redirects=False) as client,
client.stream("GET", FEED_URL, headers=headers) as response,
):
if response.status_code != 200:
raise FeedRejected(f"HTTP {response.status_code}")
chunks: list[bytes] = []
size = 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > MAX_FEED_BYTES:
raise FeedRejected("larger than the feed is allowed to be")
chunks.append(chunk)
return b"".join(chunks)
async def refresh(db: AsyncSession) -> int | None:
"""Fetch, verify and store. Returns how many apply here, or None if nothing changed.
Never raises for a network problem or a bad feed -- those are logged and the
last good list stays. A switched-off install fetches nothing.
"""
if not await is_enabled(db):
return None
seen_before = int(await _get(db, SERIAL_KEY) or 0)
try:
content = await _download()
payload = verify_feed(content)
count = await apply_payload(db, payload, await install_facts(db))
except FeedRejected as exc:
await db.rollback()
logger.warning("Announcements feed ignored: %s", exc)
return None
except httpx.HTTPError as exc:
await db.rollback()
# Offline and air-gapped installs land here every time; not worth a warning.
logger.info("Announcements feed not fetched: %s", type(exc).__name__)
return None
await _set(db, LAST_FETCH_KEY, datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
await db.commit()
logger.info("Announcements feed #%s: %d for this install", payload["serial"], count)
if payload["serial"] > seen_before:
await _tell_open_pages()
return count
async def _tell_open_pages() -> None:
"""Have every open Bambuddy page re-read the list, so a new message's dot and
banner appear without a reload. The event carries nothing: each page asks
GET /announcements, which answers by who is asking."""
from backend.app.core.websocket import ws_manager
try:
await ws_manager.broadcast({"type": "announcements_changed"})
except Exception: # A page that misses it catches up on its own poll.
logger.debug("announcements_changed broadcast failed", exc_info=True)
# ---- read state ----------------------------------------------------------------------
async def list_for(db: AsyncSession, user_id: int | None) -> list[dict]:
"""Every stored announcement, newest first, with this user's read state.
``archived`` marks history: messages past their expiry, which the panel lists
under "Earlier" and which never count as unread or raise a banner. The feed
decides how much history there is; a message it drops is deleted here.
"""
now = datetime.now(timezone.utc).replace(tzinfo=None)
rows = (
(await db.execute(select(Announcement).order_by(Announcement.published_at.desc(), Announcement.id.desc())))
.scalars()
.all()
)
reader = AnnouncementRead.user_id.is_(None) if user_id is None else AnnouncementRead.user_id == user_id
read_ids = set((await db.execute(select(AnnouncementRead.announcement_id).where(reader))).scalars().all())
result = []
for a in rows:
try:
texts = json.loads(a.texts)
except (TypeError, ValueError):
continue
result.append(
{
"id": a.public_id,
"level": a.level,
"texts": texts,
"link_url": a.link_url,
"published_at": a.published_at.isoformat() + "Z" if a.published_at else None,
"expires_at": a.expires_at.isoformat() + "Z" if a.expires_at else None,
"archived": a.expires_at is not None and a.expires_at <= now,
"read": a.id in read_ids,
}
)
return result
async def mark_read(db: AsyncSession, public_id: str, user_id: int | None) -> bool:
"""Record that this user read it. False if there is no such announcement."""
announcement = (
await db.execute(select(Announcement).where(Announcement.public_id == public_id))
).scalar_one_or_none()
if announcement is None:
return False
reader = AnnouncementRead.user_id.is_(None) if user_id is None else AnnouncementRead.user_id == user_id
# Checked rather than left to the unique constraint: NULL user_ids never
# collide in one, so the auth-off row would be duplicated on every click.
already = (
await db.execute(select(AnnouncementRead.id).where(AnnouncementRead.announcement_id == announcement.id, reader))
).first()
if already is None:
db.add(AnnouncementRead(announcement_id=announcement.id, user_id=user_id))
return True
# ---- background loop -----------------------------------------------------------------
_task: asyncio.Task | None = None
async def _loop() -> None:
from backend.app.core.database import async_session
await asyncio.sleep(FIRST_FETCH_DELAY_SECONDS)
while True:
try:
async with async_session() as db:
await refresh(db)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Announcements refresh failed")
await asyncio.sleep(FETCH_INTERVAL_SECONDS + random.uniform(0, FETCH_JITTER_SECONDS))
def start() -> None:
global _task
if _task is None:
_task = asyncio.create_task(_loop())
def stop() -> None:
global _task
if _task is not None:
_task.cancel()
_task = None