Public rank change log: sa_rank_log, written on rank changes, listed on the Staff page

This commit is contained in:
Astra 2026-09-29 16:56:37 +01:00
parent 861692c534
commit 611b79cdba
11 changed files with 346 additions and 17 deletions

140
internal/store/ranklog.go Normal file
View file

@ -0,0 +1,140 @@
package store
import (
"context"
"database/sql"
"strings"
"time"
)
// Rank history lives in sa_rank_log, which the panel owns (sql/rank-log.sql). Our SimpleAdmin fork
// writes the same rows for in-game changes. A player's rank is their highest-immunity group, the
// one the staff page lists them under.
// Actor is who made a change. An empty SteamID is Console.
type Actor struct {
SteamID string
Name string
}
// Where a rank change was made, stored in sa_rank_log.source.
const (
SourcePanel = "panel"
SourceGame = "game"
)
// RankChange is a row of sa_rank_log.
type RankChange struct {
ID int64 `json:"id"`
SteamID string `json:"steamid"`
Name string `json:"name"`
OldRank string `json:"oldRank,omitempty"`
NewRank string `json:"newRank,omitempty"`
Kind string `json:"kind"`
AdminSteamID string `json:"adminSteamid,omitempty"`
AdminName string `json:"admin"`
Source string `json:"source"`
Created time.Time `json:"created"`
}
// EnsureRankLog creates sa_rank_log if it's missing.
func (s *Store) EnsureRankLog(ctx context.Context, ddl string) error {
_, err := s.db.ExecContext(ctx, strings.TrimSuffix(strings.TrimSpace(ddl), ";"))
return err
}
// RankChanges lists rank changes, newest first.
func (s *Store) RankChanges(ctx context.Context, limit, offset int) ([]RankChange, int, error) {
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sa_rank_log`).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.db.QueryContext(ctx, `SELECT id, player_steamid, player_name, COALESCE(old_rank,''), COALESCE(new_rank,''),
kind, admin_steamid, admin_name, source, created
FROM sa_rank_log ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []RankChange{}
for rows.Next() {
var c RankChange
var player, admin sql.NullInt64
if err := rows.Scan(&c.ID, &player, &c.Name, &c.OldRank, &c.NewRank, &c.Kind, &admin, &c.AdminName, &c.Source, &c.Created); err != nil {
return nil, 0, err
}
c.SteamID, c.AdminSteamID = sid(player), sid(admin)
if c.AdminSteamID == "" {
c.AdminName = "Console"
}
c.Created = s.fromWall(c.Created)
out = append(out, c)
}
return out, total, rows.Err()
}
type rankState struct {
group string // "" for no rank
immunity int
}
// rankTx reads a player's current rank inside tx, counting only unexpired admin rows and groups
// that apply to this server, as Admins and Groups do.
func (s *Store) rankTx(ctx context.Context, tx *sql.Tx, n int64) (rankState, error) {
gsCond, gsArgs := s.serverFilter("gs.server_id")
aCond, aArgs := s.serverFilter("a.server_id")
args := append(gsArgs, n, s.wall(time.Now()))
args = append(args, aArgs...)
var r rankState
err := tx.QueryRowContext(ctx, `SELECT g.name, g.immunity
FROM sa_admins a
JOIN sa_admins_flags f ON f.admin_id = a.id
JOIN sa_groups g ON g.name = f.flag
JOIN sa_groups_servers gs ON gs.group_id = g.id AND `+gsCond+`
WHERE a.player_steamid = ? AND (a.ends IS NULL OR a.ends > ?) AND `+aCond+`
ORDER BY g.immunity DESC, g.id LIMIT 1`, args...).Scan(&r.group, &r.immunity)
if err == sql.ErrNoRows {
return rankState{}, nil
}
return r, err
}
func rankKind(before, after rankState) string {
switch {
case before.group == "":
return "added"
case after.group == "":
return "removed"
case after.immunity > before.immunity:
return "promoted"
case after.immunity < before.immunity:
return "demoted"
}
return "changed"
}
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
// logRank records a rank change inside tx, if the rank actually changed.
func (s *Store) logRank(ctx context.Context, tx *sql.Tx, n int64, name string, before, after rankState, by Actor) error {
if before.group == after.group {
return nil
}
a, _ := parseSID(by.SteamID)
_, err := tx.ExecContext(ctx, `INSERT INTO sa_rank_log (player_steamid, player_name, old_rank, new_rank, kind, admin_steamid, admin_name, source, created)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n, name, nullIfEmpty(before.group), nullIfEmpty(after.group), rankKind(before, after), a, by.Name, SourcePanel, s.wall(time.Now()))
return err
}
// adminNameTx is the name on a player's admin rows, or "".
func adminNameTx(ctx context.Context, tx *sql.Tx, n int64) string {
var name sql.NullString
_ = tx.QueryRowContext(ctx, `SELECT player_name FROM sa_admins WHERE player_steamid = ? ORDER BY server_id IS NULL DESC, id LIMIT 1`, n).Scan(&name)
return name.String
}

View file

@ -780,8 +780,9 @@ func (s *Store) UpdateGroup(ctx context.Context, id int64, name string, immunity
}
// 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 {
// groups are removed too, which is what makes them regular players again. Members whose rank
// changes because of it are logged as changed by by.
func (s *Store) DeleteGroup(ctx context.Context, by Actor, id int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
@ -794,6 +795,33 @@ func (s *Store) DeleteGroup(ctx context.Context, id int64) error {
}
return err
}
type member struct {
n int64
name string
before rankState
}
var members []member
rows, err := tx.QueryContext(ctx, `SELECT DISTINCT a.player_steamid FROM sa_admins a
JOIN sa_admins_flags f ON f.admin_id = a.id WHERE f.flag = ? AND a.player_steamid <> 0`, name)
if err != nil {
return err
}
for rows.Next() {
var m member
if err := rows.Scan(&m.n); err != nil {
rows.Close()
return err
}
members = append(members, m)
}
rows.Close()
for i := range members {
m := &members[i]
m.name = adminNameTx(ctx, tx, m.n)
if m.before, err = s.rankTx(ctx, tx, m.n); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sa_admins_flags WHERE flag = ?`, name); err != nil {
return err
}
@ -803,6 +831,15 @@ func (s *Store) DeleteGroup(ctx context.Context, id int64) error {
if err := deleteEmptyAdmins(ctx, tx); err != nil {
return err
}
for _, m := range members {
after, err := s.rankTx(ctx, tx, m.n)
if err != nil {
return err
}
if err := s.logRank(ctx, tx, m.n, m.name, m.before, after, by); err != nil {
return err
}
}
return tx.Commit()
}
@ -816,8 +853,8 @@ func deleteEmptyAdmins(ctx context.Context, tx *sql.Tx) error {
// 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 {
// ends is nil for no expiry. A change of rank is logged as made by by.
func (s *Store) SetAdminGroup(ctx context.Context, by Actor, steamid, name, group string, immunity int, ends *time.Time) error {
n, err := parseSID(steamid)
if err != nil {
return err
@ -827,6 +864,10 @@ func (s *Store) SetAdminGroup(ctx context.Context, steamid, name, group string,
return err
}
defer tx.Rollback()
before, err := s.rankTx(ctx, tx, n)
if err != nil {
return err
}
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)
@ -856,17 +897,40 @@ func (s *Store) SetAdminGroup(ctx context.Context, steamid, name, group string,
if _, err := tx.ExecContext(ctx, `INSERT INTO sa_admins_flags (admin_id, flag) VALUES (?, ?)`, rowID, group); err != nil {
return err
}
after, err := s.rankTx(ctx, tx, n)
if err != nil {
return err
}
if err := s.logRank(ctx, tx, n, name, before, after, by); 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 {
// RemoveAdmin deletes every sa_admins row for a SteamID, like css_deladmin ... -g, and logs the
// lost rank as removed by by.
func (s *Store) RemoveAdmin(ctx context.Context, by Actor, 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
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
before, err := s.rankTx(ctx, tx, n)
if err != nil {
return err
}
name := adminNameTx(ctx, tx, n)
if _, err := tx.ExecContext(ctx, `DELETE FROM sa_admins WHERE player_steamid = ? AND id <> 0`, n); err != nil {
return err
}
if err := s.logRank(ctx, tx, n, name, before, rankState{}, by); err != nil {
return err
}
return tx.Commit()
}
// ---------------------------------------------------------------- Writes for penalties