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

411 lines
12 KiB
Go

package web
import (
"errors"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"time"
"git.zio.sh/cs2/simpleadmin-web/internal/saconfig"
"git.zio.sh/cs2/simpleadmin-web/internal/steam"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
)
// scrubIPs removes IP addresses for staff without @css/showip, like SimpleAdmin's css_players does.
func scrubIPs(id *Identity, ps []store.Penalty) []store.Penalty {
if id.Has("@css/showip") {
return ps
}
for i := range ps {
ps[i].IP = ""
}
return ps
}
func nonNil[T any](v []T) []T {
if v == nil {
return []T{}
}
return v
}
func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
ctx := r.Context()
view := s.serverView(x, s.poll.Current())
var ids []string
for _, p := range view.Players {
if !p.Bot {
ids = append(ids, p.SteamID)
}
}
warns, err := s.st.ActiveWarnCounts(ctx, ids)
if err != nil {
internal(w, r, err)
return
}
for i, p := range view.Players {
if p.Bot {
continue
}
view.Players[i].CanTarget = id.CanTarget(x, p.SteamID)
comms, _, err := s.st.Comms(ctx, store.Query{SteamID: p.SteamID, Status: store.StatusActive, Limit: 5})
if err != nil {
internal(w, r, err)
return
}
for _, c := range comms {
view.Players[i].Tags = append(view.Players[i].Tags, c.Type)
}
if n := warns[p.SteamID]; n > 0 {
view.Players[i].Tags = append(view.Players[i].Tags, "WARN:"+strconv.Itoa(n))
}
}
activity, err := s.st.Activity(ctx, 12)
if err != nil {
internal(w, r, err)
return
}
expiring, err := s.st.Expiring(ctx, 6)
if err != nil {
internal(w, r, err)
return
}
_, activeBans, err := s.st.Bans(ctx, store.Query{Status: store.StatusActive, Limit: 1})
if err != nil {
internal(w, r, err)
return
}
_, activeComms, err := s.st.Comms(ctx, store.Query{Status: store.StatusActive, Limit: 1})
if err != nil {
internal(w, r, err)
return
}
staff := 0
for _, a := range x.admins {
if s.isStaff(x, a.SteamID) {
staff++
}
}
writeJSON(w, http.StatusOK, map[string]any{
"server": view, "serverError": s.poll.Current().Error,
"activity": nonNil(activity), "expiring": nonNil(scrubIPs(id, expiring)),
"activeBans": activeBans, "activeComms": activeComms, "staffCount": staff,
})
}
type playerRow struct {
SteamID string `json:"steamid"`
Name string `json:"name"`
Online bool `json:"online"`
LastSeen *time.Time `json:"lastSeen,omitempty"`
Rank *rankView `json:"rank,omitempty"`
Bans int `json:"bans"`
Comms int `json:"comms"`
Warns int `json:"warns"`
Banned bool `json:"banned"`
}
func (s *Server) adminPlayers(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
ctx := r.Context()
search := strings.TrimSpace(r.URL.Query().Get("q"))
lim, off, num := page(r)
st := s.poll.Current()
var rows []playerRow
total := 0
switch r.URL.Query().Get("filter") {
case "all":
players, n, err := s.st.Players(ctx, search, id.Has("@css/showip"), nil, lim, off)
if err != nil {
internal(w, r, err)
return
}
total = n
for _, p := range players {
seen := p.LastSeen
rows = append(rows, playerRow{SteamID: p.SteamID, Name: p.Name, LastSeen: &seen})
}
case "ranked":
var ids []string
for _, a := range x.admins {
ids = append(ids, a.SteamID)
}
seen := map[string]time.Time{}
if players, _, err := s.st.Players(ctx, "", false, ids, 200, 0); err == nil {
for _, p := range players {
seen[p.SteamID] = p.LastSeen
}
}
for _, a := range x.admins {
if search != "" && !strings.Contains(strings.ToLower(a.Name), strings.ToLower(search)) && a.SteamID != search {
continue
}
row := playerRow{SteamID: a.SteamID, Name: a.Name}
if t, ok := seen[a.SteamID]; ok {
row.LastSeen = &t
}
rows = append(rows, row)
}
slices.SortStableFunc(rows, func(a, b playerRow) int { return x.immunity(b.SteamID) - x.immunity(a.SteamID) })
total = len(rows)
rows = rows[min(off, len(rows)):min(off+lim, len(rows))]
default: // online
for _, p := range st.Humans() {
if search != "" && !strings.Contains(strings.ToLower(p.Name), strings.ToLower(search)) && p.SteamID != search {
continue
}
rows = append(rows, playerRow{SteamID: p.SteamID, Name: p.Name})
}
total = len(rows)
}
var ids []string
for _, p := range rows {
ids = append(ids, p.SteamID)
}
bans, comms, err := s.st.RecordCounts(ctx, ids)
if err != nil {
internal(w, r, err)
return
}
warns, err := s.st.ActiveWarnCounts(ctx, ids)
if err != nil {
internal(w, r, err)
return
}
for i := range rows {
p := &rows[i]
_, p.Online = st.Find(p.SteamID)
p.Rank = s.rankOf(x, p.SteamID)
p.Bans, p.Comms, p.Warns = bans[p.SteamID], comms[p.SteamID], warns[p.SteamID]
if p.Bans > 0 {
_, active, err := s.st.Bans(ctx, store.Query{SteamID: p.SteamID, Status: store.StatusActive, Limit: 1})
if err == nil {
p.Banned = active > 0
}
}
}
writeJSON(w, http.StatusOK, newList(rows, total, num))
}
func (s *Server) adminPlayer(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
ctx := r.Context()
steamid := r.PathValue("steamid")
if !steam.ValidID(steamid) {
fail(w, http.StatusBadRequest, "That isn't a SteamID64.")
return
}
d, err := s.st.Player(ctx, steamid)
if errors.Is(err, store.ErrNotFound) {
d = store.PlayerDetail{SteamID: steamid, Names: []string{}}
} else if err != nil {
internal(w, r, err)
return
}
if !id.Has("@css/showip") {
d.IPs = nil
}
bans, _, err := s.st.Bans(ctx, store.Query{SteamID: steamid, Limit: 200})
if err != nil {
internal(w, r, err)
return
}
comms, _, err := s.st.Comms(ctx, store.Query{SteamID: steamid, Limit: 200})
if err != nil {
internal(w, r, err)
return
}
warns, err := s.st.Warns(ctx, steamid)
if err != nil {
internal(w, r, err)
return
}
lp, online := s.poll.Current().Find(steamid)
resp := map[string]any{
"player": d, "online": online, "rank": s.rankOf(x, steamid),
"immunity": x.immunity(steamid), "canTarget": id.CanTarget(x, steamid),
"bans": nonNil(scrubIPs(id, bans)), "comms": nonNil(comms), "warns": nonNil(warns),
}
if online {
resp["live"] = lp
}
if a, ok := x.bySteam[steamid]; ok {
resp["groups"] = a.Groups
resp["adminEnds"] = a.Ends
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) adminBans(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
q, num := penaltyQuery(r, false)
q.SearchAdmin, q.SearchIP = true, id.Has("@css/showip")
items, total, err := s.st.Bans(r.Context(), q)
if err != nil {
internal(w, r, err)
return
}
writeJSON(w, http.StatusOK, newList(scrubIPs(id, items), total, num))
}
func (s *Server) adminComms(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
q, num := penaltyQuery(r, true)
q.SearchAdmin = true
items, total, err := s.st.Comms(r.Context(), q)
if err != nil {
internal(w, r, err)
return
}
writeJSON(w, http.StatusOK, newList(items, total, num))
}
// knownFlags are the permissions offered as checkboxes. Groups may hold others; those are kept.
var knownFlags = [][3]string{
{"@css/reservation", "Reserved slot", "Joins when the server is full"},
{"@css/generic", "Generic admin", "Required for any admin command and for this panel"},
{"@css/chat", "Chat", "Admin chat, gag, mute and silence"},
{"@css/permmute", "Permanent blocks", "Gag and mute longer than the limit, or permanently"},
{"@css/kick", "Kick", "Kick and warn players"},
{"@css/slay", "Slay", "Slay, slap and respawn players"},
{"@css/ban", "Ban", "Ban players, up to the longest ban setting"},
{"@css/permban", "Permanent bans", "Ban longer than the limit, or permanently"},
{"@css/unban", "Unban", "Lift bans"},
{"@css/showip", "Show IPs", "See players' IP addresses"},
{"@css/changemap", "Change map", "Change or restart the map"},
{"@css/vote", "Votes", "Start votes"},
{"@css/cvar", "Cvars", "Change server cvars"},
{"@css/config", "Config", "Exec config files"},
{"@css/rcon", "RCON", "Run raw console commands"},
{"@css/cheats", "Cheats", "sv_cheats-protected commands"},
{"@css/bypassipban", "Bypass IP bans", "Joins even when their IP, or another account on it, is banned"},
{"@css/vip", "VIP", "VIP perks in other plugins"},
{"@css/root", "Root", "Every permission, and managing ranks and staff"},
}
func (s *Server) adminStaff(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
st := s.poll.Current()
groups := s.groupViews(x, st, true)
var unranked []memberView
for _, a := range x.admins {
if x.primaryGroup(&a) == "" {
_, online := st.Find(a.SteamID)
unranked = append(unranked, memberView{SteamID: a.SteamID, Name: a.Name, Online: online, Ends: a.Ends})
}
}
// Last-seen times for members, from SimpleAdmin's connection log.
var ids []string
for _, a := range x.admins {
ids = append(ids, a.SteamID)
}
if players, _, err := s.st.Players(r.Context(), "", false, ids, 200, 0); err == nil {
seen := map[string]time.Time{}
for _, p := range players {
seen[p.SteamID] = p.LastSeen
}
for gi := range groups {
for mi := range groups[gi].Members {
if t, ok := seen[groups[gi].Members[mi].SteamID]; ok {
groups[gi].Members[mi].LastSeen = &t
}
}
}
for mi := range unranked {
if t, ok := seen[unranked[mi].SteamID]; ok {
unranked[mi].LastSeen = &t
}
}
}
flags := make([]map[string]string, 0, len(knownFlags))
for _, f := range knownFlags {
flags = append(flags, map[string]string{"flag": f[0], "label": f[1], "help": f[2]})
}
writeJSON(w, http.StatusOK, map[string]any{
"groups": groups, "unranked": nonNil(unranked), "flags": flags,
"canManage": id.Has("@css/root"), "myImmunity": id.Immunity, "me": id.SteamID,
})
}
// ---------------------------------------------------------------- Settings
type cvarDef struct {
Key string `json:"key"`
Label string `json:"label"`
Help string `json:"help"`
Type string `json:"type"` // text, int, bool
Value any `json:"value"`
}
var cvars = []cvarDef{
{Key: "hostname", Label: "Server name", Help: "Shown in the server browser.", Type: "text"},
{Key: "sv_password", Label: "Join password", Help: "Leave empty for a public server.", Type: "text"},
{Key: "sv_visiblemaxplayers", Label: "Visible slots", Help: "Slots shown in the server browser. -1 shows them all.", Type: "int"},
{Key: "mp_autoteambalance", Label: "Auto team balance", Help: "Move players to keep teams even.", Type: "bool"},
{Key: "mp_friendlyfire", Label: "Friendly fire", Help: "Let teammates damage each other.", Type: "bool"},
{Key: "mp_timelimit", Label: "Map time limit", Help: "Minutes. 0 for no limit.", Type: "int"},
{Key: "mp_maxrounds", Label: "Rounds per map", Help: "The map ends after this many rounds.", Type: "int"},
}
var cvarLine = regexp.MustCompile(`(?m)^\s*"?([A-Za-z0-9_]+)"?\s*=\s*"?([^"\r\n]*?)"?\s*(?:\(|$)`)
// readCvar asks the server for a cvar's value. CS2 prints `name = value` (sometimes quoted, sometimes
// followed by flags and help text).
func (s *Server) readCvar(key string) (string, bool) {
out, err := s.rc.Exec(key)
if err != nil {
return "", false
}
for _, m := range cvarLine.FindAllStringSubmatch(out, -1) {
if strings.EqualFold(m[1], key) {
return strings.TrimSpace(m[2]), true
}
}
return "", false
}
func (s *Server) adminSettings(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
resp := map[string]any{"canCvar": id.Has("@css/cvar"), "canRoot": id.Has("@css/root")}
if id.Has("@css/cvar") && s.poll.Current().Online {
defs := make([]cvarDef, 0, len(cvars))
for _, c := range cvars {
v, ok := s.readCvar(c.Key)
if !ok {
continue
}
switch c.Type {
case "bool":
c.Value = v == "1" || strings.EqualFold(v, "true")
case "int":
c.Value = strings.TrimSuffix(strings.SplitN(v, ".", 2)[0], " ")
default:
c.Value = v
}
defs = append(defs, c)
}
resp["cvars"] = defs
}
sa := map[string]any{"available": s.sacfg != nil}
if s.sacfg != nil {
if id.Has("@css/root") {
sa["path"] = s.sacfg.Path()
}
if vals, err := s.sacfg.Values(); err == nil {
sa["settings"], sa["values"] = saconfig.Settings, vals
} else {
sa["error"] = "Couldn't read CS2-SimpleAdmin.json. Check the panel's log."
}
}
resp["simpleadmin"] = sa
dbOK := s.st.DB().PingContext(r.Context()) == nil
resp["database"] = map[string]any{"connected": dbOK}
resp["server"] = map[string]any{"online": s.poll.Current().Online, "error": s.poll.Current().Error}
writeJSON(w, http.StatusOK, resp)
}