Staff push notifications, and revalidate embedded assets

Notifications: staff turn them on per device from a new Notifications page,
pick each type (bans, gags, mutes and silences added or lifted, server down
and back up) and quiet hours. A watcher polls SimpleAdmin's tables, so in-game
penalties are sent too. Web Push with VAPID and RFC 8291 encryption in
internal/push, standard library only. Tables in sql/push.sql, created at
startup; the VAPID key is made on first start and kept in saw_settings.
Penalty notifications link to the public list searching for the player
(#bans?q=<steamid64>). The Android app delegates notifications (versionCode 3).

Caching: pages, scripts and styles are served with Cache-Control: no-cache
and an ETag, so a deploy shows up at once instead of after Cloudflare's
default 4-hour browser cache.
This commit is contained in:
Astra 2026-10-03 13:49:40 +01:00
parent 7900bf5b3e
commit 17232bd5a2
23 changed files with 2135 additions and 12 deletions

View file

@ -0,0 +1,257 @@
package web
import (
"bytes"
"context"
"crypto/ecdh"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"slices"
"sync"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"git.zio.sh/cs2/simpleadmin-web/internal/live"
"git.zio.sh/cs2/simpleadmin-web/internal/push"
"git.zio.sh/cs2/simpleadmin-web/internal/rcon"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
)
// TestNotifyDB runs the notification endpoints and the watcher against a real MySQL holding
// SimpleAdmin 1.8.2b's migrations, with a fake push service. It empties SimpleAdmin's admin,
// group, ban and mute tables and the panel's push tables, so only point it at a scratch database:
//
// SAW_TEST_DSN='root:test@tcp(127.0.0.1:33061)/sa?parseTime=true&loc=UTC' go test ./internal/web -run NotifyDB
func TestNotifyDB(t *testing.T) {
dsn := os.Getenv("SAW_TEST_DSN")
if dsn == "" {
t.Skip("SAW_TEST_DSN not set")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx := context.Background()
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
st := store.New(db, time.UTC, 0, 1)
ddl, err := os.ReadFile("../../sql/push.sql")
if err != nil {
t.Fatal(err)
}
for range 2 { // creating the tables twice is fine
if err := st.EnsurePush(ctx, string(ddl)); err != nil {
t.Fatal(err)
}
}
const (
owner = "76561198000000001"
mod = "76561198000000002"
former = "76561198000000003" // has a device, but is no longer staff
player = "76561198000000009"
)
for _, table := range []string{"saw_push_devices", "saw_push_prefs", "sa_unbans", "sa_unmutes", "sa_bans", "sa_mutes",
"sa_admins_flags", "sa_admins", "sa_groups_flags", "sa_groups_servers", "sa_groups"} {
exec("DELETE FROM " + table)
}
exec("INSERT INTO sa_groups (id, name, immunity) VALUES (1, '#rank/owner', 9999), (2, '#rank/mod', 30)")
exec("INSERT INTO sa_groups_servers (group_id, server_id) VALUES (1, NULL), (2, NULL)")
exec("INSERT INTO sa_groups_flags (group_id, flag) VALUES (1, '@css/root'), (2, '@css/generic')")
exec("INSERT INTO sa_admins (id, player_name, player_steamid, immunity, server_id) VALUES (1, 'Owner', ?, 0, NULL), (2, 'Mod', ?, 0, NULL)", owner, mod)
exec("INSERT INTO sa_admins_flags (admin_id, flag) VALUES (1, '#rank/owner'), (2, '#rank/mod')")
// The fake push service records which device each message went to. /gone has unsubscribed.
var mu sync.Mutex
got := map[string]int{}
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
got[r.URL.Path]++
mu.Unlock()
if r.URL.Path == "/gone" {
w.WriteHeader(http.StatusGone)
return
}
w.WriteHeader(http.StatusCreated)
}))
defer fake.Close()
sent := func() map[string]int {
mu.Lock()
defer mu.Unlock()
out := got
got = map[string]int{}
return out
}
keys, _ := push.GenerateKeys()
rc := rcon.New("127.0.0.1:1", "unused")
srv, err := New(Config{BaseURL: "http://panel.test", SessionSecret: []byte("secret"),
Push: &push.Sender{Keys: keys, Subject: "http://panel.test", Client: fake.Client()}}, st, rc, live.NewPoller(rc, time.Hour), nil)
if err != nil {
t.Fatal(err)
}
h := srv.Handler()
call := func(method, path, as string, body any) (int, map[string]any) {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest(method, path, bytes.NewReader(b))
req.Header.Set(csrfHeader, csrfValue)
rec := httptest.NewRecorder()
srv.setSession(rec, as)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
expect := func(want, got int, out map[string]any, what string) {
t.Helper()
if got != want {
t.Fatalf("%s: status %d, want %d (%v)", what, got, want, out)
}
}
browserKeys := func() (string, string) {
k, _ := ecdh.P256().GenerateKey(rand.Reader)
auth := make([]byte, 16)
_, _ = rand.Read(auth)
return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(auth)
}
// The page's data: push is set up, defaults are every ready type, no devices yet.
code, out := call("GET", "/api/admin/notifications", mod, nil)
expect(200, code, out, "get notifications")
if out["available"] != true || out["publicKey"] != keys.Public() || len(out["devices"].([]any)) != 0 {
t.Fatalf("notifications: %v", out)
}
if n := len(out["prefs"].(map[string]any)["types"].([]any)); n != len(readyTypes()) {
t.Errorf("default types: %d, want %d", n, len(readyTypes()))
}
// Subscribing: only known push services, with valid keys.
p256dh, auth := browserKeys()
sub := map[string]any{"endpoint": "https://fcm.googleapis.com/fcm/send/abc", "expirationTime": nil,
"keys": map[string]string{"p256dh": p256dh, "auth": auth}, "userAgent": "test", "timezone": "Europe/London"}
code, out = call("POST", "/api/admin/notifications/subscribe", mod, sub)
expect(200, code, out, "subscribe")
if p, ok, _ := st.PushPrefsFor(ctx, mod); !ok || p.Timezone != "Europe/London" || len(p.Types) != len(readyTypes()) {
t.Errorf("prefs after subscribing: %+v %v", p, ok)
}
bad := map[string]any{"endpoint": "https://169.254.169.254/latest", "keys": map[string]string{"p256dh": p256dh, "auth": auth}}
code, out = call("POST", "/api/admin/notifications/subscribe", mod, bad)
expect(400, code, out, "subscribe to an unknown host")
code, out = call("GET", "/api/admin/notifications", mod, nil)
if len(out["devices"].([]any)) != 1 {
t.Fatalf("devices after subscribing: %v", out["devices"])
}
code, out = call("POST", "/api/admin/notifications/unsubscribe", mod, map[string]any{"endpoint": "https://fcm.googleapis.com/fcm/send/abc"})
expect(200, code, out, "unsubscribe")
if d, _ := st.PushDevices(ctx, mod); len(d) != 0 {
t.Fatalf("devices after unsubscribing: %v", d)
}
// Preferences: types that aren't ready, or unknown, are dropped.
code, out = call("PUT", "/api/admin/notifications/prefs", mod, map[string]any{
"types": []string{"ban", "gag", "calladmin", "bogus", "ban"}, "quiet": map[string]any{"enabled": false, "start": "23:00", "end": "08:00"}, "timezone": "Mars/Base"})
expect(200, code, out, "save prefs")
if p, _, _ := st.PushPrefsFor(ctx, mod); !slices.Equal(p.Types, []string{"ban", "gag"}) || p.Timezone != "UTC" {
t.Errorf("saved prefs: %+v", p)
}
code, out = call("PUT", "/api/admin/notifications/prefs", mod, map[string]any{"types": []string{}, "quiet": map[string]any{"enabled": true, "start": "25:00", "end": "08:00"}, "timezone": "UTC"})
expect(400, code, out, "bad quiet hours")
// Devices pointing at the fake push service. The owner is in quiet hours all day.
addDevice := func(steamid, path string) {
p, a := browserKeys()
if err := st.SavePushDevice(ctx, steamid, fake.URL+path, p, a, "test"); err != nil {
t.Fatal(err)
}
}
addDevice(mod, "/mod")
addDevice(mod, "/gone")
addDevice(owner, "/owner")
addDevice(former, "/former")
now := time.Now().UTC()
start := now.Hour()*60 + now.Minute()
_ = st.SavePushPrefs(ctx, owner, store.PushPrefs{Types: readyTypes(), QuietEnabled: true, QuietStart: (start + 1380) % 1440, QuietEnd: (start + 60) % 1440, Timezone: "UTC"})
_ = st.SavePushPrefs(ctx, mod, store.PushPrefs{Types: []string{"ban", "unban", "gag"}, Timezone: "UTC"})
w := &watcher{s: srv, after: map[string]int64{}, seen: map[string]time.Time{}}
for kind, table := range store.PenaltyEventTable {
w.after[kind] = st.MaxID(ctx, table)
}
np := func(admin, name string, typ string) store.NewPenalty {
return store.NewPenalty{SteamID: player, Name: "Victim", Type: typ, AdminSteamID: admin, AdminName: name, Reason: "test", Duration: 60}
}
if err := st.InsertBan(ctx, np(owner, "Owner", "")); err != nil { // mod wants bans: sent
t.Fatal(err)
}
if err := st.InsertComm(ctx, np(owner, "Owner", "GAG")); err != nil { // mod wants gags: sent
t.Fatal(err)
}
if err := st.InsertComm(ctx, np(owner, "Owner", "MUTE")); err != nil { // mod doesn't want mutes
t.Fatal(err)
}
if _, err := st.LiftDirect(ctx, "sa_bans", player, "", 2, "appeal"); err != nil { // lifted by the mod themselves
t.Fatal(err)
}
// Rows wait to settle before they're sent.
w.penalties(ctx)
if s := sent(); len(s) != 0 {
t.Fatalf("sent before settling: %v", s)
}
if len(w.seen) != 4 {
t.Fatalf("seen %d rows, want 4", len(w.seen))
}
for k := range w.seen {
w.seen[k] = time.Now().Add(-time.Minute)
}
w.penalties(ctx)
s := sent()
// /gone answers 410 to the first message and is deleted, so it misses the second.
if s["/mod"] != 2 || s["/gone"] != 1 || s["/owner"] != 0 || s["/former"] != 0 {
t.Errorf("sent %v, want 2 to /mod, 1 to /gone, none to /owner (quiet) or /former (not staff)", s)
}
if d, _ := st.PushDevices(ctx, mod); len(d) != 1 {
t.Errorf("the gone device should be deleted, mod has %d", len(d))
}
w.penalties(ctx)
if s := sent(); len(s) != 0 {
t.Errorf("sent again: %v", s)
}
// The test button reaches only your own devices, ignoring preferences.
code, out = call("POST", "/api/admin/notifications/test", mod, map[string]any{})
expect(200, code, out, "test")
if s := sent(); s["/mod"] != 1 || len(s) != 1 {
t.Errorf("test sent %v", s)
}
code, out = call("POST", "/api/admin/notifications/test", owner, map[string]any{})
expect(200, code, out, "owner test in quiet hours")
if s := sent(); s["/owner"] != 1 {
t.Errorf("owner test sent %v", s)
}
// Turning off everywhere.
code, out = call("POST", "/api/admin/notifications/unsubscribe", mod, map[string]any{"all": true})
expect(200, code, out, "unsubscribe all")
code, out = call("POST", "/api/admin/notifications/test", mod, map[string]any{})
expect(400, code, out, "test with no devices")
}