410 lines
12 KiB
Go
410 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/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)
|
|
}
|