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:
parent
b2b4698776
commit
9e4bc63201
15 changed files with 1270 additions and 13 deletions
268
internal/store/verify.go
Normal file
268
internal/store/verify.go
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue