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 }