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

View file

@ -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.

View file

@ -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()

View file

@ -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")
}
}

268
internal/store/verify.go Normal file
View file

@ -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()
}

View file

@ -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"},
}

View file

@ -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) {
<thead><tr><th>Player</th><th class="hide-sm">Rank</th><th class="num">K / D</th><th class="num hide-sm">Ping</th><th><span class="sr">Actions</span></th></tr></thead>
<tbody>
${list.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="team ${p.team === 3 ? "ct" : p.team === 2 ? "t" : ""}">${teamName[p.team] || "SPEC"}</span><div><b>${esc(p.name)}</b> ${tagsFor(p)}</div></div></td>
<td><div class="who"><span class="team ${p.team === 3 ? "ct" : p.team === 2 ? "t" : ""}">${teamName[p.team] || "SPEC"}</span><div><b>${esc(p.name)}</b> ${p.verified ? VERIFIED_TAG : ""} ${tagsFor(p)}</div></div></td>
<td class="hide-sm">${rankTag(p.rank)}</td>
<td class="num">${p.kills} / ${p.deaths}</td>
<td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</td>
@ -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() {
</div>` : `<div class="empty">SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}</div>`}`;
}
/* ---------- 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 = `
<div class="page-head">
<div><h1>Verification</h1><p class="sub">GroupVerify verifies players who are in the server's Steam group, or have an override given here. Other plugins give verified players their perks.</p></div>
<div id="verify-actions"></div>
</div>
<div class="stack" id="verify-status">${loading()}</div>
<section class="panel" style="margin-top:18px">
<div class="toolbar">
${searchBox("Search by name or SteamID64", state.query)}
${segmented("verifyView", state.verifyView, [["overrides", "Overrides"], ["group", "Steam group members"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
const PLUGIN_STATE = {
loaded: (v) => `<span class="tag green">Loaded</span> <span class="muted">${esc(v)}</span>`,
missing: () => '<span class="tag red">Not loaded</span>',
unknown: () => '<span class="tag">Unknown</span> <span class="muted">the game server isn\'t answering</span>',
};
// 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 ? `<button class="btn primary" type="button" data-act="override">${ICON.plus}Give override</button>` : "";
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
? `<a href="https://steamcommunity.com/gid/${esc(st.groupId)}" target="_blank" rel="noopener">${esc(st.groupId)}</a>`
: '<span class="faint">Not set</span>';
box.innerHTML = `
${warn.map((w) => `<div class="panel"><div class="panel-body"><p style="margin:0"><span class="tag amber">Off</span> ${esc(w)}</p></div></div>`).join("")}
<section class="panel">
<div class="panel-head"><h2>GroupVerify</h2>${st.active && st.plugin !== "missing" ? '<span class="tag green">On</span>' : '<span class="tag amber">Off</span>'}</div>
<div class="panel-body">
<dl class="kv">
<dt>Plugin</dt><dd>${PLUGIN_STATE[st.plugin](st.version)}</dd>
<dt>Steam group</dt><dd>${group}</dd>
<dt>Group members</dt><dd>${st.groupMembers}${st.groupFetched ? ` <span class="muted">list saved ${fmtAgo(st.groupFetched)}</span>` : ' <span class="faint">never fetched</span>'}</dd>
<dt>Overrides</dt><dd>${st.overrides}</dd>
<dt>Changes apply</dt><dd>Within ${st.reloadSeconds} seconds <span class="muted">(times in ${esc(st.timezone)})</span></dd>
</dl>
${st.canConfigure ? `<form id="gv-form" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-top:16px">
<label class="field" style="flex:1;min-width:240px"><span>Steam group ID64</span>
<input class="input" name="group" value="${esc(st.groupId)}" inputmode="numeric" pattern="[0-9]{0,20}" placeholder="103582791…, blank or 0 turns verification off">
<small>&lt;groupID64&gt; on https://steamcommunity.com/groups/&lt;name&gt;/memberslistxml/?xml=1</small>
</label>
<button class="btn" type="submit">Save group</button>
<button class="btn ${st.enabled ? "danger" : "primary"}" type="button" data-act="gv-toggle" data-on="${st.enabled ? "0" : "1"}">${st.enabled ? "Turn off" : "Turn on"}</button>
</form>` : '<p class="muted" style="margin:16px 0 0">Only Root can change the group or turn verification on and off.</p>'}
</div>
</section>`;
}
function verifyResults(d) {
verifyStatus(d.status);
const list = d.list;
const groupView = state.verifyView === "group";
if (!list.items.length) {
if (state.query) return `<div class="empty">Nobody matches "${esc(state.query)}".</div>`;
return `<div class="empty">${groupView ? "No group members saved yet. GroupVerify saves the list each time it fetches it." : "No overrides."}</div>`;
}
const mayEdit = (sid) => d.status.canOverride && (sid !== state.me.steamid || can("@css/root"));
const who = (e) => `<td><div class="who"><div><b><a href="#" data-player="${esc(e.steamid)}">${e.name ? esc(e.name) : '<span class="faint">Never joined</span>'}</a></b><span class="sid">${esc(e.steamid)}</span></div></div></td>`;
if (groupView) {
return `<table>
<thead><tr><th>Player</th><th>In the group since</th><th class="hide-sm">Last seen in the list</th></tr></thead>
<tbody>${list.items.map((e) => `<tr>${who(e)}<td>${fmtDate(e.created)}</td><td class="hide-sm muted">${fmtAgo(e.seen)}</td></tr>`).join("")}</tbody>
</table>${pager(list)}`;
}
return `<table>
<thead><tr><th>Player</th><th>Ends</th><th class="hide-sm">Given</th><th><span class="sr">Actions</span></th></tr></thead>
<tbody>
${list.items.map((e) => `<tr>
${who(e)}
<td>${e.expires ? `${fmtDate(e.expires)}<br><small class="muted">${fmtLeft((new Date(e.expires) - Date.now()) / MIN)}</small>` : "Never"}</td>
<td class="hide-sm">${fmtDate(e.created)}${e.addedBy ? `<br><small class="muted">by ${esc(e.addedBy)}</small>` : ""}</td>
<td><div class="row-actions">${mayEdit(e.steamid) ? `
<button class="btn sm ghost" type="button" data-act="override" data-sid="${esc(e.steamid)}" data-name="${esc(e.name)}">Change</button>
<button class="btn sm danger" type="button" data-act="remove-override" data-sid="${esc(e.steamid)}" data-name="${esc(e.name)}">Remove</button>` : ""}
</div></td>
</tr>`).join("")}
</tbody>
</table>${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 ? '<span class="tag amber">Verification is off</span>'
: v.verified ? VERIFIED_TAG : '<span class="tag">Not verified</span>';
return `<section>
<h3>Verification</h3>
<p style="margin:0 0 10px">${badge}</p>
<dl class="kv">
<dt>Steam group</dt><dd>${v.inGroup ? `Member <span class="muted">seen in the list ${fmtAgo(v.groupSeen)}</span>` : '<span class="faint">Not a member</span>'}</dd>
<dt>Override</dt><dd>${o ? `${o.expires ? `Until ${fmtDate(o.expires)}` : "No expiry"}${o.addedBy ? ` <span class="muted">from ${esc(o.addedBy)}</span>` : ""}` : '<span class="faint">None</span>'}</dd>
</dl>
${v.canOverride ? `<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn" type="button" data-act="override" data-sid="${esc(sid)}" data-name="${esc(name)}">${o ? "Change override" : "Give override"}</button>
${o ? `<button class="btn danger" type="button" data-act="remove-override" data-sid="${esc(sid)}" data-name="${esc(name)}">Remove override</button>` : ""}
</div>` : ""}
</section>`;
}
function overrideDialog(sid, name) {
openModal(`
<form method="dialog" data-submit="override">
<div class="modal-head"><h2>${sid ? "Override" : "Give override"}${name ? `: ${esc(name)}` : ""}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body">
<label class="field"><span>Player</span>
<input class="input" name="steamid" required pattern="7656119[0-9]{10}" value="${esc(sid)}" placeholder="SteamID64, like 76561198012345678" ${sid ? "readonly" : ""}>
<small>They count as verified whether or not they're in the Steam group, until it ends.</small>
</label>
<div class="field"><span>Length</span>
<div class="chips" data-chips="days">${VERIFY_DAYS.map(([v, l]) => `<button type="button" class="chip-btn" data-value="${v}" aria-pressed="${v === 30}">${l}</button>`).join("")}</div>
<small>Changing an override sets a new end from now.</small>
</div>
</div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Save override</button></div>
</form>`);
}
/* ---------- Settings ---------- */
async function settings() {
@ -472,7 +618,7 @@ async function openPlayer(sid) {
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
<div class="drawer-head">
<span class="avatar lg">${initial(name)}</span>
<div><h2>${esc(name)}</h2><div>${rankTag(d.rank)} ${d.online ? '<span class="tag green" style="margin-left:6px">Online</span>' : ""}</div></div>
<div><h2>${esc(name)}</h2><div>${rankTag(d.rank)} ${d.online ? '<span class="tag green" style="margin-left:6px">Online</span>' : ""}${d.verify?.verified ? ` ${VERIFIED_TAG}` : ""}</div></div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
<section>
@ -498,6 +644,7 @@ async function openPlayer(sid) {
${d.adminEnds ? `<dt>Rank expires</dt><dd>${fmtDate(d.adminEnds)}</dd>` : ""}
</dl>
</section>
${verifySection(d.verify, p.steamid, name)}
${manage ? `<section>
<h3>Rank</h3>
<div style="display:flex;gap:8px">
@ -640,12 +787,12 @@ async function act(path, method, body) {
/* ---------- Routing & events ---------- */
const RENDER = { overview, players, bans, comms, staff, settings };
const RENDER = { overview, players, bans, comms, staff, verify, settings };
function renderNav() {
const c = state.counts;
const count = { players: c.players, bans: c.bans, comms: c.comms, staff: c.staff };
$("#nav-links").innerHTML = PAGES.filter((p) => !p.flag || can(p.flag)).map((p) => `<li><a class="link" href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}${count[p.id] !== undefined && count[p.id] !== "" ? `<span class="count">${esc(count[p.id])}</span>` : ""}</a></li>`).join("");
$("#nav-links").innerHTML = PAGES.filter(pageAllowed).map((p) => `<li><a class="link" href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}${count[p.id] !== undefined && count[p.id] !== "" ? `<span class="count">${esc(count[p.id])}</span>` : ""}</a></li>`).join("");
}
// The mobile sidebar covers the Menu button, so it closes from its own Close button, the
@ -658,7 +805,7 @@ function setNav(open) {
function route() {
const page = location.hash.slice(1);
const def = PAGES.find((p) => p.id === page);
state.page = def && (!def.flag || can(def.flag)) ? page : "overview";
state.page = def && pageAllowed(def) ? page : "overview";
renderNav();
setNav(false);
if (!$("#view").innerHTML) $("#view").innerHTML = loading();
@ -763,6 +910,19 @@ document.addEventListener("click", (e) => {
case "restart":
confirmDialog.reason = false;
return confirmDialog("Restart the match", "Restart the game on the current map? The current match ends.", "Restart match", () => act("/api/admin/restart", "POST", {}));
case "override": return overrideDialog(d.sid || "", d.name || "");
case "remove-override":
confirmDialog.reason = false;
return confirmDialog("Remove override", `Remove <b>${esc(d.name || d.sid)}</b>'s override? They stay verified only if they're in the Steam group.`, "Remove",
() => act(`/api/admin/verify/overrides/${encodeURIComponent(d.sid)}`, "DELETE"));
case "gv-toggle": {
const on = d.on === "1";
confirmDialog.reason = false;
return confirmDialog(on ? "Turn verification on" : "Turn verification off", on
? "Turn GroupVerify on? Players in the Steam group and with overrides become verified."
: "Turn GroupVerify off? Nobody is verified while it's off, overrides included, and verified players lose their perks.",
on ? "Turn on" : "Turn off", () => act("/api/admin/verify/settings", "POST", { enabled: on }));
}
case "save-settings": return saveSettings();
case "discard": return settings();
}
@ -779,6 +939,11 @@ document.addEventListener("submit", async (e) => {
const kind = form.dataset.submit;
const f = form.elements;
if (form.id === "gv-form") {
await act("/api/admin/verify/settings", "POST", { groupId: f.group.value.trim() });
return;
}
if (form.id === "group-form") {
const flags = [...form.querySelectorAll('input[name="flag"]')].filter((i) => i.checked && (!i.disabled || i.dataset.kept)).map((i) => i.value);
await act(`/api/admin/groups/${form.dataset.id}`, "PUT", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags });
@ -811,6 +976,11 @@ document.addEventListener("submit", async (e) => {
case "map": await act("/api/admin/map", "POST", { map: f.value.value.trim() }); break;
case "say": await act("/api/admin/say", "POST", { message: f.value.value }); break;
case "kick": await act("/api/admin/kick", "POST", { steamid: form.dataset.sid, reason: f.value.value }); break;
case "override": {
const days = form.querySelector('[data-chips="days"] [aria-pressed="true"]');
await act("/api/admin/verify/overrides", "POST", { steamid: f.steamid.value.trim(), days: Number(days?.dataset.value ?? 30) });
break;
}
case "confirm": await confirmDialog.run?.(f); break;
}
});

View file

@ -125,6 +125,9 @@ function statusTag(p) {
}
const COMM_LABEL = { GAG: "Gag", MUTE: "Mute", SILENCE: "Silence" };
// Verified by GroupVerify: in the server's Steam group, or given an override by staff.
const VERIFIED_TAG = '<span class="tag verified" title="In the server\'s Steam group, or verified by staff">Verified</span>';
const COMM_HELP = { GAG: "Text chat blocked", MUTE: "Voice blocked", SILENCE: "Text and voice blocked" };
function segmented(name, value, options) {

View file

@ -38,7 +38,7 @@ async function server() {
<thead><tr><th>Player</th><th class="num">Kills</th><th class="num">Deaths</th><th class="num hide-sm">Ping</th></tr></thead>
<tbody>
${list.map((p) => `<tr ${p.bot ? "" : `class="clickable" data-player="${esc(p.steamid)}"`}>
<td><div class="who"><div><b>${esc(p.name)}</b>${p.bot ? '<span class="sid">Bot</span>' : p.rank ? rankTag(p.rank) : ""}</div></div></td>
<td><div class="who"><div><b>${esc(p.name)}</b>${p.verified ? ` ${VERIFIED_TAG}` : ""}${p.bot ? '<span class="sid">Bot</span>' : p.rank ? rankTag(p.rank) : ""}</div></div></td>
<td class="num">${p.kills}</td>
<td class="num">${p.deaths}</td>
<td class="num hide-sm muted">${p.bot ? "" : p.ping}</td>
@ -238,8 +238,8 @@ async function openPlayer(sid) {
<div><h2>${esc(name)}</h2><span class="sid">${esc(p.steamid)} · <a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></span>${p.rank ? `<div>${rankTag(p.rank)}</div>` : ""}</div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
${now.length || p.online ? `<section>
<div class="chips">${p.online ? '<span class="tag">On the server</span>' : ""}${now.map((h) => `<span class="tag ${h.kind === "ban" ? "red" : "amber"}">${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"}</span>`).join("")}</div>
${now.length || p.online || p.verified ? `<section>
<div class="chips">${p.verified ? VERIFIED_TAG : ""}${p.online ? '<span class="tag">On the server</span>' : ""}${now.map((h) => `<span class="tag ${h.kind === "ban" ? "red" : "amber"}">${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"}</span>`).join("")}</div>
</section>` : ""}
<section>
<h3>History</h3>

View file

@ -427,6 +427,7 @@ td.num, th.num { text-align: right; white-space: nowrap; }
.tag.red { background: var(--accent-soft); color: #ff5a78; }
.tag.amber { background: var(--amber-soft); color: var(--amber); }
.tag.green { background: var(--green-soft); color: var(--green); }
.tag.verified { background: rgba(79, 179, 217, .14); color: #4fb3d9; }
.rank {
display: inline-flex;

View file

@ -181,7 +181,9 @@ type meView struct {
Rank *rankView `json:"rank,omitempty"`
Perms []string `json:"perms,omitempty"`
Immunity int `json:"immunity,omitempty"`
Site string `json:"site"`
// Verify is true when GroupVerify's tables are in the database, for the Verification page.
Verify bool `json:"verify,omitempty"`
Site string `json:"site"`
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
@ -197,6 +199,10 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
if id != nil {
v.Staff, v.Name, v.Rank, v.Immunity = true, id.Name, s.rankOf(x, id.SteamID), id.Immunity
v.Perms = id.Flags
// Never fails the page: without the tables, or on an error, there's just no Verification page.
if info, err := s.verifyInfo(r.Context()); err == nil {
v.Verify = info.Tables
}
}
writeJSON(w, http.StatusOK, v)
}

View file

@ -38,6 +38,8 @@ func toPublic(ps []store.Penalty) []publicPenalty {
type livePlayer struct {
live.Player
Rank *rankView `json:"rank,omitempty"`
// Verified by GroupVerify, when that's shown at all (see verifyInfo.Shown).
Verified bool `json:"verified,omitempty"`
// Staff-only fields, left empty on the public page.
Tags []string `json:"tags,omitempty"`
CanTarget bool `json:"canTarget,omitempty"`
@ -82,7 +84,9 @@ func (s *Server) publicServer(w http.ResponseWriter, r *http.Request) {
internal(w, r, err)
return
}
writeJSON(w, http.StatusOK, s.serverView(x, s.poll.Current()))
view := s.serverView(x, s.poll.Current())
s.markVerified(r.Context(), view.Players)
writeJSON(w, http.StatusOK, view)
}
// penaltyQuery reads ?status=&q=&page= (and for comms, a type in place of the status).
@ -286,8 +290,10 @@ func (s *Server) publicPlayer(w http.ResponseWriter, r *http.Request) {
return
}
_, online := s.poll.Current().Find(steamid)
// Only whether they're verified: why, and overrides' expiry and who gave them, are for staff.
st, verified := s.verifiedSet(r.Context(), []string{steamid})[steamid]
writeJSON(w, http.StatusOK, map[string]any{
"steamid": steamid, "name": d.Name, "online": online, "rank": s.rankOf(x, steamid),
"bans": toPublic(bans), "comms": toPublic(comms),
"verified": verified && st.Verified(), "bans": toPublic(bans), "comms": toPublic(comms),
})
}

View file

@ -41,6 +41,7 @@ type Server struct {
poll *live.Poller
sacfg *saconfig.File // nil when no config path is set
staff *staffCache
verify *verifyCache
avatars *avatarCache
http *http.Client
secure bool
@ -63,6 +64,7 @@ func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg
poll: poll,
sacfg: sacfg,
staff: &staffCache{st: st, ttl: 15 * time.Second},
verify: &verifyCache{st: st, ttl: 30 * time.Second},
avatars: newAvatarCache(&http.Client{Timeout: 10 * time.Second}),
http: &http.Client{Timeout: 10 * time.Second},
secure: u.Scheme == "https",
@ -101,6 +103,7 @@ func (s *Server) Handler() http.Handler {
admin("GET /api/admin/comms", s.adminComms)
admin("GET /api/admin/staff", s.adminStaff)
admin("GET /api/admin/settings", s.adminSettings)
admin("GET /api/admin/verify", s.adminVerify)
admin("POST /api/admin/penalties", s.addPenalty)
admin("POST /api/admin/penalties/lift", s.liftPenalty)
@ -117,6 +120,9 @@ func (s *Server) Handler() http.Handler {
admin("DELETE /api/admin/staff/{steamid}", s.removeStaff)
admin("POST /api/admin/settings/cvars", s.saveCvars)
admin("POST /api/admin/settings/simpleadmin", s.saveSimpleAdmin)
admin("POST /api/admin/verify/overrides", s.setOverride)
admin("DELETE /api/admin/verify/overrides/{steamid}", s.removeOverride)
admin("POST /api/admin/verify/settings", s.saveVerifySettings)
return s.headers(mux)
}

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))
}
}

View file

@ -0,0 +1,222 @@
package web
import (
"bytes"
"database/sql"
"encoding/json"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"git.zio.sh/cs2/simpleadmin-web/internal/live"
"git.zio.sh/cs2/simpleadmin-web/internal/rcon"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
)
// TestVerifyDB runs the GroupVerify endpoints against a real MySQL holding SimpleAdmin 1.8.2b's
// migrations and GroupVerify's Migrations/001_CreateTables.sql. It empties those tables first, so
// only point it at a scratch database:
//
// SAW_TEST_DSN='root:test@tcp(127.0.0.1:33061)/sa?parseTime=true&loc=UTC' go test ./internal/web -run VerifyDB
func TestVerifyDB(t *testing.T) {
dsn := os.Getenv("SAW_TEST_DSN")
if dsn == "" {
t.Skip("SAW_TEST_DSN not set")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
const (
owner = "76561198000000001"
mod = "76561198000000002"
charlie = "76561198000000003"
member = "76561198000000004"
expired = "76561198000000005"
group = "103582791429521412"
)
for _, table := range []string{"gv_verified", "gv_settings", "sa_admins_flags", "sa_admins", "sa_groups_flags", "sa_groups_servers", "sa_groups", "sa_players_ips"} {
exec("DELETE FROM " + table)
}
exec("INSERT INTO sa_groups (id, name, immunity) VALUES (1, '#rank/owner', 9999), (2, '#rank/mod', 30)")
exec("INSERT INTO sa_groups_servers (group_id, server_id) VALUES (1, NULL), (2, NULL)")
exec("INSERT INTO sa_groups_flags (group_id, flag) VALUES (1, '@css/root'), (2, '@css/generic')")
exec("INSERT INTO sa_admins (id, player_name, player_steamid, immunity, server_id) VALUES (1, 'Owner', ?, 0, NULL), (2, 'Mod', ?, 0, NULL)", owner, mod)
exec("INSERT INTO sa_admins_flags (admin_id, flag) VALUES (1, '#rank/owner'), (2, '#rank/mod')")
exec("INSERT INTO sa_players_ips (steamid, name, address, used_at) VALUES (?, 'Charlie', INET_ATON('10.0.0.3'), NOW())", charlie)
st := store.New(db, time.UTC, 0, 1)
rc := rcon.New("127.0.0.1:1", "unused") // nothing listens: the plugin's state stays unknown
srv, err := New(Config{BaseURL: "http://panel.test", SessionSecret: []byte("secret")}, st, rc, live.NewPoller(rc, time.Hour), nil)
if err != nil {
t.Fatal(err)
}
h := srv.Handler()
call := func(method, path, as string, body any) (int, map[string]any) {
t.Helper()
var rd *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
} else {
rd = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rd)
req.Header.Set(csrfHeader, csrfValue)
if as != "" {
rec := httptest.NewRecorder()
srv.setSession(rec, as)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
expect := func(want, got int, out map[string]any, what string) {
t.Helper()
if got != want {
t.Fatalf("%s: status %d, want %d (%v)", what, got, want, out)
}
}
verified := func(path, as string) bool {
t.Helper()
code, out := call("GET", path, as, nil)
expect(200, code, out, path)
if v, ok := out["verify"].(map[string]any); ok {
return v["verified"] == true
}
return out["verified"] == true
}
// The tables are found, and GroupVerify's defaults apply until something is set.
if _, out := call("GET", "/api/me", mod, nil); out["verify"] != true {
t.Fatalf("me.verify = %v, want true", out["verify"])
}
// Settings: Root only, and only real group IDs.
code, out := call("POST", "/api/admin/verify/settings", mod, map[string]any{"groupId": group})
expect(403, code, out, "mod sets the group")
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"groupId": "76561198000000003"})
expect(400, code, out, "a player's ID as the group")
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"groupId": group})
expect(200, code, out, "owner sets the group")
// Overrides need @zio/verify.
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 7})
expect(403, code, out, "mod without @zio/verify")
exec("INSERT INTO sa_groups_flags (group_id, flag) VALUES (2, '@zio/verify')")
srv.staff.invalidate()
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": mod, "days": 7})
expect(403, code, out, "mod overrides themselves")
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 7})
expect(200, code, out, "mod gives an override")
// A group member, and an override that has run out.
exec("INSERT INTO gv_verified (steamid64, source, `override`, added_by, created_at, updated_at) VALUES (?, 'group', 0, 'steam-group', NOW(), NOW())", member)
exec("INSERT INTO gv_verified (steamid64, source, `override`, expires_at, added_by, created_at, updated_at) VALUES (?, 'manual', 1, ?, 'x', NOW(), NOW())",
expired, time.Now().UTC().Add(-time.Hour).Format(time.DateTime))
code, out = call("GET", "/api/admin/verify", mod, nil)
expect(200, code, out, "verify page")
status := out["status"].(map[string]any)
if status["active"] != true || status["groupMembers"] != float64(1) || status["overrides"] != float64(1) || status["plugin"] != "unknown" {
t.Fatalf("status %v", status)
}
items := out["list"].(map[string]any)["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["steamid"] != charlie || items[0].(map[string]any)["name"] != "Charlie" {
t.Fatalf("overrides %v", items)
}
ends, _ := time.Parse(time.RFC3339, items[0].(map[string]any)["expires"].(string))
if d := time.Until(ends); d < 7*24*time.Hour-time.Minute || d > 7*24*time.Hour+time.Minute {
t.Fatalf("override ends in %v, want 7 days", d)
}
code, out = call("GET", "/api/admin/verify?view=group&q="+member, mod, nil)
expect(200, code, out, "group view")
if items := out["list"].(map[string]any)["items"].([]any); len(items) != 1 || items[0].(map[string]any)["steamid"] != member {
t.Fatalf("group members %v", items)
}
code, out = call("GET", "/api/admin/verify?q=Charl", mod, nil)
expect(200, code, out, "search by name")
if items := out["list"].(map[string]any)["items"].([]any); len(items) != 1 {
t.Fatalf("name search %v", items)
}
for sid, want := range map[string]bool{charlie: true, member: true, expired: false, mod: false} {
if got := verified("/api/admin/players/"+sid, mod); got != want {
t.Errorf("staff view of %s: verified %v, want %v", sid, got, want)
}
if got := verified("/api/public/players/"+sid, ""); got != want {
t.Errorf("public view of %s: verified %v, want %v", sid, got, want)
}
}
// Removing it, twice.
code, out = call("DELETE", "/api/admin/verify/overrides/"+charlie, mod, nil)
expect(200, code, out, "remove override")
code, out = call("DELETE", "/api/admin/verify/overrides/"+charlie, mod, nil)
expect(404, code, out, "remove it again")
if verified("/api/public/players/"+charlie, "") {
t.Error("still verified after removing the override")
}
// Turned off, nobody is verified, publicly or for staff.
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"enabled": false})
expect(200, code, out, "turn off")
if !strings.Contains(out["message"].(string), "off") {
t.Errorf("turn-off message %q", out["message"])
}
if verified("/api/public/players/"+member, "") || verified("/api/admin/players/"+member, mod) {
t.Error("verified while GroupVerify is off")
}
// Override times are written in GroupVerify's timezone.
exec("UPDATE gv_settings SET value = '1' WHERE name = 'enabled'")
exec("INSERT INTO gv_settings (name, value) VALUES ('timezone', 'Asia/Tokyo') ON DUPLICATE KEY UPDATE value = 'Asia/Tokyo'")
srv.verify.invalidate()
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 1})
expect(200, code, out, "override in Tokyo time")
var raw string
if err := db.QueryRow("SELECT DATE_FORMAT(expires_at, '%Y-%m-%d %H:%i') FROM gv_verified WHERE steamid64 = ? AND source = 'manual'", charlie).Scan(&raw); err != nil {
t.Fatal(err)
}
tokyo, _ := time.LoadLocation("Asia/Tokyo")
if want := time.Now().Add(24 * time.Hour).In(tokyo).Format("2006-01-02 15:04"); raw != want && raw != time.Now().Add(24*time.Hour+time.Minute).In(tokyo).Format("2006-01-02 15:04") {
t.Errorf("expires_at %s, want Tokyo wall time %s", raw, want)
}
if !verified("/api/public/players/"+charlie, "") {
t.Error("override in Tokyo time isn't counted")
}
// Without GroupVerify's tables, there's no sign of it anywhere.
exec("RENAME TABLE gv_settings TO gv_settings_hidden")
defer db.Exec("RENAME TABLE gv_settings_hidden TO gv_settings")
srv.verify.invalidate()
if _, out := call("GET", "/api/me", mod, nil); out["verify"] != nil {
t.Errorf("me.verify = %v without the tables", out["verify"])
}
code, out = call("GET", "/api/admin/verify", mod, nil)
expect(404, code, out, "verify page without the tables")
if _, out := call("GET", "/api/admin/players/"+charlie, mod, nil); out["verify"] != nil {
t.Errorf("player drawer has verify %v without the tables", out["verify"])
}
if verified("/api/public/players/"+charlie, "") {
t.Error("publicly verified without the tables")
}
}

View file

@ -0,0 +1,43 @@
package web
import (
"testing"
"git.zio.sh/cs2/simpleadmin-web/internal/live"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
)
func TestIsClanID(t *testing.T) {
for _, c := range []struct {
id uint64
want bool
}{
{103582791429521412, true}, // a group
{103582791429521408, false}, // account 0
{76561198012345678, false}, // a player
{0, false},
} {
if got := isClanID(c.id); got != c.want {
t.Errorf("isClanID(%d) = %v, want %v", c.id, got, c.want)
}
}
}
func TestVerifyShown(t *testing.T) {
on := store.VerifySettings{Enabled: true, GroupID: "103582791429521412"}
for _, c := range []struct {
info verifyInfo
want bool
}{
{verifyInfo{Tables: true, Settings: on, Plugin: live.PluginLoaded}, true},
{verifyInfo{Tables: true, Settings: on, Plugin: live.PluginUnknown}, true},
{verifyInfo{Tables: true, Settings: on, Plugin: live.PluginMissing}, false},
{verifyInfo{Tables: false, Settings: on, Plugin: live.PluginLoaded}, false},
{verifyInfo{Tables: true, Settings: store.VerifySettings{Enabled: false, GroupID: on.GroupID}, Plugin: live.PluginLoaded}, false},
{verifyInfo{Tables: true, Settings: store.VerifySettings{Enabled: true}, Plugin: live.PluginLoaded}, false},
} {
if got := c.info.Shown(); got != c.want {
t.Errorf("%+v: Shown() = %v, want %v", c.info, got, c.want)
}
}
}