simpleadmin-web 0.1.0: web panel for CS2-SimpleAdmin
This commit is contained in:
commit
4f38daf0e6
31 changed files with 6870 additions and 0 deletions
410
internal/web/admin.go
Normal file
410
internal/web/admin.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue