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() }