586 lines
17 KiB
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
|
|
}
|