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:
parent
7900bf5b3e
commit
17232bd5a2
23 changed files with 2135 additions and 12 deletions
287
internal/push/push.go
Normal file
287
internal/push/push.go
Normal 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
187
internal/push/push_test.go
Normal 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
282
internal/store/push.go
Normal 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()
|
||||
}
|
||||
|
|
@ -41,6 +41,9 @@ func New(db *sql.DB, loc *time.Location, serverID int64, timeMode int) *Store {
|
|||
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// Location is SimpleAdmin's Timezone.
|
||||
func (s *Store) Location() *time.Location { return s.loc }
|
||||
|
||||
// wall turns a time into the naive wall-clock form SimpleAdmin stores.
|
||||
func (s *Store) wall(t time.Time) time.Time {
|
||||
t = t.In(s.loc)
|
||||
|
|
|
|||
47
internal/web/assets.go
Normal file
47
internal/web/assets.go
Normal 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)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ const PAGES = [
|
|||
// Only when GroupVerify's tables are in the database.
|
||||
{ id: "verify", label: "Verification", show: () => !!state.me?.verify },
|
||||
{ id: "settings", label: "Server settings" },
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
];
|
||||
|
||||
const pageAllowed = (p) => (!p.flag || can(p.flag)) && (!p.show || p.show());
|
||||
|
|
@ -517,6 +518,136 @@ function overrideDialog(sid, name) {
|
|||
</form>`);
|
||||
}
|
||||
|
||||
/* ---------- Notifications ---------- */
|
||||
|
||||
// Push notifications to this staff member's devices (internal/web/notify.go, sw.js). Preferences
|
||||
// apply to all their devices and save as soon as they change.
|
||||
|
||||
const b64urlToBytes = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
|
||||
const bytesToB64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
const browserTimezone = () => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } };
|
||||
const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
|
||||
async function sha256Hex(text) {
|
||||
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
||||
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
// This browser's push subscription, or null.
|
||||
async function pushSubscription() {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
return reg ? reg.pushManager.getSubscription() : null;
|
||||
}
|
||||
|
||||
async function notifications() {
|
||||
const v = $("#view");
|
||||
let d;
|
||||
try { d = await api("/api/admin/notifications"); } catch (err) { v.innerHTML = errorBox(err); return; }
|
||||
let here = false;
|
||||
if (d.available && pushSupported() && window.crypto?.subtle) {
|
||||
try {
|
||||
const sub = await pushSubscription();
|
||||
here = !!sub && d.devices.includes(await sha256Hex(sub.endpoint));
|
||||
} catch { /* treat as off */ }
|
||||
}
|
||||
if (state.page !== "notifications") return;
|
||||
state.notif = d;
|
||||
const p = d.prefs;
|
||||
const others = d.devices.length - (here ? 1 : 0);
|
||||
const row = (label, help, control) => `<div class="setting">
|
||||
<div><b>${label}</b>${help ? `<p>${help}</p>` : ""}</div>
|
||||
<div class="control">${control}</div>
|
||||
</div>`;
|
||||
|
||||
let device;
|
||||
if (!d.available) device = row("This device", "Notifications aren't set up on the panel. Its log says why.", '<span class="tag red">Unavailable</span>');
|
||||
else if (!pushSupported()) device = row("This device", "This browser can't get notifications. On an iPhone or iPad, add the site to your home screen and open it from there.", '<span class="tag red">Not supported</span>');
|
||||
else if (Notification.permission === "denied") device = row("This device", "Notifications are blocked for this site. Allow them in the browser's site settings (or the app's notification settings), then come back.", '<span class="tag red">Blocked</span>');
|
||||
else if (here) device = row("This device", "Notifications are on here.", '<div style="display:flex;gap:8px"><button class="btn" type="button" data-act="notif-test">Send a test</button><button class="btn" type="button" data-act="notif-off">Turn off</button></div>');
|
||||
else device = row("This device", "Turn notifications on to get the ones picked below here, even with the panel closed.", '<button class="btn primary" type="button" data-act="notif-on">Turn on</button>');
|
||||
|
||||
const dis = d.available ? "" : "disabled";
|
||||
const groups = [...new Set(d.types.map((t) => t.group))];
|
||||
v.innerHTML = `
|
||||
<div class="page-head">
|
||||
<div><h1>Notifications</h1><p class="sub">Get bans, gags, mutes and server alerts on your phone or computer. Nothing about your own actions is sent to you.</p></div>
|
||||
</div>
|
||||
<div class="settings">
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><h2>Devices</h2></div></div>
|
||||
${device}
|
||||
${others > 0 ? row(`${here ? "Other devices" : "Your devices"}`, `${others} ${others === 1 ? "device has" : "devices have"} notifications on.`, '<button class="btn" type="button" data-act="notif-off-all">Turn off everywhere</button>') : ""}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><h2>Quiet hours</h2><p>Nothing is sent between these times, in your timezone (${esc(browserTimezone())}).</p></div></div>
|
||||
${row("Quiet hours", "", `<button class="switch" type="button" role="switch" aria-checked="${p.quiet.enabled}" aria-label="Quiet hours" data-notif="quiet" ${dis}></button>`)}
|
||||
${row("From and until", "", `<div class="quiet-times"><input class="input" type="time" id="quiet-start" value="${esc(p.quiet.start)}" aria-label="Quiet from" data-notif-time ${dis}><span class="muted">to</span><input class="input" type="time" id="quiet-end" value="${esc(p.quiet.end)}" aria-label="Quiet until" data-notif-time ${dis}></div>`)}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><h2>What to send</h2><p>For all your devices.</p></div></div>
|
||||
${groups.map((g) => `<div class="notif-group">${esc(g)}</div>
|
||||
${d.types.filter((t) => t.group === g).map((t) => row(esc(t.label), t.help ? esc(t.help) : "",
|
||||
t.ready
|
||||
? `<button class="switch" type="button" role="switch" aria-checked="${p.types.includes(t.key)}" aria-label="${esc(t.label)}" data-ntype="${esc(t.key)}" ${dis}></button>`
|
||||
: `<span class="tag">Coming later</span><button class="switch" type="button" role="switch" aria-checked="false" aria-label="${esc(t.label)}" disabled></button>`)).join("")}`).join("")}
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let notifSave;
|
||||
function saveNotifPrefs() {
|
||||
const types = [...document.querySelectorAll("[data-ntype]")].filter((s) => s.getAttribute("aria-checked") === "true").map((s) => s.dataset.ntype);
|
||||
const body = {
|
||||
types,
|
||||
quiet: { enabled: $("[data-notif=quiet]").getAttribute("aria-checked") === "true", start: $("#quiet-start").value, end: $("#quiet-end").value },
|
||||
timezone: browserTimezone(),
|
||||
};
|
||||
// Several quick toggles make one save.
|
||||
clearTimeout(notifSave);
|
||||
notifSave = setTimeout(async () => {
|
||||
try { await api("/api/admin/notifications/prefs", { method: "PUT", body }); toast("Saved."); } catch (err) { toast(err.message); }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
async function enableNotifications() {
|
||||
const d = state.notif;
|
||||
try {
|
||||
if (await Notification.requestPermission() !== "granted") {
|
||||
toast("Notifications weren't allowed.");
|
||||
notifications();
|
||||
return;
|
||||
}
|
||||
if (!(await navigator.serviceWorker.getRegistration())) await navigator.serviceWorker.register("/sw.js");
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
// A subscription made for another panel key can't be reused.
|
||||
if (sub && sub.options?.applicationServerKey && bytesToB64url(sub.options.applicationServerKey) !== d.publicKey) {
|
||||
await sub.unsubscribe();
|
||||
sub = null;
|
||||
}
|
||||
if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64urlToBytes(d.publicKey) });
|
||||
await act("/api/admin/notifications/subscribe", "POST", { ...sub.toJSON(), userAgent: navigator.userAgent, timezone: browserTimezone() });
|
||||
} catch (err) {
|
||||
toast(`Couldn't turn notifications on: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableNotifications(all) {
|
||||
let endpoint = "";
|
||||
try {
|
||||
const sub = await pushSubscription();
|
||||
if (sub) {
|
||||
endpoint = sub.endpoint;
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
} catch { /* the server copy is removed below either way */ }
|
||||
if (all) return act("/api/admin/notifications/unsubscribe", "POST", { all: true });
|
||||
if (!endpoint) { notifications(); return; }
|
||||
return act("/api/admin/notifications/unsubscribe", "POST", { endpoint });
|
||||
}
|
||||
|
||||
/* ---------- Settings ---------- */
|
||||
|
||||
async function settings() {
|
||||
|
|
@ -812,7 +943,7 @@ async function act(path, method, body) {
|
|||
|
||||
/* ---------- Routing & events ---------- */
|
||||
|
||||
const RENDER = { overview, players, bans, comms, staff, verify, settings };
|
||||
const RENDER = { overview, players, bans, comms, staff, verify, settings, notifications };
|
||||
|
||||
function renderNav() {
|
||||
const c = state.counts;
|
||||
|
|
@ -849,6 +980,7 @@ document.addEventListener("input", (e) => {
|
|||
|
||||
document.addEventListener("change", (e) => {
|
||||
if (e.target.matches("select[data-sa]")) { e.target.classList.add("dirty"); markDirty(); }
|
||||
if (e.target.matches("[data-notif-time]")) saveNotifPrefs();
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
|
|
@ -888,6 +1020,11 @@ document.addEventListener("click", (e) => {
|
|||
}
|
||||
|
||||
const sw = t.closest(".switch");
|
||||
if (sw && !sw.disabled && sw.matches("[data-ntype], [data-notif]")) {
|
||||
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
|
||||
saveNotifPrefs();
|
||||
return;
|
||||
}
|
||||
if (sw && !sw.disabled) {
|
||||
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
|
||||
sw.classList.add("dirty");
|
||||
|
|
@ -957,6 +1094,12 @@ document.addEventListener("click", (e) => {
|
|||
: "Turn GroupVerify off? Nobody is verified while it's off, overrides included, and verified players lose their perks.",
|
||||
on ? "Turn on" : "Turn off", () => act("/api/admin/verify/settings", "POST", { enabled: on }));
|
||||
}
|
||||
case "notif-on": return enableNotifications();
|
||||
case "notif-off": return disableNotifications(false);
|
||||
case "notif-off-all":
|
||||
confirmDialog.reason = false;
|
||||
return confirmDialog("Turn off everywhere", "Turn notifications off on all your devices? Turn them on again on each device you want them on.", "Turn off", () => disableNotifications(true));
|
||||
case "notif-test": return act("/api/admin/notifications/test", "POST", {});
|
||||
case "save-settings": return saveSettings();
|
||||
case "discard": return settings();
|
||||
}
|
||||
|
|
|
|||
BIN
internal/web/assets/badge-96.png
Normal file
BIN
internal/web/assets/badge-96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -259,8 +259,16 @@ async function openPlayer(sid) {
|
|||
const RENDER = { server, bans, comms, staff };
|
||||
|
||||
function route() {
|
||||
const page = location.hash.slice(1);
|
||||
const [page, query] = location.hash.slice(1).split("?");
|
||||
state.page = RENDER[page] ? page : "server";
|
||||
// A link like #bans?q=<steamid> (from a staff notification) opens the list searching for that
|
||||
// player, showing every status so lifted and expired ones are there too.
|
||||
const q = new URLSearchParams(query || "").get("q");
|
||||
if (q && (state.page === "bans" || state.page === "comms")) {
|
||||
state.query = q.slice(0, 64);
|
||||
state.num = 1;
|
||||
state[state.page === "bans" ? "banFilter" : "commFilter"] = "all";
|
||||
}
|
||||
$("#nav-links").innerHTML = PAGES.map((p) => `<li><a href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}</a></li>`).join("");
|
||||
RENDER[state.page]();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
})());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -166,10 +166,10 @@ func (s *Server) adminPage(static fs.FS) http.HandlerFunc {
|
|||
slog.Error("admin page", "err", err)
|
||||
}
|
||||
if id == nil {
|
||||
http.ServeFileFS(w, r, static, "signin.html")
|
||||
serveAsset(w, r, static, "signin.html")
|
||||
return
|
||||
}
|
||||
http.ServeFileFS(w, r, static, "admin.html")
|
||||
serveAsset(w, r, static, "admin.html")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
587
internal/web/notify.go
Normal file
587
internal/web/notify.go
Normal 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
|
||||
}
|
||||
257
internal/web/notify_db_test.go
Normal file
257
internal/web/notify_db_test.go
Normal 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")
|
||||
}
|
||||
78
internal/web/notify_test.go
Normal file
78
internal/web/notify_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue