1069 lines
35 KiB
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
|
|
}
|