simpleadmin-web/internal/store/verify.go
Astra 9e4bc63201 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.
2026-10-02 17:59:26 +01:00

268 lines
9.6 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
}
// 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()
}