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

282
internal/store/push.go Normal file
View file

@ -0,0 +1,282 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"strings"
)
// The panel's push notification tables (sql/push.sql).
// EnsurePush creates the push tables if they're missing. The file holds several statements.
func (s *Store) EnsurePush(ctx context.Context, ddl string) error {
var lines []string
for line := range strings.SplitSeq(ddl, "\n") {
if !strings.HasPrefix(strings.TrimSpace(line), "--") {
lines = append(lines, line)
}
}
for stmt := range strings.SplitSeq(strings.Join(lines, "\n"), ";") {
if stmt = strings.TrimSpace(stmt); stmt != "" {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return err
}
}
}
return nil
}
// Setting reads a panel setting from saw_settings, "" if unset.
func (s *Store) Setting(ctx context.Context, name string) (string, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM saw_settings WHERE name = ?`, name).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return v, err
}
// SettingOnce stores a setting unless it's already set, and returns whichever value is stored, so
// two panels starting at once agree.
func (s *Store) SettingOnce(ctx context.Context, name, value string) (string, error) {
if _, err := s.db.ExecContext(ctx, `INSERT IGNORE INTO saw_settings (name, value) VALUES (?, ?)`, name, value); err != nil {
return "", err
}
return s.Setting(ctx, name)
}
// PushDevice is a browser or app that turned notifications on.
type PushDevice struct {
ID int64
SteamID string
Endpoint string
P256dh string
Auth string
}
func endpointHash(endpoint string) string {
h := sha256.Sum256([]byte(endpoint))
return hex.EncodeToString(h[:])
}
// SavePushDevice adds a device, or moves it to this staff member if someone else had it.
func (s *Store) SavePushDevice(ctx context.Context, steamid, endpoint, p256dh, auth, userAgent string) error {
n, err := parseSID(steamid)
if err != nil {
return err
}
if len(userAgent) > 255 {
userAgent = userAgent[:255]
}
_, err = s.db.ExecContext(ctx, `INSERT INTO saw_push_devices (steamid, endpoint, endpoint_hash, p256dh, auth, user_agent)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE steamid = VALUES(steamid), p256dh = VALUES(p256dh), auth = VALUES(auth), user_agent = VALUES(user_agent)`,
n, endpoint, endpointHash(endpoint), p256dh, auth, userAgent)
return err
}
// DeletePushDevice removes one of a staff member's devices, or all of them when endpoint is "".
func (s *Store) DeletePushDevice(ctx context.Context, steamid, endpoint string) (int64, error) {
n, err := parseSID(steamid)
if err != nil {
return 0, err
}
q, args := `DELETE FROM saw_push_devices WHERE steamid = ?`, []any{n}
if endpoint != "" {
q += ` AND endpoint_hash = ?`
args = append(args, endpointHash(endpoint))
}
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// DeletePushDeviceID removes a device the push service says is gone.
func (s *Store) DeletePushDeviceID(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM saw_push_devices WHERE id = ?`, id)
return err
}
// PushDeviceHashes lists the endpoint hashes of a staff member's devices, so a browser can tell
// whether it's one of them without the endpoints being sent back.
func (s *Store) PushDeviceHashes(ctx context.Context, steamid string) ([]string, error) {
n, err := parseSID(steamid)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `SELECT endpoint_hash FROM saw_push_devices WHERE steamid = ? ORDER BY id`, n)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
// PushDevices lists one staff member's devices, or everyone's when steamid is "".
func (s *Store) PushDevices(ctx context.Context, steamid string) ([]PushDevice, error) {
q, args := `SELECT id, steamid, endpoint, p256dh, auth FROM saw_push_devices`, []any{}
if steamid != "" {
n, err := parseSID(steamid)
if err != nil {
return nil, err
}
q += ` WHERE steamid = ?`
args = append(args, n)
}
rows, err := s.db.QueryContext(ctx, q+` ORDER BY id`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PushDevice
for rows.Next() {
var d PushDevice
var n sql.NullInt64
if err := rows.Scan(&d.ID, &n, &d.Endpoint, &d.P256dh, &d.Auth); err != nil {
return nil, err
}
d.SteamID = sid(n)
out = append(out, d)
}
return out, rows.Err()
}
// PushPrefs is what a staff member wants notifications for.
type PushPrefs struct {
Types []string
QuietEnabled bool
QuietStart int // minutes after midnight
QuietEnd int
Timezone string
}
// AllPushPrefs returns everyone's saved preferences by SteamID64.
func (s *Store) AllPushPrefs(ctx context.Context) (map[string]PushPrefs, error) {
return s.pushPrefs(ctx, "")
}
// PushPrefsFor returns one staff member's saved preferences, and false if they have none yet.
func (s *Store) PushPrefsFor(ctx context.Context, steamid string) (PushPrefs, bool, error) {
all, err := s.pushPrefs(ctx, steamid)
if err != nil {
return PushPrefs{}, false, err
}
p, ok := all[steamid]
return p, ok, nil
}
func (s *Store) pushPrefs(ctx context.Context, steamid string) (map[string]PushPrefs, error) {
q, args := `SELECT steamid, types, quiet_enabled, quiet_start, quiet_end, timezone FROM saw_push_prefs`, []any{}
if steamid != "" {
n, err := parseSID(steamid)
if err != nil {
return nil, err
}
q += ` WHERE steamid = ?`
args = append(args, n)
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]PushPrefs{}
for rows.Next() {
var n sql.NullInt64
var types string
var p PushPrefs
if err := rows.Scan(&n, &types, &p.QuietEnabled, &p.QuietStart, &p.QuietEnd, &p.Timezone); err != nil {
return nil, err
}
p.Types = []string{}
for t := range strings.SplitSeq(types, ",") {
if t = strings.TrimSpace(t); t != "" {
p.Types = append(p.Types, t)
}
}
out[sid(n)] = p
}
return out, rows.Err()
}
// SavePushPrefs stores a staff member's preferences.
func (s *Store) SavePushPrefs(ctx context.Context, steamid string, p PushPrefs) error {
n, err := parseSID(steamid)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `INSERT INTO saw_push_prefs (steamid, types, quiet_enabled, quiet_start, quiet_end, timezone)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE types = VALUES(types), quiet_enabled = VALUES(quiet_enabled),
quiet_start = VALUES(quiet_start), quiet_end = VALUES(quiet_end), timezone = VALUES(timezone)`,
n, strings.Join(p.Types, ","), p.QuietEnabled, p.QuietStart, p.QuietEnd, p.Timezone)
return err
}
// PenaltyEvent is a new ban, comm block, unban or unmute, for notifications.
type PenaltyEvent struct {
ID int64
Kind string // ban, comm, unban, unmute
Type string // GAG, MUTE or SILENCE for comm and unmute
Name string
SteamID string
AdminSteamID string
AdminName string
Reason string
Duration int // minutes, 0 permanent; bans and comms only
}
// penaltyEventSQL selects rows after an id, oldest first, in PenaltyEvent's column order. Lifts
// take the player from the penalty they lift and the admin from sa_admins.
var penaltyEventSQL = map[string]string{
"ban": `SELECT b.id, '', ` + nameOr("b") + `, b.player_steamid, b.admin_steamid, b.admin_name, b.reason, b.duration
FROM sa_bans b WHERE b.id > ? ORDER BY b.id LIMIT 50`,
"comm": `SELECT m.id, m.type, ` + nameOr("m") + `, m.player_steamid, m.admin_steamid, m.admin_name, m.reason, m.duration
FROM sa_mutes m WHERE m.id > ? ORDER BY m.id LIMIT 50`,
"unban": `SELECT u.id, '', ` + nameOr("b") + `, b.player_steamid, COALESCE(a.player_steamid, 0), COALESCE(a.player_name, ''), COALESCE(u.reason, ''), 0
FROM sa_unbans u JOIN sa_bans b ON b.id = u.ban_id LEFT JOIN sa_admins a ON a.id = u.admin_id
WHERE u.id > ? ORDER BY u.id LIMIT 50`,
"unmute": `SELECT u.id, m.type, ` + nameOr("m") + `, m.player_steamid, COALESCE(a.player_steamid, 0), COALESCE(a.player_name, ''), COALESCE(u.reason, ''), 0
FROM sa_unmutes u JOIN sa_mutes m ON m.id = u.mute_id LEFT JOIN sa_admins a ON a.id = u.admin_id
WHERE u.id > ? ORDER BY u.id LIMIT 50`,
}
// PenaltyEventTable is the table whose ids PenaltyEvents counts, for MaxID.
var PenaltyEventTable = map[string]string{"ban": "sa_bans", "comm": "sa_mutes", "unban": "sa_unbans", "unmute": "sa_unmutes"}
// PenaltyEvents lists rows of one kind with ids after `after`, oldest first.
func (s *Store) PenaltyEvents(ctx context.Context, kind string, after int64) ([]PenaltyEvent, error) {
q, ok := penaltyEventSQL[kind]
if !ok {
return nil, errors.New("unknown penalty event kind " + kind)
}
rows, err := s.db.QueryContext(ctx, q, after)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PenaltyEvent
for rows.Next() {
e := PenaltyEvent{Kind: kind}
var psid, asid sql.NullInt64
if err := rows.Scan(&e.ID, &e.Type, &e.Name, &psid, &asid, &e.AdminName, &e.Reason, &e.Duration); err != nil {
return nil, err
}
e.SteamID, e.AdminSteamID = sid(psid), sid(asid)
out = append(out, e)
}
return out, rows.Err()
}