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.
268 lines
9.6 KiB
Go
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()
|
|
}
|