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.
282 lines
9.2 KiB
Go
282 lines
9.2 KiB
Go
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()
|
|
}
|