simpleadmin-web/internal/store/store.go
Astra 6324fc7f81 Names on offline penalties, mobile menu close, player drawer tidy, IP-ban bypass flag
Bans and comms saved without a player name (SimpleAdmin's offline css_addban etc.) show the
player's latest name from sa_players_ips, and the panel writes that name onto new rows it claims.
The mobile sidebar closes from a Close button, the backdrop or Escape, since it covers the Menu
button. The public player drawer drops "Right now" and "Free to play and talk." and puts the
Steam profile link after the SteamID. Adds @css/bypassipban to the permission checkboxes.
2026-09-28 11:23:14 +01:00

1079 lines
36 KiB
Go

// Package store reads and writes CS2-SimpleAdmin's MySQL tables (schema as of 1.8.2b, migrations
// 001-016). Times are stored by SimpleAdmin as wall-clock values in its configured Timezone
// (UTC unless changed), so every time here is read and written in that location.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
)
// Normalised penalty states. SimpleAdmin uses UNBANNED for bans and UNMUTED for mutes.
const (
StatusActive = "active"
StatusExpired = "expired"
StatusLifted = "lifted"
)
var ErrNotFound = errors.New("not found")
type Store struct {
db *sql.DB
loc *time.Location
serverID sql.NullInt64
// TimeMode 0 counts mute time only while the player is online (sa_mutes.passed).
timeMode int
}
func New(db *sql.DB, loc *time.Location, serverID int64, timeMode int) *Store {
s := &Store{db: db, loc: loc, timeMode: timeMode}
if serverID > 0 {
s.serverID = sql.NullInt64{Int64: serverID, Valid: true}
}
return s
}
func (s *Store) DB() *sql.DB { return s.db }
// wall turns a time into the naive wall-clock form SimpleAdmin stores.
func (s *Store) wall(t time.Time) time.Time {
t = t.In(s.loc)
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, time.UTC)
}
// fromWall reads a stored wall-clock value back as a real instant.
func (s *Store) fromWall(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, s.loc)
}
func (s *Store) nullWall(t sql.NullTime) *time.Time {
if !t.Valid {
return nil
}
v := s.fromWall(t.Time)
return &v
}
func sid(n sql.NullInt64) string {
if !n.Valid || n.Int64 == 0 {
return ""
}
return strconv.FormatInt(n.Int64, 10)
}
func parseSID(steamid string) (int64, error) {
return strconv.ParseInt(steamid, 10, 64)
}
// Server is a row of sa_servers.
type Server struct {
ID int64
Hostname string
Address string
RconPassword string
}
// Servers lists sa_servers. SimpleAdmin fills it in, including each server's rcon_password.
func (s *Store) Servers(ctx context.Context) ([]Server, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, COALESCE(hostname,''), address, COALESCE(rcon_password,'') FROM sa_servers ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Server
for rows.Next() {
var v Server
if err := rows.Scan(&v.ID, &v.Hostname, &v.Address, &v.RconPassword); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// ---------------------------------------------------------------- Penalties
// Penalty is a ban, gag/mute/silence or warning.
type Penalty struct {
ID int64 `json:"id"`
Kind string `json:"kind"` // ban, comm, warn
Type string `json:"type,omitempty"`
Name string `json:"name"`
SteamID string `json:"steamid"`
IP string `json:"ip,omitempty"`
AdminSteamID string `json:"adminSteamid,omitempty"`
AdminName string `json:"admin,omitempty"`
Reason string `json:"reason"`
Duration int `json:"duration"`
Passed *int `json:"passed,omitempty"`
Ends *time.Time `json:"ends,omitempty"`
Created time.Time `json:"created"`
Status string `json:"status"`
LiftedBy string `json:"liftedBy,omitempty"`
LiftReason string `json:"liftReason,omitempty"`
}
// Remaining returns minutes left on an active timed penalty.
func (p Penalty) Remaining(now time.Time) float64 {
if p.Duration == 0 {
return 0
}
if p.Passed != nil && p.Ends == nil {
return float64(p.Duration - *p.Passed)
}
if p.Ends == nil {
return 0
}
return p.Ends.Sub(now).Minutes()
}
// status normalises SimpleAdmin's status, treating a timed penalty whose end has passed as expired
// even before SimpleAdmin's 61-second sweep marks it.
func (s *Store) status(raw string, duration int, ends *time.Time, passed *int) string {
switch raw {
case "UNBANNED", "UNMUTED":
return StatusLifted
case "EXPIRED", "":
return StatusExpired
}
if duration == 0 {
return StatusActive
}
if s.timeMode == 0 && passed != nil {
if *passed >= duration {
return StatusExpired
}
return StatusActive
}
if ends != nil && !ends.After(time.Now()) {
return StatusExpired
}
return StatusActive
}
// Query filters a penalty list.
type Query struct {
Status string // active, expired, lifted, all
Type string // GAG, MUTE, SILENCE (comms only)
Search string
SteamID string // exact player
// Which fields Search may match. Admin names and IPs are only searchable by staff.
SearchAdmin bool
SearchIP bool
Limit int
Offset int
}
// statusSQL builds the WHERE condition for a status. Only sa_mutes has "passed" (withPassed).
func (s *Store) statusSQL(status string, withPassed bool) (string, []any) {
now := s.wall(time.Now())
online := withPassed && s.timeMode == 0
switch status {
case StatusActive:
if online {
return `status = 'ACTIVE' AND (duration = 0 OR (passed IS NOT NULL AND passed < duration) OR (passed IS NULL AND ends > ?))`, []any{now}
}
return `status = 'ACTIVE' AND (duration = 0 OR ends > ?)`, []any{now}
case StatusExpired:
if online {
return `(status IN ('EXPIRED','') OR (status = 'ACTIVE' AND duration > 0 AND ((passed IS NOT NULL AND passed >= duration) OR (passed IS NULL AND ends <= ?))))`, []any{now}
}
return `(status IN ('EXPIRED','') OR (status = 'ACTIVE' AND duration > 0 AND ends <= ?))`, []any{now}
case StatusLifted:
return `status IN ('UNBANNED','UNMUTED')`, nil
}
return "1=1", nil
}
func (s *Store) where(table string, q Query) (string, []any) {
var conds []string
var args []any
if q.Status != "" {
c, a := s.statusSQL(q.Status, table == "sa_mutes")
conds, args = append(conds, c), append(args, a...)
}
if q.Type != "" {
conds, args = append(conds, "type = ?"), append(args, q.Type)
}
if q.SteamID != "" {
n, _ := parseSID(q.SteamID)
conds, args = append(conds, "player_steamid = ?"), append(args, n)
}
if t := strings.TrimSpace(q.Search); t != "" {
like := "%" + escapeLike(t) + "%"
or := []string{"player_name LIKE ?", "reason LIKE ?"}
or2 := []any{like, like}
if n, err := strconv.ParseInt(t, 10, 64); err == nil {
or, or2 = append(or, "player_steamid = ?"), append(or2, n)
}
if q.SearchAdmin {
or, or2 = append(or, "admin_name LIKE ?"), append(or2, like)
}
if q.SearchIP && table == "sa_bans" {
or, or2 = append(or, "player_ip = ?"), append(or2, t)
}
conds = append(conds, "("+strings.Join(or, " OR ")+")")
args = append(args, or2...)
}
if len(conds) == 0 {
return "", nil
}
return " WHERE " + strings.Join(conds, " AND "), args
}
func escapeLike(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
// nameOr is a record's player_name, or when that's empty (SimpleAdmin's offline css_addban and
// css_addgag etc. save none) the player's latest name from sa_players_ips.
func nameOr(alias string) string {
return `COALESCE(NULLIF(` + alias + `.player_name,''), (SELECT i.name FROM sa_players_ips i
WHERE i.steamid = ` + alias + `.player_steamid AND i.name IS NOT NULL AND i.name <> ''
ORDER BY i.used_at DESC LIMIT 1), '')`
}
// Bans lists sa_bans, newest first, with the total matching count.
func (s *Store) Bans(ctx context.Context, q Query) ([]Penalty, int, error) {
where, args := s.where("sa_bans", q)
var total int
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sa_bans"+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
sqlq := `SELECT b.id, ` + nameOr("b") + `, b.player_steamid, COALESCE(b.player_ip,''), b.admin_steamid,
b.admin_name, b.reason, b.duration, b.ends, b.created, b.status,
COALESCE(ua.player_name,''), COALESCE(u.reason,'')
FROM (SELECT * FROM sa_bans` + where + ` ORDER BY id DESC LIMIT ? OFFSET ?) b
LEFT JOIN sa_unbans u ON u.id = b.unban_id
LEFT JOIN sa_admins ua ON ua.id = u.admin_id
ORDER BY b.id DESC`
rows, err := s.db.QueryContext(ctx, sqlq, append(args, limit(q.Limit), q.Offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []Penalty
for rows.Next() {
var p Penalty
var psid, asid sql.NullInt64
var ends, created sql.NullTime
var raw string
if err := rows.Scan(&p.ID, &p.Name, &psid, &p.IP, &asid, &p.AdminName, &p.Reason, &p.Duration, &ends,
&created, &raw, &p.LiftedBy, &p.LiftReason); err != nil {
return nil, 0, err
}
p.Kind, p.SteamID, p.AdminSteamID = "ban", sid(psid), sid(asid)
p.Ends, p.Created = s.nullWall(ends), s.fromWall(created.Time)
p.Status = s.status(raw, p.Duration, p.Ends, nil)
out = append(out, p)
}
return out, total, rows.Err()
}
// Comms lists sa_mutes (gags, mutes and silences), newest first.
func (s *Store) Comms(ctx context.Context, q Query) ([]Penalty, int, error) {
where, args := s.where("sa_mutes", q)
var total int
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sa_mutes"+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
sqlq := `SELECT m.id, m.type, ` + nameOr("m") + `, m.player_steamid, m.admin_steamid, m.admin_name,
m.reason, m.duration, m.passed, m.ends, m.created, m.status,
COALESCE(ua.player_name,''), COALESCE(u.reason,'')
FROM (SELECT * FROM sa_mutes` + where + ` ORDER BY id DESC LIMIT ? OFFSET ?) m
LEFT JOIN sa_unmutes u ON u.id = m.unmute_id
LEFT JOIN sa_admins ua ON ua.id = u.admin_id
ORDER BY m.id DESC`
rows, err := s.db.QueryContext(ctx, sqlq, append(args, limit(q.Limit), q.Offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []Penalty
for rows.Next() {
var p Penalty
var psid, asid sql.NullInt64
var passed sql.NullInt64
var ends, created sql.NullTime
var raw string
if err := rows.Scan(&p.ID, &p.Type, &p.Name, &psid, &asid, &p.AdminName, &p.Reason, &p.Duration, &passed,
&ends, &created, &raw, &p.LiftedBy, &p.LiftReason); err != nil {
return nil, 0, err
}
p.Kind, p.SteamID, p.AdminSteamID = "comm", sid(psid), sid(asid)
if passed.Valid {
v := int(passed.Int64)
p.Passed = &v
}
p.Ends, p.Created = s.nullWall(ends), s.fromWall(created.Time)
p.Status = s.status(raw, p.Duration, p.Ends, p.Passed)
out = append(out, p)
}
return out, total, rows.Err()
}
// Warns lists sa_warns for one player, newest first.
func (s *Store) Warns(ctx context.Context, steamid string) ([]Penalty, error) {
n, _ := parseSID(steamid)
rows, err := s.db.QueryContext(ctx, `SELECT id, COALESCE(player_name,''), admin_steamid, admin_name, reason, duration, ends, created, status
FROM sa_warns WHERE player_steamid = ? ORDER BY id DESC LIMIT 100`, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Penalty
for rows.Next() {
var p Penalty
var asid sql.NullInt64
var ends, created sql.NullTime
var raw string
if err := rows.Scan(&p.ID, &p.Name, &asid, &p.AdminName, &p.Reason, &p.Duration, &ends, &created, &raw); err != nil {
return nil, err
}
p.Kind, p.SteamID, p.AdminSteamID = "warn", steamid, sid(asid)
p.Ends, p.Created = s.nullWall(ends), s.fromWall(created.Time)
p.Status = s.status(raw, p.Duration, p.Ends, nil)
out = append(out, p)
}
return out, rows.Err()
}
// ActiveWarnCounts returns active warnings per player for the given SteamIDs.
func (s *Store) ActiveWarnCounts(ctx context.Context, steamids []string) (map[string]int, error) {
out := map[string]int{}
if len(steamids) == 0 {
return out, nil
}
in, args := inList(steamids)
args = append(args, s.wall(time.Now()))
rows, err := s.db.QueryContext(ctx, `SELECT player_steamid, COUNT(*) FROM sa_warns
WHERE player_steamid IN (`+in+`) AND status = 'ACTIVE' AND (duration = 0 OR ends > ?) GROUP BY player_steamid`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id sql.NullInt64
var n int
if err := rows.Scan(&id, &n); err != nil {
return nil, err
}
out[sid(id)] = n
}
return out, rows.Err()
}
// RecordCounts returns how many bans and comm blocks (any status) each player has.
func (s *Store) RecordCounts(ctx context.Context, steamids []string) (bans, comms map[string]int, err error) {
bans, comms = map[string]int{}, map[string]int{}
if len(steamids) == 0 {
return
}
in, args := inList(steamids)
for table, m := range map[string]map[string]int{"sa_bans": bans, "sa_mutes": comms} {
rows, qerr := s.db.QueryContext(ctx, `SELECT player_steamid, COUNT(*) FROM `+table+` WHERE player_steamid IN (`+in+`) GROUP BY player_steamid`, args...)
if qerr != nil {
return nil, nil, qerr
}
for rows.Next() {
var id sql.NullInt64
var n int
if err = rows.Scan(&id, &n); err != nil {
rows.Close()
return
}
m[sid(id)] = n
}
rows.Close()
}
return
}
func inList(steamids []string) (string, []any) {
args := make([]any, 0, len(steamids))
for _, v := range steamids {
n, _ := parseSID(v)
args = append(args, n)
}
return strings.TrimSuffix(strings.Repeat("?,", len(args)), ","), args
}
func limit(n int) int {
if n <= 0 || n > 200 {
return 50
}
return n
}
// Activity returns the most recent bans, comm blocks, warnings and unbans, newest first.
type Activity struct {
Kind string `json:"kind"` // ban, comm, warn, unban, unmute
Type string `json:"type,omitempty"`
Name string `json:"name"`
SteamID string `json:"steamid"`
Admin string `json:"admin"`
Reason string `json:"reason"`
Minutes int `json:"duration"`
At time.Time `json:"at"`
}
func (s *Store) Activity(ctx context.Context, n int) ([]Activity, error) {
q := `
(SELECT 'ban' kind, '' type, COALESCE(player_name,'') name, player_steamid, admin_name, reason, duration, created FROM sa_bans ORDER BY id DESC LIMIT ?)
UNION ALL
(SELECT 'comm', type, COALESCE(player_name,''), player_steamid, admin_name, reason, duration, created FROM sa_mutes ORDER BY id DESC LIMIT ?)
UNION ALL
(SELECT 'warn', '', COALESCE(player_name,''), player_steamid, admin_name, reason, duration, created FROM sa_warns ORDER BY id DESC LIMIT ?)
UNION ALL
(SELECT 'unban', '', COALESCE(b.player_name,''), b.player_steamid, COALESCE(a.player_name,'Console'), u.reason, 0, u.date
FROM sa_unbans u JOIN sa_bans b ON b.id = u.ban_id LEFT JOIN sa_admins a ON a.id = u.admin_id ORDER BY u.id DESC LIMIT ?)
UNION ALL
(SELECT 'unmute', m.type, COALESCE(m.player_name,''), m.player_steamid, COALESCE(a.player_name,'Console'), u.reason, 0, u.date
FROM sa_unmutes u JOIN sa_mutes m ON m.id = u.mute_id LEFT JOIN sa_admins a ON a.id = u.admin_id ORDER BY u.id DESC LIMIT ?)
ORDER BY 8 DESC LIMIT ?`
rows, err := s.db.QueryContext(ctx, q, n, n, n, n, n, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Activity
for rows.Next() {
var a Activity
var id sql.NullInt64
var at sql.NullTime
if err := rows.Scan(&a.Kind, &a.Type, &a.Name, &id, &a.Admin, &a.Reason, &a.Minutes, &at); err != nil {
return nil, err
}
a.SteamID, a.At = sid(id), s.fromWall(at.Time)
out = append(out, a)
}
return out, rows.Err()
}
// ---------------------------------------------------------------- Players
// Player is someone SimpleAdmin has seen connect (sa_players_ips).
type Player struct {
SteamID string `json:"steamid"`
Name string `json:"name"`
LastSeen time.Time `json:"lastSeen"`
}
// Players lists known players, most recently seen first. Search matches name, SteamID64 or, when
// byIP is set, an exact IPv4 address.
func (s *Store) Players(ctx context.Context, search string, byIP bool, onlyIDs []string, lim, offset int) ([]Player, int, error) {
var conds []string
var args []any
if t := strings.TrimSpace(search); t != "" {
or := []string{"name LIKE ?"}
or2 := []any{"%" + escapeLike(t) + "%"}
if n, err := strconv.ParseInt(t, 10, 64); err == nil {
or, or2 = append(or, "steamid = ?"), append(or2, n)
}
if byIP && strings.Count(t, ".") == 3 {
or, or2 = append(or, "address = INET_ATON(?)"), append(or2, t)
}
conds = append(conds, "steamid IN (SELECT steamid FROM sa_players_ips WHERE "+strings.Join(or, " OR ")+")")
args = append(args, or2...)
}
if onlyIDs != nil {
if len(onlyIDs) == 0 {
return nil, 0, nil
}
in, a := inList(onlyIDs)
conds = append(conds, "steamid IN ("+in+")")
args = append(args, a...)
}
where := ""
if len(conds) > 0 {
where = " WHERE " + strings.Join(conds, " AND ")
}
var total int
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(DISTINCT steamid) FROM sa_players_ips"+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `SELECT steamid, MAX(used_at) AS seen,
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(name,'') ORDER BY used_at DESC SEPARATOR '\n'), '\n', 1)
FROM sa_players_ips` + where + ` GROUP BY steamid ORDER BY seen DESC LIMIT ? OFFSET ?`
rows, err := s.db.QueryContext(ctx, q, append(args, limit(lim), offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []Player
for rows.Next() {
var p Player
var id sql.NullInt64
var seen sql.NullTime
if err := rows.Scan(&id, &seen, &p.Name); err != nil {
return nil, 0, err
}
p.SteamID, p.LastSeen = sid(id), s.fromWall(seen.Time)
out = append(out, p)
}
return out, total, rows.Err()
}
// PlayerDetail is everything known about one SteamID.
type PlayerDetail struct {
SteamID string `json:"steamid"`
Name string `json:"name"`
Names []string `json:"names"`
IPs []string `json:"ips,omitempty"`
LastSeen *time.Time `json:"lastSeen,omitempty"`
}
func (s *Store) Player(ctx context.Context, steamid string) (PlayerDetail, error) {
n, err := parseSID(steamid)
if err != nil {
return PlayerDetail{}, ErrNotFound
}
d := PlayerDetail{SteamID: steamid}
rows, err := s.db.QueryContext(ctx, `SELECT COALESCE(name,''), INET_NTOA(address), used_at FROM sa_players_ips WHERE steamid = ? ORDER BY used_at DESC`, n)
if err != nil {
return d, err
}
defer rows.Close()
for rows.Next() {
var name string
var ip sql.NullString
var at sql.NullTime
if err := rows.Scan(&name, &ip, &at); err != nil {
return d, err
}
if d.LastSeen == nil && at.Valid {
t := s.fromWall(at.Time)
d.LastSeen = &t
}
if name != "" && !slices.Contains(d.Names, name) {
d.Names = append(d.Names, name)
}
if ip.Valid && !slices.Contains(d.IPs, ip.String) {
d.IPs = append(d.IPs, ip.String)
}
}
if err := rows.Err(); err != nil {
return d, err
}
// Players who were banned offline may never have joined; fall back to names on their records.
for _, table := range []string{"sa_bans", "sa_mutes", "sa_admins"} {
var name sql.NullString
err := s.db.QueryRowContext(ctx, `SELECT player_name FROM `+table+` WHERE player_steamid = ? AND player_name IS NOT NULL AND player_name <> '' ORDER BY id DESC LIMIT 1`, n).Scan(&name)
if err == nil && name.Valid && !slices.Contains(d.Names, name.String) {
d.Names = append(d.Names, name.String)
}
}
if len(d.Names) == 0 {
return d, ErrNotFound
}
d.Name = d.Names[0]
return d, nil
}
// LatestName returns the last name SimpleAdmin saw for a SteamID, or "".
func (s *Store) LatestName(ctx context.Context, steamid string) string {
d, err := s.Player(ctx, steamid)
if err != nil {
return ""
}
return d.Name
}
// ---------------------------------------------------------------- Admins and groups
// Group is a SimpleAdmin group ("rank"), e.g. #rank/mod.
type Group struct {
ID int64 `json:"id"`
Name string `json:"name"`
Immunity int `json:"immunity"`
Flags []string `json:"flags"`
}
// Admin merges every applicable sa_admins row for one SteamID.
type Admin struct {
RowIDs []int64 `json:"-"`
SteamID string `json:"steamid"`
Name string `json:"name"`
Immunity int `json:"immunity"`
Flags []string `json:"flags"` // @css/... set directly on the admin
Groups []string `json:"groups"` // #group names
Ends *time.Time `json:"ends,omitempty"`
Global bool `json:"global"`
}
func (s *Store) serverFilter(col string) (string, []any) {
if s.serverID.Valid {
return "(" + col + " IS NULL OR " + col + " = ?)", []any{s.serverID.Int64}
}
return "1=1", nil
}
// Groups returns the groups that apply to this server, the same set SimpleAdmin loads.
func (s *Store) Groups(ctx context.Context) ([]Group, error) {
cond, args := s.serverFilter("gs.server_id")
rows, err := s.db.QueryContext(ctx, `SELECT g.id, g.name, g.immunity, COALESCE(f.flag,'')
FROM sa_groups g
JOIN sa_groups_servers gs ON gs.group_id = g.id AND `+cond+`
LEFT JOIN sa_groups_flags f ON f.group_id = g.id
ORDER BY g.immunity DESC, g.id`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Group
idx := map[int64]int{}
for rows.Next() {
var g Group
var flag string
if err := rows.Scan(&g.ID, &g.Name, &g.Immunity, &flag); err != nil {
return nil, err
}
i, ok := idx[g.ID]
if !ok {
g.Flags = []string{}
out = append(out, g)
i = len(out) - 1
idx[g.ID] = i
}
if flag != "" && !slices.Contains(out[i].Flags, flag) {
out[i].Flags = append(out[i].Flags, flag)
}
}
return out, rows.Err()
}
// Admins returns every unexpired admin that applies to this server.
func (s *Store) Admins(ctx context.Context) ([]Admin, error) {
cond, args := s.serverFilter("a.server_id")
args = append([]any{s.wall(time.Now())}, args...)
rows, err := s.db.QueryContext(ctx, `SELECT a.id, a.player_steamid, COALESCE(a.player_name,''), a.immunity, a.ends, a.server_id IS NULL, COALESCE(f.flag,'')
FROM sa_admins a
LEFT JOIN sa_admins_flags f ON f.admin_id = a.id
WHERE a.player_steamid IS NOT NULL AND a.player_steamid <> 0
AND (a.ends IS NULL OR a.ends > ?) AND `+cond+`
ORDER BY a.server_id IS NULL DESC, a.id`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Admin
idx := map[string]int{}
for rows.Next() {
var rowID int64
var id sql.NullInt64
var name, flag string
var imm int
var ends sql.NullTime
var global bool
if err := rows.Scan(&rowID, &id, &name, &imm, &ends, &global, &flag); err != nil {
return nil, err
}
key := sid(id)
i, ok := idx[key]
if !ok {
out = append(out, Admin{SteamID: key, Name: name, Flags: []string{}, Groups: []string{}, Ends: s.nullWall(ends)})
i = len(out) - 1
idx[key] = i
}
a := &out[i]
if !slices.Contains(a.RowIDs, rowID) {
a.RowIDs = append(a.RowIDs, rowID)
}
a.Immunity = max(a.Immunity, imm)
a.Global = a.Global || global
switch {
case strings.HasPrefix(flag, "#") && !slices.Contains(a.Groups, flag):
a.Groups = append(a.Groups, flag)
case strings.HasPrefix(flag, "@") && !slices.Contains(a.Flags, flag):
a.Flags = append(a.Flags, flag)
}
}
// SimpleAdmin ignores admin rows with no flags at all (its admin query joins the flags table).
out = slices.DeleteFunc(out, func(a Admin) bool { return len(a.Flags) == 0 && len(a.Groups) == 0 })
return out, rows.Err()
}
// AdminRowID returns the sa_admins.id to credit for unbans by this SteamID, or 0 (Console).
func (s *Store) AdminRowID(ctx context.Context, steamid string) int64 {
n, _ := parseSID(steamid)
var id int64
_ = s.db.QueryRowContext(ctx, `SELECT id FROM sa_admins WHERE player_steamid = ? ORDER BY server_id IS NULL DESC, id LIMIT 1`, n).Scan(&id)
return id
}
// CreateGroup adds a global group, like css_addgroup ... -g.
func (s *Store) CreateGroup(ctx context.Context, name string, immunity int, flags []string) (int64, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
var exists int
_ = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM sa_groups WHERE name = ?`, name).Scan(&exists)
if exists > 0 {
return 0, fmt.Errorf("A rank named %s already exists.", name)
}
res, err := tx.ExecContext(ctx, `INSERT INTO sa_groups (name, immunity) VALUES (?, ?)`, name, immunity)
if err != nil {
return 0, err
}
id, _ := res.LastInsertId()
if err := insertGroupFlags(ctx, tx, id, flags); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sa_groups_servers (group_id, server_id) VALUES (?, NULL)`, id); err != nil {
return 0, err
}
return id, tx.Commit()
}
func insertGroupFlags(ctx context.Context, tx *sql.Tx, id int64, flags []string) error {
for _, f := range flags {
if _, err := tx.ExecContext(ctx, `INSERT INTO sa_groups_flags (group_id, flag) VALUES (?, ?)`, id, f); err != nil {
return err
}
}
return nil
}
// UpdateGroup changes a group's name, immunity and flags. Renaming also updates every admin's
// "#name" reference so members keep the rank.
func (s *Store) UpdateGroup(ctx context.Context, id int64, name string, immunity int, flags []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var old string
if err := tx.QueryRowContext(ctx, `SELECT name FROM sa_groups WHERE id = ?`, id).Scan(&old); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if name != old {
var exists int
_ = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM sa_groups WHERE name = ? AND id <> ?`, name, id).Scan(&exists)
if exists > 0 {
return fmt.Errorf("A rank named %s already exists.", name)
}
if _, err := tx.ExecContext(ctx, `UPDATE sa_admins_flags SET flag = ? WHERE flag = ?`, name, old); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `UPDATE sa_groups SET name = ?, immunity = ? WHERE id = ?`, name, immunity, id); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sa_groups_flags WHERE group_id = ?`, id); err != nil {
return err
}
if err := insertGroupFlags(ctx, tx, id, flags); err != nil {
return err
}
return tx.Commit()
}
// DeleteGroup removes a group and every admin's reference to it. Admins left with no flags or
// groups are removed too, which is what makes them regular players again.
func (s *Store) DeleteGroup(ctx context.Context, id int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var name string
if err := tx.QueryRowContext(ctx, `SELECT name FROM sa_groups WHERE id = ?`, id).Scan(&name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sa_admins_flags WHERE flag = ?`, name); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sa_groups WHERE id = ?`, id); err != nil {
return err
}
if err := deleteEmptyAdmins(ctx, tx); err != nil {
return err
}
return tx.Commit()
}
func deleteEmptyAdmins(ctx context.Context, tx *sql.Tx) error {
// id 0 is SimpleAdmin's "Console" row, which unbans reference.
_, err := tx.ExecContext(ctx, `DELETE FROM sa_admins WHERE id <> 0 AND player_steamid <> 0
AND NOT EXISTS (SELECT 1 FROM sa_admins_flags f WHERE f.admin_id = sa_admins.id)
AND (flags IS NULL OR flags = '')`)
return err
}
// SetAdminGroup gives a player exactly one rank (replacing any #group they had), creating a
// global admin row if they have none. immunity is stored on the admin row, as css_addadmin does.
// ends is nil for no expiry.
func (s *Store) SetAdminGroup(ctx context.Context, steamid, name, group string, immunity int, ends *time.Time) error {
n, err := parseSID(steamid)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var rowID int64
err = tx.QueryRowContext(ctx, `SELECT id FROM sa_admins WHERE player_steamid = ? ORDER BY server_id IS NULL DESC, id LIMIT 1`, n).Scan(&rowID)
var endsArg any
if ends != nil {
endsArg = s.wall(*ends)
}
switch {
case errors.Is(err, sql.ErrNoRows):
res, err := tx.ExecContext(ctx, `INSERT INTO sa_admins (player_steamid, player_name, immunity, ends, created, server_id) VALUES (?, ?, ?, ?, ?, NULL)`,
n, name, immunity, endsArg, s.wall(time.Now()))
if err != nil {
return err
}
rowID, _ = res.LastInsertId()
case err != nil:
return err
default:
if _, err := tx.ExecContext(ctx, `UPDATE sa_admins SET immunity = ?, ends = ?, player_name = COALESCE(NULLIF(?, ''), player_name) WHERE player_steamid = ?`,
immunity, endsArg, name, n); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `DELETE f FROM sa_admins_flags f JOIN sa_admins a ON a.id = f.admin_id WHERE a.player_steamid = ? AND f.flag LIKE '#%'`, n); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sa_admins_flags (admin_id, flag) VALUES (?, ?)`, rowID, group); err != nil {
return err
}
return tx.Commit()
}
// RemoveAdmin deletes every sa_admins row for a SteamID, like css_deladmin ... -g.
func (s *Store) RemoveAdmin(ctx context.Context, steamid string) error {
n, err := parseSID(steamid)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `DELETE FROM sa_admins WHERE player_steamid = ? AND id <> 0`, n)
return err
}
// ---------------------------------------------------------------- Writes for penalties
// MaxID returns the highest id in a penalty table, to find rows a console command creates.
func (s *Store) MaxID(ctx context.Context, table string) int64 {
var id sql.NullInt64
_ = s.db.QueryRowContext(ctx, `SELECT MAX(id) FROM `+table).Scan(&id)
return id.Int64
}
// ClaimConsoleRow finds a row created after afterID for the player by "Console" (admin_steamid 0)
// and credits it to the web admin instead. SimpleAdmin writes the row asynchronously, so this polls
// for a few seconds. It returns the row id, or 0 if none appeared.
func (s *Store) ClaimConsoleRow(ctx context.Context, table string, afterID int64, steamid, commType, adminSteamID, adminName, playerName string) int64 {
n, _ := parseSID(steamid)
a, _ := parseSID(adminSteamID)
q := `SELECT id FROM ` + table + ` WHERE id > ? AND player_steamid = ? AND admin_steamid = 0`
args := []any{afterID, n}
if commType != "" {
q += ` AND type = ?`
args = append(args, commType)
}
q += ` ORDER BY id DESC LIMIT 1`
for range 20 {
var id int64
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&id); err == nil {
// Offline penalties arrive without a player name, so fill it in from what we know.
_, _ = s.db.ExecContext(ctx, `UPDATE `+table+` SET admin_steamid = ?, admin_name = ?,
player_name = COALESCE(NULLIF(player_name, ''), NULLIF(?, '')) WHERE id = ?`, a, adminName, playerName, id)
return id
}
select {
case <-ctx.Done():
return 0
case <-time.After(250 * time.Millisecond):
}
}
return 0
}
// ActivePenaltyIDs lists a player's active bans (table sa_bans) or active comm blocks of one type.
func (s *Store) ActivePenaltyIDs(ctx context.Context, table, steamid, commType string) []int64 {
n, _ := parseSID(steamid)
q := `SELECT id FROM ` + table + ` WHERE player_steamid = ? AND status = 'ACTIVE'`
args := []any{n}
if commType != "" {
q += ` AND type = ?`
args = append(args, commType)
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if rows.Scan(&id) == nil {
ids = append(ids, id)
}
}
return ids
}
// ClaimLift credits the unban/unmute rows that a console command wrote for these penalties, which
// were active before the command. SimpleAdmin gives console lifts the id of its "Console" admin row,
// which isn't reliably 0, so the rows are found through the penalties instead.
func (s *Store) ClaimLift(ctx context.Context, table string, ids []int64, adminRowID int64) {
if len(ids) == 0 || adminRowID == 0 {
return
}
liftTable, col := "sa_unbans", "unban_id"
if table == "sa_mutes" {
liftTable, col = "sa_unmutes", "unmute_id"
}
in := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
args := make([]any, 0, len(ids)+1)
args = append(args, adminRowID)
for _, id := range ids {
args = append(args, id)
}
for range 20 {
res, err := s.db.ExecContext(ctx, `UPDATE `+liftTable+` SET admin_id = ? WHERE id IN (SELECT `+col+` FROM `+table+` WHERE id IN (`+in+`) AND `+col+` IS NOT NULL)`, args...)
if err == nil {
if n, _ := res.RowsAffected(); n > 0 {
return
}
}
select {
case <-ctx.Done():
return
case <-time.After(250 * time.Millisecond):
}
}
}
// NewPenalty is a ban or comm block written directly, used when the game server is unreachable.
type NewPenalty struct {
SteamID string
Name string
Type string // GAG/MUTE/SILENCE for comms
AdminSteamID string
AdminName string
Reason string
Duration int
}
func (s *Store) serverArg() any {
if s.serverID.Valid {
return s.serverID.Int64
}
return nil
}
// InsertBan writes a ban the way SimpleAdmin's AddBanBySteamid does.
func (s *Store) InsertBan(ctx context.Context, p NewPenalty) error {
n, _ := parseSID(p.SteamID)
a, _ := parseSID(p.AdminSteamID)
now := time.Now()
var ends any
if p.Duration > 0 {
ends = s.wall(now.Add(time.Duration(p.Duration) * time.Minute))
}
_, err := s.db.ExecContext(ctx, `INSERT INTO sa_bans (player_steamid, player_name, admin_steamid, admin_name, reason, duration, ends, created, server_id)
VALUES (?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)`, n, p.Name, a, p.AdminName, p.Reason, p.Duration, ends, s.wall(now), s.serverArg())
return err
}
// InsertComm writes a gag, mute or silence the way SimpleAdmin's AddMuteBySteamid does.
func (s *Store) InsertComm(ctx context.Context, p NewPenalty) error {
n, _ := parseSID(p.SteamID)
a, _ := parseSID(p.AdminSteamID)
now := time.Now()
var ends any
if p.Duration > 0 {
ends = s.wall(now.Add(time.Duration(p.Duration) * time.Minute))
}
_, err := s.db.ExecContext(ctx, `INSERT INTO sa_mutes (player_steamid, player_name, admin_steamid, admin_name, reason, duration, ends, created, type, server_id, passed)
VALUES (?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?, ?, 0)`, n, p.Name, a, p.AdminName, p.Reason, p.Duration, ends, s.wall(now), p.Type, s.serverArg())
return err
}
// LiftDirect marks a player's active bans (or comm blocks of one type) lifted and records who did
// it, the way SimpleAdmin's unban/unmute do. Used when the game server is unreachable.
func (s *Store) LiftDirect(ctx context.Context, table, steamid, commType string, adminRowID int64, reason string) (int, error) {
ids := s.ActivePenaltyIDs(ctx, table, steamid, commType)
liftTable, fk, col, status := "sa_unbans", "ban_id", "unban_id", "UNBANNED"
if table == "sa_mutes" {
liftTable, fk, col, status = "sa_unmutes", "mute_id", "unmute_id", "UNMUTED"
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
for _, id := range ids {
res, err := tx.ExecContext(ctx, `INSERT INTO `+liftTable+` (`+fk+`, admin_id, reason) VALUES (?, ?, ?)`, id, adminRowID, reason)
if err != nil {
return 0, err
}
liftID, _ := res.LastInsertId()
if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET status = ?, `+col+` = ? WHERE id = ?`, status, liftID, id); err != nil {
return 0, err
}
}
return len(ids), tx.Commit()
}
// PenaltyOwner returns the player SteamID and (for comms) type of a penalty row.
func (s *Store) PenaltyOwner(ctx context.Context, table string, id int64) (steamid, commType string, err error) {
var n sql.NullInt64
if table == "sa_mutes" {
err = s.db.QueryRowContext(ctx, `SELECT player_steamid, type FROM sa_mutes WHERE id = ?`, id).Scan(&n, &commType)
} else {
err = s.db.QueryRowContext(ctx, `SELECT player_steamid FROM sa_bans WHERE id = ?`, id).Scan(&n)
}
if errors.Is(err, sql.ErrNoRows) {
return "", "", ErrNotFound
}
return sid(n), commType, err
}
// Expiring returns active timed bans and comm blocks, soonest end first.
func (s *Store) Expiring(ctx context.Context, n int) ([]Penalty, error) {
bans, _, err := s.Bans(ctx, Query{Status: StatusActive, Limit: 200})
if err != nil {
return nil, err
}
comms, _, err := s.Comms(ctx, Query{Status: StatusActive, Limit: 200})
if err != nil {
return nil, err
}
now := time.Now()
all := slices.DeleteFunc(append(bans, comms...), func(p Penalty) bool { return p.Duration == 0 })
slices.SortFunc(all, func(a, b Penalty) int {
ra, rb := a.Remaining(now), b.Remaining(now)
switch {
case ra < rb:
return -1
case ra > rb:
return 1
}
return 0
})
if len(all) > n {
all = all[:n]
}
return all, nil
}