diff --git a/README.md b/README.md index 1feb55b..79fdd17 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ A SourceBans-style web panel for [CS2-SimpleAdmin](https://github.com/daffyyyy/C - **Staff panel** (`/admin`): sign in through Steam. Ban, gag, mute, silence, warn and kick; lift bans and blocks; manage ranks, permissions and staff; change server cvars and SimpleAdmin's punishment settings. +- **Notifications** (staff): push notifications for new and lifted bans, gags, mutes and silences, + and the game server going down, on phones and computers, with quiet hours (see below). - **Verification** (optional): who our GroupVerify plugin verifies, with overrides to give and remove, and its settings. Only appears when GroupVerify is installed (see below). @@ -163,6 +165,28 @@ rsvg-convert -w 512 -h 512 icon-maskable.svg -o icon-maskable-512.png rsvg-convert -w 180 -h 180 icon-maskable.svg -o apple-touch-icon.png ``` +### Notifications + +Staff turn notifications on per device from the panel's **Notifications** page, and pick which +types they want and their quiet hours there; those apply to all their devices. It's standard Web +Push: it works in Chrome, Edge, Firefox and Safari (on iPhone only from a home-screen install), and +in the Android app, where they show as the app's own. + +- **Types**: ban added or lifted, and the same for gags, mutes and silences, each on its own + switch; and the game server down (no RCON answer for 30 seconds) and back up. "Live score" and + "Admin called" are listed but switched off until they're built (admin calls need a plugin). +- **What's watched**: SimpleAdmin's `sa_bans`, `sa_mutes`, `sa_unbans` and `sa_unmutes`, every 5 + seconds, so penalties made in game are sent too, not just ones from the panel. New rows wait 7 + seconds first, so panel actions are credited to the staff member rather than "Console". +- **Who gets them**: staff who can use the panel (`@css/generic`), checked on every send, so people + removed from staff stop getting them at once. Nobody is told about their own actions. +- **Links**: tapping a penalty notification, or its **View player** button, opens the public ban or + gag/mute list searching for the player (`/#bans?q=`, `/#comms?q=...`). +- **Setup**: none. The panel creates `saw_push_devices`, `saw_push_prefs` and `saw_settings` at + startup (`sql/push.sql`), and makes its VAPID key on first start, kept in `saw_settings`. Deleting + that key stops every device until it's turned on again. Messages are encrypted for each device + (RFC 8291) and only sent to the browsers' own push services (Google, Mozilla, Apple, Microsoft). + ### Android app `android/` is the site as an Android app: a Trusted Web Activity, which opens the public site in @@ -183,7 +207,9 @@ Back it up: an APK signed with another key won't install over the old one. Then: - Bump `versionCode` in `android/app/build.gradle` for every APK you hand out. - The site and start page are at the top of `android/app/build.gradle`; never change `applicationId`. -The app needs Chrome (or another browser with TWA support) on the phone. +The app needs Chrome (or another browser with TWA support) on the phone. Notifications from the +panel are delegated to the app (`DelegationService`), so they show under its name and icon, and +Android 13+ asks for the app's notification permission when they're turned on. ## Limitations and caveats @@ -217,5 +243,9 @@ podman run -d --rm --name sa-test -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE= SAW_TEST_DSN='root:test@tcp(127.0.0.1:33061)/sa?parseTime=true&loc=UTC' go test ./internal/web -run VerifyDB ``` +`internal/web/notify_db_test.go` does the same for notifications (`-run NotifyDB`): the endpoints, +and the watcher sending to a fake push service. It needs only SimpleAdmin's migrations, and empties +its ban, mute, admin and group tables and the panel's push tables. + The pages are plain HTML, CSS and JS in `internal/web/assets`, embedded into the binary. The design mockup they came from is in `docs/mockup` at the repo root. diff --git a/android/app/build.gradle b/android/app/build.gradle index 6e45092..7b221de 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -17,7 +17,7 @@ android { minSdk 24 targetSdk 36 // Bump versionCode for every APK you hand out, or Android refuses to update over the old one. - versionCode 2 + versionCode 3 versionName "1.0" resValue "string", "launch_url", siteURL + startPath diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 08474ae..58168a3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ androidbrowserhelper's LauncherActivity does all of it; there is no code of our own. --> + + + + + + + + + + + + + + + + + + + diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..40cc0f3 --- /dev/null +++ b/internal/push/push.go @@ -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 +} diff --git a/internal/push/push_test.go b/internal/push/push_test.go new file mode 100644 index 0000000..d1a9364 --- /dev/null +++ b/internal/push/push_test.go @@ -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 +} diff --git a/internal/store/push.go b/internal/store/push.go new file mode 100644 index 0000000..408883e --- /dev/null +++ b/internal/store/push.go @@ -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() +} diff --git a/internal/store/store.go b/internal/store/store.go index 2e1ee5f..ab1cc11 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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) diff --git a/internal/web/assets.go b/internal/web/assets.go new file mode 100644 index 0000000..0a5b4f9 --- /dev/null +++ b/internal/web/assets.go @@ -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) +} diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js index cc2b5c9..5199b85 100644 --- a/internal/web/assets/admin.js +++ b/internal/web/assets/admin.js @@ -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) { `); } +/* ---------- 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) => `
+
${label}${help ? `

${help}

` : ""}
+
${control}
+
`; + + let device; + if (!d.available) device = row("This device", "Notifications aren't set up on the panel. Its log says why.", 'Unavailable'); + 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.", 'Not supported'); + 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.", 'Blocked'); + else if (here) device = row("This device", "Notifications are on here.", '
'); + else device = row("This device", "Turn notifications on to get the ones picked below here, even with the panel closed.", ''); + + const dis = d.available ? "" : "disabled"; + const groups = [...new Set(d.types.map((t) => t.group))]; + v.innerHTML = ` +
+

Notifications

Get bans, gags, mutes and server alerts on your phone or computer. Nothing about your own actions is sent to you.

+
+
+
+

Devices

+ ${device} + ${others > 0 ? row(`${here ? "Other devices" : "Your devices"}`, `${others} ${others === 1 ? "device has" : "devices have"} notifications on.`, '') : ""} +
+ +
+

Quiet hours

Nothing is sent between these times, in your timezone (${esc(browserTimezone())}).

+ ${row("Quiet hours", "", ``)} + ${row("From and until", "", `
to
`)} +
+ +
+

What to send

For all your devices.

+ ${groups.map((g) => `
${esc(g)}
+ ${d.types.filter((t) => t.group === g).map((t) => row(esc(t.label), t.help ? esc(t.help) : "", + t.ready + ? `` + : `Coming later`)).join("")}`).join("")} +
+
`; +} + +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(); } diff --git a/internal/web/assets/badge-96.png b/internal/web/assets/badge-96.png new file mode 100644 index 0000000..0e37ce6 Binary files /dev/null and b/internal/web/assets/badge-96.png differ diff --git a/internal/web/assets/public.js b/internal/web/assets/public.js index bbf27c5..bc0ea2c 100644 --- a/internal/web/assets/public.js +++ b/internal/web/assets/public.js @@ -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= (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) => `
  • ${p.label}
  • `).join(""); RENDER[state.page](); } diff --git a/internal/web/assets/styles.css b/internal/web/assets/styles.css index 3526afa..c0d3120 100644 --- a/internal/web/assets/styles.css +++ b/internal/web/assets/styles.css @@ -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; diff --git a/internal/web/assets/sw.js b/internal/web/assets/sw.js index 47a780f..8f56ee7 100644 --- a/internal/web/assets/sw.js +++ b/internal/web/assets/sw.js @@ -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 }), + }); + })()); +}); diff --git a/internal/web/auth.go b/internal/web/auth.go index 86b4a3e..268fbcf 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -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") } } diff --git a/internal/web/notify.go b/internal/web/notify.go new file mode 100644 index 0000000..a9bca94 --- /dev/null +++ b/internal/web/notify.go @@ -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 +} diff --git a/internal/web/notify_db_test.go b/internal/web/notify_db_test.go new file mode 100644 index 0000000..21e6f53 --- /dev/null +++ b/internal/web/notify_db_test.go @@ -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") +} diff --git a/internal/web/notify_test.go b/internal/web/notify_test.go new file mode 100644 index 0000000..b00a4a6 --- /dev/null +++ b/internal/web/notify_test.go @@ -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) + } + } +} diff --git a/internal/web/pwa.go b/internal/web/pwa.go index 83bc364..0ed0b13 100644 --- a/internal/web/pwa.go +++ b/internal/web/pwa.go @@ -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") } } diff --git a/internal/web/pwa_test.go b/internal/web/pwa_test.go index d6e6614..3348e9f 100644 --- a/internal/web/pwa_test.go +++ b/internal/web/pwa_test.go @@ -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) + } + } +} diff --git a/internal/web/server.go b/internal/web/server.go index d5327f6..5e6ab93 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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) } diff --git a/main.go b/main.go index 64a0005..5914170 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/sql/push.sql b/sql/push.sql new file mode 100644 index 0000000..5d81e6f --- /dev/null +++ b/sql/push.sql @@ -0,0 +1,46 @@ +-- Push notifications for staff: the panel's own tables, not SimpleAdmin's. +-- +-- The panel creates these itself at startup (this file is embedded in the binary), so running it +-- by hand is only needed if the panel's database user can't CREATE TABLE. +-- +-- saw_push_devices: one row per browser or app that turned notifications on. endpoint is the push +-- service URL the browser gave, endpoint_hash its SHA-256 (hex) for the unique key, p256dh and +-- auth the browser's keys for encrypting messages to it. Rows are deleted when the push service +-- says the subscription is gone. +-- +-- saw_push_prefs: what each staff member wants, for all their devices. types is a comma-separated +-- list of notification types (ban, unban, gag, ungag, ...). Quiet hours are minutes after midnight +-- in their own timezone (an IANA name, from their browser); start after end wraps past midnight. +-- +-- saw_settings: panel settings. vapid_private_key is the key push services know the panel by; +-- changing or deleting it stops every device getting notifications until it turns them on again. + +CREATE TABLE IF NOT EXISTS `saw_push_devices` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `steamid` bigint(20) NOT NULL, + `endpoint` varchar(1024) NOT NULL, + `endpoint_hash` char(64) NOT NULL, + `p256dh` varchar(128) NOT NULL, + `auth` varchar(64) NOT NULL, + `user_agent` varchar(255) NOT NULL DEFAULT '', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_push_devices_endpoint` (`endpoint_hash`), + KEY `idx_push_devices_steamid` (`steamid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `saw_push_prefs` ( + `steamid` bigint(20) NOT NULL, + `types` varchar(512) NOT NULL DEFAULT '', + `quiet_enabled` tinyint(1) NOT NULL DEFAULT 0, + `quiet_start` smallint(6) NOT NULL DEFAULT 1380, + `quiet_end` smallint(6) NOT NULL DEFAULT 480, + `timezone` varchar(64) NOT NULL DEFAULT 'UTC', + PRIMARY KEY (`steamid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `saw_settings` ( + `name` varchar(64) NOT NULL, + `value` text NOT NULL, + PRIMARY KEY (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;