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

35
main.go
View file

@ -22,6 +22,7 @@ import (
"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/saconfig"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
@ -33,6 +34,11 @@ import (
//go:embed sql/rank-log.sql
var rankLogSQL string
// pushSQL creates the push notification tables (see the file).
//
//go:embed sql/push.sql
var pushSQL string
func env(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
@ -162,10 +168,12 @@ func run() error {
MaxMuteDuration: maxMute,
AndroidPackage: env("SAW_ANDROID_PACKAGE", ""),
AndroidCerts: strings.Split(env("SAW_ANDROID_CERT_SHA256", ""), ","),
Push: pushSender(ctx, st, baseURL),
}, st, rc, poller, sacfg)
if err != nil {
return err
}
go app.RunNotifier(ctx)
listen := env("SAW_LISTEN", ":8080")
hs := &http.Server{
@ -187,6 +195,33 @@ func run() error {
return nil
}
// pushSender sets up staff notifications: the panel's tables, and the VAPID key push services know
// it by, made on first start and kept in the database. It returns nil, turning notifications off,
// if either fails.
func pushSender(ctx context.Context, st *store.Store, baseURL string) *push.Sender {
if err := st.EnsurePush(ctx, pushSQL); err != nil {
slog.Warn("couldn't create the push notification tables; notifications are off until sql/push.sql is run", "err", err)
return nil
}
saved, err := st.Setting(ctx, "vapid_private_key")
if err == nil && saved == "" {
var k *push.Keys
if k, err = push.GenerateKeys(); err == nil {
saved, err = st.SettingOnce(ctx, "vapid_private_key", k.Private())
}
}
if err != nil {
slog.Warn("couldn't read or make the VAPID key; notifications are off", "err", err)
return nil
}
keys, err := push.ParseKeys(saved)
if err != nil {
slog.Warn("saw_settings has a bad vapid_private_key; notifications are off", "err", err)
return nil
}
return &push.Sender{Keys: keys, Subject: baseURL, Client: &http.Client{Timeout: 15 * time.Second}}
}
// openDB connects with SAW_DB_DSN, or with the DatabaseConfig in CS2-SimpleAdmin.json.
func openDB(sacfg *saconfig.File) (*sql.DB, error) {
var cfg *mysql.Config