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,78 @@
package web
import (
"testing"
"time"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
)
func TestPenaltyNotification(t *testing.T) {
for _, c := range []struct {
e store.PenaltyEvent
typ, title, body, url string
}{
{store.PenaltyEvent{ID: 56, Kind: "ban", Name: "teabag_tim", SteamID: "76561198012345622", AdminSteamID: "76561198000000001", AdminName: "mossy", Reason: "Toxicity after warnings", Duration: 4320},
"ban", "Ban added: teabag_tim", "mossy · 3 days · Toxicity after warnings", "/#bans?q=76561198012345622"},
{store.PenaltyEvent{ID: 7, Kind: "comm", Type: "GAG", Name: "noodlearm", SteamID: "765", AdminName: "Kestrel", Reason: "Spam", Duration: 30},
"gag", "Gag added: noodlearm", "Kestrel · 30 min · Spam", "/#comms?q=765"},
{store.PenaltyEvent{ID: 8, Kind: "comm", Type: "SILENCE", SteamID: "765", AdminName: "Console", Duration: 0},
"silence", "Silence added: 765", "Console · Permanent", "/#comms?q=765"},
{store.PenaltyEvent{ID: 3, Kind: "unban", Name: "mika", SteamID: "765", AdminName: "astra", Reason: "Appeal accepted"},
"unban", "Ban lifted: mika", "astra · Appeal accepted", "/#bans?q=765"},
{store.PenaltyEvent{ID: 4, Kind: "unmute", Type: "MUTE", Name: "pip", SteamID: "765"},
"unmute", "Mute lifted: pip", "Console", "/#comms?q=765"},
} {
n, ok := penaltyNotification(c.e)
if !ok || n.Type != c.typ || n.Title != c.title || n.Body != c.body || n.URL != c.url {
t.Errorf("%+v:\n got %v %q %q %q %q", c.e, ok, n.Type, n.Title, n.Body, n.URL)
}
if n.Actor != c.e.AdminSteamID || len(n.Actions) != 1 || n.Actions[0].Title != "View player" {
t.Errorf("%+v: actor %q actions %v", c.e, n.Actor, n.Actions)
}
}
if _, ok := penaltyNotification(store.PenaltyEvent{Kind: "comm", Type: "OTHER"}); ok {
t.Error("unknown comm type should be skipped")
}
}
func TestQuietHours(t *testing.T) {
at := func(h, m int) time.Time { return time.Date(2026, 10, 3, h, m, 0, 0, time.UTC) }
night := store.PushPrefs{QuietEnabled: true, QuietStart: 23 * 60, QuietEnd: 8 * 60, Timezone: "UTC"}
day := store.PushPrefs{QuietEnabled: true, QuietStart: 9 * 60, QuietEnd: 17 * 60, Timezone: "UTC"}
for _, c := range []struct {
p store.PushPrefs
t time.Time
want bool
}{
{night, at(23, 0), true},
{night, at(3, 0), true},
{night, at(7, 59), true},
{night, at(8, 0), false},
{night, at(12, 0), false},
{day, at(9, 0), true},
{day, at(17, 0), false},
{day, at(8, 59), false},
{store.PushPrefs{QuietEnabled: false, QuietStart: 0, QuietEnd: 1439}, at(12, 0), false},
{store.PushPrefs{QuietEnabled: true, QuietStart: 600, QuietEnd: 600}, at(10, 0), false},
// 23:30 UTC is 00:30 in London (BST), inside 00:00-06:00 there.
{store.PushPrefs{QuietEnabled: true, QuietStart: 0, QuietEnd: 360, Timezone: "Europe/London"}, at(23, 30), true},
} {
if got := inQuietHours(c.p, c.t); got != c.want {
t.Errorf("%+v at %s: got %v", c.p, c.t.Format("15:04"), got)
}
}
}
func TestHHMM(t *testing.T) {
for _, m := range []int{0, 59, 60, 480, 1380, 1439} {
if got, ok := parseHHMM(hhmm(m)); !ok || got != m {
t.Errorf("%d -> %s -> %d", m, hhmm(m), got)
}
}
for _, s := range []string{"24:00", "7:00", "12:60", "", "12:00:00"} {
if _, ok := parseHHMM(s); ok {
t.Errorf("%q parsed", s)
}
}
}