Staff push notifications, and revalidate embedded assets

Notifications: staff turn them on per device from a new Notifications page,
pick each type (bans, gags, mutes and silences added or lifted, server down
and back up) and quiet hours. A watcher polls SimpleAdmin's tables, so in-game
penalties are sent too. Web Push with VAPID and RFC 8291 encryption in
internal/push, standard library only. Tables in sql/push.sql, created at
startup; the VAPID key is made on first start and kept in saw_settings.
Penalty notifications link to the public list searching for the player
(#bans?q=<steamid64>). The Android app delegates notifications (versionCode 3).

Caching: pages, scripts and styles are served with Cache-Control: no-cache
and an ETag, so a deploy shows up at once instead of after Cloudflare's
default 4-hour browser cache.
This commit is contained in:
Astra 2026-10-03 13:49:40 +01:00
parent 7900bf5b3e
commit 17232bd5a2
23 changed files with 2135 additions and 12 deletions

View file

@ -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 - **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 bans and blocks; manage ranks, permissions and staff; change server cvars and SimpleAdmin's
punishment settings. 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 - **Verification** (optional): who our GroupVerify plugin verifies, with overrides to give and
remove, and its settings. Only appears when GroupVerify is installed (see below). 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 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=<steamid64>`, `/#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 app
`android/` is the site as an Android app: a Trusted Web Activity, which opens the public site in `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. - 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 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 ## 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 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 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. design mockup they came from is in `docs/mockup` at the repo root.

View file

@ -17,7 +17,7 @@ android {
minSdk 24 minSdk 24
targetSdk 36 targetSdk 36
// Bump versionCode for every APK you hand out, or Android refuses to update over the old one. // Bump versionCode for every APK you hand out, or Android refuses to update over the old one.
versionCode 2 versionCode 3
versionName "1.0" versionName "1.0"
resValue "string", "launch_url", siteURL + startPath resValue "string", "launch_url", siteURL + startPath

View file

@ -3,6 +3,9 @@
androidbrowserhelper's LauncherActivity does all of it; there is no code of our own. --> androidbrowserhelper's LauncherActivity does all of it; there is no code of our own. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Android 13+ asks before an app may show notifications; the site's request comes here. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@ -66,6 +69,23 @@
android:name="com.google.androidbrowserhelper.trusted.WebViewFallbackActivity" android:name="com.google.androidbrowserhelper.trusted.WebViewFallbackActivity"
android:configChanges="orientation|screenSize" /> android:configChanges="orientation|screenSize" />
<!-- Notification delegation: Chrome hands the site's push notifications to this service, so
they show as this app's, with its icon and settings, instead of Chrome's. -->
<service
android:name="com.google.androidbrowserhelper.trusted.DelegationService"
android:enabled="true"
android:exported="true">
<meta-data
android:name="android.support.customtabs.trusted.SMALL_ICON"
android:resource="@drawable/ic_notification" />
<intent-filter>
<action android:name="android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
<!-- Shows Android's notification permission prompt when the site asks for it. -->
<activity android:name="com.google.androidbrowserhelper.trusted.NotificationPermissionRequestActivity" />
<!-- Hands the splash image to Chrome while the panel loads. --> <!-- Hands the splash image to Chrome while the panel loads. -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The status bar icon for notifications: the shield, white on transparent (Android tints it). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M16,2L28,7v9c0,7 -5,12 -12,14C9,28 4,23 4,16V7z"
android:strokeWidth="2.8"
android:strokeColor="#FFFFFFFF"
android:fillColor="#00000000" />
<path
android:pathData="M11,16h10M16,11v10"
android:strokeWidth="2.8"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round" />
</vector>

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 } 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. // wall turns a time into the naive wall-clock form SimpleAdmin stores.
func (s *Store) wall(t time.Time) time.Time { func (s *Store) wall(t time.Time) time.Time {
t = t.In(s.loc) 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. // Only when GroupVerify's tables are in the database.
{ id: "verify", label: "Verification", show: () => !!state.me?.verify }, { id: "verify", label: "Verification", show: () => !!state.me?.verify },
{ id: "settings", label: "Server settings" }, { id: "settings", label: "Server settings" },
{ id: "notifications", label: "Notifications" },
]; ];
const pageAllowed = (p) => (!p.flag || can(p.flag)) && (!p.show || p.show()); const pageAllowed = (p) => (!p.flag || can(p.flag)) && (!p.show || p.show());
@ -517,6 +518,136 @@ function overrideDialog(sid, name) {
</form>`); </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 ---------- */ /* ---------- Settings ---------- */
async function settings() { async function settings() {
@ -812,7 +943,7 @@ async function act(path, method, body) {
/* ---------- Routing & events ---------- */ /* ---------- Routing & events ---------- */
const RENDER = { overview, players, bans, comms, staff, verify, settings }; const RENDER = { overview, players, bans, comms, staff, verify, settings, notifications };
function renderNav() { function renderNav() {
const c = state.counts; const c = state.counts;
@ -849,6 +980,7 @@ document.addEventListener("input", (e) => {
document.addEventListener("change", (e) => { document.addEventListener("change", (e) => {
if (e.target.matches("select[data-sa]")) { e.target.classList.add("dirty"); markDirty(); } 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) => { document.addEventListener("click", (e) => {
@ -888,6 +1020,11 @@ document.addEventListener("click", (e) => {
} }
const sw = t.closest(".switch"); 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) { if (sw && !sw.disabled) {
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true")); sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
sw.classList.add("dirty"); 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.", : "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 })); 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 "save-settings": return saveSettings();
case "discard": return settings(); 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 }; const RENDER = { server, bans, comms, staff };
function route() { function route() {
const page = location.hash.slice(1); const [page, query] = location.hash.slice(1).split("?");
state.page = RENDER[page] ? page : "server"; 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(""); $("#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](); 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 p { margin: 2px 0 0; color: var(--muted); font-size: 14px; }
.setting .control { display: flex; justify-content: flex-end; } .setting .control { display: flex; justify-content: flex-end; }
.setting code { font: 13px ui-monospace, "Cascadia Mono", Menlo, monospace; color: var(--faint); } .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 { .savebar {
position: sticky; 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 // The service worker lets the site be installed as an app, and shows staff notifications. It caches
// offline page: every request still goes to the network, so nobody sees stale bans or ranks. // nothing but the offline page: every request still goes to the network, so nobody sees stale bans
// or ranks.
const CACHE = "offline-v1"; const CACHE = "offline-v1";
const OFFLINE = "/static/offline.html"; const OFFLINE = "/static/offline.html";
@ -21,3 +22,54 @@ self.addEventListener("fetch", (e) => {
if (e.request.mode !== "navigate") return; if (e.request.mode !== "navigate") return;
e.respondWith(fetch(e.request).catch(() => caches.match(OFFLINE))); 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) slog.Error("admin page", "err", err)
} }
if id == nil { if id == nil {
http.ServeFileFS(w, r, static, "signin.html") serveAsset(w, r, static, "signin.html")
return 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. // serviceWorker serves sw.js from the root, since a worker only controls pages under its own path.
func serviceWorker(static fs.FS) http.HandlerFunc { func serviceWorker(static fs.FS) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache") serveAsset(w, r, static, "sw.js")
http.ServeFileFS(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) 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" "time"
"git.zio.sh/cs2/simpleadmin-web/internal/live" "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/rcon"
"git.zio.sh/cs2/simpleadmin-web/internal/saconfig" "git.zio.sh/cs2/simpleadmin-web/internal/saconfig"
"git.zio.sh/cs2/simpleadmin-web/internal/store" "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. // the SHA-256 fingerprints of the keys it's signed with. Empty serves no assetlinks.json.
AndroidPackage string AndroidPackage string
AndroidCerts []string AndroidCerts []string
// Push sends staff notifications; nil turns them off.
Push *push.Sender
} }
type Server struct { type Server struct {
@ -81,10 +84,13 @@ func (s *Server) Handler() http.Handler {
files := http.FileServerFS(static) files := http.FileServerFS(static)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { 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.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 /manifest.webmanifest", s.manifest(false))
mux.HandleFunc("GET /admin.webmanifest", s.manifest(true)) mux.HandleFunc("GET /admin.webmanifest", s.manifest(true))
mux.HandleFunc("GET /sw.js", serviceWorker(static)) 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("DELETE /api/admin/verify/overrides/{steamid}", s.removeOverride)
admin("POST /api/admin/verify/settings", s.saveVerifySettings) 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) return s.headers(mux)
} }

35
main.go
View file

@ -22,6 +22,7 @@ import (
"github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
"git.zio.sh/cs2/simpleadmin-web/internal/live" "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/rcon"
"git.zio.sh/cs2/simpleadmin-web/internal/saconfig" "git.zio.sh/cs2/simpleadmin-web/internal/saconfig"
"git.zio.sh/cs2/simpleadmin-web/internal/store" "git.zio.sh/cs2/simpleadmin-web/internal/store"
@ -33,6 +34,11 @@ import (
//go:embed sql/rank-log.sql //go:embed sql/rank-log.sql
var rankLogSQL string 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 { func env(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" { if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v return v
@ -162,10 +168,12 @@ func run() error {
MaxMuteDuration: maxMute, MaxMuteDuration: maxMute,
AndroidPackage: env("SAW_ANDROID_PACKAGE", ""), AndroidPackage: env("SAW_ANDROID_PACKAGE", ""),
AndroidCerts: strings.Split(env("SAW_ANDROID_CERT_SHA256", ""), ","), AndroidCerts: strings.Split(env("SAW_ANDROID_CERT_SHA256", ""), ","),
Push: pushSender(ctx, st, baseURL),
}, st, rc, poller, sacfg) }, st, rc, poller, sacfg)
if err != nil { if err != nil {
return err return err
} }
go app.RunNotifier(ctx)
listen := env("SAW_LISTEN", ":8080") listen := env("SAW_LISTEN", ":8080")
hs := &http.Server{ hs := &http.Server{
@ -187,6 +195,33 @@ func run() error {
return nil 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. // openDB connects with SAW_DB_DSN, or with the DatabaseConfig in CS2-SimpleAdmin.json.
func openDB(sacfg *saconfig.File) (*sql.DB, error) { func openDB(sacfg *saconfig.File) (*sql.DB, error) {
var cfg *mysql.Config var cfg *mysql.Config

46
sql/push.sql Normal file
View file

@ -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;