feat(admin): add announcements list + dismiss endpoint

Fourth piece of #419: a small in-code (not admin-authored) announcement
list, target-scoped ("app"/"admin", "chooser" reserved but not wired since
the landing page has no JS yet) and filterable by live server state via
ShowWhile. First entry: the admin-area-gate heads-up, shown on the admin
target while AdminAreaAuth is unset.

GET /api/announcements?target=... and POST /api/announcements/{id}/dismiss
are deliberately NOT behind BasicAuthAdmin — the whole point of the gate
notice is to reach operators who haven't set up credentials yet, the exact
audience an admin-only endpoint would exclude. The dismiss endpoint
validates id against the known announcement list before it reaches
RecordActivity, since this is the one call site where an id comes from an
HTTP request rather than a compile-time constant.

Updated the router snapshot (testdata/router_routes.txt) for the two new
routes.

Not wired into any UI yet — nothing calls these endpoints.

Refs #419
This commit is contained in:
Tobias Gesellchen
2026-08-08 23:49:57 +02:00
parent d930886f07
commit 8a4e4191a9
5 changed files with 325 additions and 7 deletions
@@ -17,10 +17,12 @@ import (
// BasicAuthAdmin middleware in isolation, to pin two things at once:
// 1. /admin and /api/setup/* (and their /setup/* legacy aliases) are open
// by default and become gated once AdminAreaAuth is "enabled".
// 2. The three routes shared with soundtouch-cli/soundtouch-player
// (ca.crt, tts/speak, tts/config) stay reachable WITHOUT credentials
// regardless of the gate — the whole reason mountSetupAPI was split
// into mountSetupAPIShared/mountSetupAPIAdmin.
// 2. A handful of routes deliberately stay reachable WITHOUT credentials
// regardless of the gate: ca.crt/tts/speak/tts/config because
// soundtouch-cli/soundtouch-player call them directly (the whole reason
// mountSetupAPI was split into mountSetupAPIShared/mountSetupAPIAdmin),
// and /api/announcements because it specifically needs to reach
// operators who haven't set up credentials yet.
func TestAdminAreaAuthGate(t *testing.T) {
tempDir := t.TempDir()
@@ -46,11 +48,12 @@ func TestAdminAreaAuthGate(t *testing.T) {
"/setup/settings",
"/api/setup/settings",
}
sharedUngatedPaths := []string{
alwaysUngatedPaths := []string{
"/setup/ca.crt",
"/api/setup/ca.crt",
"/setup/tts/config",
"/api/setup/tts/config",
"/api/announcements?target=admin",
}
t.Run("open by default (AdminAreaAuth unset)", func(t *testing.T) {
@@ -83,8 +86,8 @@ func TestAdminAreaAuthGate(t *testing.T) {
}
})
t.Run("shared cli/player routes stay reachable without credentials", func(t *testing.T) {
for _, path := range sharedUngatedPaths {
t.Run("routes intentionally left outside the gate stay reachable without credentials", func(t *testing.T) {
for _, path := range alwaysUngatedPaths {
status := getStatus(t, ts.URL, path, "", "")
if status != http.StatusOK {
t.Errorf("%s: expected 200 without credentials even with the gate enabled, got %d", path, status)
+4
View File
@@ -1297,6 +1297,10 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
r.Get("/", server.HandleRoot)
r.With(server.BasicAuthAdmin()).Get("/admin", server.HandleAdmin)
r.Get("/health", server.HandleHealth)
// Deliberately not behind BasicAuthAdmin — see HandleListAnnouncements'
// doc comment. #419.
r.Get("/api/announcements", server.HandleListAnnouncements)
r.Post("/api/announcements/{id}/dismiss", server.HandleDismissAnnouncement)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
// The favicon lives in the embedded web/img bundle, not under
// static/media — HandleMedia would 404. HandleWeb serves from
+2
View File
@@ -36,6 +36,7 @@ GET /accounts/{account}/devices/{device}/recents handlers.(
GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm
GET /admin handlers.(*Server).HandleAdmin-fm
GET /api/announcements handlers.(*Server).HandleListAnnouncements-fm
GET /api/control/devices/ soundtouchweb.(*WebApp).HandleAPIDevices-fm
GET /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleAPIDevice-fm
GET /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
@@ -182,6 +183,7 @@ POST /accounts/{account}/group handlers.(
POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
POST /api/announcements/{id}/dismiss handlers.(*Server).HandleDismissAnnouncement-fm
POST /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
POST /api/control/devices/{id}/key/{key} soundtouchweb.(*WebApp).HandleDeviceKey-fm
POST /api/control/devices/{id}/library/play soundtouchweb.(*WebApp).HandlePlayLibrary-fm