GroupVerify: Verification page, overrides and public Verified tags

Shown only where GroupVerify is: staff get the Verification page once its gv_* tables are in
the database, and the public site shows "Verified" only while it's on (enabled, with a group)
and not missing from css_plugins list, which the poller reads once a minute over RCON.

- Verification page: plugin state and version, Steam group, saved member list, overrides.
- Overrides (@zio/verify, not on yourself unless Root): give, change, remove; player drawer
  shows why a player is or isn't verified.
- Root sets the group and turns it on/off: saved to gv_settings, and sent as css_gv_* when the
  plugin is loaded so it applies at once.
- gv_* times use GroupVerify's own timezone setting.
- MySQL integration test (SAW_TEST_DSN), unit tests for plugin list parsing and the show rule.
This commit is contained in:
Astra 2026-10-02 17:59:26 +01:00
parent b2b4698776
commit 9e4bc63201
15 changed files with 1270 additions and 13 deletions

389
internal/web/verify.go Normal file
View file

@ -0,0 +1,389 @@
package web
import (
"context"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
"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"`
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(),
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
}
// saveVerifySettings turns GroupVerify on or off or changes its group: {enabled?, groupId?}. 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"`
}
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 {
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.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))
}
}