diff --git a/README.md b/README.md
index 0161ace..2e4b46d 100644
--- a/README.md
+++ b/README.md
@@ -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
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.
+- **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
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
@@ -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.
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.
+GroupVerify (0.3.0+) checks each player with the Steam Web API as they join, so "Confirmed group
+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)
diff --git a/internal/store/verify.go b/internal/store/verify.go
index 1d20a65..3187566 100644
--- a/internal/store/verify.go
+++ b/internal/store/verify.go
@@ -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
diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js
index 797cba9..5a734c9 100644
--- a/internal/web/assets/admin.js
+++ b/internal/web/assets/admin.js
@@ -380,7 +380,7 @@ function verify() {
${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"]])}
${loading()}
`;
@@ -415,16 +415,21 @@ function verifyStatus(st) {
- Plugin
- ${PLUGIN_STATE[st.plugin](st.version)}
- Steam group
- ${group}
- - Group members
- ${st.groupMembers}${st.groupFetched ? ` list saved ${fmtAgo(st.groupFetched)}` : ' never fetched'}
+ - Clan tag
- ${st.clanTag ? `${esc(st.clanTag)} for verified players without a rank; unverified ones get none` : 'None: players keep their own tags'}
+ - Group members
- ${st.groupMembers}${st.groupFetched ? ` last confirmed with Steam ${fmtAgo(st.groupFetched)}` : ' nobody confirmed yet'}
- Overrides
- ${st.overrides}
- Changes apply
- Within ${st.reloadSeconds} seconds (times in ${esc(st.timezone)})
${st.canConfigure ? `` : 'Only Root can change the group or turn verification on and off.
'}
@@ -437,13 +442,13 @@ function verifyResults(d) {
const groupView = state.verifyView === "group";
if (!list.items.length) {
if (state.query) return `Nobody matches "${esc(state.query)}".
`;
- return `${groupView ? "No group members saved yet. GroupVerify saves the list each time it fetches it." : "No overrides."}
`;
+ return `${groupView ? "Nobody confirmed yet. GroupVerify checks players with Steam as they join." : "No overrides."}
`;
}
const mayEdit = (sid) => d.status.canOverride && (sid !== state.me.steamid || can("@css/root"));
const who = (e) => ` | `;
if (groupView) {
return `
- | Player | In the group since | Last seen in the list |
+ | Player | First confirmed | Last confirmed |
${list.items.map((e) => `${who(e)}| ${fmtDate(e.created)} | ${fmtAgo(e.seen)} |
`).join("")}
${pager(list)}`;
}
@@ -473,7 +478,7 @@ function verifySection(v, sid, name) {
Verification
${badge}
- - Steam group
- ${v.inGroup ? `Member seen in the list ${fmtAgo(v.groupSeen)}` : 'Not a member'}
+ - Steam group
- ${v.inGroup ? `Member confirmed with Steam ${fmtAgo(v.groupSeen)}` : 'Not confirmed as a member'}
- Override
- ${o ? `${o.expires ? `Until ${fmtDate(o.expires)}` : "No expiry"}${o.addedBy ? ` from ${esc(o.addedBy)}` : ""}` : 'None'}
${v.canOverride ? `
@@ -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;
}
diff --git a/internal/web/verify.go b/internal/web/verify.go
index 0742fe8..03cd2de 100644
--- a/internal/web/verify.go
+++ b/internal/web/verify.go
@@ -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 {
diff --git a/internal/web/verify_db_test.go b/internal/web/verify_db_test.go
index 4481af5..27ca1c8 100644
--- a/internal/web/verify_db_test.go
+++ b/internal/web/verify_db_test.go
@@ -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)
diff --git a/internal/web/verify_test.go b/internal/web/verify_test.go
index bd1d12f..ff653ce 100644
--- a/internal/web/verify_test.go
+++ b/internal/web/verify_test.go
@@ -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 {