package web import ( "context" "fmt" "log/slog" "net/http" "strconv" "strings" "sync" "time" "unicode" "unicode/utf16" "git.zio.sh/cs2/simpleadmin-web/internal/live" "git.zio.sh/cs2/simpleadmin-web/internal/steam" "git.zio.sh/cs2/simpleadmin-web/internal/store" ) // GroupVerify (plugins/GroupVerify in the cs2 repo) verifies players who are in a Steam group or // have a manual override. The panel shows it only where it's there: staff get the Verification // page once its tables are in the database, and "Verified" shows on the public site only while // it's on (enabled, with a group) and not known to be missing from the server's plugin list. const groupVerifyPlugin = "GroupVerify" // verifyOverridePerm lets staff give and remove overrides. Settings need @css/root, as in game. const verifyOverridePerm = "@zio/verify" // verifyInfo is what's known about GroupVerify right now. type verifyInfo struct { Tables bool Settings store.VerifySettings Plugin live.PluginState Version string } // Shown reports whether players' verified status should be shown at all. func (v verifyInfo) Shown() bool { return v.Tables && v.Settings.Active() && v.Plugin != live.PluginMissing } // verifyCache keeps the table check and gv_settings for a while, so the scoreboard and every page // view don't ask the database each time. type verifyCache struct { st *store.Store ttl time.Duration mu sync.Mutex tables bool settings store.VerifySettings fetched time.Time } func (c *verifyCache) get(ctx context.Context) (bool, store.VerifySettings, error) { c.mu.Lock() defer c.mu.Unlock() if !c.fetched.IsZero() && time.Since(c.fetched) < c.ttl { return c.tables, c.settings, nil } tables, err := c.st.HasVerifyTables(ctx) if err != nil { return false, store.VerifySettings{}, err } settings := store.VerifySettings{} if tables { if settings, err = c.st.VerifySettings(ctx); err != nil { return false, store.VerifySettings{}, err } } c.tables, c.settings, c.fetched = tables, settings, time.Now() return tables, settings, nil } func (c *verifyCache) invalidate() { c.mu.Lock() c.fetched = time.Time{} c.mu.Unlock() } func (s *Server) verifyInfo(ctx context.Context) (verifyInfo, error) { tables, settings, err := s.verify.get(ctx) if err != nil { return verifyInfo{}, err } state, version := s.poll.Plugin(groupVerifyPlugin) return verifyInfo{Tables: tables, Settings: settings, Plugin: state, Version: version}, nil } // verifiedSet returns which of these players are verified, or nil when verification isn't shown. // Errors are logged and treated as not shown: verification is extra, never worth failing a page. func (s *Server) verifiedSet(ctx context.Context, steamids []string) map[string]store.VerifyStatus { info, err := s.verifyInfo(ctx) if err == nil && info.Shown() { var st map[string]store.VerifyStatus if st, err = s.st.VerifyStatuses(ctx, info.Settings.Location, steamids); err == nil { return st } } if err != nil { slog.Warn("GroupVerify status unavailable", "err", err) } return nil } // markVerified sets Verified on the live players who are. func (s *Server) markVerified(ctx context.Context, players []livePlayer) { var ids []string for _, p := range players { if !p.Bot { ids = append(ids, p.SteamID) } } set := s.verifiedSet(ctx, ids) for i, p := range players { if st, ok := set[p.SteamID]; ok && st.Verified() { players[i].Verified = true } } } // ---------------------------------------------------------------- Staff API type verifyStatusView struct { Tables bool `json:"tables"` Plugin live.PluginState `json:"plugin"` Version string `json:"version,omitempty"` Enabled bool `json:"enabled"` GroupID string `json:"groupId"` Active bool `json:"active"` Shown bool `json:"shown"` ClanTag string `json:"clanTag"` Timezone string `json:"timezone"` ReloadSeconds int `json:"reloadSeconds"` GroupMembers int `json:"groupMembers"` Overrides int `json:"overrides"` GroupFetched *time.Time `json:"groupFetched,omitempty"` CanOverride bool `json:"canOverride"` CanConfigure bool `json:"canConfigure"` } // adminVerify is the Verification page: GroupVerify's state, and a page of overrides or group // members (?view=overrides|group&q=&page=). func (s *Server) adminVerify(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { ctx := r.Context() info, err := s.verifyInfo(ctx) if err != nil { internal(w, r, err) return } if !info.Tables { fail(w, http.StatusNotFound, "GroupVerify's tables aren't in the database. Install GroupVerify on the server first.") return } loc := info.Settings.Location view := verifyStatusView{ Tables: true, Plugin: info.Plugin, Version: info.Version, Enabled: info.Settings.Enabled, GroupID: info.Settings.GroupID, Active: info.Settings.Active(), Shown: info.Shown(), ClanTag: info.Settings.ClanTag, Timezone: loc.String(), ReloadSeconds: info.Settings.ReloadSeconds, CanOverride: id.Has(verifyOverridePerm), CanConfigure: id.Has("@css/root"), } if view.GroupMembers, view.Overrides, view.GroupFetched, err = s.st.VerifyCounts(ctx, loc); err != nil { internal(w, r, err) return } kind := store.VerifyOverrides if r.URL.Query().Get("view") == store.VerifyGroup { kind = store.VerifyGroup } lim, off, num := page(r) items, total, err := s.st.VerifyList(ctx, loc, kind, r.URL.Query().Get("q"), lim, off) if err != nil { internal(w, r, err) return } writeJSON(w, http.StatusOK, map[string]any{"status": view, "list": newList(items, total, num)}) } // playerVerify is the Verification section of a player's drawer, or nil without GroupVerify. func (s *Server) playerVerify(ctx context.Context, id *Identity, steamid string) (map[string]any, error) { info, err := s.verifyInfo(ctx) if err != nil || !info.Tables { return nil, err } set, err := s.st.VerifyStatuses(ctx, info.Settings.Location, []string{steamid}) if err != nil { return nil, err } st := set[steamid] return map[string]any{ "active": info.Settings.Active(), "verified": info.Settings.Active() && st.Verified(), "inGroup": st.InGroup, "groupSeen": st.GroupSeen, "override": st.Override, "canOverride": canOverride(id, steamid), }, nil } // canOverride: staff with @zio/verify, and not on themselves unless they're Root. func canOverride(id *Identity, steamid string) bool { return id.Has(verifyOverridePerm) && (steamid != id.SteamID || id.Has("@css/root")) } // verifyReady fails the request unless GroupVerify's tables exist, and returns its settings. func (s *Server) verifyReady(w http.ResponseWriter, r *http.Request) (store.VerifySettings, bool) { info, err := s.verifyInfo(r.Context()) if err != nil { internal(w, r, err) return store.VerifySettings{}, false } if !info.Tables { fail(w, http.StatusNotFound, "GroupVerify's tables aren't in the database.") return store.VerifySettings{}, false } return info.Settings, true } // pickedUp says when GroupVerify will notice a change to its tables. func pickedUp(v store.VerifySettings) string { return fmt.Sprintf("GroupVerify picks it up within %d seconds.", v.ReloadSeconds) } // setOverride gives or changes an override: {steamid, days}, days 0 for one that never ends. func (s *Server) setOverride(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { var req struct { SteamID string `json:"steamid"` Days int `json:"days"` } if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } if !steam.ValidID(req.SteamID) { fail(w, http.StatusBadRequest, "That isn't a SteamID64.") return } if !id.Has(verifyOverridePerm) { fail(w, http.StatusForbidden, "You need "+verifyOverridePerm+" to give overrides.") return } if !canOverride(id, req.SteamID) { fail(w, http.StatusForbidden, "Only Root can give themselves an override.") return } if req.Days < 0 || req.Days > 3650 { fail(w, http.StatusBadRequest, "Pick a length between 1 day and 10 years, or no expiry.") return } settings, ok := s.verifyReady(w, r) if !ok { return } var expires *time.Time if req.Days > 0 { t := time.Now().Add(time.Duration(req.Days) * 24 * time.Hour) expires = &t } addedBy := fmt.Sprintf("%s (%s)", id.Name, id.SteamID) if err := s.st.SetOverride(r.Context(), settings.Location, req.SteamID, expires, addedBy); err != nil { internal(w, r, err) return } slog.Info("verify override", "admin", id.SteamID, "steamid", req.SteamID, "days", req.Days) name := s.st.LatestName(r.Context(), req.SteamID) if name == "" { name = req.SteamID } length := "with no expiry" if expires != nil { length = "for " + strconv.Itoa(req.Days) + " days" } msg := "Verified " + name + " " + length + ". " + pickedUp(settings) if !settings.Active() { msg = "Saved the override for " + name + ", but verification is off, so it does nothing until it's turned on." } done(w, msg) } func (s *Server) removeOverride(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { steamid := r.PathValue("steamid") if !steam.ValidID(steamid) { fail(w, http.StatusBadRequest, "That isn't a SteamID64.") return } if !canOverride(id, steamid) { fail(w, http.StatusForbidden, "You need "+verifyOverridePerm+" to remove overrides.") return } settings, ok := s.verifyReady(w, r) if !ok { return } removed, err := s.st.RemoveOverride(r.Context(), steamid) if err != nil { internal(w, r, err) return } if !removed { fail(w, http.StatusNotFound, "That player has no override.") return } slog.Info("verify override removed", "admin", id.SteamID, "steamid", steamid) done(w, "Removed the override. "+pickedUp(settings)) } // isClanID is a Steam group's 64-bit ID: universe Public, account type Clan, a non-zero account. func isClanID(n uint64) bool { return n>>56 == 1 && (n>>52)&0xF == 7 && n&0xFFFFFFFF != 0 } // clanTagMax matches GroupVerify (and Steam's own group tags). It counts UTF-16 units, as C# does. const clanTagMax = 12 // validClanTag is GroupVerify's rule, plus no backslashes, which the console would mangle. func validClanTag(tag string) bool { if len(utf16.Encode([]rune(tag))) > clanTagMax { return false } for _, r := range tag { if unicode.IsControl(r) || r == '"' || r == ';' || r == '\\' { return false } } return true } // saveVerifySettings turns GroupVerify on or off, or changes its group or clan tag: // {enabled?, groupId?, clanTag?}. Saved // to gv_settings, then, when the plugin is loaded, sent as its own command so it applies at once. func (s *Server) saveVerifySettings(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { var req struct { Enabled *bool `json:"enabled"` GroupID *string `json:"groupId"` ClanTag *string `json:"clanTag"` } if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } if !id.Has("@css/root") { fail(w, http.StatusForbidden, "Only Root can change GroupVerify's settings.") return } if req.Enabled == nil && req.GroupID == nil && req.ClanTag == nil { fail(w, http.StatusBadRequest, "Nothing to change.") return } if _, ok := s.verifyReady(w, r); !ok { return } type change struct{ name, value, cmd string } var changes []change if req.GroupID != nil { g := strings.TrimSpace(*req.GroupID) if g == "" { g = "0" } n, err := strconv.ParseUint(g, 10, 64) if err != nil || (n != 0 && !isClanID(n)) { fail(w, http.StatusBadRequest, "That isn't a Steam group ID64 (103582791…). 0 or blank clears it.") return } g = strconv.FormatUint(n, 10) changes = append(changes, change{"group_id64", g, "css_gv_group " + g}) } if req.ClanTag != nil { tag := strings.TrimSpace(*req.ClanTag) if !validClanTag(tag) { fail(w, http.StatusBadRequest, fmt.Sprintf("A clan tag is at most %d characters, without quotes, ; or \\.", clanTagMax)) return } cmd := `css_gv_clantag "` + tag + `"` if tag == "" { cmd = "css_gv_clantag -" } changes = append(changes, change{"clan_tag", tag, cmd}) } if req.Enabled != nil { c := change{"enabled", "0", "css_gv_disable"} if *req.Enabled { c = change{"enabled", "1", "css_gv_enable"} } changes = append(changes, c) } ctx := r.Context() for _, c := range changes { if err := s.st.SetVerifySetting(ctx, c.name, c.value); err != nil { internal(w, r, err) return } slog.Info("verify setting", "admin", id.SteamID, "name", c.name, "value", c.value) } s.verify.invalidate() applied := false if state, _ := s.poll.Plugin(groupVerifyPlugin); state == live.PluginLoaded { applied = true for _, c := range changes { if out, err := s.rc.Exec(c.cmd); err != nil || consoleRejected(out) != "" { applied = false } } } settings, err := s.st.VerifySettings(ctx) if err != nil { internal(w, r, err) return } state := "Verification is on." switch { case !settings.Enabled: state = "Verification is off." case settings.GroupID == "": state = "Verification is off until a Steam group is set." } if applied { done(w, "Saved and applied. "+state) } else { done(w, "Saved. "+state+" "+pickedUp(settings)) } }