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

@ -19,6 +19,7 @@ type VerifySettings struct {
GroupID string // "" when no group is set
ReloadSeconds int // how often GroupVerify rereads its tables
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.
@ -57,6 +58,8 @@ func (s *Store) VerifySettings(ctx context.Context) (VerifySettings, error) {
if n, err := strconv.Atoi(value); err == nil && n >= 10 {
v.ReloadSeconds = n
}
case "clan_tag":
v.ClanTag = value
case "timezone":
if loc, err := time.LoadLocation(value); err == nil {
v.Location = loc
@ -150,7 +153,7 @@ func (s *Store) VerifyStatuses(ctx context.Context, loc *time.Location, steamids
// What VerifyList lists.
const (
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
@ -207,8 +210,8 @@ func (s *Store) VerifyList(ctx context.Context, loc *time.Location, kind, search
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).
// VerifyCounts is how many confirmed group members and unexpired overrides there are, and when
// 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) {
var last sql.NullTime
err = s.db.QueryRowContext(ctx, `SELECT

View file

@ -380,7 +380,7 @@ function verify() {
<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"]])}
${segmented("verifyView", state.verifyView, [["overrides", "Overrides"], ["group", "Confirmed group members"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
@ -415,16 +415,21 @@ function verifyStatus(st) {
<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>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>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">
<label class="field" style="flex:2;min-width:240px"><span>Steam group ID64</span>
<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>
</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>
</form>` : '<p class="muted" style="margin:16px 0 0">Only Root can change the group or turn verification on and off.</p>'}
</div>
@ -437,13 +442,13 @@ function verifyResults(d) {
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>`;
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 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>
<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>
</table>${pager(list)}`;
}
@ -473,7 +478,7 @@ function verifySection(v, sid, name) {
<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>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>
</dl>
${v.canOverride ? `<div style="display:flex;gap:8px;margin-top:12px">
@ -940,7 +945,12 @@ document.addEventListener("submit", async (e) => {
const f = form.elements;
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;
}

View file

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

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})
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.
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 7})
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)
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" {
if status["active"] != true || status["groupMembers"] != float64(1) || status["overrides"] != float64(1) || status["plugin"] != "unknown" || status["clanTag"] != "zio.sh" {
t.Fatalf("status %v", status)
}
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) {
on := store.VerifySettings{Enabled: true, GroupID: "103582791429521412"}
for _, c := range []struct {