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 - **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 bans and blocks; manage ranks, permissions and staff; change server cvars and SimpleAdmin's
punishment settings. 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 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. 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` | | Change map | `@css/changemap` |
| Server cvars | `@css/cvar` | | Server cvars | `@css/cvar` |
| See the Staff & ranks page | `@zio/webpanel` | | 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, `@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. 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. that lapse when their expiry passes, `css_delgroup`, and edits made straight in the database.
History starts when the table is created. 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) ### Name history (optional)
SimpleAdmin keeps one row per SteamID + IP in `sa_players_ips` and overwrites its name on each 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 ./... 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 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. design mockup they came from is in `docs/mockup` at the repo root.

View file

@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog" "log/slog"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -114,12 +115,46 @@ func Parse(out string) (Status, error) {
return st, nil 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 { type Poller struct {
rc *rcon.Client rc *rcon.Client
interval time.Duration interval time.Duration
mu sync.RWMutex mu sync.RWMutex
current Status 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 { func NewPoller(rc *rcon.Client, interval time.Duration) *Poller {
@ -169,10 +204,44 @@ func (p *Poller) Refresh() Status {
st.Error = p.current.Error st.Error = p.current.Error
} }
p.current = st 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() p.mu.Unlock()
if checkPlugins {
p.refreshPlugins()
}
return st 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 { func (p *Poller) Current() Status {
p.mu.RLock() p.mu.RLock()
defer p.mu.RUnlock() 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") 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) { func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
ctx := r.Context() ctx := r.Context()
view := s.serverView(x, s.poll.Current()) view := s.serverView(x, s.poll.Current())
s.markVerified(ctx, view.Players)
var ids []string var ids []string
for _, p := range view.Players { 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["groups"] = a.Groups
resp["adminEnds"] = a.Ends 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) writeJSON(w, http.StatusOK, resp)
} }
@ -299,6 +306,7 @@ var knownFlags = [][3]string{
{"@zio/vote", "Start votes", "css_vote"}, {"@zio/vote", "Start votes", "css_vote"},
{"@zio/plugins", "Plugins", "css_plugins and css"}, {"@zio/plugins", "Plugins", "css_plugins and css"},
{"@zio/webpanel", "Staff page", "See the Staff & ranks page in this panel"}, {"@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"}, {"@css/root", "Root", "Every permission, and managing ranks and staff"},
} }

View file

@ -7,14 +7,19 @@ const PAGES = [
{ id: "bans", label: "Bans" }, { id: "bans", label: "Bans" },
{ id: "comms", label: "Gags & mutes" }, { id: "comms", label: "Gags & mutes" },
{ id: "staff", label: "Staff & ranks", flag: "@zio/webpanel" }, { 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" }, { id: "settings", label: "Server settings" },
]; ];
const pageAllowed = (p) => (!p.flag || can(p.flag)) && (!p.show || p.show());
const state = { const state = {
page: "overview", page: "overview",
banFilter: "active", banFilter: "active",
commFilter: "active", commFilter: "active",
playerFilter: "online", playerFilter: "online",
verifyView: "overrides",
query: "", query: "",
num: 1, num: 1,
group: null, 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> <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> <tbody>
${list.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}"> ${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="hide-sm">${rankTag(p.rank)}</td>
<td class="num">${p.kills} / ${p.deaths}</td> <td class="num">${p.kills} / ${p.deaths}</td>
<td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</td> <td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</td>
@ -246,12 +251,18 @@ async function loadResults() {
let path, status, render; let path, status, render;
if (state.page === "players") { if (state.page === "players") {
path = "/api/admin/players"; render = playersTable; path = "/api/admin/players"; render = playersTable;
} else if (state.page === "verify") {
path = "/api/admin/verify"; status = undefined; render = verifyResults;
} else if (state.page === "bans") { } else if (state.page === "bans") {
path = "/api/admin/bans"; status = state.banFilter; render = (l) => punishTable(l, "ban"); path = "/api/admin/bans"; status = state.banFilter; render = (l) => punishTable(l, "ban");
} else { } else {
path = "/api/admin/comms"; status = state.commFilter; render = (l) => punishTable(l, "comm"); 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]); const want = JSON.stringify([state.page, params]);
loadResults.want = want; loadResults.want = want;
try { 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>`}`; </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 ---------- */ /* ---------- Settings ---------- */
async function settings() { async function settings() {
@ -472,7 +618,7 @@ async function openPlayer(sid) {
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}"> <aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
<div class="drawer-head"> <div class="drawer-head">
<span class="avatar lg">${initial(name)}</span> <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> <button class="close" type="button" data-close aria-label="Close">×</button>
</div> </div>
<section> <section>
@ -498,6 +644,7 @@ async function openPlayer(sid) {
${d.adminEnds ? `<dt>Rank expires</dt><dd>${fmtDate(d.adminEnds)}</dd>` : ""} ${d.adminEnds ? `<dt>Rank expires</dt><dd>${fmtDate(d.adminEnds)}</dd>` : ""}
</dl> </dl>
</section> </section>
${verifySection(d.verify, p.steamid, name)}
${manage ? `<section> ${manage ? `<section>
<h3>Rank</h3> <h3>Rank</h3>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
@ -640,12 +787,12 @@ async function act(path, method, body) {
/* ---------- Routing & events ---------- */ /* ---------- Routing & events ---------- */
const RENDER = { overview, players, bans, comms, staff, settings }; const RENDER = { overview, players, bans, comms, staff, verify, settings };
function renderNav() { function renderNav() {
const c = state.counts; const c = state.counts;
const count = { players: c.players, bans: c.bans, comms: c.comms, staff: c.staff }; 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 // 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() { function route() {
const page = location.hash.slice(1); const page = location.hash.slice(1);
const def = PAGES.find((p) => p.id === page); 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(); renderNav();
setNav(false); setNav(false);
if (!$("#view").innerHTML) $("#view").innerHTML = loading(); if (!$("#view").innerHTML) $("#view").innerHTML = loading();
@ -763,6 +910,19 @@ document.addEventListener("click", (e) => {
case "restart": case "restart":
confirmDialog.reason = false; 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", {})); 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 "save-settings": return saveSettings();
case "discard": return settings(); case "discard": return settings();
} }
@ -779,6 +939,11 @@ document.addEventListener("submit", async (e) => {
const kind = form.dataset.submit; const kind = form.dataset.submit;
const f = form.elements; 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") { 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); 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 }); 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 "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 "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 "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; 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" }; 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" }; const COMM_HELP = { GAG: "Text chat blocked", MUTE: "Voice blocked", SILENCE: "Text and voice blocked" };
function segmented(name, value, options) { 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> <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> <tbody>
${list.map((p) => `<tr ${p.bot ? "" : `class="clickable" data-player="${esc(p.steamid)}"`}> ${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.kills}</td>
<td class="num">${p.deaths}</td> <td class="num">${p.deaths}</td>
<td class="num hide-sm muted">${p.bot ? "" : p.ping}</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> <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> <button class="close" type="button" data-close aria-label="Close">×</button>
</div> </div>
${now.length || p.online ? `<section> ${now.length || p.online || p.verified ? `<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> <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>` : ""}
<section> <section>
<h3>History</h3> <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.red { background: var(--accent-soft); color: #ff5a78; }
.tag.amber { background: var(--amber-soft); color: var(--amber); } .tag.amber { background: var(--amber-soft); color: var(--amber); }
.tag.green { background: var(--green-soft); color: var(--green); } .tag.green { background: var(--green-soft); color: var(--green); }
.tag.verified { background: rgba(79, 179, 217, .14); color: #4fb3d9; }
.rank { .rank {
display: inline-flex; display: inline-flex;

View file

@ -181,6 +181,8 @@ type meView struct {
Rank *rankView `json:"rank,omitempty"` Rank *rankView `json:"rank,omitempty"`
Perms []string `json:"perms,omitempty"` Perms []string `json:"perms,omitempty"`
Immunity int `json:"immunity,omitempty"` Immunity int `json:"immunity,omitempty"`
// Verify is true when GroupVerify's tables are in the database, for the Verification page.
Verify bool `json:"verify,omitempty"`
Site string `json:"site"` Site string `json:"site"`
} }
@ -197,6 +199,10 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
if id != nil { if id != nil {
v.Staff, v.Name, v.Rank, v.Immunity = true, id.Name, s.rankOf(x, id.SteamID), id.Immunity v.Staff, v.Name, v.Rank, v.Immunity = true, id.Name, s.rankOf(x, id.SteamID), id.Immunity
v.Perms = id.Flags 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) writeJSON(w, http.StatusOK, v)
} }

View file

@ -38,6 +38,8 @@ func toPublic(ps []store.Penalty) []publicPenalty {
type livePlayer struct { type livePlayer struct {
live.Player live.Player
Rank *rankView `json:"rank,omitempty"` 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. // Staff-only fields, left empty on the public page.
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
CanTarget bool `json:"canTarget,omitempty"` CanTarget bool `json:"canTarget,omitempty"`
@ -82,7 +84,9 @@ func (s *Server) publicServer(w http.ResponseWriter, r *http.Request) {
internal(w, r, err) internal(w, r, err)
return 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). // 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 return
} }
_, online := s.poll.Current().Find(steamid) _, 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{ writeJSON(w, http.StatusOK, map[string]any{
"steamid": steamid, "name": d.Name, "online": online, "rank": s.rankOf(x, steamid), "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 poll *live.Poller
sacfg *saconfig.File // nil when no config path is set sacfg *saconfig.File // nil when no config path is set
staff *staffCache staff *staffCache
verify *verifyCache
avatars *avatarCache avatars *avatarCache
http *http.Client http *http.Client
secure bool secure bool
@ -63,6 +64,7 @@ func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg
poll: poll, poll: poll,
sacfg: sacfg, sacfg: sacfg,
staff: &staffCache{st: st, ttl: 15 * time.Second}, staff: &staffCache{st: st, ttl: 15 * time.Second},
verify: &verifyCache{st: st, ttl: 30 * time.Second},
avatars: newAvatarCache(&http.Client{Timeout: 10 * time.Second}), avatars: newAvatarCache(&http.Client{Timeout: 10 * time.Second}),
http: &http.Client{Timeout: 10 * time.Second}, http: &http.Client{Timeout: 10 * time.Second},
secure: u.Scheme == "https", 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/comms", s.adminComms)
admin("GET /api/admin/staff", s.adminStaff) admin("GET /api/admin/staff", s.adminStaff)
admin("GET /api/admin/settings", s.adminSettings) 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", s.addPenalty)
admin("POST /api/admin/penalties/lift", s.liftPenalty) 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("DELETE /api/admin/staff/{steamid}", s.removeStaff)
admin("POST /api/admin/settings/cvars", s.saveCvars) admin("POST /api/admin/settings/cvars", s.saveCvars)
admin("POST /api/admin/settings/simpleadmin", s.saveSimpleAdmin) 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) 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)
}
}
}