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.
411 lines
12 KiB
Go
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)
|
|
}
|