From 611b79cdba3009ad2f995ef7a0768fd112c63641 Mon Sep 17 00:00:00 2001 From: Astra Date: Tue, 29 Sep 2026 16:56:37 +0100 Subject: [PATCH] Public rank change log: sa_rank_log, written on rank changes, listed on the Staff page --- README.md | 15 +++- internal/store/ranklog.go | 140 +++++++++++++++++++++++++++++++++ internal/store/store.go | 80 +++++++++++++++++-- internal/web/actions.go | 6 +- internal/web/assets/public.js | 48 ++++++++++- internal/web/assets/styles.css | 1 + internal/web/public.go | 33 ++++++++ internal/web/server.go | 1 + internal/web/staff.go | 3 + main.go | 9 +++ sql/rank-log.sql | 27 +++++++ 11 files changed, 346 insertions(+), 17 deletions(-) create mode 100644 internal/store/ranklog.go create mode 100644 sql/rank-log.sql diff --git a/README.md b/README.md index c1268f3..a599d6e 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ A SourceBans-style web panel for [CS2-SimpleAdmin](https://github.com/daffyyyy/C (built against 1.8.2b's MySQL schema). - **Public site** (`/`): the live server (name, map, players, team score and a scoreboard), bans, - gags and mutes, and the staff list. Read-only. It never shows admin names, IP addresses or - internal immunity values. + gags and mutes, the staff list and its rank changes (promotions, demotions, who made them). + Read-only. It never shows who issued a penalty, IP addresses or internal immunity values. - **Staff panel** (`/admin`): sign in through Steam. Ban, gag, mute, silence, warn and kick; lift bans and blocks; manage ranks, permissions and staff; change server cvars and SimpleAdmin's punishment settings. @@ -82,6 +82,17 @@ SimpleAdmin groups have no display name or colour, so those come from a site con `"supporter": true` are listed apart from staff and can't sign in unless they hold `@css/generic`. +### Rank changes + +Every change to a player's rank (their highest-immunity group) is recorded in `sa_rank_log` and +listed under the public Staff page: who, from which rank to which, who changed it and when. The +panel creates the table at startup (`sql/rank-log.sql`, embedded; run it by hand if the database +user can't create tables). It logs changes made in the panel (giving, moving or removing a rank, +and deleting a rank that people hold). Our SimpleAdmin fork (1.8.2b-zio10+) logs `css_addadmin`, +`css_deladmin` and the in-game admin menu to the same table, marked "in game". Not logged: ranks +that lapse when their expiry passes, `css_delgroup`, and edits made straight in the database. +History starts when the table is created. + ### Name history (optional) SimpleAdmin keeps one row per SteamID + IP in `sa_players_ips` and overwrites its name on each diff --git a/internal/store/ranklog.go b/internal/store/ranklog.go new file mode 100644 index 0000000..ca83462 --- /dev/null +++ b/internal/store/ranklog.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go index 516b0e3..2e1ee5f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 diff --git a/internal/web/actions.go b/internal/web/actions.go index 6fd726a..8630d9d 100644 --- a/internal/web/actions.go +++ b/internal/web/actions.go @@ -517,7 +517,7 @@ func (s *Server) deleteGroup(w http.ResponseWriter, r *http.Request, id *Identit fail(w, http.StatusForbidden, "You can't delete your own rank.") return } - if err := s.st.DeleteGroup(r.Context(), g.ID); err != nil { + if err := s.st.DeleteGroup(r.Context(), id.actor(), g.ID); err != nil { internal(w, r, err) return } @@ -576,7 +576,7 @@ func (s *Server) applyRank(w http.ResponseWriter, r *http.Request, id *Identity, if name == "" { name = req.SteamID } - if err := s.st.SetAdminGroup(r.Context(), req.SteamID, name, req.Group, grp.Immunity, ends); err != nil { + if err := s.st.SetAdminGroup(r.Context(), id.actor(), req.SteamID, name, req.Group, grp.Immunity, ends); err != nil { internal(w, r, err) return } @@ -618,7 +618,7 @@ func (s *Server) removeStaff(w http.ResponseWriter, r *http.Request, id *Identit if !s.checkStaffTarget(w, id, x, steamid) { return } - if err := s.st.RemoveAdmin(r.Context(), steamid); err != nil { + if err := s.st.RemoveAdmin(r.Context(), id.actor(), steamid); err != nil { internal(w, r, err) return } diff --git a/internal/web/assets/public.js b/internal/web/assets/public.js index bb6562b..3a1b6d1 100644 --- a/internal/web/assets/public.js +++ b/internal/web/assets/public.js @@ -1,5 +1,5 @@ -// Public, read-only site: the live server, bans, gags/mutes and staff. The API leaves out admin -// names and IPs; nothing here can change anything. +// Public, read-only site: the live server, bans, gags/mutes, and staff with their rank changes. The +// API leaves out who issued penalties and IPs; nothing here can change anything. const PAGES = [ { id: "server", label: "Server" }, @@ -175,7 +175,46 @@ async function staff() {

Staff

${online ? `${online} staff ${online === 1 ? "is" : "are"} on the server right now.` : "No staff are on the server right now."}

${staffGroups.length ? `
${staffGroups.map(rankRow).join("")}
` : '
No staff are listed yet.
'} - ${supporters.length ? `

Supporters

${supporters.map(rankRow).join("")}
` : ""}`; + ${supporters.length ? `

Supporters

${supporters.map(rankRow).join("")}
` : ""} +

Rank changes

+
${loading()}
`; + loadRankLog(); +} + +const RANK_CHANGE = { + added: ["Joined", "green"], + promoted: ["Promoted", "green"], + demoted: ["Demoted", "amber"], + changed: ["Moved", ""], + removed: ["Removed", "red"], +}; + +async function loadRankLog() { + const box = $("#rank-log"); + if (!box) return; + const want = state.num; + loadRankLog.want = want; + let list; + try { list = await api("/api/public/ranks" + qs({ page: state.num })); } catch (err) { box.innerHTML = errorBox(err); return; } + if (loadRankLog.want !== want || !$("#rank-log")) return; + if (!list.items.length) { box.innerHTML = '
No rank changes recorded yet.
'; return; } + box.innerHTML = ` + + + ${list.items.map((c) => { + const [label, tone] = RANK_CHANGE[c.kind] || [c.kind, ""]; + const by = c.adminSteamid + ? `${esc(c.admin)}` + : esc(c.admin); + return ` + + + + + `; + }).join("")} + +
PlayerChangeByWhen
${esc(c.name || "Unknown")}${esc(c.steamid)}
${label}${rankTag(c.old)}→${rankTag(c.new)}
${by}${c.source === "game" ? ' in game' : ""}${fmtDate(c.created)}
` + pager(list); } /* ---------- Player lookup (read-only) ---------- */ @@ -243,8 +282,9 @@ document.addEventListener("click", (e) => { return; } const pg = t.closest("[data-page]"); - if (pg) { state.num = Number(pg.dataset.page); loadResults(); return; } + if (pg) { state.num = Number(pg.dataset.page); (state.page === "staff" ? loadRankLog : loadResults)(); return; } if (t.closest("[data-close]")) { closeDrawer(); return; } + if (t.closest("a[href]")) return; const row = t.closest("[data-player]"); if (row) openPlayer(row.dataset.player); }); diff --git a/internal/web/assets/styles.css b/internal/web/assets/styles.css index f8b9829..4caccb4 100644 --- a/internal/web/assets/styles.css +++ b/internal/web/assets/styles.css @@ -928,6 +928,7 @@ dialog::backdrop { background: rgba(5, 6, 9, 0.65); } .crew small { color: var(--muted); font-size: 13px; } .crew-sep { margin: 28px 0 12px; } +.rank-move { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; } @media (max-width: 820px) { .topbar-inner { gap: 12px; padding: 0 16px; } diff --git a/internal/web/public.go b/internal/web/public.go index cc2d947..2312e2b 100644 --- a/internal/web/public.go +++ b/internal/web/public.go @@ -208,6 +208,39 @@ func (s *Server) publicStaff(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"groups": groups, "staffOnline": s.serverView(x, st).StaffOnline}) } +type rankChangeView struct { + store.RankChange + Old *rankView `json:"old,omitempty"` + New *rankView `json:"new,omitempty"` +} + +// publicRankChanges is the promotions and demotions list, with each rank's label and colour. +func (s *Server) publicRankChanges(w http.ResponseWriter, r *http.Request) { + lim, off, num := page(r) + items, total, err := s.st.RankChanges(r.Context(), lim, off) + if err != nil { + internal(w, r, err) + return + } + x, err := s.staffIdx(r.Context()) + if err != nil { + internal(w, r, err) + return + } + view := func(g string) *rankView { + if g == "" { + return nil + } + rk := s.site.rank(g, x.groupIndex(g)) + return &rankView{Name: g, Label: rk.Label, Color: rk.Color, Supporter: rk.Supporter} + } + out := make([]rankChangeView, 0, len(items)) + for _, c := range items { + out = append(out, rankChangeView{RankChange: c, Old: view(c.OldRank), New: view(c.NewRank)}) + } + writeJSON(w, http.StatusOK, newList(out, total, num)) +} + func (s *Server) publicPlayer(w http.ResponseWriter, r *http.Request) { steamid := r.PathValue("steamid") if !steam.ValidID(steamid) { diff --git a/internal/web/server.go b/internal/web/server.go index 9bdee52..d602d55 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -89,6 +89,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/public/bans", s.publicBans) mux.HandleFunc("GET /api/public/comms", s.publicComms) mux.HandleFunc("GET /api/public/staff", s.publicStaff) + mux.HandleFunc("GET /api/public/ranks", s.publicRankChanges) mux.HandleFunc("GET /api/public/players/{steamid}", s.publicPlayer) mux.HandleFunc("GET /avatar/{steamid}", s.avatar) diff --git a/internal/web/staff.go b/internal/web/staff.go index e3a1f46..3e5a1aa 100644 --- a/internal/web/staff.go +++ b/internal/web/staff.go @@ -124,6 +124,9 @@ func (id *Identity) Has(flag string) bool { return slices.Contains(id.Flags, "@css/root") || slices.Contains(id.Flags, flag) } +// actor is who the store credits for a change. +func (id *Identity) actor() store.Actor { return store.Actor{SteamID: id.SteamID, Name: id.Name} } + // CanTarget follows CounterStrikeSharp: you can act on players whose immunity isn't above yours. // Nobody acts on themselves through the panel. func (id *Identity) CanTarget(x *staffIndex, steamid string) bool { diff --git a/main.go b/main.go index a540ab7..297b950 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ package main import ( "context" "database/sql" + _ "embed" "errors" "fmt" "log/slog" @@ -27,6 +28,11 @@ import ( "git.zio.sh/cs2/simpleadmin-web/internal/web" ) +// rankLogSQL creates sa_rank_log, the rank history (see the file). +// +//go:embed sql/rank-log.sql +var rankLogSQL string + func env(key, def string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v @@ -127,6 +133,9 @@ func run() error { return errors.New("sa_servers lists several servers; set SAW_SERVER_ID to this server's id") } st = store.New(db, loc, srv.ID, timeMode) + if err := st.EnsureRankLog(ctx, rankLogSQL); err != nil { + slog.Warn("couldn't create sa_rank_log; rank changes won't be logged until sql/rank-log.sql is run", "err", err) + } rconAddr := env("SAW_RCON_ADDR", srv.Address) rconPass := env("SAW_RCON_PASSWORD", srv.RconPassword) diff --git a/sql/rank-log.sql b/sql/rank-log.sql new file mode 100644 index 0000000..ce4e348 --- /dev/null +++ b/sql/rank-log.sql @@ -0,0 +1,27 @@ +-- Rank history: one row each time a player's rank (their highest-immunity SimpleAdmin group) +-- changes, for the public promotions/demotions list. +-- +-- The panel creates this table itself at startup (it's embedded in the binary), so running it by +-- hand is only needed if the panel's database user can't CREATE TABLE. Written by the panel for +-- changes made there, and by our SimpleAdmin fork (1.8.2b-zio10+) for css_addadmin/css_deladmin +-- and the in-game admin menu. Nothing writes it for ranks that lapse when an admin's `ends` passes. +-- +-- kind is added, removed, promoted, demoted (compared by group immunity) or changed (same +-- immunity). old_rank/new_rank are group names ("#rank/mod"), NULL for no rank. admin_steamid 0 +-- is Console. created is a wall-clock value in SimpleAdmin's Timezone, like its other tables. + +CREATE TABLE IF NOT EXISTS `sa_rank_log` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `player_steamid` bigint(20) NOT NULL, + `player_name` varchar(128) NOT NULL DEFAULT '', + `old_rank` varchar(64) NULL, + `new_rank` varchar(64) NULL, + `kind` varchar(16) NOT NULL, + `admin_steamid` bigint(20) NOT NULL DEFAULT 0, + `admin_name` varchar(128) NOT NULL DEFAULT '', + `source` varchar(16) NOT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_rank_log_created` (`created`), + KEY `idx_rank_log_player` (`player_steamid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;