Busiest hours and the heatmap count the different players on T or CT in each hour, open segments included, instead of averaging player-hours, so one player for part of an hour is 1, not 0.38. Their descriptions end in the span covered (Last 30 days, or Since <date>). Side-by-side chart panels match heights, and Players per day notes its busiest day. Dropped the page descriptions on Bans, Gags & mutes, Staff and Playtime, and the Verified badges on the staff Overview scoreboard and drawer header; the Verification page and the drawer's Verification section still show it. The GroupVerify DB test now checks the staff drawer, since the public site says nothing about verification.
441 lines
14 KiB
Go
441 lines
14 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
|
|
}
|
|
if v, err := s.playerVerify(ctx, id, steamid); err != nil {
|
|
internal(w, r, err)
|
|
return
|
|
} else if v != nil {
|
|
resp["verify"] = v
|
|
}
|
|
if pt := s.playerPlaytime(r, steamid, s.ptStaff(ctx, id)); pt != nil {
|
|
resp["playtime"] = pt
|
|
}
|
|
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"},
|
|
// Our own flags: admin_overrides.json (in the server repo) moves these commands onto them, and
|
|
// our SimpleAdmin fork checks @zio/penalties itself. Each command also accepts @css/root.
|
|
// @zio/webpanel is the panel's own: it shows the Staff & ranks page (adminStaff).
|
|
{"@zio/menu", "Admin menu", "css_admin and css_adminhelp"},
|
|
{"@zio/who", "Who", "css_who and css_players"},
|
|
{"@zio/penalties", "Others' penalties", "css_penalties on another player"},
|
|
{"@zio/psay", "Private message", "css_psay"},
|
|
{"@zio/csay", "Centre messages", "css_csay, css_hsay and css_cssay"},
|
|
{"@zio/listenall", "Listen to all", "css_listenall and css_adminvoice"},
|
|
{"@zio/team", "Move teams", "css_team"},
|
|
{"@zio/rename", "Rename", "css_rename"},
|
|
{"@zio/banip", "IP bans", "css_banip"},
|
|
{"@zio/restart", "Restart game", "css_rr, css_rg, css_restart and css_restartgame"},
|
|
{"@zio/vote", "Start votes", "css_vote"},
|
|
{"@zio/plugins", "Plugins", "css_plugins and css"},
|
|
{"@zio/webpanel", "Staff page", "See the Staff & ranks page in this panel"},
|
|
{"@zio/verify", "Verification overrides", "Give and remove GroupVerify overrides in this panel"},
|
|
{"@css/root", "Root", "Every permission, and managing ranks and staff"},
|
|
}
|
|
|
|
func (s *Server) adminStaff(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
|
if !id.Has("@zio/webpanel") {
|
|
fail(w, http.StatusForbidden, "You need @zio/webpanel to see the staff page.")
|
|
return
|
|
}
|
|
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"), "canRcon": id.Has("@css/rcon")}
|
|
|
|
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)
|
|
}
|