From 9e4bc63201ec69e58a71fda68d959ca36c8d2087 Mon Sep 17 00:00:00 2001 From: Astra Date: Fri, 2 Oct 2026 17:59:26 +0100 Subject: [PATCH] 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. --- README.md | 44 +++- internal/live/live.go | 69 ++++++ internal/live/live_test.go | 24 ++ internal/store/verify.go | 268 +++++++++++++++++++++++ internal/web/admin.go | 8 + internal/web/assets/admin.js | 182 ++++++++++++++- internal/web/assets/common.js | 3 + internal/web/assets/public.js | 6 +- internal/web/assets/styles.css | 1 + internal/web/auth.go | 8 +- internal/web/public.go | 10 +- internal/web/server.go | 6 + internal/web/verify.go | 389 +++++++++++++++++++++++++++++++++ internal/web/verify_db_test.go | 222 +++++++++++++++++++ internal/web/verify_test.go | 43 ++++ 15 files changed, 1270 insertions(+), 13 deletions(-) create mode 100644 internal/store/verify.go create mode 100644 internal/web/verify.go create mode 100644 internal/web/verify_db_test.go create mode 100644 internal/web/verify_test.go diff --git a/README.md b/README.md index 0fa2a1c..0161ace 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ A SourceBans-style web panel for [CS2-SimpleAdmin](https://github.com/daffyyyy/C - **Staff panel** (`/admin`): sign in through Steam. Ban, gag, mute, silence, warn and kick; lift bans and blocks; manage ranks, permissions and staff; change server cvars and SimpleAdmin's punishment settings. +- **Verification** (optional): who our GroupVerify plugin verifies, with overrides to give and + remove, and its settings. Only appears when GroupVerify is installed (see below). Live data comes from the **WebPanelBridge** plugin (`plugins/WebPanelBridge` in this repo) over RCON. Without it the panel still works, but shows the server as offline and can't kick or warn. @@ -42,7 +44,8 @@ RCON. Without it the panel still works, but shows the server as offline and can' | Change map | `@css/changemap` | | Server cvars | `@css/cvar` | | See the Staff & ranks page | `@zio/webpanel` | - | Ranks, staff, SimpleAdmin settings, reload admins | `@css/root` | + | Give and remove verification overrides | `@zio/verify` (not on themselves, unless Root) | + | Ranks, staff, SimpleAdmin settings, reload admins, GroupVerify's group and on/off | `@css/root` | `@css/root` grants everything. Nobody can act on a player whose immunity is higher than theirs, or on themselves, or give a rank with more immunity than their own. @@ -100,6 +103,34 @@ and deleting a rank that people hold). Our SimpleAdmin fork (1.8.2b-zio10+) logs that lapse when their expiry passes, `css_delgroup`, and edits made straight in the database. History starts when the table is created. +### Verification (GroupVerify, optional) + +[GroupVerify](https://git.zio.sh/cs2/GroupVerify) (`plugins/GroupVerify` in the cs2 repo) verifies +players who are in the server's Steam group or have a manual override, and other plugins give +verified players perks. It keeps everything in SimpleAdmin's database (`gv_verified`, +`gv_settings`), so the panel needs no setup for it: + +- **Staff** get a Verification page as soon as GroupVerify's tables are in the database: whether + it's on, the plugin's state and version, the Steam group, its saved member list, and the + overrides. The player drawer shows why a player is or isn't verified. With `@zio/verify` (give + it to the ranks that should have it in the rank editor), staff give overrides for a day up to a + year or with no expiry, change them and remove them. Root sets the group and turns verification + on and off. +- **Everyone** sees a "Verified" tag on the scoreboard and on player pages, but only while + verification is on (enabled, with a group set) and GroupVerify isn't missing from the server's + plugin list. The public site never shows why someone is verified, or who gave an override. +- **Whether it's loaded** comes from `css_plugins list` over RCON, read once a minute alongside + the live status. While the server is down it's unknown, and that doesn't hide anything. +- **Changes**: overrides are written to `gv_verified` and GroupVerify picks them up on its next + reload (`db_reload_seconds`, 60 by default). Settings are written to `gv_settings` and, when + the plugin is loaded, also sent as its own `css_gv_group`/`css_gv_enable`/`css_gv_disable`, so + they apply at once. +- **Times** in its tables are in GroupVerify's own `timezone` setting, which the panel follows. + Keep it the same as SimpleAdmin's `Timezone`. + +The panel never shows a group member list from Steam itself: "Steam group members" is the list +GroupVerify last fetched and saved. + ### Name history (optional) SimpleAdmin keeps one row per SteamID + IP in `sa_players_ips` and overwrites its name on each @@ -129,5 +160,16 @@ SimpleAdmin database; the file has the revert. Names are still only recorded at go vet ./... && go test ./... ``` +`internal/web/verify_db_test.go` also runs the Verification endpoints against a real MySQL when +`SAW_TEST_DSN` is set. It empties SimpleAdmin's admin and group tables and GroupVerify's, so give +it a scratch database holding SimpleAdmin 1.8.2b's migrations and GroupVerify's +`Migrations/001_CreateTables.sql`: + +``` +podman run -d --rm --name sa-test -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=sa -p 127.0.0.1:33061:3306 mysql:8.4 +# apply the migrations, then: +SAW_TEST_DSN='root:test@tcp(127.0.0.1:33061)/sa?parseTime=true&loc=UTC' go test ./internal/web -run VerifyDB +``` + The pages are plain HTML, CSS and JS in `internal/web/assets`, embedded into the binary. The design mockup they came from is in `docs/mockup` at the repo root. diff --git a/internal/live/live.go b/internal/live/live.go index b922e74..a864794 100644 --- a/internal/live/live.go +++ b/internal/live/live.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "log/slog" + "regexp" "strings" "sync" "time" @@ -114,12 +115,46 @@ func Parse(out string) (Status, error) { return st, nil } +// pluginLine is one plugin in `css_plugins list`: [#3:LOADED]: "GroupVerify" (0.2.0) by astra +var pluginLine = regexp.MustCompile(`\[#\d+:([A-Z]+)\]: "([^"]*)" \(([^)]*)\)`) + +// ParsePlugins reads `css_plugins list` output into loaded plugins' versions by ModuleName. ok is +// false when the output isn't a plugin list at all. +func ParsePlugins(out string) (loaded map[string]string, ok bool) { + if !strings.Contains(out, "List of all plugins") { + return nil, false + } + loaded = map[string]string{} + for _, m := range pluginLine.FindAllStringSubmatch(out, -1) { + if m[1] == "LOADED" { + loaded[m[2]] = m[3] + } + } + return loaded, true +} + +// Whether a CSS plugin is loaded on the server, as far as the last check knows. +type PluginState string + +const ( + PluginLoaded PluginState = "loaded" + PluginMissing PluginState = "missing" + PluginUnknown PluginState = "unknown" // server down, or not checked yet +) + +// pluginInterval is how often the plugin list is reread; plugins rarely change. +const pluginInterval = time.Minute + type Poller struct { rc *rcon.Client interval time.Duration mu sync.RWMutex current Status + + // From css_plugins list, nil while unknown. + plugins map[string]string + pluginsAt time.Time } func NewPoller(rc *rcon.Client, interval time.Duration) *Poller { @@ -169,10 +204,44 @@ func (p *Poller) Refresh() Status { st.Error = p.current.Error } p.current = st + if !st.Online { + p.plugins = nil + } + checkPlugins := st.Online && time.Since(p.pluginsAt) >= pluginInterval + if checkPlugins { + p.pluginsAt = time.Now() // claimed, so concurrent refreshes don't all ask + } p.mu.Unlock() + if checkPlugins { + p.refreshPlugins() + } return st } +func (p *Poller) refreshPlugins() { + out, err := p.rc.Exec("css_plugins list") + loaded, ok := ParsePlugins(out) + if err != nil || !ok { + loaded = nil + } + p.mu.Lock() + p.plugins = loaded + p.mu.Unlock() +} + +// Plugin reports whether a CSS plugin (by ModuleName) is loaded, and its version if it is. +func (p *Poller) Plugin(name string) (PluginState, string) { + p.mu.RLock() + defer p.mu.RUnlock() + if p.plugins == nil { + return PluginUnknown, "" + } + if v, ok := p.plugins[name]; ok { + return PluginLoaded, v + } + return PluginMissing, "" +} + func (p *Poller) Current() Status { p.mu.RLock() defer p.mu.RUnlock() diff --git a/internal/live/live_test.go b/internal/live/live_test.go index b7a86c1..a5c829e 100644 --- a/internal/live/live_test.go +++ b/internal/live/live_test.go @@ -36,3 +36,27 @@ func TestParseErrors(t *testing.T) { t.Fatal("output without end marker should be an error") } } + +func TestParsePlugins(t *testing.T) { + out := ` List of all plugins currently loaded by CounterStrikeSharp: 3 plugins loaded. + [#1:LOADED]: "CS2-SimpleAdmin (RELEASE)" (1.8.2b-zio10) by daffyy + Simple admin plugin + [#2:LOADED]: "GroupVerify" (0.2.0) by astra + Verifies players by Steam group membership or a manual override + [#3:UNLOADED]: "NoGoZones" (0.14.0) by astra + Termination Reason: whatever +` + loaded, ok := ParsePlugins(out) + if !ok { + t.Fatal("not recognised as a plugin list") + } + if loaded["GroupVerify"] != "0.2.0" || loaded["CS2-SimpleAdmin (RELEASE)"] != "1.8.2b-zio10" { + t.Fatalf("loaded %v", loaded) + } + if _, ok := loaded["NoGoZones"]; ok { + t.Fatal("unloaded plugins must not count as loaded") + } + if _, ok := ParsePlugins("Unknown command \"css_plugins\"\n"); ok { + t.Fatal("an error reply isn't a plugin list") + } +} diff --git a/internal/store/verify.go b/internal/store/verify.go new file mode 100644 index 0000000..1d20a65 --- /dev/null +++ b/internal/store/verify.go @@ -0,0 +1,268 @@ +package store + +import ( + "context" + "database/sql" + "strconv" + "strings" + "time" +) + +// GroupVerify's tables (plugins/GroupVerify in the cs2 repo) live in the same database. The plugin +// creates them; the panel reads them, writes 'manual' override rows and changes gv_settings. Their +// times are wall-clock values in gv_settings' timezone, the zone GroupVerify itself uses, so they're +// read and written in that location rather than SimpleAdmin's. + +// VerifySettings is gv_settings. +type VerifySettings struct { + Enabled bool + GroupID string // "" when no group is set + ReloadSeconds int // how often GroupVerify rereads its tables + Location *time.Location +} + +// Active matches GroupVerify: it only verifies anyone when enabled with a group set. +func (v VerifySettings) Active() bool { return v.Enabled && v.GroupID != "" } + +// HasVerifyTables reports whether GroupVerify's tables exist in this database. +func (s *Store) HasVerifyTables(ctx context.Context) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name IN ('gv_verified', 'gv_settings')`).Scan(&n) + return n == 2, err +} + +// VerifySettings reads gv_settings, with GroupVerify's defaults for anything missing. +func (s *Store) VerifySettings(ctx context.Context) (VerifySettings, error) { + v := VerifySettings{Enabled: true, ReloadSeconds: 60, Location: time.UTC} + rows, err := s.db.QueryContext(ctx, `SELECT name, value FROM gv_settings`) + if err != nil { + return v, err + } + defer rows.Close() + for rows.Next() { + var name, value string + if err := rows.Scan(&name, &value); err != nil { + return v, err + } + value = strings.TrimSpace(value) + switch name { + case "enabled": + v.Enabled = value != "0" + case "group_id64": + if n, err := strconv.ParseUint(value, 10, 64); err == nil && n != 0 { + v.GroupID = value + } + case "db_reload_seconds": + if n, err := strconv.Atoi(value); err == nil && n >= 10 { + v.ReloadSeconds = n + } + case "timezone": + if loc, err := time.LoadLocation(value); err == nil { + v.Location = loc + } + } + } + return v, rows.Err() +} + +// SetVerifySetting writes one gv_settings value. +func (s *Store) SetVerifySetting(ctx context.Context, name, value string) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO gv_settings (name, value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)`, name, value) + return err +} + +func wallIn(t time.Time, loc *time.Location) time.Time { + t = t.In(loc) + return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, time.UTC) +} + +func fromWallIn(t time.Time, loc *time.Location) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, loc) +} + +// VerifyEntry is a row of gv_verified: an override, or a Steam group member. +type VerifyEntry struct { + SteamID string `json:"steamid"` + Name string `json:"name"` // the last name SimpleAdmin saw, "" if they never joined + Source string `json:"source"` + Expires *time.Time `json:"expires,omitempty"` // overrides only; nil never ends + AddedBy string `json:"addedBy,omitempty"` + Created time.Time `json:"created"` + Seen time.Time `json:"seen"` // group rows: last seen in the group's member list +} + +// VerifyStatus is why a player is or isn't verified, as far as the database knows. +type VerifyStatus struct { + InGroup bool `json:"inGroup"` + GroupSeen *time.Time `json:"groupSeen,omitempty"` + Override *VerifyEntry `json:"override,omitempty"` // the unexpired override, if any +} + +func (v VerifyStatus) Verified() bool { return v.InGroup || v.Override != nil } + +// VerifyStatuses looks up several players at once. Players with no rows are left out. +func (s *Store) VerifyStatuses(ctx context.Context, loc *time.Location, steamids []string) (map[string]VerifyStatus, error) { + out := map[string]VerifyStatus{} + if len(steamids) == 0 { + return out, nil + } + in, args := inList(steamids) + rows, err := s.db.QueryContext(ctx, `SELECT steamid64, source, `+"`override`"+`, expires_at, added_by, created_at, updated_at + FROM gv_verified WHERE steamid64 IN (`+in+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + now := time.Now() + for rows.Next() { + var id int64 + var e VerifyEntry + var override bool + var expires sql.NullTime + if err := rows.Scan(&id, &e.Source, &override, &expires, &e.AddedBy, &e.Created, &e.Seen); err != nil { + return nil, err + } + e.SteamID = strconv.FormatInt(id, 10) + e.Created, e.Seen = fromWallIn(e.Created, loc), fromWallIn(e.Seen, loc) + if expires.Valid { + t := fromWallIn(expires.Time, loc) + e.Expires = &t + } + st := out[e.SteamID] + if e.Source == "group" { + st.InGroup = true + st.GroupSeen = &e.Seen + } + // The same rule as GroupVerify: any unexpired override row counts, the longest one wins. + if override && (e.Expires == nil || e.Expires.After(now)) { + if st.Override == nil || (st.Override.Expires != nil && (e.Expires == nil || e.Expires.After(*st.Override.Expires))) { + entry := e + st.Override = &entry + } + } + out[e.SteamID] = st + } + return out, rows.Err() +} + +// What VerifyList lists. +const ( + VerifyOverrides = "overrides" // unexpired rows with override = 1 + VerifyGroup = "group" // the Steam group's members, as of GroupVerify's last fetch +) + +// VerifyList lists overrides or group members, newest first. Search matches a SteamID64 or a name +// SimpleAdmin has seen them use. +func (s *Store) VerifyList(ctx context.Context, loc *time.Location, kind, search string, lim, offset int) ([]VerifyEntry, int, error) { + var conds []string + var args []any + if kind == VerifyGroup { + conds = append(conds, "v.source = 'group'") + } else { + conds = append(conds, "v.`override` = 1", "(v.expires_at IS NULL OR v.expires_at > ?)") + args = append(args, wallIn(time.Now(), loc)) + } + if t := strings.TrimSpace(search); t != "" { + or := "v.steamid64 IN (SELECT steamid FROM sa_players_ips WHERE name LIKE ?)" + orArgs := []any{"%" + escapeLike(t) + "%"} + if n, err := strconv.ParseInt(t, 10, 64); err == nil { + or += " OR v.steamid64 = ?" + orArgs = append(orArgs, n) + } + conds = append(conds, "("+or+")") + args = append(args, orArgs...) + } + where := " WHERE " + strings.Join(conds, " AND ") + + var total int + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM gv_verified v"+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := s.db.QueryContext(ctx, `SELECT v.steamid64, v.source, v.expires_at, v.added_by, v.created_at, v.updated_at, + COALESCE((SELECT p.name FROM sa_players_ips p WHERE p.steamid = v.steamid64 ORDER BY p.used_at DESC LIMIT 1), '') + FROM gv_verified v`+where+` ORDER BY v.created_at DESC, v.steamid64 LIMIT ? OFFSET ?`, + append(args, limit(lim), offset)...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + out := []VerifyEntry{} + for rows.Next() { + var id int64 + var e VerifyEntry + var expires sql.NullTime + if err := rows.Scan(&id, &e.Source, &expires, &e.AddedBy, &e.Created, &e.Seen, &e.Name); err != nil { + return nil, 0, err + } + e.SteamID = strconv.FormatInt(id, 10) + e.Created, e.Seen = fromWallIn(e.Created, loc), fromWallIn(e.Seen, loc) + if expires.Valid { + t := fromWallIn(expires.Time, loc) + e.Expires = &t + } + out = append(out, e) + } + return out, total, rows.Err() +} + +// VerifyCounts is how many group members and unexpired overrides there are, and when GroupVerify +// last saved the group's member list (nil if it never has). +func (s *Store) VerifyCounts(ctx context.Context, loc *time.Location) (group, overrides int, fetched *time.Time, err error) { + var last sql.NullTime + err = s.db.QueryRowContext(ctx, `SELECT + COALESCE(SUM(source = 'group'), 0), + COALESCE(SUM(`+"`override`"+` = 1 AND (expires_at IS NULL OR expires_at > ?)), 0), + MAX(CASE WHEN source = 'group' THEN updated_at END) + FROM gv_verified`, wallIn(time.Now(), loc)).Scan(&group, &overrides, &last) + if last.Valid { + t := fromWallIn(last.Time, loc) + fetched = &t + } + return group, overrides, fetched, err +} + +// SetOverride gives a player a manual override ending at expires (nil: never), or changes the one +// they have. created_at keeps when it was first given. +func (s *Store) SetOverride(ctx context.Context, loc *time.Location, steamid string, expires *time.Time, addedBy string) error { + n, err := parseSID(steamid) + if err != nil { + return err + } + var ends any + if expires != nil { + ends = wallIn(*expires, loc) + } + now := wallIn(time.Now(), loc) + _, err = s.db.ExecContext(ctx, `INSERT INTO gv_verified (steamid64, source, `+"`override`"+`, expires_at, added_by, created_at, updated_at) + VALUES (?, 'manual', 1, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE `+"`override`"+` = 1, expires_at = VALUES(expires_at), added_by = VALUES(added_by), updated_at = VALUES(updated_at)`, + n, ends, addedBy, now, now) + return err +} + +// RemoveOverride takes a player's override away: deletes their manual row, and clears override on +// their group row in case it was set by hand. Reports whether there was one. +func (s *Store) RemoveOverride(ctx context.Context, steamid string) (bool, error) { + n, err := parseSID(steamid) + if err != nil { + return false, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer tx.Rollback() + res, err := tx.ExecContext(ctx, `DELETE FROM gv_verified WHERE steamid64 = ? AND source = 'manual'`, n) + if err != nil { + return false, err + } + deleted, _ := res.RowsAffected() + res, err = tx.ExecContext(ctx, `UPDATE gv_verified SET `+"`override`"+` = 0 WHERE steamid64 = ? AND source = 'group' AND `+"`override`"+` = 1`, n) + if err != nil { + return false, err + } + cleared, _ := res.RowsAffected() + return deleted+cleared > 0, tx.Commit() +} diff --git a/internal/web/admin.go b/internal/web/admin.go index d8f99af..577b267 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -35,6 +35,7 @@ func nonNil[T any](v []T) []T { func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { ctx := r.Context() view := s.serverView(x, s.poll.Current()) + s.markVerified(ctx, view.Players) var ids []string for _, p := range view.Players { @@ -238,6 +239,12 @@ func (s *Server) adminPlayer(w http.ResponseWriter, r *http.Request, id *Identit resp["groups"] = a.Groups resp["adminEnds"] = a.Ends } + if v, err := s.playerVerify(ctx, id, steamid); err != nil { + internal(w, r, err) + return + } else if v != nil { + resp["verify"] = v + } writeJSON(w, http.StatusOK, resp) } @@ -299,6 +306,7 @@ var knownFlags = [][3]string{ {"@zio/vote", "Start votes", "css_vote"}, {"@zio/plugins", "Plugins", "css_plugins and css"}, {"@zio/webpanel", "Staff page", "See the Staff & ranks page in this panel"}, + {"@zio/verify", "Verification overrides", "Give and remove GroupVerify overrides in this panel"}, {"@css/root", "Root", "Every permission, and managing ranks and staff"}, } diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js index 24b98b2..797cba9 100644 --- a/internal/web/assets/admin.js +++ b/internal/web/assets/admin.js @@ -7,14 +7,19 @@ const PAGES = [ { id: "bans", label: "Bans" }, { id: "comms", label: "Gags & mutes" }, { id: "staff", label: "Staff & ranks", flag: "@zio/webpanel" }, + // Only when GroupVerify's tables are in the database. + { id: "verify", label: "Verification", show: () => !!state.me?.verify }, { id: "settings", label: "Server settings" }, ]; +const pageAllowed = (p) => (!p.flag || can(p.flag)) && (!p.show || p.show()); + const state = { page: "overview", banFilter: "active", commFilter: "active", playerFilter: "online", + verifyView: "overrides", query: "", num: 1, group: null, @@ -75,7 +80,7 @@ function onlineTable(players) { PlayerRankK / DPingActions ${list.map((p) => ` -
${teamName[p.team] || "SPEC"}
${esc(p.name)} ${tagsFor(p)}
+
${teamName[p.team] || "SPEC"}
${esc(p.name)} ${p.verified ? VERIFIED_TAG : ""} ${tagsFor(p)}
${rankTag(p.rank)} ${p.kills} / ${p.deaths} ${p.ping} @@ -246,12 +251,18 @@ async function loadResults() { let path, status, render; if (state.page === "players") { path = "/api/admin/players"; render = playersTable; + } else if (state.page === "verify") { + path = "/api/admin/verify"; status = undefined; render = verifyResults; } else if (state.page === "bans") { path = "/api/admin/bans"; status = state.banFilter; render = (l) => punishTable(l, "ban"); } else { path = "/api/admin/comms"; status = state.commFilter; render = (l) => punishTable(l, "comm"); } - const params = { status, q: state.query, page: state.num, filter: state.page === "players" ? state.playerFilter : undefined }; + const params = { + status, q: state.query, page: state.num, + filter: state.page === "players" ? state.playerFilter : undefined, + view: state.page === "verify" ? state.verifyView : undefined, + }; const want = JSON.stringify([state.page, params]); loadResults.want = want; try { @@ -355,6 +366,141 @@ async function staff() { ` : `
SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}
`}`; } +/* ---------- Verification (GroupVerify) ---------- */ + +const VERIFY_DAYS = [[1, "1 day"], [7, "1 week"], [30, "30 days"], [90, "90 days"], [365, "1 year"], [0, "No expiry"]]; + +function verify() { + $("#view").innerHTML = ` +
+

Verification

GroupVerify verifies players who are in the server's Steam group, or have an override given here. Other plugins give verified players their perks.

+
+
+
${loading()}
+
+
+ ${searchBox("Search by name or SteamID64", state.query)} + ${segmented("verifyView", state.verifyView, [["overrides", "Overrides"], ["group", "Steam group members"]])} +
+
${loading()}
+
`; + loadResults(); +} + +const PLUGIN_STATE = { + loaded: (v) => `Loaded ${esc(v)}`, + missing: () => 'Not loaded', + unknown: () => 'Unknown the game server isn\'t answering', +}; + +// verifyStatus is the top of the page, drawn once per visit so typing in the group field survives +// searches and paging. +function verifyStatus(st) { + const box = $("#verify-status"); + if (!box || box.dataset.drawn) return; + box.dataset.drawn = "1"; + $("#verify-actions").innerHTML = st.canOverride ? `` : ""; + const warn = []; + if (st.plugin === "missing") warn.push("GroupVerify isn't loaded on the game server, so nobody is verified in game, and the public site doesn't show verification."); + if (!st.enabled) warn.push("Verification is turned off: nobody is verified, overrides included."); + else if (!st.groupId) warn.push("No Steam group is set, so verification is off: nobody is verified, overrides included."); + const group = st.groupId + ? `${esc(st.groupId)}` + : 'Not set'; + box.innerHTML = ` + ${warn.map((w) => `

Off ${esc(w)}

`).join("")} +
+

GroupVerify

${st.active && st.plugin !== "missing" ? 'On' : 'Off'}
+
+
+
Plugin
${PLUGIN_STATE[st.plugin](st.version)}
+
Steam group
${group}
+
Group members
${st.groupMembers}${st.groupFetched ? ` list saved ${fmtAgo(st.groupFetched)}` : ' never fetched'}
+
Overrides
${st.overrides}
+
Changes apply
Within ${st.reloadSeconds} seconds (times in ${esc(st.timezone)})
+
+ ${st.canConfigure ? `
+ + + +
` : '

Only Root can change the group or turn verification on and off.

'} +
+
`; +} + +function verifyResults(d) { + verifyStatus(d.status); + const list = d.list; + const groupView = state.verifyView === "group"; + if (!list.items.length) { + if (state.query) return `
Nobody matches "${esc(state.query)}".
`; + return `
${groupView ? "No group members saved yet. GroupVerify saves the list each time it fetches it." : "No overrides."}
`; + } + const mayEdit = (sid) => d.status.canOverride && (sid !== state.me.steamid || can("@css/root")); + const who = (e) => `
${e.name ? esc(e.name) : 'Never joined'}${esc(e.steamid)}
`; + if (groupView) { + return ` + + ${list.items.map((e) => `${who(e)}`).join("")} +
PlayerIn the group sinceLast seen in the list
${fmtDate(e.created)}${fmtAgo(e.seen)}
${pager(list)}`; + } + return ` + + + ${list.items.map((e) => ` + ${who(e)} + + + + `).join("")} + +
PlayerEndsGivenActions
${e.expires ? `${fmtDate(e.expires)}
${fmtLeft((new Date(e.expires) - Date.now()) / MIN)}` : "Never"}
${fmtDate(e.created)}${e.addedBy ? `
by ${esc(e.addedBy)}` : ""}
${mayEdit(e.steamid) ? ` + + ` : ""} +
${pager(list)}`; +} + +// verifySection is the Verification part of a player's drawer. +function verifySection(v, sid, name) { + if (!v) return ""; + const o = v.override; + const badge = !v.active ? 'Verification is off' + : v.verified ? VERIFIED_TAG : 'Not verified'; + return `
+

Verification

+

${badge}

+
+
Steam group
${v.inGroup ? `Member seen in the list ${fmtAgo(v.groupSeen)}` : 'Not a member'}
+
Override
${o ? `${o.expires ? `Until ${fmtDate(o.expires)}` : "No expiry"}${o.addedBy ? ` from ${esc(o.addedBy)}` : ""}` : 'None'}
+
+ ${v.canOverride ? `
+ + ${o ? `` : ""} +
` : ""} +
`; +} + +function overrideDialog(sid, name) { + openModal(` +
+ + + +
`); +} + /* ---------- Settings ---------- */ async function settings() { @@ -472,7 +618,7 @@ async function openPlayer(sid) {