simpleadmin-web/internal/store/store.go

1069 lines
35 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)
}
// 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, COALESCE(b.player_name,''), 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, COALESCE(m.player_name,''), 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 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 {
_, _ = s.db.ExecContext(ctx, `UPDATE `+table+` SET admin_steamid = ?, admin_name = ? WHERE id = ?`, a, adminName, 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
}