simpleadmin-web/internal/store/verify.go
Astra 3c62874f91 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.
2026-10-02 18:44:44 +01:00

271 lines
9.7 KiB
Go

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
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.
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 "clan_tag":
v.ClanTag = value
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" // 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
// 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 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
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()
}