simpleadmin-web 0.1.0: web panel for CS2-SimpleAdmin

This commit is contained in:
Astra 2026-09-25 20:08:14 +01:00
commit 4f38daf0e6
31 changed files with 6870 additions and 0 deletions

410
internal/web/admin.go Normal file
View file

@ -0,0 +1,410 @@
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/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)
}