simpleadmin-web/internal/web/notify.go

586 lines
17 KiB
Go

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"`
Label string `json:"label"`
Help string `json:"help,omitempty"`
Ready bool `json:"ready"`
}
var notifyTypes = []notifyType{
{Key: "ban", Label: "Ban added", Ready: true},
{Key: "unban", Label: "Ban lifted", Ready: true},
{Key: "gag", Label: "Gag added", Ready: true},
{Key: "ungag", Label: "Gag lifted", Ready: true},
{Key: "mute", Label: "Mute added", Ready: true},
{Key: "unmute", Label: "Mute lifted", Ready: true},
{Key: "silence", Label: "Silence added", Ready: true},
{Key: "unsilence", Label: "Silence lifted", Ready: true},
{Key: "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", Label: "Live score", Help: "An updating scoreboard while people are playing."},
{Key: "calladmin", Label: "Admin called", Help: "A player asks for an admin in game. Needs a server plugin."},
}
// 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
}