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

287
internal/push/push.go Normal file
View file

@ -0,0 +1,287 @@
// Package push sends Web Push messages (RFC 8030), encrypted for the browser (RFC 8291) and signed
// for the push service with VAPID (RFC 8292). Only the standard library is used.
package push
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
var b64 = base64.RawURLEncoding
// decode64 accepts base64url with or without padding, which is how browsers hand out keys.
func decode64(s string) ([]byte, error) {
return b64.DecodeString(strings.TrimRight(s, "="))
}
// Keys is the server's VAPID key pair. Browsers are given the public key when they subscribe, and
// the push service only accepts messages signed with the matching private key.
type Keys struct {
priv *ecdsa.PrivateKey
}
// GenerateKeys makes a new VAPID key pair.
func GenerateKeys() (*Keys, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
return &Keys{priv: priv}, nil
}
// ParseKeys reads a private key written by Keys.Private.
func ParseKeys(private string) (*Keys, error) {
raw, err := decode64(private)
if err != nil {
return nil, fmt.Errorf("VAPID key: %w", err)
}
priv, err := ecdsa.ParseRawPrivateKey(elliptic.P256(), raw)
if err != nil {
return nil, fmt.Errorf("VAPID key: %w", err)
}
return &Keys{priv: priv}, nil
}
// Private is the private key as base64url, for storing.
func (k *Keys) Private() string {
raw, _ := k.priv.Bytes()
return b64.EncodeToString(raw)
}
// Public is the uncompressed public key as base64url: the applicationServerKey for subscribing.
func (k *Keys) Public() string {
raw, _ := k.priv.PublicKey.Bytes()
return b64.EncodeToString(raw)
}
// vapid returns the Authorization header for a push service: a JWT naming its origin, signed ES256.
func (k *Keys) vapid(endpoint, subject string, now time.Time) (string, error) {
u, err := url.Parse(endpoint)
if err != nil {
return "", err
}
claims, _ := json.Marshal(map[string]any{
"aud": u.Scheme + "://" + u.Host,
"exp": now.Add(12 * time.Hour).Unix(),
"sub": subject,
})
unsigned := b64.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`)) + "." + b64.EncodeToString(claims)
digest := sha256.Sum256([]byte(unsigned))
r, s, err := ecdsa.Sign(rand.Reader, k.priv, digest[:])
if err != nil {
return "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return "vapid t=" + unsigned + "." + b64.EncodeToString(sig) + ", k=" + k.Public(), nil
}
// Subscription is what a browser's PushManager.subscribe() returns.
type Subscription struct {
Endpoint string
P256dh string // the browser's public key, base64url
Auth string // its 16-byte auth secret, base64url
}
// Check validates the keys, so a bad subscription is refused when it's saved rather than on send.
func (s Subscription) Check() error {
pub, err := decode64(s.P256dh)
if err != nil || len(pub) != 65 {
return errors.New("the browser's push key isn't valid")
}
if _, err := ecdh.P256().NewPublicKey(pub); err != nil {
return errors.New("the browser's push key isn't valid")
}
auth, err := decode64(s.Auth)
if err != nil || len(auth) != 16 {
return errors.New("the browser's push secret isn't valid")
}
return nil
}
const recordSize = 4096
// encrypt is RFC 8291's aes128gcm encoding of one message for one browser. The salt and the
// sender's one-off key are passed in so the RFC's test vector can check it.
func encrypt(plaintext, uaPublic, authSecret []byte, as *ecdh.PrivateKey, salt []byte) ([]byte, error) {
ua, err := ecdh.P256().NewPublicKey(uaPublic)
if err != nil {
return nil, err
}
secret, err := as.ECDH(ua)
if err != nil {
return nil, err
}
asPublic := as.PublicKey().Bytes()
prkKey, err := hkdf.Extract(sha256.New, secret, authSecret)
if err != nil {
return nil, err
}
keyInfo := "WebPush: info\x00" + string(uaPublic) + string(asPublic)
ikm, err := hkdf.Expand(sha256.New, prkKey, keyInfo, 32)
if err != nil {
return nil, err
}
prk, err := hkdf.Extract(sha256.New, ikm, salt)
if err != nil {
return nil, err
}
cek, err := hkdf.Expand(sha256.New, prk, "Content-Encoding: aes128gcm\x00", 16)
if err != nil {
return nil, err
}
nonce, err := hkdf.Expand(sha256.New, prk, "Content-Encoding: nonce\x00", 12)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(cek)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// One record, ended with the last-record delimiter and no padding.
padded := append(append([]byte{}, plaintext...), 0x02)
header := make([]byte, 0, 16+4+1+len(asPublic))
header = append(header, salt...)
header = binary.BigEndian.AppendUint32(header, recordSize)
header = append(header, byte(len(asPublic)))
header = append(header, asPublic...)
return gcm.Seal(header, nonce, padded, nil), nil
}
// MaxPayload is the most a message can carry. Push services take 4096 bytes after encryption.
const MaxPayload = recordSize - 86 - 16 - 1
// Message is one push.
type Message struct {
Payload []byte
// TTL is how long the push service keeps it for a phone that's offline.
TTL time.Duration
// Urgency is very-low, low, normal or high. High wakes a phone in battery saving.
Urgency string
// Topic replaces an undelivered message with the same topic (at most 32 base64url characters).
Topic string
}
// Sender delivers messages. Subject is a mailto: or https: URL the push service can contact.
type Sender struct {
Keys *Keys
Subject string
Client *http.Client
}
// ErrGone means the browser has unsubscribed or the subscription expired: delete it.
var ErrGone = errors.New("push subscription is gone")
// Send encrypts and posts one message to one subscription.
func (s *Sender) Send(ctx context.Context, sub Subscription, m Message) error {
if len(m.Payload) > MaxPayload {
return fmt.Errorf("push payload is %d bytes, over %d", len(m.Payload), MaxPayload)
}
uaPublic, err := decode64(sub.P256dh)
if err != nil {
return err
}
auth, err := decode64(sub.Auth)
if err != nil {
return err
}
as, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return err
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return err
}
body, err := encrypt(m.Payload, uaPublic, auth, as, salt)
if err != nil {
return err
}
authz, err := s.Keys.vapid(sub.Endpoint, s.Subject, time.Now())
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.Endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Encoding", "aes128gcm")
req.Header.Set("Authorization", authz)
req.Header.Set("TTL", strconv.Itoa(int(m.TTL.Seconds())))
if m.Urgency != "" {
req.Header.Set("Urgency", m.Urgency)
}
if m.Topic != "" {
req.Header.Set("Topic", m.Topic)
}
client := s.Client
if client == nil {
client = http.DefaultClient
}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
msg, _ := io.ReadAll(io.LimitReader(res.Body, 512))
switch {
case res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusGone:
return ErrGone
case res.StatusCode >= 300:
return fmt.Errorf("push service answered %s: %s", res.Status, strings.TrimSpace(string(msg)))
}
return nil
}
// pushHosts are the push services of the browsers in use. Subscriptions are only accepted for
// these, so the panel can't be pointed at an arbitrary URL.
var pushHosts = []string{
"fcm.googleapis.com", // Chrome, Edge on Android, Samsung Internet, Brave, Opera
"push.services.mozilla.com", // Firefox
"web.push.apple.com", // Safari
"notify.windows.com", // Edge on Windows
}
// AllowedEndpoint reports whether an endpoint is an https URL on a known push service.
func AllowedEndpoint(endpoint string) bool {
u, err := url.Parse(endpoint)
if err != nil || u.Scheme != "https" || u.User != nil || u.Port() != "" {
return false
}
host := strings.ToLower(u.Hostname())
for _, h := range pushHosts {
if host == h || strings.HasSuffix(host, "."+h) {
return true
}
}
return false
}

187
internal/push/push_test.go Normal file
View file

@ -0,0 +1,187 @@
package push
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"io"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func mustDecode(t *testing.T, s string) []byte {
t.Helper()
b, err := decode64(strings.Join(strings.Fields(s), ""))
if err != nil {
t.Fatal(err)
}
return b
}
// TestEncryptRFC8291 is the worked example in RFC 8291 section 5 and appendix A.
func TestEncryptRFC8291(t *testing.T) {
as, err := ecdh.P256().NewPrivateKey(mustDecode(t, "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"))
if err != nil {
t.Fatal(err)
}
got, err := encrypt(
[]byte("When I grow up, I want to be a watermelon"),
mustDecode(t, "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"),
mustDecode(t, "BTBZMqHH6r4Tts7J_aSIgg"),
as,
mustDecode(t, "DGv6ra1nlYgDCS1FRnbzlw"),
)
if err != nil {
t.Fatal(err)
}
want := append(
mustDecode(t, `DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z 9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml
mlMoZIIgDll6e3vCYLocInmYWAmS6Tlz AC8wEqKK6PBru3jl7A8`),
mustDecode(t, `8pfeW0KbunFT06SuDKoJH9Ql87S1QUrd irN6GcG7sFz1y1sqLgVi1VhjVkHsUoEs bI_0LpXMuGvnzQ`)...)
if !bytes.Equal(got, want) {
t.Errorf("got %x\nwant %x", got, want)
}
}
func TestKeysRoundTrip(t *testing.T) {
k, err := GenerateKeys()
if err != nil {
t.Fatal(err)
}
k2, err := ParseKeys(k.Private())
if err != nil {
t.Fatal(err)
}
if k2.Public() != k.Public() || len(mustDecode(t, k.Public())) != 65 {
t.Errorf("public key changed or has the wrong length: %s", k.Public())
}
}
// TestSend posts to a fake push service, then checks the VAPID signature and decrypts the body
// the way a browser would.
func TestSend(t *testing.T) {
keys, _ := GenerateKeys()
ua, _ := ecdh.P256().GenerateKey(rand.Reader)
auth := []byte("0123456789abcdef")
var got *http.Request
var body []byte
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r
body, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
s := &Sender{Keys: keys, Subject: "https://bans.example.com", Client: srv.Client()}
sub := Subscription{Endpoint: srv.URL + "/send/abc", P256dh: b64.EncodeToString(ua.PublicKey().Bytes()), Auth: b64.EncodeToString(auth)}
if err := sub.Check(); err != nil {
t.Fatal(err)
}
if err := s.Send(context.Background(), sub, Message{Payload: []byte(`{"title":"hi"}`), TTL: time.Hour, Urgency: "high", Topic: "server"}); err != nil {
t.Fatal(err)
}
if got.Header.Get("Content-Encoding") != "aes128gcm" || got.Header.Get("TTL") != "3600" || got.Header.Get("Urgency") != "high" || got.Header.Get("Topic") != "server" {
t.Errorf("headers: %v", got.Header)
}
// VAPID: the JWT's audience is the push service's origin, signed by the key in k=.
authz := got.Header.Get("Authorization")
tok, k, ok := strings.Cut(strings.TrimPrefix(authz, "vapid t="), ", k=")
if !ok || k != keys.Public() {
t.Fatalf("Authorization: %s", authz)
}
parts := strings.Split(tok, ".")
var claims struct {
Aud string `json:"aud"`
Sub string `json:"sub"`
Exp int64 `json:"exp"`
}
_ = json.Unmarshal(mustDecode(t, parts[1]), &claims)
if claims.Aud != srv.URL || claims.Sub != s.Subject || claims.Exp < time.Now().Unix() {
t.Errorf("claims: %+v", claims)
}
sig := mustDecode(t, parts[2])
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
r, ss := new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])
if !ecdsa.Verify(&keys.priv.PublicKey, digest[:], r, ss) {
t.Error("VAPID signature doesn't verify")
}
// Decrypt as the browser: header is salt, record size, key id (the sender's one-off key).
salt, asPub, ct := body[:16], body[21:86], body[86:]
as, err := ecdh.P256().NewPublicKey(asPub)
if err != nil {
t.Fatal(err)
}
secret, _ := ua.ECDH(as)
plain := decryptForTest(t, secret, auth, ua.PublicKey().Bytes(), asPub, salt, ct)
if string(plain) != `{"title":"hi"}`+"\x02" {
t.Errorf("decrypted %q", plain)
}
}
func decryptForTest(t *testing.T, secret, auth, uaPub, asPub, salt, ct []byte) []byte {
t.Helper()
// The same derivation as encrypt, from the receiving side.
prkKey := hkdfExtract(t, secret, auth)
ikm := hkdfExpand(t, prkKey, "WebPush: info\x00"+string(uaPub)+string(asPub), 32)
prk := hkdfExtract(t, ikm, salt)
cek := hkdfExpand(t, prk, "Content-Encoding: aes128gcm\x00", 16)
nonce := hkdfExpand(t, prk, "Content-Encoding: nonce\x00", 12)
block, _ := aes.NewCipher(cek)
gcm, _ := cipher.NewGCM(block)
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
t.Fatal(err)
}
return plain
}
func TestAllowedEndpoint(t *testing.T) {
for ep, want := range map[string]bool{
"https://fcm.googleapis.com/fcm/send/abc": true,
"https://updates.push.services.mozilla.com/wpush/v2/abc": true,
"https://web.push.apple.com/abc": true,
"https://wns2-par02p.notify.windows.com/w/?token=abc": true,
"http://fcm.googleapis.com/fcm/send/abc": false,
"https://fcm.googleapis.com.evil.example/abc": false,
"https://evilfcm.googleapis.com/abc": false,
"https://fcm.googleapis.com:8443/abc": false,
"https://user@fcm.googleapis.com/abc": false,
"https://127.0.0.1/abc": false,
} {
if got := AllowedEndpoint(ep); got != want {
t.Errorf("%s: got %v", ep, got)
}
}
}
func hkdfExtract(t *testing.T, secret, salt []byte) []byte {
t.Helper()
b, err := hkdf.Extract(sha256.New, secret, salt)
if err != nil {
t.Fatal(err)
}
return b
}
func hkdfExpand(t *testing.T, prk []byte, info string, n int) []byte {
t.Helper()
b, err := hkdf.Expand(sha256.New, prk, info, n)
if err != nil {
t.Fatal(err)
}
return b
}

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()
}

View file

@ -41,6 +41,9 @@ func New(db *sql.DB, loc *time.Location, serverID int64, timeMode int) *Store {
func (s *Store) DB() *sql.DB { return s.db }
// Location is SimpleAdmin's Timezone.
func (s *Store) Location() *time.Location { return s.loc }
// wall turns a time into the naive wall-clock form SimpleAdmin stores.
func (s *Store) wall(t time.Time) time.Time {
t = t.In(s.loc)

47
internal/web/assets.go Normal file
View file

@ -0,0 +1,47 @@
package web
import (
"crypto/sha256"
"encoding/hex"
"io/fs"
"net/http"
"sync"
)
// The pages, scripts and styles are embedded, so they only change with a new build. They're served
// with Cache-Control: no-cache and an ETag (a hash of the file): browsers, the Android app and
// Cloudflare check each time and get a quick 304 while the file is unchanged, and a new deploy
// shows up at once. Without these headers Cloudflare tells browsers to keep them for 4 hours.
var assetTags = sync.OnceValue(func() map[string]string {
tags := map[string]string{}
static, _ := fs.Sub(assets, "assets")
_ = fs.WalkDir(static, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
data, err := fs.ReadFile(static, path)
if err != nil {
return err
}
sum := sha256.Sum256(data)
tags[path] = `"` + hex.EncodeToString(sum[:8]) + `"`
return nil
})
return tags
})
// revalidate sets the caching headers for an embedded file. http.ServeContent answers a matching
// If-None-Match with 304 from the ETag set here.
func revalidate(w http.ResponseWriter, name string) {
w.Header().Set("Cache-Control", "no-cache")
if tag, ok := assetTags()[name]; ok {
w.Header().Set("ETag", tag)
}
}
// serveAsset serves one embedded file, see revalidate.
func serveAsset(w http.ResponseWriter, r *http.Request, static fs.FS, name string) {
revalidate(w, name)
http.ServeFileFS(w, r, static, name)
}

View file

@ -10,6 +10,7 @@ const PAGES = [
// Only when GroupVerify's tables are in the database.
{ id: "verify", label: "Verification", show: () => !!state.me?.verify },
{ id: "settings", label: "Server settings" },
{ id: "notifications", label: "Notifications" },
];
const pageAllowed = (p) => (!p.flag || can(p.flag)) && (!p.show || p.show());
@ -517,6 +518,136 @@ function overrideDialog(sid, name) {
</form>`);
}
/* ---------- Notifications ---------- */
// Push notifications to this staff member's devices (internal/web/notify.go, sw.js). Preferences
// apply to all their devices and save as soon as they change.
const b64urlToBytes = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
const bytesToB64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const browserTimezone = () => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } };
const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
async function sha256Hex(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
// This browser's push subscription, or null.
async function pushSubscription() {
const reg = await navigator.serviceWorker.getRegistration();
return reg ? reg.pushManager.getSubscription() : null;
}
async function notifications() {
const v = $("#view");
let d;
try { d = await api("/api/admin/notifications"); } catch (err) { v.innerHTML = errorBox(err); return; }
let here = false;
if (d.available && pushSupported() && window.crypto?.subtle) {
try {
const sub = await pushSubscription();
here = !!sub && d.devices.includes(await sha256Hex(sub.endpoint));
} catch { /* treat as off */ }
}
if (state.page !== "notifications") return;
state.notif = d;
const p = d.prefs;
const others = d.devices.length - (here ? 1 : 0);
const row = (label, help, control) => `<div class="setting">
<div><b>${label}</b>${help ? `<p>${help}</p>` : ""}</div>
<div class="control">${control}</div>
</div>`;
let device;
if (!d.available) device = row("This device", "Notifications aren't set up on the panel. Its log says why.", '<span class="tag red">Unavailable</span>');
else if (!pushSupported()) device = row("This device", "This browser can't get notifications. On an iPhone or iPad, add the site to your home screen and open it from there.", '<span class="tag red">Not supported</span>');
else if (Notification.permission === "denied") device = row("This device", "Notifications are blocked for this site. Allow them in the browser's site settings (or the app's notification settings), then come back.", '<span class="tag red">Blocked</span>');
else if (here) device = row("This device", "Notifications are on here.", '<div style="display:flex;gap:8px"><button class="btn" type="button" data-act="notif-test">Send a test</button><button class="btn" type="button" data-act="notif-off">Turn off</button></div>');
else device = row("This device", "Turn notifications on to get the ones picked below here, even with the panel closed.", '<button class="btn primary" type="button" data-act="notif-on">Turn on</button>');
const dis = d.available ? "" : "disabled";
const groups = [...new Set(d.types.map((t) => t.group))];
v.innerHTML = `
<div class="page-head">
<div><h1>Notifications</h1><p class="sub">Get bans, gags, mutes and server alerts on your phone or computer. Nothing about your own actions is sent to you.</p></div>
</div>
<div class="settings">
<section class="panel">
<div class="panel-head"><div><h2>Devices</h2></div></div>
${device}
${others > 0 ? row(`${here ? "Other devices" : "Your devices"}`, `${others} ${others === 1 ? "device has" : "devices have"} notifications on.`, '<button class="btn" type="button" data-act="notif-off-all">Turn off everywhere</button>') : ""}
</section>
<section class="panel">
<div class="panel-head"><div><h2>Quiet hours</h2><p>Nothing is sent between these times, in your timezone (${esc(browserTimezone())}).</p></div></div>
${row("Quiet hours", "", `<button class="switch" type="button" role="switch" aria-checked="${p.quiet.enabled}" aria-label="Quiet hours" data-notif="quiet" ${dis}></button>`)}
${row("From and until", "", `<div class="quiet-times"><input class="input" type="time" id="quiet-start" value="${esc(p.quiet.start)}" aria-label="Quiet from" data-notif-time ${dis}><span class="muted">to</span><input class="input" type="time" id="quiet-end" value="${esc(p.quiet.end)}" aria-label="Quiet until" data-notif-time ${dis}></div>`)}
</section>
<section class="panel">
<div class="panel-head"><div><h2>What to send</h2><p>For all your devices.</p></div></div>
${groups.map((g) => `<div class="notif-group">${esc(g)}</div>
${d.types.filter((t) => t.group === g).map((t) => row(esc(t.label), t.help ? esc(t.help) : "",
t.ready
? `<button class="switch" type="button" role="switch" aria-checked="${p.types.includes(t.key)}" aria-label="${esc(t.label)}" data-ntype="${esc(t.key)}" ${dis}></button>`
: `<span class="tag">Coming later</span><button class="switch" type="button" role="switch" aria-checked="false" aria-label="${esc(t.label)}" disabled></button>`)).join("")}`).join("")}
</section>
</div>`;
}
let notifSave;
function saveNotifPrefs() {
const types = [...document.querySelectorAll("[data-ntype]")].filter((s) => s.getAttribute("aria-checked") === "true").map((s) => s.dataset.ntype);
const body = {
types,
quiet: { enabled: $("[data-notif=quiet]").getAttribute("aria-checked") === "true", start: $("#quiet-start").value, end: $("#quiet-end").value },
timezone: browserTimezone(),
};
// Several quick toggles make one save.
clearTimeout(notifSave);
notifSave = setTimeout(async () => {
try { await api("/api/admin/notifications/prefs", { method: "PUT", body }); toast("Saved."); } catch (err) { toast(err.message); }
}, 400);
}
async function enableNotifications() {
const d = state.notif;
try {
if (await Notification.requestPermission() !== "granted") {
toast("Notifications weren't allowed.");
notifications();
return;
}
if (!(await navigator.serviceWorker.getRegistration())) await navigator.serviceWorker.register("/sw.js");
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
// A subscription made for another panel key can't be reused.
if (sub && sub.options?.applicationServerKey && bytesToB64url(sub.options.applicationServerKey) !== d.publicKey) {
await sub.unsubscribe();
sub = null;
}
if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64urlToBytes(d.publicKey) });
await act("/api/admin/notifications/subscribe", "POST", { ...sub.toJSON(), userAgent: navigator.userAgent, timezone: browserTimezone() });
} catch (err) {
toast(`Couldn't turn notifications on: ${err.message}`);
}
}
async function disableNotifications(all) {
let endpoint = "";
try {
const sub = await pushSubscription();
if (sub) {
endpoint = sub.endpoint;
await sub.unsubscribe();
}
} catch { /* the server copy is removed below either way */ }
if (all) return act("/api/admin/notifications/unsubscribe", "POST", { all: true });
if (!endpoint) { notifications(); return; }
return act("/api/admin/notifications/unsubscribe", "POST", { endpoint });
}
/* ---------- Settings ---------- */
async function settings() {
@ -812,7 +943,7 @@ async function act(path, method, body) {
/* ---------- Routing & events ---------- */
const RENDER = { overview, players, bans, comms, staff, verify, settings };
const RENDER = { overview, players, bans, comms, staff, verify, settings, notifications };
function renderNav() {
const c = state.counts;
@ -849,6 +980,7 @@ document.addEventListener("input", (e) => {
document.addEventListener("change", (e) => {
if (e.target.matches("select[data-sa]")) { e.target.classList.add("dirty"); markDirty(); }
if (e.target.matches("[data-notif-time]")) saveNotifPrefs();
});
document.addEventListener("click", (e) => {
@ -888,6 +1020,11 @@ document.addEventListener("click", (e) => {
}
const sw = t.closest(".switch");
if (sw && !sw.disabled && sw.matches("[data-ntype], [data-notif]")) {
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
saveNotifPrefs();
return;
}
if (sw && !sw.disabled) {
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
sw.classList.add("dirty");
@ -957,6 +1094,12 @@ document.addEventListener("click", (e) => {
: "Turn GroupVerify off? Nobody is verified while it's off, overrides included, and verified players lose their perks.",
on ? "Turn on" : "Turn off", () => act("/api/admin/verify/settings", "POST", { enabled: on }));
}
case "notif-on": return enableNotifications();
case "notif-off": return disableNotifications(false);
case "notif-off-all":
confirmDialog.reason = false;
return confirmDialog("Turn off everywhere", "Turn notifications off on all your devices? Turn them on again on each device you want them on.", "Turn off", () => disableNotifications(true));
case "notif-test": return act("/api/admin/notifications/test", "POST", {});
case "save-settings": return saveSettings();
case "discard": return settings();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -259,8 +259,16 @@ async function openPlayer(sid) {
const RENDER = { server, bans, comms, staff };
function route() {
const page = location.hash.slice(1);
const [page, query] = location.hash.slice(1).split("?");
state.page = RENDER[page] ? page : "server";
// A link like #bans?q=<steamid> (from a staff notification) opens the list searching for that
// player, showing every status so lifted and expired ones are there too.
const q = new URLSearchParams(query || "").get("q");
if (q && (state.page === "bans" || state.page === "comms")) {
state.query = q.slice(0, 64);
state.num = 1;
state[state.page === "bans" ? "banFilter" : "commFilter"] = "all";
}
$("#nav-links").innerHTML = PAGES.map((p) => `<li><a href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}</a></li>`).join("");
RENDER[state.page]();
}

View file

@ -676,6 +676,18 @@ td.num, th.num { text-align: right; white-space: nowrap; }
.setting p { margin: 2px 0 0; color: var(--muted); font-size: 14px; }
.setting .control { display: flex; justify-content: flex-end; }
.setting code { font: 13px ui-monospace, "Cascadia Mono", Menlo, monospace; color: var(--faint); }
.setting .control .tag + .switch { margin-left: 10px; }
/* Notifications page. */
.notif-group {
padding: 14px 18px 2px;
color: var(--faint);
font: 600 12px var(--body);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.quiet-times { display: flex; align-items: center; gap: 8px; }
.quiet-times .input { width: 120px; }
.savebar {
position: sticky;

View file

@ -1,5 +1,6 @@
// The service worker is what lets the site be installed as an app. It caches nothing but the
// offline page: every request still goes to the network, so nobody sees stale bans or ranks.
// The service worker lets the site be installed as an app, and shows staff notifications. It caches
// nothing but the offline page: every request still goes to the network, so nobody sees stale bans
// or ranks.
const CACHE = "offline-v1";
const OFFLINE = "/static/offline.html";
@ -21,3 +22,54 @@ self.addEventListener("fetch", (e) => {
if (e.request.mode !== "navigate") return;
e.respondWith(fetch(e.request).catch(() => caches.match(OFFLINE)));
});
// Staff notifications (internal/web/notify.go). The panel sends title, body, icon, tag, url and
// actions; a notification with the same tag replaces the last one quietly.
self.addEventListener("push", (e) => {
let d = {};
try { d = e.data ? e.data.json() : {}; } catch { d = { body: e.data.text() }; }
e.waitUntil(self.registration.showNotification(d.title || "zio.sh", {
body: d.body || "",
icon: d.icon || "/static/icon-192.png",
badge: "/static/badge-96.png",
tag: d.tag,
timestamp: d.timestamp,
actions: d.actions || [],
data: { url: d.url || "/" },
}));
});
// Tapping the notification or its "View player" button opens its page, in an open window if
// there is one.
self.addEventListener("notificationclick", (e) => {
e.notification.close();
const url = new URL(e.notification.data?.url || "/", self.location.origin).href;
e.waitUntil((async () => {
const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const win = wins.find((w) => new URL(w.url).origin === self.location.origin);
if (win) {
try {
await win.focus();
await win.navigate(url);
return;
} catch { /* not ours to navigate; open a new one */ }
}
await self.clients.openWindow(url);
})());
});
// Browsers sometimes replace a subscription. Hand the new one to the panel so notifications keep
// coming; this only works while the staff member's session cookie is still valid.
self.addEventListener("pushsubscriptionchange", (e) => {
e.waitUntil((async () => {
const opts = e.oldSubscription?.options;
const sub = e.newSubscription || (opts && await self.registration.pushManager.subscribe(opts));
if (!sub) return;
await fetch("/api/admin/notifications/subscribe", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-Requested-With": "simpleadmin-web" },
body: JSON.stringify({ ...sub.toJSON(), userAgent: navigator.userAgent }),
});
})());
});

View file

@ -166,10 +166,10 @@ func (s *Server) adminPage(static fs.FS) http.HandlerFunc {
slog.Error("admin page", "err", err)
}
if id == nil {
http.ServeFileFS(w, r, static, "signin.html")
serveAsset(w, r, static, "signin.html")
return
}
http.ServeFileFS(w, r, static, "admin.html")
serveAsset(w, r, static, "admin.html")
}
}

587
internal/web/notify.go Normal file
View file

@ -0,0 +1,587 @@
package web
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"git.zio.sh/cs2/simpleadmin-web/internal/push"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
)
// Push notifications for staff. A watcher polls SimpleAdmin's tables for new bans, comm blocks and
// lifts (so ones made in game are caught too) and the live poller for the server going down, and
// sends each to the devices of staff who want that type and aren't in their quiet hours.
// notifyType is one kind of notification staff can pick. Ready is false for ones the panel can't
// send yet: the page shows them, switched off.
type notifyType struct {
Key string `json:"key"`
Group string `json:"group"`
Label string `json:"label"`
Help string `json:"help,omitempty"`
Ready bool `json:"ready"`
}
var notifyTypes = []notifyType{
{Key: "ban", Group: "Bans", Label: "Ban added", Ready: true},
{Key: "unban", Group: "Bans", Label: "Ban lifted", Ready: true},
{Key: "gag", Group: "Gags", Label: "Gag added", Ready: true},
{Key: "ungag", Group: "Gags", Label: "Gag lifted", Ready: true},
{Key: "mute", Group: "Mutes", Label: "Mute added", Ready: true},
{Key: "unmute", Group: "Mutes", Label: "Mute lifted", Ready: true},
{Key: "silence", Group: "Silences", Label: "Silence added", Ready: true},
{Key: "unsilence", Group: "Silences", Label: "Silence lifted", Ready: true},
{Key: "server", Group: "Server", Label: "Server down and back up", Help: "When the panel loses the game server for 30 seconds, and when it's back.", Ready: true},
{Key: "livescore", Group: "Server", Label: "Live score", Help: "An updating scoreboard while people are playing. Coming later."},
{Key: "calladmin", Group: "Players", Label: "Admin called", Help: "A player asks for an admin in game. Needs a server plugin, coming later."},
}
// readyTypes are the keys that can be switched on, which is also everyone's default.
func readyTypes() []string {
var out []string
for _, t := range notifyTypes {
if t.Ready {
out = append(out, t.Key)
}
}
return out
}
// commKey maps SimpleAdmin's comm types to notification types.
var commKey = map[string]string{"GAG": "gag", "MUTE": "mute", "SILENCE": "silence"}
// ---------------------------------------------------------------- Preferences
type quietView struct {
Enabled bool `json:"enabled"`
Start string `json:"start"` // HH:MM
End string `json:"end"`
}
type prefsView struct {
Types []string `json:"types"`
Quiet quietView `json:"quiet"`
Timezone string `json:"timezone"`
}
func defaultPrefs() store.PushPrefs {
return store.PushPrefs{Types: readyTypes(), QuietStart: 23 * 60, QuietEnd: 8 * 60, Timezone: "UTC"}
}
func hhmm(m int) string {
return strconv.Itoa(100 + m/60)[1:] + ":" + strconv.Itoa(100 + m%60)[1:]
}
var hhmmRe = regexp.MustCompile(`^([01]\d|2[0-3]):([0-5]\d)$`)
func parseHHMM(s string) (int, bool) {
m := hhmmRe.FindStringSubmatch(s)
if m == nil {
return 0, false
}
h, _ := strconv.Atoi(m[1])
min, _ := strconv.Atoi(m[2])
return h*60 + min, true
}
// inQuietHours reports whether now falls in someone's quiet hours, in their timezone.
func inQuietHours(p store.PushPrefs, now time.Time) bool {
if !p.QuietEnabled || p.QuietStart == p.QuietEnd {
return false
}
loc, err := time.LoadLocation(p.Timezone)
if err != nil {
loc = time.UTC
}
t := now.In(loc)
m := t.Hour()*60 + t.Minute()
if p.QuietStart < p.QuietEnd {
return m >= p.QuietStart && m < p.QuietEnd
}
return m >= p.QuietStart || m < p.QuietEnd
}
type notificationsView struct {
Available bool `json:"available"`
PublicKey string `json:"publicKey,omitempty"`
Types []notifyType `json:"types"`
Prefs prefsView `json:"prefs"`
// SHA-256 (hex) of each device's endpoint, so a browser can tell if it's one of them.
Devices []string `json:"devices"`
}
func (s *Server) adminNotifications(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
v := notificationsView{Available: s.cfg.Push != nil, Types: notifyTypes, Devices: []string{}}
p := defaultPrefs()
if s.cfg.Push != nil {
v.PublicKey = s.cfg.Push.Keys.Public()
saved, ok, err := s.st.PushPrefsFor(r.Context(), id.SteamID)
if err != nil {
internal(w, r, err)
return
}
if ok {
p = saved
}
if v.Devices, err = s.st.PushDeviceHashes(r.Context(), id.SteamID); err != nil {
internal(w, r, err)
return
}
}
v.Prefs = prefsView{Types: p.Types, Quiet: quietView{Enabled: p.QuietEnabled, Start: hhmm(p.QuietStart), End: hhmm(p.QuietEnd)}, Timezone: p.Timezone}
writeJSON(w, http.StatusOK, v)
}
func (s *Server) needPush(w http.ResponseWriter) bool {
if s.cfg.Push == nil {
fail(w, http.StatusServiceUnavailable, "Notifications aren't set up on the panel. Its log says why.")
return false
}
return true
}
func (s *Server) saveNotificationPrefs(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !s.needPush(w) {
return
}
var req prefsView
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
p := store.PushPrefs{Types: []string{}, QuietEnabled: req.Quiet.Enabled, Timezone: req.Timezone}
ready := readyTypes()
for _, t := range req.Types {
// Types that aren't ready can't be switched on yet.
if slices.Contains(ready, t) && !slices.Contains(p.Types, t) {
p.Types = append(p.Types, t)
}
}
var ok1, ok2 bool
p.QuietStart, ok1 = parseHHMM(req.Quiet.Start)
p.QuietEnd, ok2 = parseHHMM(req.Quiet.End)
if !ok1 || !ok2 {
fail(w, http.StatusBadRequest, "Quiet hours need a start and end time, like 23:00.")
return
}
if _, err := time.LoadLocation(p.Timezone); err != nil || p.Timezone == "" || len(p.Timezone) > 64 {
p.Timezone = "UTC"
}
if err := s.st.SavePushPrefs(r.Context(), id.SteamID, p); err != nil {
internal(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Saved."})
}
// subscribeRequest is PushSubscription.toJSON(), plus the browser's user agent.
type subscribeRequest struct {
Endpoint string `json:"endpoint"`
ExpirationTime *float64 `json:"expirationTime"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
UserAgent string `json:"userAgent"`
// The browser's timezone, for the quiet hours in the first saved preferences.
Timezone string `json:"timezone"`
}
func (s *Server) subscribeNotifications(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !s.needPush(w) {
return
}
var req subscribeRequest
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
sub := push.Subscription{Endpoint: req.Endpoint, P256dh: req.Keys.P256dh, Auth: req.Keys.Auth}
if len(req.Endpoint) > 1024 || !push.AllowedEndpoint(req.Endpoint) {
fail(w, http.StatusBadRequest, "This browser's push service isn't one the panel knows. Try Chrome, Firefox, Edge or Safari.")
return
}
if err := sub.Check(); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if err := s.st.SavePushDevice(r.Context(), id.SteamID, sub.Endpoint, sub.P256dh, sub.Auth, req.UserAgent); err != nil {
internal(w, r, err)
return
}
// Save the defaults the page showed, so they're what the watcher uses.
if _, ok, err := s.st.PushPrefsFor(r.Context(), id.SteamID); err == nil && !ok {
p := defaultPrefs()
if _, err := time.LoadLocation(req.Timezone); err == nil && req.Timezone != "" && len(req.Timezone) <= 64 {
p.Timezone = req.Timezone
}
_ = s.st.SavePushPrefs(r.Context(), id.SteamID, p)
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Notifications are on for this device."})
}
func (s *Server) unsubscribeNotifications(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !s.needPush(w) {
return
}
var req struct {
Endpoint string `json:"endpoint"`
All bool `json:"all"`
}
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if req.Endpoint == "" && !req.All {
fail(w, http.StatusBadRequest, "Say which device to turn off.")
return
}
if req.All {
req.Endpoint = ""
}
n, err := s.st.DeletePushDevice(r.Context(), id.SteamID, req.Endpoint)
if err != nil {
internal(w, r, err)
return
}
msg := "Notifications are off for this device."
if req.All {
msg = "Notifications are off on all your devices."
if n == 1 {
msg = "Notifications are off on your 1 device."
}
}
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
}
func (s *Server) testNotification(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !s.needPush(w) {
return
}
devices, err := s.st.PushDevices(r.Context(), id.SteamID)
if err != nil {
internal(w, r, err)
return
}
if len(devices) == 0 {
fail(w, http.StatusBadRequest, "Turn notifications on for a device first.")
return
}
n := notification{
Title: "Test notification",
Body: "Notifications from " + s.site.Title + " are working on this device.",
Tag: "test",
URL: "/admin#notifications",
}
sent := s.deliver(r.Context(), devices, n)
if sent == 0 {
fail(w, http.StatusBadGateway, "The push service didn't accept the notification. The panel's log has the details.")
return
}
msg := "Sent to your device."
if sent > 1 {
msg = "Sent to your " + strconv.Itoa(sent) + " devices."
}
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
}
// ---------------------------------------------------------------- Sending
// notification is what the service worker shows (see sw.js).
type notification struct {
Type string `json:"-"`
Actor string `json:"-"` // SteamID64 of whoever caused it; they aren't told about their own actions
Urgency string `json:"-"`
TTL time.Duration `json:"-"`
Topic string `json:"-"`
Title string `json:"title"`
Body string `json:"body"`
Icon string `json:"icon,omitempty"`
Tag string `json:"tag,omitempty"`
URL string `json:"url"`
Actions []notifAction `json:"actions,omitempty"`
Time int64 `json:"timestamp"`
}
type notifAction struct {
Action string `json:"action"`
Title string `json:"title"`
}
// deliver sends one notification to these devices and returns how many accepted it. Devices the
// push service says are gone are deleted.
func (s *Server) deliver(ctx context.Context, devices []store.PushDevice, n notification) int {
if n.Time == 0 {
n.Time = time.Now().UnixMilli()
}
if n.TTL == 0 {
n.TTL = 4 * time.Hour
}
if n.Urgency == "" {
n.Urgency = "normal"
}
payload, _ := json.Marshal(n)
msg := push.Message{Payload: payload, TTL: n.TTL, Urgency: n.Urgency, Topic: n.Topic}
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, 8)
sent := 0
for _, d := range devices {
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
sctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
err := s.cfg.Push.Send(sctx, push.Subscription{Endpoint: d.Endpoint, P256dh: d.P256dh, Auth: d.Auth}, msg)
switch {
case errors.Is(err, push.ErrGone):
slog.Info("push device unsubscribed, removing it", "steamid", d.SteamID, "device", d.ID)
_ = s.st.DeletePushDeviceID(context.WithoutCancel(ctx), d.ID)
case err != nil:
host := ""
if u, perr := url.Parse(d.Endpoint); perr == nil {
host = u.Host
}
slog.Warn("push failed", "steamid", d.SteamID, "device", d.ID, "service", host, "err", err)
default:
mu.Lock()
sent++
mu.Unlock()
}
}()
}
wg.Wait()
return sent
}
// broadcast sends a notification to every staff member who wants its type right now.
func (s *Server) broadcast(ctx context.Context, n notification) {
devices, err := s.st.PushDevices(ctx, "")
if err != nil || len(devices) == 0 {
if err != nil {
slog.Warn("notifications: couldn't list devices", "err", err)
}
return
}
prefs, err := s.st.AllPushPrefs(ctx)
if err != nil {
slog.Warn("notifications: couldn't read preferences", "err", err)
return
}
x, err := s.staffIdx(ctx)
if err != nil {
slog.Warn("notifications: couldn't read staff", "err", err)
return
}
now := time.Now()
var to []store.PushDevice
for _, d := range devices {
if d.SteamID == n.Actor || !panelStaff(x, d.SteamID) {
continue
}
p, ok := prefs[d.SteamID]
if !ok {
p = defaultPrefs()
}
if !slices.Contains(p.Types, n.Type) || inQuietHours(p, now) {
continue
}
to = append(to, d)
}
if len(to) > 0 {
s.deliver(ctx, to, n)
}
}
// panelStaff is whether someone may still use the panel (see identity), so people removed from
// staff stop getting notifications at once, before their devices are cleaned up.
func panelStaff(x *staffIndex, steamid string) bool {
a, ok := x.bySteam[steamid]
if !ok {
return false
}
flags := x.flags(a)
return slices.Contains(flags, "@css/generic") || slices.Contains(flags, "@css/root")
}
// ---------------------------------------------------------------- Watching for events
const (
notifyPoll = 5 * time.Second
// New rows wait this long before they're sent: when the panel issues a penalty over RCON,
// SimpleAdmin writes it as Console and the panel credits the real staff member a few seconds
// later (ClaimConsoleRow, ClaimLift).
notifySettle = 7 * time.Second
// The game server must be unreachable this long before staff are told.
serverDownAfter = 30 * time.Second
)
// RunNotifier watches for events to notify staff about, until ctx is done. It does nothing when
// push isn't set up.
func (s *Server) RunNotifier(ctx context.Context) {
if s.cfg.Push == nil {
return
}
w := &watcher{s: s, after: map[string]int64{}, seen: map[string]time.Time{}}
for kind, table := range store.PenaltyEventTable {
w.after[kind] = s.st.MaxID(ctx, table)
}
t := time.NewTicker(notifyPoll)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.penalties(ctx)
w.server(ctx)
}
}
}
type watcher struct {
s *Server
after map[string]int64 // last row handled, per kind
seen map[string]time.Time // when an unhandled row was first seen, by kind:id
serverSeenUp bool
downSince time.Time
downSent bool
}
func (w *watcher) penalties(ctx context.Context) {
now := time.Now()
for kind := range store.PenaltyEventTable {
events, err := w.s.st.PenaltyEvents(ctx, kind, w.after[kind])
if err != nil {
slog.Warn("notifications: couldn't read new penalties", "kind", kind, "err", err)
continue
}
waiting := false
for _, e := range events {
key := kind + ":" + strconv.FormatInt(e.ID, 10)
first, ok := w.seen[key]
if !ok {
w.seen[key] = now
first = now
}
// Keep order: once a row is waiting, later ones wait too, but their clocks still start.
if waiting = waiting || now.Sub(first) < notifySettle; waiting {
continue
}
delete(w.seen, key)
w.after[kind] = e.ID
if n, ok := penaltyNotification(e); ok {
w.s.broadcast(ctx, n)
}
}
}
}
func (w *watcher) server(ctx context.Context) {
st := w.s.poll.Current()
if st.Updated.IsZero() {
return
}
if st.Online {
if w.downSent {
w.s.broadcast(ctx, notification{
Type: "server", Urgency: "high", TTL: time.Hour, Topic: "server", Tag: "server",
Title: "Server back online",
Body: st.Map + ", " + strconv.Itoa(len(st.Humans())) + "/" + strconv.Itoa(st.MaxPlayers) + " players.",
Icon: "/static/icon-192.png", URL: "/",
})
}
w.serverSeenUp, w.downSince, w.downSent = true, time.Time{}, false
return
}
// Only a server that was up can go down: no alert when the panel starts with it offline.
if !w.serverSeenUp || w.downSent {
return
}
if w.downSince.IsZero() {
w.downSince = time.Now()
}
if time.Since(w.downSince) < serverDownAfter {
return
}
w.downSent = true
w.s.broadcast(ctx, notification{
Type: "server", Urgency: "high", TTL: time.Hour, Topic: "server", Tag: "server",
Title: "Server unreachable",
Body: "No answer over RCON since " + w.downSince.In(w.s.st.Location()).Format("15:04") + ". Bans and blocks still save, and apply when players rejoin.",
Icon: "/static/icon-192.png", URL: "/admin",
})
}
// penaltyNotification describes a new penalty or lift. The link opens the public ban or comm list
// searching for the player.
func penaltyNotification(e store.PenaltyEvent) (notification, bool) {
var key, noun, list string
switch e.Kind {
case "ban", "unban":
key, noun, list = "ban", "Ban", "bans"
case "comm", "unmute":
key = commKey[e.Type]
if key == "" {
return notification{}, false
}
noun, list = strings.ToUpper(key[:1])+key[1:], "comms"
default:
return notification{}, false
}
lifted := e.Kind == "unban" || e.Kind == "unmute"
verb := "added"
if lifted {
key, verb = "un"+key, "lifted"
}
name := e.Name
if name == "" {
name = e.SteamID
}
admin := e.AdminName
if admin == "" || e.AdminSteamID == "" && strings.EqualFold(admin, "console") {
admin = "Console"
}
parts := []string{admin}
if !lifted {
if e.Duration == 0 {
parts = append(parts, "Permanent")
} else {
parts = append(parts, fmtMinutes(e.Duration))
}
}
if r := strings.TrimSpace(e.Reason); r != "" {
parts = append(parts, r)
}
n := notification{
Type: key,
Actor: e.AdminSteamID,
Title: noun + " " + verb + ": " + name,
Body: strings.Join(parts, " · "),
Tag: e.Kind + "-" + strconv.FormatInt(e.ID, 10),
URL: "/#" + list + "?q=" + url.QueryEscape(e.SteamID),
Actions: []notifAction{{Action: "view", Title: "View player"}},
}
if e.SteamID != "" {
n.Icon = "/avatar/" + e.SteamID
}
if r := []rune(n.Body); len(r) > 300 {
n.Body = string(r[:300]) + "…"
}
return n, true
}

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")
}

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)
}
}
}

View file

@ -94,7 +94,6 @@ func (s *Server) assetLinks(w http.ResponseWriter, r *http.Request) {
// serviceWorker serves sw.js from the root, since a worker only controls pages under its own path.
func serviceWorker(static fs.FS) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
http.ServeFileFS(w, r, static, "sw.js")
serveAsset(w, r, static, "sw.js")
}
}

View file

@ -60,3 +60,23 @@ func TestAssetLinks(t *testing.T) {
t.Errorf("status %d, body %s", rec.Code, got)
}
}
// TestAssetCaching checks embedded files must be revalidated, and answer 304 while unchanged.
func TestAssetCaching(t *testing.T) {
h := (&Server{site: defaultSite()}).Handler()
for _, path := range []string{"/", "/static/admin.js", "/static/styles.css", "/sw.js"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
tag := rec.Header().Get("ETag")
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-cache" || tag == "" {
t.Fatalf("%s: status %d, Cache-Control %q, ETag %q", path, rec.Code, rec.Header().Get("Cache-Control"), tag)
}
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("If-None-Match", tag)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotModified {
t.Errorf("%s with its ETag: status %d, want 304", path, rec.Code)
}
}
}

View file

@ -14,6 +14,7 @@ import (
"time"
"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"
@ -35,6 +36,8 @@ type Config struct {
// the SHA-256 fingerprints of the keys it's signed with. Empty serves no assetlinks.json.
AndroidPackage string
AndroidCerts []string
// Push sends staff notifications; nil turns them off.
Push *push.Sender
}
type Server struct {
@ -81,10 +84,13 @@ func (s *Server) Handler() http.Handler {
files := http.FileServerFS(static)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, static, "public.html")
serveAsset(w, r, static, "public.html")
})
mux.HandleFunc("GET /admin", s.adminPage(static))
mux.Handle("GET /static/", http.StripPrefix("/static/", files))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
revalidate(w, r.URL.Path)
files.ServeHTTP(w, r)
})))
mux.HandleFunc("GET /manifest.webmanifest", s.manifest(false))
mux.HandleFunc("GET /admin.webmanifest", s.manifest(true))
mux.HandleFunc("GET /sw.js", serviceWorker(static))
@ -133,6 +139,12 @@ func (s *Server) Handler() http.Handler {
admin("DELETE /api/admin/verify/overrides/{steamid}", s.removeOverride)
admin("POST /api/admin/verify/settings", s.saveVerifySettings)
admin("GET /api/admin/notifications", s.adminNotifications)
admin("PUT /api/admin/notifications/prefs", s.saveNotificationPrefs)
admin("POST /api/admin/notifications/subscribe", s.subscribeNotifications)
admin("POST /api/admin/notifications/unsubscribe", s.unsubscribeNotifications)
admin("POST /api/admin/notifications/test", s.testNotification)
return s.headers(mux)
}