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) }