Skip to content

Commit 27aa882

Browse files
authored
feat(dashboard): admin-only project announcements feed (#1393)
* feat(dashboard): admins-only project announcements feed Anonymous, opt-out-by-default daily fetch of project news + security advisories from a JSON file the maintainers publish at https://sbpp.github.io/announcements.json. The dashboard renders the freshest non-expired entry as a slim disclosure strip between the stat cards and the recent-activity panels — visible only to logged-in admins. The shape mirrors the existing telemetry tick (`Sbpp\Telemetry\Telemetry`) and update-check cache (`system.check_version`'s `_api_system_release_*` helpers) so the lifecycle, cache contract, and never-fail-the-request guarantees are familiar. Architecture ------------ Source of truth lives in this repo at `docs/public/announcements.json` — Astro publishes everything under `docs/public/` as a static asset at the docs site root (proven by `favicon.svg`). Maintainers ship announcements via PRs against this file; the existing `docs-deploy-trigger.yml` workflow lands the updated file at `https://sbpp.github.io/announcements.json` within minutes of merge to `main`. The starter file ships as `[]` (empty array) so the deploy chain validates end-to-end before any real content goes out. `web/includes/Announce/AnnouncementFetcher.php` (`Sbpp\Announce\AnnouncementFetcher`): - `latest()` reads the cache only — never blocks on the network. A cold-cache install renders no banner; the next render after the shutdown hook lands the cache shows it. This is the documented "first render after install renders no banner" behaviour. - `tickIfDue()` is registered as a `register_shutdown_function` at the tail of `web/init.php`, gated on a non-empty `SB_ANNOUNCEMENTS_URL` constant. The `if (!defined(...))` guard lets `config.php` win — `define('SB_ANNOUNCEMENTS_URL', '')` is the documented air-gap escape hatch (covered in `docs/src/content/docs/configuring/announcements.mdx`). - 24h TTL gate inside `tickIfDue()` keeps the actual outbound call to at most one per install per day regardless of request volume. On FPM, `fastcgi_finish_request()` flushes the response BEFORE the upstream call so the user's TCP socket closes first; non-FPM SAPIs fall back to `ob_end_flush + flush`. The outer `try { run(); } catch (\Throwable) {}` wrapper guarantees a page render or JSON API call NEVER fails because the upstream is flapping. - Cache shape mirrors `system.check_version`'s `_api_system_release_save_cache`: atomic tempfile + `rename()` write under `SB_CACHE/announcements.json`, persisted as the raw upstream body verbatim. Parsing happens at read time so the on-disk file is byte-identical to what the upstream served — easier to triage a "wrong content rendered" report. - Stale-while-error: a failed upstream call leaves the previous cache untouched; the operator keeps seeing the previous announcement until a successful fetch overwrites it. - Wire layer mirrors `system.check_version`'s helper: 5s timeout, 256 KiB hard cap (enforced by the stream wrapper's `length` parameter AND re-asserted post-read so a future swap of the read layer doesn't bypass it), `User-Agent: SourceBans++/<ver> (announcements)`, no query parameters, no cookies, no tracking pixels. - Test override: `_setHttpFetcherForTests(?callable)` mirrors `Sbpp\Servers\SourceQueryCache::setProbeOverrideForTesting`. Production never sets it. Surface gating -------------- The page handler in `web/pages/page.home.php` short-circuits to `null` for anonymous + non-admin viewers (`$userbank->is_admin() ? AnnouncementFetcher::latest() : null`); the `HomeDashboardView` carries `?array $announcement`; the template (`page_dashboard.tpl`) gates the entire `<aside>` block on truthy `$announcement` so the strip never paints for visitors who can't act on the content. The DTO is converted to a Smarty array so the template reads `{$announcement.title}` etc. with global auto-escape; `body_html` is the only field with `{nofilter}` because it's already `Sbpp\Markup\IntroRenderer` output (the only "this is already safe HTML" exit point the panel supports — see "`nofilter` discipline" + "Admin-authored display text" in AGENTS.md). The strip's chrome is a slim `<details>` disclosure styled to match the existing `.empty-state` / card vocabulary: subtle muted background, single-line summary with megaphone icon + title + right-aligned date, body slot for IntroRenderer-rendered HTML, optional "Read more" external link with the textbook `target="_blank" rel="noopener noreferrer"` reverse-tabnabbing guard. Dark mode flips automatically via the `var(--*)` tokens. The chevron rotation honours the global `prefers-reduced-motion: reduce` reset; spinner / shimmer-style per-rule overrides aren't needed here because the disclosure is *motion-of-state*, not essential feedback. Trust + safety -------------- - Markdown bodies (`body_md`) go through `Sbpp\Markup\IntroRenderer::renderIntroText()` which wraps league/commonmark with `html_input: 'escape'` + `allow_unsafe_links: false`. Inline HTML is rendered as escaped text; `javascript:` / `data:` / `vbscript:` URLs are stripped. Same renderer the dashboard intro uses — reaching for any other Markdown library here would re-open the #1113-class stored-XSS vector. - The top-level `url` field is rendered as a literal `<a href>` so the parser additionally rejects non-`http(s)://` schemes at validation time (defence-in-depth). - Outbound URL is hardcoded `https://` in the default define; operators with strict egress controls disable the fetch entirely with `define('SB_ANNOUNCEMENTS_URL', '')` in `config.php`. There is no in-panel toggle by design — the feed is intentionally low-frequency + audit-friendly, so the only sensible "off" position is the documented config.php-side escape hatch. - Operators audit content via the public diff history of `docs/public/announcements.json` — every announcement that has ever been pushed lands there before it lands on their dashboard. The single-file shape is the load-bearing audit property. - No DB writes, no `:prefix_settings` rows, no migration script. Cache file lives under `SB_CACHE`. - The parser drops malformed entries (missing / empty / overlong `id`, missing / empty `title`, non-string body_md, non-http url), expired entries (`expires_at` past), and duplicates (first-occurrence-wins on `id`). Sorts newest-first by `published_at`; entries without it sort below dated entries so a maintainer who forgets the field doesn't accidentally pin their entry to the top. Tests ----- - `web/tests/integration/AnnouncementFetcherTest.php` — 18 cases covering cache shape (atomic write, no tempfile leak), TTL gate (cold / fresh / stale), stale-while-error, body-cap enforcement (oversized rejected), malformed JSON, missing required fields, expired-entry filter, newest-by-published_at sort, undated entries sort last, duplicate-id de-dup, non-http URL rejection, IntroRenderer integration (literal `<script>` in body_md is escaped), verbatim-cache persistence, and ISO + integer timestamp parsing. - `web/tests/integration/HomeDashboardAnnouncementTest.php` — 4 cases on the page handler's surface gate. Process-isolated render with a stub Smarty (mirrors `Php82DeprecationsTest`'s shape) so the `HomeDashboardView`'s `announcement` property gets captured without rendering the actual `.tpl`. Three tests cover the gate (admin + populated → array, anonymous + populated → null, admin + cold → null); the fourth pins the View DTO contract via `ReflectionClass`. - `web/tests/e2e/specs/flows/dashboard-announcement.spec.ts` — end-to-end coverage. Seeds the cache via a PHP shim (`web/tests/e2e/scripts/seed-announcements-e2e.php` + `seedAnnouncementsE2e` / `clearAnnouncementsCacheE2e` in `fixtures/db.ts`) so the spec is deterministic without making the real https://sbpp.github.io fetch. Asserts the strip mounts when populated, the disclosure expands, the body paints the IntroRenderer-rendered HTML, the external link carries `rel="noopener noreferrer"`, and axe-core passes. An anonymous-storage-state `describe` block asserts the strip never paints for logged-out visitors. Docs ---- - `docs/src/content/docs/configuring/announcements.mdx` — new operator-facing doc explaining the lifecycle, what gets fetched, what isn't sent (no cookies, no query params), the rationale for opt-out by default, and the air-gap escape hatch. Filed under a new "Configuring" sidebar group in `docs/astro.config.mjs`. - `AGENTS.md` — new "Project announcements feed" Conventions block (full lifecycle / cache shape / wire layer / Markdown rendering / test-override contract), two new "Where to find what" rows (publish/amend an announcement; build/extend the feed wiring), one new "Keep the docs in sync" row, and four new Anti-patterns (in-panel toggle; reaching for `league/commonmark` directly; editing announcements outside the source-of-truth file; removing the eager `register_shutdown_function` call). - `ARCHITECTURE.md` — new directory-layout entry under "Web panel → Directory layout" for `web/includes/Announce/`. Quality gates ------------- PHPStan, PHPUnit, ts-check, api-contract all green locally. PHPStan needed one source-side fix during the run: an unused `CONNECT_TIMEOUT_SECONDS` constant flagged by the dba plugin's `classConstant.unused` rule. The constant was reserved for a future cURL-based reshape (`CURLOPT_CONNECTTIMEOUT` vs `CURLOPT_TIMEOUT`); since the current `file_get_contents` + stream-context implementation only honours a single timeout, the constant came out and the docblock on `TOTAL_TIMEOUT_SECONDS` documents the future split. No baseline entries added. * fix(announcements): defence-in-depth scheme guard + bounded cache load Two adversarial-review hardenings on the daily project-announcements fetcher. Both close gaps where a misconfigured config.php (or a hostile actor with write access to it) could turn the cache path into something it shouldn't be. Scheme guard in `resolveUpstreamUrl` ------------------------------------ `SB_ANNOUNCEMENTS_URL` is operator-overridable via `config.php`. The pre-fix resolver passed the URL through to `file_get_contents` verbatim, which honours every stream wrapper PHP ships (`file://`, `php://`, `phar://`, `data://`, `ftp://`, `gopher://`, …). A typo, an SSRF gadget, or a compromised config could land: - `file:///etc/passwd` → arbitrary local file read into the cache, then re-rendered to admins on the dashboard. - `php://filter/read=convert.base64-encode/resource=…` → arbitrary file disclosure dressed as base64 payload. - `phar://` → unmarshalling-side attack on PHP < 8.0 paths (we're PHP 8.5 so this is moot, but cheap belt-and-braces). `resolveUpstreamUrl()` now `preg_match('~^https?://~i', …)`s the configured value and short-circuits anything else to the empty- string air-gap branch. The constant itself is still operator-overridable; the gap is closed at the resolver, which is the single funnel every code path goes through. Side-effect of the rewrite: pulled the value through `mixed` (via `/** @var mixed */ + is_string`) so PHPStan stops narrowing to the init.php compile-time literal. With the literal narrowing, `$url === ''` was flagged as `identical.alwaysFalse`; with `mixed`, both the empty-string and the non-empty arms stay reachable from the analyser's perspective (which is the actual contract — operators do redefine the constant in `config.php`). Bounded cache load in `loadCachedEntries` ----------------------------------------- Pre-fix the cache reader called `file_get_contents($file)` with no `maxlen`, so a hand-edited / hostile / accidentally-bloated cache file (`SB_CACHE/announcements.json`) was loaded fully into memory BEFORE the body-size assertion ran. A 1 GiB JSON blob would OOM the worker; the asserter would never fire. The fetcher's wire side already bounded reads at `MAX_BODY_BYTES + 1`; the cache reader now does the same so the parse boundary is identical regardless of whether bytes got onto disk through `tickIfDue()` or some other path. The post-read size check stays in place as defence-in-depth — anything strictly larger than the cap means the source served > 256 KiB; reject silently and let the next tick overwrite with a fresh upstream body. Test override hook ------------------ The constant is write-once at runtime, so the existing `testEmptyAirGapUrlShortCircuitsTheFetcher` test was a no-op stub (`assertSame('', '')`) — it couldn't actually drive the air-gap branch. Added `_setUpstreamUrlForTests(?string $url)` mirroring the existing `_setHttpFetcherForTests` shape; tests can now exercise the empty-URL branch AND the new scheme-guard branch deterministically (see the paired test commit on top of this one). AGENTS.md --------- - Wire layer: corrected the timeout description from "5s timeout, 3s connect timeout" to "5s combined connect+read timeout" — PHP's stream wrapper exposes a single `timeout` knob covering both legs (the original copy was a copy-paste from the telemetry block which uses cURL with split connect / total). - Documented the new scheme guard alongside the air-gap hatch in the "Wire layer" + "Where to find what" rows. - Documented the bounded cache load in the "Where to find what" row's cache-shape blurb so future readers know why the read is capped at MAX_BODY_BYTES + 1 in two places (fetch and load). * fix(announcements): serialize e2e spec to avoid cache-file race Every test in `dashboard-announcement.spec.ts` mutates the SAME `SB_CACHE/announcements.json` file: each `beforeEach` seeds the cache via the shell-out helper, the test renders the dashboard, the `afterEach` clears the file. The dev container has one panel root and one cache directory — the cache file is a process-global singleton from the panel's perspective. The suite default is `fullyParallel: true` plus `workers: undefined`, so non-CI runs spawn multiple workers and sibling tests in this file race against each other: - Worker A's `renders the strip` seeds the cache, navigates, expects the strip to be visible. - Worker B's `strip is absent when the cache is cleared mid-test` seeds, clears, expects no strip. If B's `clearAnnouncementsCacheE2e()` lands between A's seed and A's first paint, A renders an empty strip and the test fails — exactly the symptom seen in adversarial review (intermittent fail locally, passes in isolation). File-scope `test.describe.configure({ mode: 'serial' })` pins all tests in this spec to a single worker so the cache file only ever has one owner at a time. Other specs continue to run in parallel — only this file's intra-file ordering is constrained. Same shape `_screenshots.spec.ts` uses for its per-route DB seed/restore loop. CI continues to enforce `workers: 1` globally (the AGENTS.md "Playwright E2E specifics" note documents why), so this only matters for local runs — but the gate is `failOnFlakyTests: true`, which would have surfaced this on the next CI flake. * test(announcements): cover air-gap and scheme-guard branches Replaces the placeholder `testEmptyAirGapUrlShortCircuitsTheFetcher` test (a `assertSame('', '')` no-op with a docstring explaining why it can't actually test anything) with real coverage of every `resolveUpstreamUrl()` arm. Pairs with the `_setUpstreamUrlForTests` override added in the previous commit. New tests: - `testEmptyAirGapUrlShortCircuitsTheFetcher` — drives the empty- URL branch via the override. The HTTP-fetcher override is a call-counter that fails the test if invoked, AND the cache file is asserted to not exist after the tick. The pre-fix stub asserted neither. - `testNonHttpUrlSchemeShortCircuitsTheFetcher` — six-row data provider covering the stream wrappers `file_get_contents` honours by default: `file://`, `php://`, `phar://`, `data://`, `ftp://`, `gopher://`. Each must short-circuit to the air-gap branch (no fetcher invocation, no cache write). - `testHttpsUrlIsAccepted` / `testHttpUrlIsAccepted` — positive arm. A self-hosted mirror at `https://mirror.example.com/…` flows through to the fetcher unchanged; same for plain `http://`. Pinning both shapes so a future tightening of the regex (e.g. `https://`-only) doesn't silently break self-hosters on intranet mirrors without TLS. Also: switched the data-provider declaration from the legacy `@dataProvider` annotation to the PHPUnit 13 `#[DataProvider]` attribute. The annotation form still parses but PHPUnit 13's attribute discovery is the supported shape going forward (and the docblock form silently no-ops on `--strict-coverage` etc.). Updated setUp/tearDown to clear the URL override too, mirroring the existing httpFetcher reset pattern. 26 tests / 57 assertions, all green. * docs(updating): mention always-on announcements feed in the v1.8→2.0 page The 1.8 → 2.0 upgrade page lists the three things to know before running the panel updater (PHP floor, theme reset, anonymous telemetry). The announcements feed is a new always-on outbound channel that ships in the same release; upgraders deserve to find it on the same page they're already reading. Adds: - A fourth bullet to the "things to know" list at the top. - A new `## Project announcements feed` section after the existing `## Anonymous telemetry` section, structured the same way: what's sent (User-Agent only, no instance ID, no cookies, no Referer, no query string), what's never sent (audit-trail anchored to the file in this repo), how to opt out (`define('SB_ANNOUNCEMENTS_URL', '')` in `config.php`), and performance impact (zero on PHP-FPM via `register_shutdown_function`, same shape as telemetry). The opt-out instructions link to the existing `docs/configuring/announcements/` page (added in the worker's commit) for the full reference; the upgrade page is intentionally a tighter "operator-needs-to-know" subset. Calls out that the two channels (telemetry, announcements) are intentionally separate: telemetry is write-only push, announcements is read-only pull. Neither can affect the other, and opting out of one leaves the other running. This is the question every privacy-conscious operator will ask after seeing two outbound channels in the same release; the docs answer it once, in the place they're already reading.
1 parent 07d8936 commit 27aa882

18 files changed

Lines changed: 2391 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 194 additions & 0 deletions
Large diffs are not rendered by default.

ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ web/
9898
│ ├── Upload/ Sbpp\Upload\UploadHandler — shared file-upload handler (perm + CSRF + extension allowlist + filename sanitiser + popup chrome) for the demo / icon / mapimage popup pages (goals#5)
9999
│ ├── Mail/ Sbpp\Mail\{Mail,Mailer,EmailType} — Symfony Mailer wrapper + enum
100100
│ ├── Telemetry/ Sbpp\Telemetry\{Telemetry,Schema1} — anonymous opt-out daily ping (#1126); schema-1.lock.json is the vendored cross-repo contract
101+
│ ├── Announce/ Sbpp\Announce\{AnnouncementFetcher,Announcement} — anonymous opt-out daily fetch of project news / security advisories from https://sbpp.github.io/announcements.json (source of truth: docs/public/announcements.json); admin-only banner on the home dashboard
101102
│ ├── SteamID/ SteamID parsing / vanity-URL resolution
102103
│ ├── PHPStan/ Sbpp\PHPStan\* — custom PHPStan rules (Smarty + SQL prefix)
103104
│ ├── page-builder.php route() + build() (the page router; procedural)

docs/astro.config.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ export default defineConfig({
125125
{ label: 'Database setup', slug: 'setup/mariadb' },
126126
],
127127
},
128+
{
129+
label: 'Configuring',
130+
items: [
131+
{ label: 'Project announcements', slug: 'configuring/announcements' },
132+
],
133+
},
128134
{
129135
label: 'Updating',
130136
items: [

docs/public/announcements.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[]
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
---
2+
title: Project announcements
3+
description: How the dashboard's "Latest announcement" strip works, where the content comes from, and how to opt out.
4+
sidebar:
5+
order: 1
6+
label: Project announcements
7+
---
8+
9+
import { Aside, Code } from '@astrojs/starlight/components';
10+
11+
The home dashboard surfaces a slim "Latest announcement" strip
12+
between the stat cards and the recent-activity panels — visible only
13+
to logged-in administrators. Content is sourced from a JSON feed
14+
the SourceBans++ maintainers publish at:
15+
16+
<Code lang="text" code="https://sbpp.github.io/announcements.json" />
17+
18+
The panel fetches the feed once per install per day in the
19+
background, caches it on disk, and renders the freshest non-expired
20+
entry on every dashboard render thereafter. The fetch uses the same
21+
opt-out-by-default architecture as the panel's update check (a
22+
shutdown hook fires after the user's response is flushed; it never
23+
delays a request and never fails one if the upstream is unreachable).
24+
25+
## What gets fetched
26+
27+
The published feed is a public, anonymous-readable JSON file. You
28+
can open it in your browser and read it yourself — there's no
29+
hidden state. A typical entry looks like this:
30+
31+
```json
32+
[
33+
{
34+
"id": "2026-05-rc1",
35+
"title": "v2.0.0 RC1 is now available",
36+
"body_md": "Read the [release notes](https://github.com/sbpp/sourcebans-pp/releases/tag/v2.0.0-rc1).",
37+
"url": "https://github.com/sbpp/sourcebans-pp/releases/tag/v2.0.0-rc1",
38+
"published_at": "2026-05-15T00:00:00Z",
39+
"expires_at": "2026-08-15T00:00:00Z"
40+
}
41+
]
42+
```
43+
44+
Field-by-field:
45+
46+
- `id` — short stable identifier (≤64 chars). Required.
47+
- `title` — one-line headline. Required.
48+
- `body_md` — optional Markdown body. Rendered through CommonMark in
49+
safe mode (raw HTML is escaped, `javascript:` / `data:` URLs are
50+
stripped). Same renderer your dashboard intro uses.
51+
- `url` — optional "read more" link. Must be `http://` or `https://`.
52+
- `published_at` — optional ISO-8601 timestamp; sets the displayed
53+
date and the sort order.
54+
- `expires_at` — optional ISO-8601 timestamp. The panel stops
55+
rendering the entry after this time and silently drops it from
56+
the cache.
57+
58+
## What's NOT sent
59+
60+
The fetch is a plain HTTP `GET` with no query parameters, no
61+
cookies, no tracking pixels. The only outbound metadata is the
62+
`User-Agent` header, which carries your panel's version number
63+
(parity with the existing update-check call):
64+
65+
<Code lang="text" code="User-Agent: SourceBans++/<version> (announcements)" />
66+
67+
## Why this is opt-out by default
68+
69+
The strip is intentionally narrow — single banner, admin-only
70+
visibility, low-frequency content (security advisories,
71+
release announcements, occasional release-blocker heads-up). The
72+
maintainers want to be able to reach panel operators with one-click
73+
visibility for things like:
74+
75+
- A critical CVE is published — please update.
76+
- A backwards-incompatible upgrade just landed — read the upgrade
77+
notes before running the updater.
78+
- A new SourceMod / MariaDB version requirement is shipping.
79+
80+
Operators audit content via the **public diff history** of the
81+
[`docs/public/announcements.json`](https://github.com/sbpp/sourcebans-pp/blob/main/docs/public/announcements.json)
82+
file. Every change ships through a pull request with the same
83+
review process as the rest of the project. There's no separate
84+
"announcements server" or admin-only API endpoint that could be
85+
compromised independently — the upstream is just a static file in
86+
this git repo.
87+
88+
The cost of "always on" is one HTTPS GET per install per day to
89+
sbpp.github.io, capped at 256 KiB of response body, with a 5-second
90+
timeout. If the upstream is unreachable, the panel keeps showing
91+
the previously cached announcement until a successful fetch
92+
overwrites it.
93+
94+
## How to disable the fetch
95+
96+
If your panel runs in an air-gapped environment, or you don't want
97+
the outbound network call regardless, define
98+
`SB_ANNOUNCEMENTS_URL` to the empty string in your `config.php`:
99+
100+
```php
101+
// In web/config.php (or wherever your install keeps it)
102+
define('SB_ANNOUNCEMENTS_URL', '');
103+
```
104+
105+
When the constant is the empty string, the shutdown hook
106+
short-circuits before flushing the response — no network call ever
107+
fires, the cache file is never written, and the dashboard simply
108+
omits the strip. There is no in-panel toggle by design: the JSON
109+
feed is intentionally low-frequency and audit-friendly, so the only
110+
sensible "off" position is the air-gap escape hatch above.
111+
112+
<Aside type="note">
113+
The `define()` must run BEFORE `init.php`'s default define fires —
114+
`config.php` is loaded first, so this works as written. Don't put it
115+
in a hook that runs later.
116+
</Aside>
117+
118+
## How announcements are proposed
119+
120+
If you maintain a fork or care about a specific upcoming
121+
announcement, the workflow is the same as any other change to the
122+
docs site:
123+
124+
1. Open a PR against
125+
[`sbpp/sourcebans-pp`](https://github.com/sbpp/sourcebans-pp).
126+
2. Edit `docs/public/announcements.json` to prepend your entry
127+
(newest-first ordering — the panel sorts by `published_at` but
128+
the file convention is "newest at the top of the array" so
129+
reviewers see the most relevant change first).
130+
3. The maintainers will review and merge. The
131+
[docs deploy trigger workflow](https://github.com/sbpp/sourcebans-pp/blob/main/.github/workflows/docs-deploy-trigger.yml)
132+
ships the updated file to `https://sbpp.github.io/announcements.json`
133+
within a few minutes of merge.
134+
135+
The strict schema, audit history, and one-file source-of-truth are
136+
the load-bearing properties: any operator with internet access can
137+
inspect every announcement that has ever been pushed before it
138+
lands on their dashboard.

docs/src/content/docs/updating/1-8-to-2-0.mdx

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ changes between 1.8.x and 2.0.x and the prep work an admin should do
1313
(back up, upload, visit `/updater/`) is the same as any other version —
1414
see [Updating SourceBans++](/updating/) for that.
1515

16-
The three things to know about the v2.0 upgrade:
16+
The four things to know about the v2.0 upgrade:
1717

1818
1. The **PHP version floor moves up** to 8.5. Check your host first.
1919
2. The **active theme resets** to default. Custom themes need
2020
porting before re-enabling.
2121
3. **Anonymous telemetry ships on by default.** Opt-out is one click.
22+
4. A **daily project-announcements feed** also ships on by default
23+
for logged-in admins. Opt-out is a one-line `config.php` define.
2224

2325
<Aside type="caution" title="Back up your database first">
2426
The updater scripts are idempotent, but a half-completed upload is
@@ -177,3 +179,69 @@ N ms remains N ms regardless of telemetry. The cURL call has
177179
3-second connect / 5-second total timeouts and is silent on
178180
failure — telemetry can never hard-fail a panel page or a JSON
179181
API request.
182+
183+
## Project announcements feed
184+
185+
SourceBans++ 2.0.0 also adds a small **admin-only announcements
186+
strip** to the home dashboard. Once a day per install, the panel
187+
fetches `https://sbpp.github.io/announcements.json` (a static file
188+
the maintainers publish from this repo's `docs/public/` directory)
189+
and renders the freshest non-expired entry as a slim
190+
`<details>` strip between the dashboard's stat cards and activity
191+
panels. Anonymous viewers and non-admin users never see the strip.
192+
193+
This is a **second always-on outbound channel** in addition to
194+
telemetry. Like telemetry, it ships on by default and is
195+
opt-out-by-config. The two channels are intentionally separate:
196+
telemetry is a write-only push (panel → collector), announcements
197+
is a read-only pull (panel ← static JSON). Neither can affect the
198+
other; opting out of one leaves the other running.
199+
200+
### What's sent
201+
202+
The GET request carries:
203+
204+
- `User-Agent: SourceBans++/<version> (announcements)` — same
205+
shape as the existing `system.check_version` release-check
206+
fetch.
207+
- Standard HTTP headers added by PHP's stream wrapper.
208+
209+
Specifically **not** sent:
210+
211+
- No instance ID, no per-install random.
212+
- No cookies, no `Referer`, no query string, no POST body.
213+
- Nothing the panel pulls from `sb_settings` or any other table.
214+
215+
The audit trail is the file in this repo at
216+
[`docs/public/announcements.json`](https://github.com/sbpp/sourcebans-pp/blob/main/docs/public/announcements.json).
217+
Every announcement is a git commit before it lands on your
218+
dashboard, so operators can review the full history before the
219+
deploy chain ships it to `sbpp.github.io`.
220+
221+
### How to opt out
222+
223+
There's no in-panel toggle. Add this line to your
224+
`web/config.php`:
225+
226+
```php
227+
define('SB_ANNOUNCEMENTS_URL', '');
228+
```
229+
230+
The empty string is the documented air-gap escape hatch. The
231+
panel skips the daily fetch entirely and the dashboard renders
232+
without the strip. See the
233+
[announcements feature page](/configuring/announcements/) for
234+
the full reference (self-hosted mirror, scheme guard, cache
235+
behaviour).
236+
237+
### Performance impact
238+
239+
Same shape as telemetry: zero on PHP-FPM.
240+
241+
The announcements tick runs in `register_shutdown_function`.
242+
`fastcgi_finish_request()` closes the user's TCP socket before
243+
the upstream fetch runs, so the user-perceived response time
244+
is unaffected. The fetch has a 5-second total timeout, a 256 KiB
245+
hard cap on the response body, stale-while-error caching, and
246+
is silent on failure — a flapping `sbpp.github.io` can never
247+
hard-fail a panel page.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
// SourceBans++ (c) 2014-2026 SourceBans++ Dev Team
3+
// Licensed under Creative Commons Attribution-NonCommercial-ShareAlike 3.0.
4+
// See LICENSE.md for the full license text and THIRD-PARTY-NOTICES.txt for attributions.
5+
6+
declare(strict_types=1);
7+
8+
namespace Sbpp\Announce;
9+
10+
/**
11+
* One announcement entry as the home dashboard renders it.
12+
*
13+
* Constructed by {@see AnnouncementFetcher::latest()} from the cached
14+
* upstream JSON feed. Body Markdown is pre-rendered through
15+
* {@see \Sbpp\Markup\IntroRenderer} so the template can drop
16+
* `body_html` straight in via `{nofilter}` without re-running the
17+
* renderer per page paint.
18+
*
19+
* Rendered fields are deliberately the bare minimum the dashboard
20+
* strip needs — anything richer belongs upstream in the JSON feed,
21+
* not on the disk cache or the wire format. Adding a field here
22+
* means revisiting:
23+
* - {@see AnnouncementFetcher::buildAnnouncement()} (the constructor
24+
* call that maps a raw cache entry onto this DTO).
25+
* - The home page handler's {@see \Sbpp\Announce\Announcement}
26+
* → array conversion in `web/pages/page.home.php`, since Smarty
27+
* consumes the array shape (`{$announcement.title}` etc.).
28+
* - The `Sbpp\View\HomeDashboardView` `?array $announcement`
29+
* property's `@var` shape annotation.
30+
* - The template (`page_dashboard.tpl`) — every new field needs an
31+
* `{if $announcement.<field>}` arm or `SmartyTemplateRule` will
32+
* flag the unused property.
33+
*/
34+
final class Announcement
35+
{
36+
public function __construct(
37+
public readonly string $id,
38+
public readonly string $title,
39+
public readonly string $body_html,
40+
public readonly string $url,
41+
public readonly ?int $published_at,
42+
public readonly ?string $published_human,
43+
) {
44+
}
45+
}

0 commit comments

Comments
 (0)