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
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue