Verification: clan tag setting, GroupVerify 0.3.0 wording

Root sets GroupVerify's clan_tag (the scoreboard tag CS2-Tags gives verified players without a
rank) next to the Steam group. Validated like the plugin (12 UTF-16 units, no quotes, ; or \),
saved to gv_settings and sent as css_gv_clantag when the plugin is loaded. Only changed fields
are sent.

GroupVerify 0.3.0 checks players with the Steam Web API as they join, so the group list is now
"Confirmed group members" with first/last confirmed dates.
This commit is contained in:
Astra 2026-10-02 18:44:44 +01:00
parent 9e4bc63201
commit 3c62874f91
6 changed files with 102 additions and 17 deletions

View file

@ -119,6 +119,9 @@ verified players perks. It keeps everything in SimpleAdmin's database (`gv_verif
- **Everyone** sees a "Verified" tag on the scoreboard and on player pages, but only while - **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 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. plugin list. The public site never shows why someone is verified, or who gave an override.
- **Clan tag**: Root can set the scoreboard tag verified players without a rank get (GroupVerify's
`clan_tag`, at most 12 characters). CS2-Tags (1.0.4c-zio6+) applies it, and gives unverified
players without a rank no tag at all. Blank turns it off.
- **Whether it's loaded** comes from `css_plugins list` over RCON, read once a minute alongside - **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. 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 - **Changes**: overrides are written to `gv_verified` and GroupVerify picks them up on its next
@ -128,8 +131,9 @@ verified players perks. It keeps everything in SimpleAdmin's database (`gv_verif
- **Times** in its tables are in GroupVerify's own `timezone` setting, which the panel follows. - **Times** in its tables are in GroupVerify's own `timezone` setting, which the panel follows.
Keep it the same as SimpleAdmin's `Timezone`. 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 (0.3.0+) checks each player with the Steam Web API as they join, so "Confirmed group
GroupVerify last fetched and saved. members" is everyone Steam has said is in the group when they joined, not the group's full member
list. The panel never asks Steam itself.
### Name history (optional) ### Name history (optional)

View file

