From 5eb37cd41c72c220427c3fe7d2916846a3d1572f Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 1 Oct 2026 10:45:06 +0200 Subject: [PATCH] Show announcements from the Bambuddy maintainers Fetch a signed feed.json from the public bambuddy-notifications repo on GitHub at startup and every 6 hours. Nothing about the install is sent; targeting (version, beta channel, install type) is decided locally. - Ed25519 against a key built into the app; an older serial is refused so a withdrawn message can't come back. The feed replaces the stored list, and a failed or rejected fetch keeps the last good one. - Sidebar entry above System with an unread count, a slide-over list, and a banner for unread important/critical messages. Read state per user. - Admins by default; Settings > General > Updates can show them to all users or switch them off, which also stops the fetch. - Plain text only; links to github.com and bambuddy.cool only. --- CHANGELOG.md | 1 + backend/app/api/routes/announcements.py | 60 +++ backend/app/api/routes/settings.py | 2 + backend/app/core/database.py | 1 + backend/app/main.py | 10 + backend/app/models/__init__.py | 3 + backend/app/models/announcement.py | 51 ++ backend/app/schemas/settings.py | 8 + backend/app/services/announcements.py | 457 ++++++++++++++++++ .../integration/test_announcements_api.py | 134 +++++ .../tests/unit/test_announcements_service.py | 307 ++++++++++++ .../components/Announcements.test.tsx | 159 ++++++ .../__tests__/hooks/useAnnouncements.test.ts | 45 ++ frontend/src/__tests__/mocks/handlers.ts | 5 + frontend/src/api/client.ts | 29 ++ .../src/components/AnnouncementBanner.tsx | 63 +++ .../src/components/AnnouncementsPanel.tsx | 129 +++++ frontend/src/components/Layout.tsx | 60 ++- frontend/src/hooks/useAnnouncements.ts | 85 ++++ frontend/src/i18n/locales/de.ts | 20 + frontend/src/i18n/locales/en.ts | 20 + frontend/src/i18n/locales/es.ts | 20 + frontend/src/i18n/locales/fr.ts | 20 + frontend/src/i18n/locales/it.ts | 20 + frontend/src/i18n/locales/ja.ts | 20 + frontend/src/i18n/locales/ko.ts | 20 + frontend/src/i18n/locales/nl.ts | 20 + frontend/src/i18n/locales/pt-BR.ts | 20 + frontend/src/i18n/locales/ru.ts | 20 + frontend/src/i18n/locales/sv.ts | 20 + frontend/src/i18n/locales/tr.ts | 20 + frontend/src/i18n/locales/uk.ts | 20 + frontend/src/i18n/locales/zh-CN.ts | 20 + frontend/src/i18n/locales/zh-TW.ts | 20 + frontend/src/pages/SettingsPage.tsx | 53 ++ 35 files changed, 1961 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/routes/announcements.py create mode 100644 backend/app/models/announcement.py create mode 100644 backend/app/services/announcements.py create mode 100644 backend/tests/integration/test_announcements_api.py create mode 100644 backend/tests/unit/test_announcements_service.py create mode 100644 frontend/src/__tests__/components/Announcements.test.tsx create mode 100644 frontend/src/__tests__/hooks/useAnnouncements.test.ts create mode 100644 frontend/src/components/AnnouncementBanner.tsx create mode 100644 frontend/src/components/AnnouncementsPanel.tsx create mode 100644 frontend/src/hooks/useAnnouncements.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 03723c27e..ce27a73f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### Added +- **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. While there is one, an **Announcements** entry sits at the bottom of the sidebar above the System icon, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched. - **Camera snapshots reach more notifications and more providers, with an Attach Photo switch per provider (#3089, requested and contributed by @bbbenji in #3199)** — Plate Not Empty now carries a photo of the plate, taken before the chamber light is switched back off, and AI Failure Detection carries the frame the model flagged. Home Assistant (with a notify service set), Bark and Slack-format webhooks get photos too: they fetch the image themselves, so Bambuddy saves it under a random name and sends a link that opens only that one photo and stops working after 3 days. This needs **External URL** set in **Settings → Network**. Each provider has an **Attach Photo** toggle, on by default so nothing changes for existing setups, and **Test** sends a sample image when it is on. The print emails sent to the user who queued a job can show the finish photo inline when the template contains `{finish_photo_url}`. The template editor shows which events can carry a photo, and lists the variables for AI Failure Detection, Plate Not Empty, Plate Clear Required and First Layer Complete. The chamber light switched on for the plate check is now always switched back off, even when the check fails. - **Combine several STLs, or several copies of one, onto one plate (#2999, requested by @Markus98, contributed by @adman234 in #3162)** — The slicer sidecar slices one model at a time, so putting separate STLs on one plate needed a desktop slicer to build the 3MF first. Select one or more STLs in the File Manager and click **Combine to 3MF**, set how many copies of each you want, and Bambuddy saves a new 3MF with every object on one plate, with a preview image. Tick **Open the slicer when done** to go straight to the Slice dialog with auto-arrange already on. The source STLs are left untouched. A plate holds at most 100 objects, and the selected STLs can be at most 300 MB and 5 million triangles in total; each STL is stored once however many copies you place. - **The API-key printer status carries layers, the job id, HMS faults and the serial (#2919, requested by @simplytoast1)** — `GET /api/v1/webhook/printer/{id}/status`, the compact status route polled by add-ons such as notify-bambuddy for iOS Live Activities, returned only state, progress and time remaining. It now also returns `layer_num` and `total_layers`, the printer's `subtask_id` for the running job (a new value means a new print, even between two polls; `null` when Bambu gives the job no id), the live `hms_errors` in the same shape as the printer status route (so a filament runout reads differently from a manual pause), and the printer's `serial_number`. `remaining_time` stays in minutes for existing clients; the new `remaining_seconds` gives the same estimate in seconds, the unit notifications use. Nothing existing changed, and the route still needs **Read Status** and honours a key's printer limits. diff --git a/backend/app/api/routes/announcements.py b/backend/app/api/routes/announcements.py new file mode 100644 index 000000000..cd770ac3f --- /dev/null +++ b/backend/app/api/routes/announcements.py @@ -0,0 +1,60 @@ +"""Announcements from the Bambuddy maintainers (services/announcements.py). + +Shown to administrators, and to every signed-in user when the +``announcements_all_users`` setting is on. With authentication off whoever opens +Bambuddy runs it, so they see them. Anyone else gets an empty list rather than a +403: the sidebar entry and the banner are hidden on "nothing to show", which is +the same answer for "nothing published" and "not for you". +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.core.auth import is_auth_enabled, require_auth_if_enabled +from backend.app.core.database import get_db +from backend.app.models.settings import Settings +from backend.app.models.user import User +from backend.app.services import announcements as service + +router = APIRouter(prefix="/announcements", tags=["announcements"]) + + +async def _may_see(db: AsyncSession, user: User | None) -> bool: + if not await service.is_enabled(db): + return False + if not await is_auth_enabled(db): + return True + # Authenticated by API key: a script, not a person with an inbox. + if user is None: + return False + if user.is_admin: + return True + all_users = ( + await db.execute(select(Settings.value).where(Settings.key == service.ALL_USERS_KEY)) + ).scalar_one_or_none() + return (all_users or "").lower() == "true" + + +@router.get("") +async def list_announcements( + db: AsyncSession = Depends(get_db), + current_user: User | None = Depends(require_auth_if_enabled), +) -> list[dict]: + """Live announcements for this user, newest first, with their read state.""" + if not await _may_see(db, current_user): + return [] + return await service.list_for(db, current_user.id if current_user else None) + + +@router.post("/{public_id}/read", status_code=status.HTTP_204_NO_CONTENT) +async def mark_announcement_read( + public_id: str, + db: AsyncSession = Depends(get_db), + current_user: User | None = Depends(require_auth_if_enabled), +) -> None: + if not await _may_see(db, current_user): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Announcement not found") + if not await service.mark_read(db, public_id, current_user.id if current_user else None): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Announcement not found") + await db.commit() diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 3243e9d8c..7fd2c0a93 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -186,6 +186,8 @@ _BOOL_SETTING_KEYS = frozenset( "check_updates", "check_printer_firmware", "include_beta_updates", + "announcements_enabled", + "announcements_all_users", "virtual_printer_enabled", "ftp_retry_enabled", "mqtt_enabled", diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 7e2c49e72..02de45019 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -288,6 +288,7 @@ async def init_db(): active_print_spoolman, ams_history, ams_label, + announcement, api_key, archive, auth_ephemeral, diff --git a/backend/app/main.py b/backend/app/main.py index 027c61c42..ac1d900b4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,6 +20,7 @@ from sqlalchemy import delete, or_, select, text from backend.app.api.routes import ( ams_history, + announcements, api_keys, archive_purge, archives, @@ -9978,6 +9979,11 @@ async def lifespan(app: FastAPI): # L-2: Start periodic auth cleanup (stale TOTP + expired revoked JTIs) start_auth_cleanup() + # Maintainer announcements: a signed feed fetched from GitHub every few hours. + from backend.app.services import announcements as announcements_service + + announcements_service.start() + from backend.app.services.printer_media import start_printer_download_cleanup start_printer_download_cleanup() @@ -10032,6 +10038,9 @@ async def lifespan(app: FastAPI): logging.warning("Failed to shut down camera broadcasters: %s", e) stop_expected_prints_cleanup() stop_auth_cleanup() + from backend.app.services import announcements as announcements_service + + announcements_service.stop() from backend.app.services.printer_media import stop_printer_download_cleanup await stop_printer_download_cleanup() @@ -10586,6 +10595,7 @@ app.include_router(spoolman.router, prefix=app_settings.api_prefix) app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix) app.include_router(updates.router, prefix=app_settings.api_prefix) app.include_router(sponsor_prompt.router, prefix=app_settings.api_prefix) +app.include_router(announcements.router, prefix=app_settings.api_prefix) app.include_router(maintenance.router, prefix=app_settings.api_prefix) app.include_router(camera.router, prefix=app_settings.api_prefix) app.include_router(camwall.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 492033ca8..f3c8a8ff3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,6 @@ from backend.app.models.ams_history import AMSSensorHistory from backend.app.models.ams_label import AmsLabel +from backend.app.models.announcement import Announcement, AnnouncementRead from backend.app.models.api_key import APIKey from backend.app.models.archive import PrintArchive from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent @@ -97,6 +98,8 @@ __all__ = [ "ColorCatalogEntry", "SpoolBuddyDevice", "SponsorToastState", + "Announcement", + "AnnouncementRead", "UserEmailPreference", "UserOTPCode", "UserTOTP", diff --git a/backend/app/models/announcement.py b/backend/app/models/announcement.py new file mode 100644 index 000000000..9dac71c66 --- /dev/null +++ b/backend/app/models/announcement.py @@ -0,0 +1,51 @@ +"""Announcements from the Bambuddy maintainers, and who has read them. + +Fetched from a signed feed on GitHub (services/announcements.py). Only the ones +that target this install are stored, and the feed is authoritative: a message +withdrawn upstream is deleted here on the next fetch, together with its read +markers. + +``AnnouncementRead.user_id`` is nullable: with authentication off there are no +users, and the install's read state is a single NULL-keyed row per message. +""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.core.database import Base + + +class Announcement(Base): + __tablename__ = "announcements" + + id: Mapped[int] = mapped_column(primary_key=True) + # The feed's id for the message. Stable across edits upstream, so a corrected + # typo does not come back as unread. + public_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + level: Mapped[str] = mapped_column(String(16)) # info | important | critical + # JSON {"en": {"title", "body", "link_label"?}, ...}. Text for SQLite/Postgres + # uniformity; the service serialises with json.dumps. + texts: Mapped[str] = mapped_column(Text) + link_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + # Naive UTC, like every other timestamp in Bambuddy. + published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + +class AnnouncementRead(Base): + __tablename__ = "announcement_reads" + __table_args__ = (UniqueConstraint("announcement_id", "user_id", name="uq_announcement_reads_user"),) + + id: Mapped[int] = mapped_column(primary_key=True) + announcement_id: Mapped[int] = mapped_column( + Integer, ForeignKey("announcements.id", ondelete="CASCADE"), index=True + ) + user_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True + ) + read_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 27f1e2d52..ad1592c5c 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -96,6 +96,12 @@ class AppSettings(BaseModel): check_updates: bool = Field(default=True, description="Automatically check for updates on startup") check_printer_firmware: bool = Field(default=True, description="Check for printer firmware updates from Bambu Lab") include_beta_updates: bool = Field(default=False, description="Include beta/prerelease versions in update checks") + announcements_enabled: bool = Field( + default=True, description="Fetch announcements from the Bambuddy maintainers (a signed file on GitHub)" + ) + announcements_all_users: bool = Field( + default=False, description="Show announcements to every signed-in user, not only administrators" + ) # Language language: str = Field(default="en", description="UI language (en, de, fr, ja, it, pt-BR)") @@ -696,6 +702,8 @@ class AppSettingsUpdate(BaseModel): check_updates: bool | None = None check_printer_firmware: bool | None = None include_beta_updates: bool | None = None + announcements_enabled: bool | None = None + announcements_all_users: bool | None = None local_login_enabled: bool | None = None language: str | None = None notification_language: str | None = None diff --git a/backend/app/services/announcements.py b/backend/app/services/announcements.py new file mode 100644 index 000000000..461256f68 --- /dev/null +++ b/backend/app/services/announcements.py @@ -0,0 +1,457 @@ +"""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": "", "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 + return Entry( + public_id=public_id, + level=level, + texts=texts, + link_url=link, + published_at=_parse_time(raw.get("published_at")), + expires_at=_parse_time(raw.get("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 + 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) + return count + + +# ---- read state ---------------------------------------------------------------------- + + +def _visible_filter(now: datetime): + return (Announcement.expires_at.is_(None)) | (Announcement.expires_at > now) + + +async def list_for(db: AsyncSession, user_id: int | None) -> list[dict]: + """Live announcements, newest first, each with whether this user has read it.""" + now = datetime.now(timezone.utc).replace(tzinfo=None) + rows = ( + ( + await db.execute( + select(Announcement) + .where(_visible_filter(now)) + .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, + "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 diff --git a/backend/tests/integration/test_announcements_api.py b/backend/tests/integration/test_announcements_api.py new file mode 100644 index 000000000..eb2425cd3 --- /dev/null +++ b/backend/tests/integration/test_announcements_api.py @@ -0,0 +1,134 @@ +"""Who sees announcements, and per-user read state. + +Administrators see them; other signed-in users only when +``announcements_all_users`` is on. With authentication off, the person running +Bambuddy sees them. Anyone else gets an empty list, which hides the sidebar entry +and the banner the same way "nothing published" does. +""" + +import json +import secrets + +import pytest +from httpx import AsyncClient + +from backend.app.models.announcement import Announcement +from backend.app.models.settings import Settings + +URL = "/api/v1/announcements" +_FIXTURE_PW = "Aa1!" + secrets.token_urlsafe(12) # pragma: allowlist secret + + +async def _seed(db, *public_ids: str, level: str = "important") -> None: + for pid in public_ids: + db.add( + Announcement( + public_id=pid, + level=level, + texts=json.dumps({"en": {"title": f"T {pid}", "body": "B"}, "de": {"title": "D", "body": "B"}}), + ) + ) + await db.commit() + + +async def _setting(db, key: str, value: str) -> None: + db.add(Settings(key=key, value=value)) + await db.commit() + + +async def _admin(client: AsyncClient, username: str) -> dict: + await client.post( + "/api/v1/auth/setup", + json={"auth_enabled": True, "admin_username": username, "admin_password": _FIXTURE_PW}, + ) + login = await client.post("/api/v1/auth/login", json={"username": username, "password": _FIXTURE_PW}) + assert login.status_code == 200, login.text + return {"Authorization": f"Bearer {login.json()['access_token']}"} + + +async def _user(client: AsyncClient, admin: dict, username: str) -> dict: + grp = await client.post( + "/api/v1/groups/", headers=admin, json={"name": f"ann_{username}", "permissions": ["printers:read"]} + ) + assert grp.status_code == 201, grp.text + created = await client.post( + "/api/v1/users/", + headers=admin, + json={"username": username, "password": _FIXTURE_PW, "role": "user", "group_ids": [grp.json()["id"]]}, + ) + assert created.status_code == 201, created.text + login = await client.post("/api/v1/auth/login", json={"username": username, "password": _FIXTURE_PW}) + return {"Authorization": f"Bearer {login.json()['access_token']}"} + + +@pytest.mark.integration +class TestAuthOff: + @pytest.mark.asyncio + async def test_listed_with_every_language_and_unread(self, async_client: AsyncClient, db_session): + await _seed(db_session, "a1") + r = await async_client.get(URL) + assert r.status_code == 200 + [item] = r.json() + assert item["id"] == "a1" and item["level"] == "important" and item["read"] is False + assert set(item["texts"]) == {"en", "de"} + + @pytest.mark.asyncio + async def test_read_sticks(self, async_client: AsyncClient, db_session): + await _seed(db_session, "a1") + assert (await async_client.post(f"{URL}/a1/read")).status_code == 204 + assert (await async_client.get(URL)).json()[0]["read"] is True + + @pytest.mark.asyncio + async def test_unknown_id_is_404(self, async_client: AsyncClient): + assert (await async_client.post(f"{URL}/nope/read")).status_code == 404 + + @pytest.mark.asyncio + async def test_switched_off_shows_nothing(self, async_client: AsyncClient, db_session): + await _seed(db_session, "a1") + await _setting(db_session, "announcements_enabled", "false") + assert (await async_client.get(URL)).json() == [] + assert (await async_client.post(f"{URL}/a1/read")).status_code == 404 + + +@pytest.mark.integration +class TestAuthOn: + @pytest.mark.asyncio + async def test_signed_out_is_401(self, async_client: AsyncClient, db_session): + await _admin(async_client, "annadmin0") + assert (await async_client.get(URL)).status_code == 401 + + @pytest.mark.asyncio + async def test_admin_sees_them_a_user_does_not(self, async_client: AsyncClient, db_session): + await _seed(db_session, "a1") + admin = await _admin(async_client, "annadmin1") + user = await _user(async_client, admin, "annuser1") + assert [i["id"] for i in (await async_client.get(URL, headers=admin)).json()] == ["a1"] + assert (await async_client.get(URL, headers=user)).json() == [] + assert (await async_client.post(f"{URL}/a1/read", headers=user)).status_code == 404 + + @pytest.mark.asyncio + async def test_all_users_setting_shows_them_with_separate_read_state(self, async_client: AsyncClient, db_session): + await _seed(db_session, "a1") + admin = await _admin(async_client, "annadmin2") + user = await _user(async_client, admin, "annuser2") + await _setting(db_session, "announcements_all_users", "true") + + assert (await async_client.post(f"{URL}/a1/read", headers=user)).status_code == 204 + assert (await async_client.get(URL, headers=user)).json()[0]["read"] is True + assert (await async_client.get(URL, headers=admin)).json()[0]["read"] is False + + +@pytest.mark.integration +class TestSettings: + @pytest.mark.asyncio + async def test_both_switches_round_trip(self, async_client: AsyncClient): + defaults = (await async_client.get("/api/v1/settings/")).json() + assert defaults["announcements_enabled"] is True + assert defaults["announcements_all_users"] is False + r = await async_client.put( + "/api/v1/settings/", json={"announcements_enabled": False, "announcements_all_users": True} + ) + assert r.status_code == 200, r.text + after = (await async_client.get("/api/v1/settings/")).json() + assert after["announcements_enabled"] is False + assert after["announcements_all_users"] is True diff --git a/backend/tests/unit/test_announcements_service.py b/backend/tests/unit/test_announcements_service.py new file mode 100644 index 000000000..b469ec7d9 --- /dev/null +++ b/backend/tests/unit/test_announcements_service.py @@ -0,0 +1,307 @@ +"""Announcements: verifying the signed feed, and keeping only what applies here. + +The feed is signed by the maintainers' registrar. These tests sign their own +feeds with a throwaway key passed in as the trusted one, and check the rules an +install applies: a bad signature, an unknown key or an older serial changes +nothing; a message that does not target this install is not stored; a withdrawn +one disappears with its read markers. +""" + +import base64 +import hashlib +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from sqlalchemy import select + +from backend.app.models.announcement import Announcement, AnnouncementRead +from backend.app.models.settings import Settings +from backend.app.services import announcements as svc +from backend.app.services.announcements import FeedRejected, InstallFacts + +KEY = Ed25519PrivateKey.generate() +_PUB = KEY.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) +KEY_ID = hashlib.sha256(_PUB).hexdigest()[:16] +TRUSTED = {KEY_ID: base64.b64encode(_PUB).decode()} + +STABLE_DOCKER = InstallFacts(version="1.2.6", channel="stable", install_type="docker") + + +def entry(public_id="a1", level="info", target=None, **extra): + raw = { + "id": public_id, + "level": level, + "published_at": "2026-10-01T12:00:00Z", + "expires_at": None, + "texts": {"en": {"title": f"Title {public_id}", "body": "Body"}}, + "link_url": None, + "target": target or {"min_version": None, "max_version": None, "channels": [], "install_types": []}, + } + raw.update(extra) + return raw + + +def payload(*entries, serial=1): + return {"format": 1, "serial": serial, "published_at": "2026-10-01T12:00:00Z", "announcements": list(entries)} + + +def signed(p: dict, key: Ed25519PrivateKey = KEY, key_id: str = KEY_ID) -> bytes: + """The file exactly as the registrar writes it: pretty, payload as an object.""" + sig = key.sign(svc.canonical(p)) + envelope = {"format": 1, "key_id": key_id, "signature": base64.b64encode(sig).decode(), "payload": p} + return json.dumps(envelope, indent=2, ensure_ascii=False).encode() + + +class TestVerify: + def test_a_correctly_signed_feed_verifies(self): + p = payload(entry(texts={"en": {"title": "Grüße — 你好", "body": "x"}})) + assert svc.verify_feed(signed(p), TRUSTED) == p + + def test_tampered_text_is_refused(self): + content = signed(payload(entry())) + with pytest.raises(FeedRejected, match="signature"): + svc.verify_feed(content.replace(b"Title a1", b"Title b1"), TRUSTED) + + def test_another_key_is_refused(self): + stranger = Ed25519PrivateKey.generate() + with pytest.raises(FeedRejected, match="signature"): + svc.verify_feed(signed(payload(entry()), key=stranger), TRUSTED) + + def test_unknown_key_id_is_refused(self): + with pytest.raises(FeedRejected, match="unknown key"): + svc.verify_feed(signed(payload(entry()), key_id="0000000000000000"), TRUSTED) + + @pytest.mark.parametrize("content", [b"", b"not json", b"[]", b'{"format": 2}']) + def test_garbage_is_refused(self, content): + with pytest.raises(FeedRejected): + svc.verify_feed(content, TRUSTED) + + @pytest.mark.parametrize("serial", [None, 0, "3", True]) + def test_a_payload_without_a_usable_serial_is_refused(self, serial): + p = payload(entry()) + p["serial"] = serial + with pytest.raises(FeedRejected, match="serial"): + svc.verify_feed(signed(p), TRUSTED) + + def test_the_built_in_key_is_well_formed(self): + for key_id, public in svc.TRUSTED_KEYS.items(): + raw = base64.b64decode(public) + assert len(raw) == 32 + assert hashlib.sha256(raw).hexdigest()[:16] == key_id + + +class TestEntries: + @pytest.mark.parametrize("public_id", [None, "", "a b", "x" * 65, 7, "../x"]) + def test_unusable_ids_are_dropped(self, public_id): + assert svc.parse_entry(entry(public_id=public_id)) is None + + def test_no_english_text_is_dropped(self): + assert svc.parse_entry(entry(texts={"de": {"title": "Hallo", "body": "x"}})) is None + + def test_texts_are_cut_to_length_and_bad_languages_skipped(self): + e = svc.parse_entry( + entry( + texts={ + "en": {"title": "t" * 500, "body": "b" * 5000, "link_label": "l" * 100}, + "de": {"title": "", "body": "x"}, + "fr": "not a dict", + } + ) + ) + assert set(e.texts) == {"en"} + assert len(e.texts["en"]["title"]) == svc.MAX_TITLE + assert len(e.texts["en"]["body"]) == svc.MAX_BODY + assert len(e.texts["en"]["link_label"]) == svc.MAX_LINK_LABEL + + def test_unknown_level_is_info(self): + assert svc.parse_entry(entry(level="apocalyptic")).level == "info" + + @pytest.mark.parametrize( + "url", + [ + "http://bambuddy.cool/", + "https://evil.example/", + "https://bambuddy.cool.evil.example/", + "https://user@github.com/", + "https://github.com:8443/", + "javascript:alert(1)", + ], + ) + def test_links_off_the_allowlist_are_dropped_not_the_message(self, url): + e = svc.parse_entry(entry(link_url=url)) + assert e is not None and e.link_url is None + + def test_allowed_link_is_kept(self): + assert svc.parse_entry(entry(link_url="https://wiki.bambuddy.cool/x/")).link_url == ( + "https://wiki.bambuddy.cool/x/" + ) + + def test_times_are_naive_utc(self): + e = svc.parse_entry(entry(expires_at="2026-11-01T02:00:00+02:00")) + assert e.expires_at == datetime(2026, 11, 1, 0, 0) + + +class TestTargeting: + @pytest.mark.parametrize( + "version, low, high, shown", + [ + ("1.2.6", None, None, True), + ("1.2.6", "1.2.6", None, True), + ("1.2.6", "1.2.7", None, False), + ("1.2.6", None, "1.2.5", False), + ("1.2.6b1", "1.2.6", None, False), # a beta is older than its release + ("1.2.6b1", None, "1.2.6", True), + ("1.2.6b3", "1.2.6b2", "1.2.6b3", True), + ], + ) + def test_version_range(self, version, low, high, shown): + facts = InstallFacts(version=version, channel="stable", install_type="docker") + target = {"min_version": low, "max_version": high} + assert svc.targets({"target": target}, facts) is shown + + def test_channel(self): + beta_only = {"target": {"channels": ["beta"]}} + assert not svc.targets(beta_only, STABLE_DOCKER) + assert svc.targets(beta_only, InstallFacts("1.2.6", "beta", "docker")) + + def test_install_type(self): + windows_only = {"target": {"install_types": ["windows"]}} + assert not svc.targets(windows_only, STABLE_DOCKER) + assert svc.targets(windows_only, InstallFacts("1.2.6", "stable", "windows")) + + def test_a_malformed_target_shows_nothing(self): + assert not svc.targets({"target": "everyone"}, STABLE_DOCKER) + + +async def _stored(db) -> list[str]: + return sorted((await db.execute(select(Announcement.public_id))).scalars().all()) + + +async def _serial(db) -> str | None: + return (await db.execute(select(Settings.value).where(Settings.key == svc.SERIAL_KEY))).scalar_one_or_none() + + +class TestApply: + @pytest.mark.asyncio + async def test_stores_only_what_targets_this_install(self, db_session): + p = payload( + entry("everyone"), + entry("beta", target={"channels": ["beta"]}), + entry("windows", target={"install_types": ["windows"]}), + entry("broken", texts={}), + ) + assert await svc.apply_payload(db_session, p, STABLE_DOCKER) == 1 + await db_session.commit() + assert await _stored(db_session) == ["everyone"] + assert await _serial(db_session) == "1" + + @pytest.mark.asyncio + async def test_withdrawn_upstream_goes_with_its_read_markers(self, db_session): + await svc.apply_payload(db_session, payload(entry("keep"), entry("drop")), STABLE_DOCKER) + await db_session.commit() + assert await svc.mark_read(db_session, "drop", None) + await db_session.commit() + + await svc.apply_payload(db_session, payload(entry("keep"), serial=2), STABLE_DOCKER) + await db_session.commit() + assert await _stored(db_session) == ["keep"] + assert (await db_session.execute(select(AnnouncementRead))).scalars().all() == [] + + @pytest.mark.asyncio + async def test_an_edit_keeps_the_read_state(self, db_session): + await svc.apply_payload(db_session, payload(entry("a1")), STABLE_DOCKER) + await db_session.commit() + await svc.mark_read(db_session, "a1", None) + await db_session.commit() + edited = entry("a1", texts={"en": {"title": "Fixed typo", "body": "Body"}}) + await svc.apply_payload(db_session, payload(edited, serial=2), STABLE_DOCKER) + await db_session.commit() + [item] = await svc.list_for(db_session, None) + assert item["texts"]["en"]["title"] == "Fixed typo" + assert item["read"] is True + + @pytest.mark.asyncio + async def test_an_older_serial_is_refused_and_changes_nothing(self, db_session): + await svc.apply_payload(db_session, payload(entry("new"), serial=5), STABLE_DOCKER) + await db_session.commit() + with pytest.raises(FeedRejected, match="older"): + await svc.apply_payload(db_session, payload(entry("old"), serial=4), STABLE_DOCKER) + await db_session.rollback() + assert await _stored(db_session) == ["new"] + assert await _serial(db_session) == "5" + + @pytest.mark.asyncio + async def test_the_same_serial_again_is_fine(self, db_session): + await svc.apply_payload(db_session, payload(entry("a1"), serial=3), STABLE_DOCKER) + await svc.apply_payload(db_session, payload(entry("a1"), serial=3), STABLE_DOCKER) + await db_session.commit() + assert await _stored(db_session) == ["a1"] + + +class TestReadState: + @pytest.mark.asyncio + async def test_expired_ones_are_not_listed(self, db_session): + past = (datetime.now(timezone.utc) - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + future = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + p = payload(entry("gone", expires_at=past), entry("live", expires_at=future), entry("forever")) + await svc.apply_payload(db_session, p, STABLE_DOCKER) + await db_session.commit() + assert sorted(i["id"] for i in await svc.list_for(db_session, None)) == ["forever", "live"] + + @pytest.mark.asyncio + async def test_reading_twice_with_auth_off_records_once(self, db_session): + await svc.apply_payload(db_session, payload(entry("a1")), STABLE_DOCKER) + await db_session.commit() + assert await svc.mark_read(db_session, "a1", None) + await db_session.commit() + assert await svc.mark_read(db_session, "a1", None) + await db_session.commit() + assert len((await db_session.execute(select(AnnouncementRead))).scalars().all()) == 1 + + @pytest.mark.asyncio + async def test_unknown_id_is_not_marked(self, db_session): + assert not await svc.mark_read(db_session, "nope", None) + + +class TestRefresh: + @pytest.mark.asyncio + async def test_switched_off_fetches_nothing(self, db_session): + db_session.add(Settings(key=svc.ENABLED_KEY, value="false")) + await db_session.commit() + with patch.object(svc, "_download", AsyncMock()) as download: + assert await svc.refresh(db_session) is None + download.assert_not_called() + + @pytest.mark.asyncio + async def test_a_good_feed_is_stored(self, db_session): + with ( + patch.object(svc, "_download", AsyncMock(return_value=signed(payload(entry("a1"))))), + patch.object(svc, "TRUSTED_KEYS", TRUSTED), + patch.object(svc, "install_facts", AsyncMock(return_value=STABLE_DOCKER)), + ): + assert await svc.refresh(db_session) == 1 + assert await _stored(db_session) == ["a1"] + + @pytest.mark.asyncio + async def test_a_bad_feed_keeps_the_last_good_list(self, db_session): + with ( + patch.object(svc, "TRUSTED_KEYS", TRUSTED), + patch.object(svc, "install_facts", AsyncMock(return_value=STABLE_DOCKER)), + ): + with patch.object(svc, "_download", AsyncMock(return_value=signed(payload(entry("a1"))))): + await svc.refresh(db_session) + forged = signed(payload(serial=9), key=Ed25519PrivateKey.generate()) + with patch.object(svc, "_download", AsyncMock(return_value=forged)): + assert await svc.refresh(db_session) is None + assert await _stored(db_session) == ["a1"] + + @pytest.mark.asyncio + async def test_offline_is_quiet_and_harmless(self, db_session): + import httpx + + with patch.object(svc, "_download", AsyncMock(side_effect=httpx.ConnectError("offline"))): + assert await svc.refresh(db_session) is None diff --git a/frontend/src/__tests__/components/Announcements.test.tsx b/frontend/src/__tests__/components/Announcements.test.tsx new file mode 100644 index 000000000..bad969605 --- /dev/null +++ b/frontend/src/__tests__/components/Announcements.test.tsx @@ -0,0 +1,159 @@ +/** + * Announcements from the Bambuddy maintainers: the sidebar entry above System, + * the slide-over list, and the banner for unread important/critical messages. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor, fireEvent, within } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { render } from '../utils'; +import { server } from '../mocks/server'; +import { AnnouncementsPanel } from '../../components/AnnouncementsPanel'; +import { AnnouncementBanner } from '../../components/AnnouncementBanner'; +import { Layout } from '../../components/Layout'; +import type { Announcement } from '../../api/client'; + +function announcement(overrides: Partial = {}): Announcement { + return { + id: 'a1', + level: 'info', + texts: { en: { title: 'Hello installs', body: 'Line one\nLine two' } }, + link_url: null, + published_at: '2026-10-01T12:00:00Z', + expires_at: null, + read: false, + ...overrides, + }; +} + +describe('AnnouncementsPanel', () => { + it('marks what was unread as read on opening, and keeps a New chip on it', () => { + const markRead = vi.fn(); + render( + {}} + markRead={markRead} + announcements={[announcement(), announcement({ id: 'a2', read: true, texts: { en: { title: 'Old news', body: 'x' } } })]} + /> + ); + expect(markRead).toHaveBeenCalledTimes(1); + expect(markRead).toHaveBeenCalledWith('a1'); + const items = screen.getAllByRole('listitem'); + expect(within(items[0]).getByText('New')).toBeInTheDocument(); + expect(within(items[1]).queryByText('New')).not.toBeInTheDocument(); + }); + + it('renders the body as plain text, never as HTML', () => { + render( + {}} + markRead={() => {}} + announcements={[announcement({ texts: { en: { title: 'T', body: '' } } })]} + /> + ); + expect(screen.getByText('')).toBeInTheDocument(); + expect(document.querySelector('img[src="x"]')).toBeNull(); + }); + + it('links only to allowed hosts', () => { + render( + {}} + markRead={() => {}} + announcements={[ + announcement({ id: 'ok', link_url: 'https://wiki.bambuddy.cool/x/', texts: { en: { title: 'A', body: 'b', link_label: 'Details' } } }), + announcement({ id: 'bad', link_url: 'https://evil.example/x', texts: { en: { title: 'B', body: 'b', link_label: 'Phish' } } }), + ]} + /> + ); + const link = screen.getByRole('link', { name: /Details/ }); + expect(link).toHaveAttribute('href', 'https://wiki.bambuddy.cool/x/'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(screen.queryByRole('link', { name: /Phish/ })).not.toBeInTheDocument(); + }); + + it('closes on Escape', () => { + const onClose = vi.fn(); + render( {}} announcements={[announcement()]} />); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); +}); + +describe('AnnouncementBanner', () => { + it('shows the first item, offers the rest, and Got it marks it read', () => { + const markRead = vi.fn(); + const onOpen = vi.fn(); + render( + + ); + expect(screen.getByRole('alert')).toHaveTextContent('Update now'); + fireEvent.click(screen.getByRole('button', { name: 'Read more (+1)' })); + expect(onOpen).toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Got it' })); + expect(markRead).toHaveBeenCalledWith('crit'); + }); + + it('renders nothing without items', () => { + render( {}} markRead={() => {}} />); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); +}); + +describe('Layout with announcements', () => { + beforeEach(() => { + server.use( + http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: false, requires_setup: false })), + http.get('/api/v1/settings/ui-flags', () => + HttpResponse.json({ check_updates: false, billing_enabled: false, user_notifications_enabled: true, currency: 'EUR' }) + ) + ); + }); + + it('shows no entry and no banner when there is nothing', async () => { + render(); + await waitFor(() => expect(screen.getAllByTitle(/System/).length).toBeGreaterThan(0)); + expect(screen.queryByText('Announcements')).not.toBeInTheDocument(); + }); + + it('shows the entry with an unread count, the banner for important, and opens the list', async () => { + let reads: string[] = []; + server.use( + http.get('/api/v1/announcements', () => + HttpResponse.json([ + announcement({ id: 'imp', level: 'important', texts: { en: { title: 'Breaking change in 2.0', body: 'b' } } }), + announcement({ id: 'inf', level: 'info', texts: { en: { title: 'Testers wanted', body: 'b' } } }), + ]) + ), + http.post('/api/v1/announcements/:id/read', ({ params }) => { + reads = [...reads, String(params.id)]; + return new HttpResponse(null, { status: 204 }); + }) + ); + render(); + + const entry = await screen.findByRole('button', { name: /Announcements/ }); + expect(within(entry).getByText('2')).toBeInTheDocument(); + // Only the important one earns a banner. + expect(screen.getByRole('status')).toHaveTextContent('Breaking change in 2.0'); + expect(screen.queryByText('Testers wanted')).not.toBeInTheDocument(); + + fireEvent.click(entry); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Testers wanted')).toBeInTheDocument(); + await waitFor(() => expect(reads.sort()).toEqual(['imp', 'inf'])); + // Read now: the banner is gone. + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); + }); +}); diff --git a/frontend/src/__tests__/hooks/useAnnouncements.test.ts b/frontend/src/__tests__/hooks/useAnnouncements.test.ts new file mode 100644 index 000000000..99ed77c06 --- /dev/null +++ b/frontend/src/__tests__/hooks/useAnnouncements.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { announcementText, isAllowedAnnouncementLink } from '../../hooks/useAnnouncements'; +import type { Announcement } from '../../api/client'; + +const a = (texts: Announcement['texts']): Announcement => ({ + id: 'x', level: 'info', texts, link_url: null, published_at: null, expires_at: null, read: false, +}); + +describe('announcementText', () => { + const texts = { + en: { title: 'Hello', body: 'b' }, + de: { title: 'Hallo', body: 'b' }, + 'pt-BR': { title: 'Olá', body: 'b' }, + 'zh-CN': { title: '你好', body: 'b' }, + }; + + it.each([ + ['de', 'Hallo'], + ['pt-BR', 'Olá'], + ['pt-br', 'Olá'], + ['pt', 'Olá'], + ['zh-TW', '你好'], + ['fr', 'Hello'], + ['en-US', 'Hello'], + ])('%s -> %s', (lang, title) => { + expect(announcementText(a(texts), lang).title).toBe(title); + }); +}); + +describe('isAllowedAnnouncementLink', () => { + it.each([ + ['https://wiki.bambuddy.cool/x', true], + ['https://bambuddy.cool', true], + ['https://github.com/maziggy/bambuddy', true], + ['http://github.com/', false], + ['https://github.com.evil.example/', false], + ['https://user:pw@github.com/', false], + ['https://github.com:8443/', false], + ['javascript:alert(1)', false], + ['not a url', false], + [null, false], + ])('%s -> %s', (url, allowed) => { + expect(isAllowedAnnouncementLink(url)).toBe(allowed); + }); +}); diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 7ec238d14..565a0eb21 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -383,6 +383,11 @@ export const handlers = [ // Version / Health // ======================================================================== + // Announcements from the maintainers: none by default. + http.get('/api/v1/announcements', () => { + return HttpResponse.json([]); + }), + http.get('/api/v1/version', () => { return HttpResponse.json({ version: '0.1.5', diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 144bd8549..1494222c7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1383,6 +1383,9 @@ export interface AppSettings { check_updates: boolean; check_printer_firmware: boolean; include_beta_updates: boolean; + // Announcements from the Bambuddy maintainers (a signed file on GitHub). + announcements_enabled?: boolean; + announcements_all_users?: boolean; // #1589: false hides the local username/password form on the login page; // BAMBUDDY_LOCAL_LOGIN=true on the server flips the reported value back to // true so the env-var recovery path is visible to the SPA. @@ -9196,6 +9199,32 @@ export const bugReportApi = { }), }; +export type AnnouncementLevel = 'info' | 'important' | 'critical'; + +export interface AnnouncementText { + title: string; + body: string; + link_label?: string; +} + +// One message from the Bambuddy maintainers. `texts` holds every language the +// message was written in; English is always there. +export interface Announcement { + id: string; + level: AnnouncementLevel; + texts: Record; + link_url: string | null; + published_at: string | null; + expires_at: string | null; + read: boolean; +} + +export const announcementsApi = { + list: () => request('/announcements'), + markRead: (id: string) => + request(`/announcements/${encodeURIComponent(id)}/read`, { method: 'POST' }), +}; + export interface SponsorPromptCheckResponse { show: boolean; milestone?: string; diff --git a/frontend/src/components/AnnouncementBanner.tsx b/frontend/src/components/AnnouncementBanner.tsx new file mode 100644 index 000000000..cb11ed82c --- /dev/null +++ b/frontend/src/components/AnnouncementBanner.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, Megaphone } from 'lucide-react'; +import type { Announcement } from '../api/client'; +import { announcementText } from '../hooks/useAnnouncements'; + +interface AnnouncementBannerProps { + // Unread important/critical announcements, most severe first. + items: Announcement[]; + onOpen: () => void; + markRead: (id: string) => void; +} + +/** + * The strip above the page for an unread important or critical announcement. + * + * One at a time, the most severe; "+N more" opens the panel. "Got it" marks it + * read on the server, so it stays gone on every browser this user signs in on. + * Info-level messages never get a banner -- only the sidebar dot. + */ +export function AnnouncementBanner({ items, onOpen, markRead }: AnnouncementBannerProps) { + const { t, i18n } = useTranslation(); + const first = items[0]; + if (!first) return null; + const text = announcementText(first, i18n.language); + const critical = first.level === 'critical'; + const Icon = critical ? AlertTriangle : Megaphone; + + return ( +
+ +
+ + {text.title} + + +
+ +
+ ); +} diff --git a/frontend/src/components/AnnouncementsPanel.tsx b/frontend/src/components/AnnouncementsPanel.tsx new file mode 100644 index 000000000..ec6032c6e --- /dev/null +++ b/frontend/src/components/AnnouncementsPanel.tsx @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ExternalLink, Megaphone, X } from 'lucide-react'; +import type { Announcement, AnnouncementLevel } from '../api/client'; +import { announcementText, isAllowedAnnouncementLink } from '../hooks/useAnnouncements'; +import { formatDateOnly } from '../utils/date'; + +const LEVEL_CHIP: Record = { + info: 'bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-300', + important: 'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300', + critical: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-300', +}; + +interface AnnouncementsPanelProps { + open: boolean; + onClose: () => void; + announcements: Announcement[]; + markRead: (id: string) => void; +} + +/** + * Slide-over list of announcements from the Bambuddy maintainers. + * + * Opening it marks everything in it read -- the dot and the banner go -- but the + * ones that were unread keep a "New" chip until it closes, so the reader can + * still tell what they came for. + */ +export function AnnouncementsPanel({ open, onClose, announcements, markRead }: AnnouncementsPanelProps) { + const { t, i18n } = useTranslation(); + const [newIds, setNewIds] = useState>(() => new Set()); + const closeRef = useRef(null); + // The list at the moment of opening; later refetches don't re-mark anything. + const latest = useRef({ announcements, markRead }); + latest.current = { announcements, markRead }; + + useEffect(() => { + if (!open) return; + const unreadIds = latest.current.announcements.filter((a) => !a.read).map((a) => a.id); + setNewIds(new Set(unreadIds)); + unreadIds.forEach((id) => latest.current.markRead(id)); + closeRef.current?.focus(); + }, [open]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( + <> +