@ -19,6 +19,7 @@ type VerifySettings struct {
GroupID string // "" when no group is set GroupID string // "" when no group is set
ReloadSeconds int // how often GroupVerify rereads its tables ReloadSeconds int // how often GroupVerify rereads its tables
Location *time.Location Location *time.Location
ClanTag string // scoreboard tag for verified players without a rank, "" for none
} }
// Active matches GroupVerify: it only verifies anyone when enabled with a group set. // Active matches GroupVerify: it only verifies anyone when enabled with a group set.
@ -57,6 +58,8 @@ func (s *Store) VerifySettings(ctx context.Context) (VerifySettings, error) {
if n, err := strconv.Atoi(value); err == nil && n >= 10 { if n, err := strconv.Atoi(value); err == nil && n >= 10 {
v.ReloadSeconds = n v.ReloadSeconds = n
} }
case "clan_tag":
v.ClanTag = value
case "timezone": case "timezone":
if loc, err := time.LoadLocation(value); err == nil { if loc, err := time.LoadLocation(value); err == nil {
v.Location = loc v.Location = loc
@ -150,7 +153,7 @@ func (s *Store) VerifyStatuses(ctx context.Context, loc *time.Location, steamids
// What VerifyList lists. // What VerifyList lists.
const ( const (
VerifyOverrides = "overrides" // unexpired rows with override = 1 VerifyOverrides = "overrides" // unexpired rows with override = 1
VerifyGroup = "group" // the Steam group's members, as of GroupVerify's last fetch VerifyGroup = "group" // players Steam confirmed are in the group as they joined
) )
// VerifyList lists overrides or group members, newest first. Search matches a SteamID64 or a name // VerifyList lists overrides or group members, newest first. Search matches a SteamID64 or a name
@ -207,8 +210,8 @@ func (s *Store) VerifyList(ctx context.Context, loc *time.Location, kind, search
return out, total, rows.Err() return out, total, rows.Err()
} }
// VerifyCounts is how many group members and unexpired overrides there are, and when GroupVerify // VerifyCounts is how many confirmed group members and unexpired overrides there are, and when
// last saved the group's member list (nil if it never has). // Steam last confirmed a member (nil if it never has).
func (s *Store) VerifyCounts(ctx context.Context, loc *time.Location) (group, overrides int, fetched *time.Time, err error) { func (s *Store) VerifyCounts(ctx context.Context, loc *time.Location) (group, overrides int, fetched *time.Time, err error) {
var last sql.NullTime var last sql.NullTime
err = s.db.QueryRowContext(ctx, `SELECT err = s.db.QueryRowContext(ctx, `SELECT

View file

@ -380,7 +380,7 @@ function verify() {
<section class="panel" style="margin-top:18px"> <section class="panel" style="margin-top:18px">
<div class="toolbar"> <div class="toolbar">
${searchBox("Search by name or SteamID64", state.query)} ${searchBox("Search by name or SteamID64", state.query)}
${segmented("verifyView", state.verifyView, [["overrides", "Overrides"], ["group", "Steam group members"]])} ${segmented("verifyView", state.verifyView, [["overrides", "Overrides"], ["group", "Confirmed group members"]])}
</div> </div>
<div class="table-wrap" id="results">${loading()}</div> <div class="table-wrap" id="results">${loading()}</div>
</section>`; </section>`;
@ -415,16 +415,21 @@ function verifyStatus(st) {
<dl class="kv"> <dl class="kv">
<dt>Plugin</dt><dd>${PLUGIN_STATE[st.plugin](st.version)}</dd> <dt>Plugin</dt><dd>${PLUGIN_STATE[st.plugin](st.version)}</dd>
<dt>Steam group</dt><dd>${group}</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>Clan tag</dt><dd>${st.clanTag ? `<b>${esc(st.clanTag)}</b> <span class="muted">for verified players without a rank; unverified ones get none</span>` : '<span class="faint">None: players keep their own tags</span>'}</dd>
<dt>Group members</dt><dd>${st.groupMembers}${st.groupFetched ? ` <span class="muted">last confirmed with Steam ${fmtAgo(st.groupFetched)}</span>` : ' <span class="faint">nobody confirmed yet</span>'}</dd>
<dt>Overrides</dt><dd>${st.overrides}</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> <dt>Changes apply</dt><dd>Within ${st.reloadSeconds} seconds <span class="muted">(times in ${esc(st.timezone)})</span></dd>
</dl> </dl>
${st.canConfigure ? `<form id="gv-form" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-top:16px"> ${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> <label class="field" style="flex:2;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"> <input class="input" name="group" value="${esc(st.groupId)}" data-was="${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> <small>&lt;groupID64&gt; on https://steamcommunity.com/groups/&lt;name&gt;/memberslistxml/?xml=1</small>
</label> </label>
<button class="btn" type="submit">Save group</button> <label class="field" style="flex:1;min-width:160px"><span>Clan tag</span>
<input class="input" name="clantag" value="${esc(st.clanTag)}" data-was="${esc(st.clanTag)}" maxlength="12" pattern="[^&quot;;\\\\]*" placeholder="none">
<small>Scoreboard tag for verified players without a rank. Blank: none.</small>
</label>
<button class="btn" type="submit">Save</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> <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>'} </form>` : '<p class="muted" style="margin:16px 0 0">Only Root can change the group or turn verification on and off.</p>'}
</div> </div>
@ -437,13 +442,13 @@ function verifyResults(d) {
const groupView = state.verifyView === "group"; const groupView = state.verifyView === "group";
if (!list.items.length) { if (!list.items.length) {
if (state.query) return `<div class="empty">Nobody matches "${esc(state.query)}".</div>`; 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>`; return `<div class="empty">${groupView ? "Nobody confirmed yet. GroupVerify checks players with Steam as they join." : "No overrides."}</div>`;
} }
const mayEdit = (sid) => d.status.canOverride && (sid !== state.me.steamid || can("@css/root")); 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>`; 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) { if (groupView) {
return `<table> 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> <thead><tr><th>Player</th><th>First confirmed</th><th class="hide-sm">Last confirmed</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> <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)}`; </table>${pager(list)}`;
} }
@ -473,7 +478,7 @@ function verifySection(v, sid, name) {
<h3>Verification</h3> <h3>Verification</h3>
<p style="margin:0 0 10px">${badge}</p> <p style="margin:0 0 10px">${badge}</p>
<dl class="kv"> <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>Steam group</dt><dd>${v.inGroup ? `Member <span class="muted">confirmed with Steam ${fmtAgo(v.groupSeen)}</span>` : '<span class="faint">Not confirmed as 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> <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> </dl>
${v.canOverride ? `<div style="display:flex;gap:8px;margin-top:12px"> ${v.canOverride ? `<div style="display:flex;gap:8px;margin-top:12px">
@ -940,7 +945,12 @@ document.addEventListener("submit", async (e) => {
const f = form.elements; const f = form.elements;
if (form.id === "gv-form") { if (form.id === "gv-form") {
await act("/api/admin/verify/settings", "POST", { groupId: f.group.value.trim() }); // Only what changed, so saving the tag doesn't resend the group (and the other way round).
const body = {};
if (f.group.value.trim() !== f.group.dataset.was) body.groupId = f.group.value.trim();
if (f.clantag.value.trim() !== f.clantag.dataset.was) body.clanTag = f.clantag.value.trim();
if (!Object.keys(body).length) { toast("Nothing changed."); return; }
await act("/api/admin/verify/settings", "POST", body);
return; return;
} }

View file

@ -9,6 +9,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"unicode/utf16"
"git.zio.sh/cs2/simpleadmin-web/internal/live" "git.zio.sh/cs2/simpleadmin-web/internal/live"
"git.zio.sh/cs2/simpleadmin-web/internal/steam" "git.zio.sh/cs2/simpleadmin-web/internal/steam"
@ -127,6 +129,7 @@ type verifyStatusView struct {
GroupID string `json:"groupId"` GroupID string `json:"groupId"`
Active bool `json:"active"` Active bool `json:"active"`
Shown bool `json:"shown"` Shown bool `json:"shown"`
ClanTag string `json:"clanTag"`
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
ReloadSeconds int `json:"reloadSeconds"` ReloadSeconds int `json:"reloadSeconds"`
GroupMembers int `json:"groupMembers"` GroupMembers int `json:"groupMembers"`
@ -153,6 +156,7 @@ func (s *Server) adminVerify(w http.ResponseWriter, r *http.Request, id *Identit
view := verifyStatusView{ view := verifyStatusView{
Tables: true, Plugin: info.Plugin, Version: info.Version, Tables: true, Plugin: info.Plugin, Version: info.Version,
Enabled: info.Settings.Enabled, GroupID: info.Settings.GroupID, Active: info.Settings.Active(), Shown: info.Shown(), Enabled: info.Settings.Enabled, GroupID: info.Settings.GroupID, Active: info.Settings.Active(), Shown: info.Shown(),
ClanTag: info.Settings.ClanTag,
Timezone: loc.String(), ReloadSeconds: info.Settings.ReloadSeconds, Timezone: loc.String(), ReloadSeconds: info.Settings.ReloadSeconds,
CanOverride: id.Has(verifyOverridePerm), CanConfigure: id.Has("@css/root"), CanOverride: id.Has(verifyOverridePerm), CanConfigure: id.Has("@css/root"),
} }
@ -304,12 +308,30 @@ func isClanID(n uint64) bool {
return n>>56 == 1 && (n>>52)&0xF == 7 && n&0xFFFFFFFF != 0 return n>>56 == 1 && (n>>52)&0xF == 7 && n&0xFFFFFFFF != 0
} }
// saveVerifySettings turns GroupVerify on or off or changes its group: {enabled?, groupId?}. Saved // clanTagMax matches GroupVerify (and Steam's own group tags). It counts UTF-16 units, as C# does.
const clanTagMax = 12
// validClanTag is GroupVerify's rule, plus no backslashes, which the console would mangle.
func validClanTag(tag string) bool {
if len(utf16.Encode([]rune(tag))) > clanTagMax {
return false
}
for _, r := range tag {
if unicode.IsControl(r) || r == '"' || r == ';' || r == '\\' {
return false
}
}
return true
}
// saveVerifySettings turns GroupVerify on or off, or changes its group or clan tag:
// {enabled?, groupId?, clanTag?}. Saved
// to gv_settings, then, when the plugin is loaded, sent as its own command so it applies at once. // 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) { func (s *Server) saveVerifySettings(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req struct { var req struct {
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
GroupID *string `json:"groupId"` GroupID *string `json:"groupId"`
ClanTag *string `json:"clanTag"`
} }
if err := readJSON(r, &req); err != nil { if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error()) fail(w, http.StatusBadRequest, err.Error())
@ -319,7 +341,7 @@ func (s *Server) saveVerifySettings(w http.ResponseWriter, r *http.Request, id *
fail(w, http.StatusForbidden, "Only Root can change GroupVerify's settings.") fail(w, http.StatusForbidden, "Only Root can change GroupVerify's settings.")
return return
} }
if req.Enabled == nil && req.GroupID == nil { if req.Enabled == nil && req.GroupID == nil && req.ClanTag == nil {
fail(w, http.StatusBadRequest, "Nothing to change.") fail(w, http.StatusBadRequest, "Nothing to change.")
return return
} }
@ -342,6 +364,18 @@ func (s *Server) saveVerifySettings(w http.ResponseWriter, r *http.Request, id *
g = strconv.FormatUint(n, 10) g = strconv.FormatUint(n, 10)
changes = append(changes, change{"group_id64", g, "css_gv_group " + g}) changes = append(changes, change{"group_id64", g, "css_gv_group " + g})
} }
if req.ClanTag != nil {
tag := strings.TrimSpace(*req.ClanTag)
if !validClanTag(tag) {
fail(w, http.StatusBadRequest, fmt.Sprintf("A clan tag is at most %d characters, without quotes, ; or \\.", clanTagMax))
return
}
cmd := `css_gv_clantag "` + tag + `"`
if tag == "" {
cmd = "css_gv_clantag -"
}
changes = append(changes, change{"clan_tag", tag, cmd})
}
if req.Enabled != nil { if req.Enabled != nil {
c := change{"enabled", "0", "css_gv_disable"} c := change{"enabled", "0", "css_gv_disable"}
if *req.Enabled { if *req.Enabled {

View file

@ -118,6 +118,20 @@ func TestVerifyDB(t *testing.T) {
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"groupId": group}) code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"groupId": group})
expect(200, code, out, "owner sets the group") expect(200, code, out, "owner sets the group")
// The clan tag: Root only, at most 12 characters, nothing the console would mangle.
code, out = call("POST", "/api/admin/verify/settings", mod, map[string]any{"clanTag": "zio"})
expect(403, code, out, "mod sets the clan tag")
for _, bad := range []string{`a"b`, "a;b", "thirteen char"} {
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"clanTag": bad})
expect(400, code, out, "clan tag "+bad)
}
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"clanTag": " zio.sh "})
expect(200, code, out, "owner sets the clan tag")
var clanTag string
if err := db.QueryRow("SELECT value FROM gv_settings WHERE name = 'clan_tag'").Scan(&clanTag); err != nil || clanTag != "zio.sh" {
t.Fatalf("clan_tag = %q (%v), want zio.sh", clanTag, err)
}
// Overrides need @zio/verify. // Overrides need @zio/verify.
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 7}) code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 7})
expect(403, code, out, "mod without @zio/verify") expect(403, code, out, "mod without @zio/verify")
@ -136,7 +150,7 @@ func TestVerifyDB(t *testing.T) {
code, out = call("GET", "/api/admin/verify", mod, nil) code, out = call("GET", "/api/admin/verify", mod, nil)
expect(200, code, out, "verify page") expect(200, code, out, "verify page")
status := out["status"].(map[string]any) status := out["status"].(map[string]any)
if status["active"] != true || status["groupMembers"] != float64(1) || status["overrides"] != float64(1) || status["plugin"] != "unknown" { if status["active"] != true || status["groupMembers"] != float64(1) || status["overrides"] != float64(1) || status["plugin"] != "unknown" || status["clanTag"] != "zio.sh" {
t.Fatalf("status %v", status) t.Fatalf("status %v", status)
} }
items := out["list"].(map[string]any)["items"].([]any) items := out["list"].(map[string]any)["items"].([]any)

View file

@ -23,6 +23,26 @@ func TestIsClanID(t *testing.T) {
} }
} }
func TestValidClanTag(t *testing.T) {
for tag, want := range map[string]bool{
"": true,
"zio": true,
"zio.sh": true,
"twelve chars": true,
"thirteen char": false,
"say \"hi\"": false,
"a;quit": false,
"back\\slash": false,
"tab\there": false,
"🙂🙂🙂🙂🙂🙂": true, // 12 UTF-16 units
"🙂🙂🙂🙂🙂🙂🙂": false, // 14
} {
if got := validClanTag(tag); got != want {
t.Errorf("validClanTag(%q) = %v, want %v", tag, got, want)
}
}
}
func TestVerifyShown(t *testing.T) { func TestVerifyShown(t *testing.T) {
on := store.VerifySettings{Enabled: true, GroupID: "103582791429521412"} on := store.VerifySettings{Enabled: true, GroupID: "103582791429521412"}
for _, c := range []struct { for _, c := range []struct {