simpleadmin-web 0.1.0: web panel for CS2-SimpleAdmin

This commit is contained in:
Astra 2026-09-25 20:08:14 +01:00
commit 4f38daf0e6
31 changed files with 6870 additions and 0 deletions

713
internal/web/actions.go Normal file
View file

@ -0,0 +1,713 @@
package web
import (
"errors"
"fmt"
"log/slog"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"time"
"git.zio.sh/cs2/simpleadmin-web/internal/rcon"
"git.zio.sh/cs2/simpleadmin-web/internal/steam"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
)
// Commands sent over RCON run as SimpleAdmin's "Console", which skips every permission, immunity
// and length check SimpleAdmin would apply to a player. Each handler here enforces them instead,
// using the same flags SimpleAdmin checks.
type result struct {
OK bool `json:"ok"`
Message string `json:"message"`
}
func done(w http.ResponseWriter, msg string) {
writeJSON(w, http.StatusOK, result{OK: true, Message: msg})
}
func (s *Server) limits() (maxBan, maxMute int) {
maxBan, maxMute = s.cfg.MaxBanDuration, s.cfg.MaxMuteDuration
if s.sacfg != nil {
if v, err := s.sacfg.Values(); err == nil {
maxBan, maxMute = v["MaxBanDuration"].(int), v["MaxMuteDuration"].(int)
}
}
return
}
func fmtMinutes(m int) string {
switch {
case m%(60*24) == 0:
d := m / (60 * 24)
if d == 1 {
return "1 day"
}
return strconv.Itoa(d) + " days"
case m%60 == 0:
return strconv.Itoa(m/60) + " h"
}
return strconv.Itoa(m) + " min"
}
var commCommands = map[string]struct{ add, lift, label string }{
"GAG": {"css_addgag", "css_ungag", "gag"},
"MUTE": {"css_addmute", "css_unmute", "mute"},
"SILENCE": {"css_addsilence", "css_unsilence", "silence"},
}
// consoleRejected spots SimpleAdmin's replies to a command it refused.
func consoleRejected(out string) string {
for _, bad := range []string{"Invalid SteamID64", "Invalid player", "Unknown command", "not found"} {
if strings.Contains(out, bad) {
return strings.TrimSpace(out)
}
}
return ""
}
type penaltyReq struct {
Kind string `json:"kind"` // ban, comm, warn
Type string `json:"type"` // GAG, MUTE, SILENCE
SteamID string `json:"steamid"`
Duration int `json:"duration"`
Reason string `json:"reason"`
}
func (s *Server) addPenalty(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req penaltyReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if !steam.ValidID(req.SteamID) {
fail(w, http.StatusBadRequest, "Enter the player's SteamID64 (17 digits, starting 7656119).")
return
}
reason := rcon.Arg(req.Reason, 200)
if reason == "" {
fail(w, http.StatusBadRequest, "Give a reason. The player sees it.")
return
}
if req.Duration < 0 || req.Duration > 60*24*365*10 {
fail(w, http.StatusBadRequest, "Pick a length between 1 minute and 10 years, or permanent.")
return
}
if !id.CanTarget(x, req.SteamID) {
if req.SteamID == id.SteamID {
fail(w, http.StatusForbidden, "You can't punish yourself from the panel.")
} else {
fail(w, http.StatusForbidden, "This player's rank has higher immunity than yours.")
}
return
}
maxBan, maxMute := s.limits()
var table, cmd, what string
switch req.Kind {
case "ban":
if !id.Has("@css/ban") {
fail(w, http.StatusForbidden, "Your rank can't ban players.")
return
}
if (req.Duration == 0 || req.Duration > maxBan) && !id.Has("@css/permban") {
fail(w, http.StatusForbidden, fmt.Sprintf("Your rank can ban for up to %s, and not permanently.", fmtMinutes(maxBan)))
return
}
table, what = "sa_bans", "Ban"
cmd = fmt.Sprintf(`css_addban %s %d "%s"`, req.SteamID, req.Duration, reason)
case "comm":
c, ok := commCommands[req.Type]
if !ok {
fail(w, http.StatusBadRequest, "Choose gag, mute or silence.")
return
}
if !id.Has("@css/chat") {
fail(w, http.StatusForbidden, "Your rank can't gag or mute players.")
return
}
if (req.Duration == 0 || req.Duration > maxMute) && !id.Has("@css/permmute") {
fail(w, http.StatusForbidden, fmt.Sprintf("Your rank can block for up to %s, and not permanently.", fmtMinutes(maxMute)))
return
}
table, what = "sa_mutes", strings.ToUpper(c.label[:1])+c.label[1:]
cmd = fmt.Sprintf(`%s %s %d "%s"`, c.add, req.SteamID, req.Duration, reason)
case "warn":
if !id.Has("@css/kick") {
fail(w, http.StatusForbidden, "Your rank can't warn players.")
return
}
p, online := s.poll.Refresh().Find(req.SteamID)
if !online {
fail(w, http.StatusConflict, "Warnings can only be given to players on the server.")
return
}
table, what = "sa_warns", "Warning"
cmd = fmt.Sprintf(`css_warn #%d %d "%s"`, p.UserID, req.Duration, reason)
default:
fail(w, http.StatusBadRequest, "Unknown penalty.")
return
}
ctx := r.Context()
before := s.st.MaxID(ctx, table)
out, err := s.rc.Exec(cmd)
if err != nil {
slog.Warn("rcon failed, writing penalty to the database", "err", err)
if req.Kind == "warn" {
fail(w, http.StatusBadGateway, "The game server isn't answering, and warnings can only be given in-game.")
return
}
np := store.NewPenalty{SteamID: req.SteamID, Name: s.st.LatestName(ctx, req.SteamID), Type: req.Type,
AdminSteamID: id.SteamID, AdminName: id.Name, Reason: reason, Duration: req.Duration}
if req.Kind == "ban" {
err = s.st.InsertBan(ctx, np)
} else {
err = s.st.InsertComm(ctx, np)
}
if err != nil {
internal(w, r, err)
return
}
done(w, what+" saved. The game server isn't answering, so it applies when the player next joins.")
return
}
if msg := consoleRejected(out); msg != "" {
fail(w, http.StatusBadGateway, "SimpleAdmin refused: "+msg)
return
}
commType := ""
if req.Kind == "comm" {
commType = req.Type
}
claimed := s.st.ClaimConsoleRow(ctx, table, before, req.SteamID, commType, id.SteamID, id.Name)
go s.poll.Refresh()
slog.Info("penalty issued", "admin", id.SteamID, "kind", req.Kind, "type", req.Type, "player", req.SteamID, "minutes", req.Duration)
if claimed == 0 {
done(w, what+" sent to the server, but its record hasn't appeared yet. Check the list in a moment.")
return
}
done(w, what+" applied.")
}
type liftReq struct {
Kind string `json:"kind"` // ban, comm
ID int64 `json:"id"`
Reason string `json:"reason"`
}
func (s *Server) liftPenalty(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req liftReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
reason := rcon.Arg(req.Reason, 200)
if reason == "" {
reason = "Lifted from the web panel"
}
table := "sa_bans"
switch req.Kind {
case "ban":
if !id.Has("@css/unban") {
fail(w, http.StatusForbidden, "Your rank can't unban players.")
return
}
case "comm":
if !id.Has("@css/chat") {
fail(w, http.StatusForbidden, "Your rank can't lift gags or mutes.")
return
}
table = "sa_mutes"
default:
fail(w, http.StatusBadRequest, "Unknown penalty.")
return
}
ctx := r.Context()
steamid, commType, err := s.st.PenaltyOwner(ctx, table, req.ID)
if errors.Is(err, store.ErrNotFound) {
fail(w, http.StatusNotFound, "That record no longer exists.")
return
} else if err != nil {
internal(w, r, err)
return
}
if !steam.ValidID(steamid) {
fail(w, http.StatusConflict, "This record has no SteamID, so it can only be lifted in-game.")
return
}
ids := s.st.ActivePenaltyIDs(ctx, table, steamid, commType)
if !slices.Contains(ids, req.ID) {
fail(w, http.StatusConflict, "That's already expired or lifted.")
return
}
var cmd, what string
if req.Kind == "ban" {
cmd, what = fmt.Sprintf(`css_unban %s "%s"`, steamid, reason), "Unbanned"
} else {
c := commCommands[commType]
cmd, what = fmt.Sprintf(`%s %s "%s"`, c.lift, steamid, reason), "Lifted the "+c.label
}
if _, err := s.rc.Exec(cmd); err != nil {
slog.Warn("rcon failed, lifting in the database", "err", err)
if _, err := s.st.LiftDirect(ctx, table, steamid, commType, id.RowID, reason); err != nil {
internal(w, r, err)
return
}
done(w, what+". The game server isn't answering, so it applies when the player next joins.")
return
}
s.st.ClaimLift(ctx, table, ids, id.RowID)
go s.poll.Refresh()
slog.Info("penalty lifted", "admin", id.SteamID, "kind", req.Kind, "player", steamid)
done(w, what+".")
}
func (s *Server) kick(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req struct {
SteamID string `json:"steamid"`
Reason string `json:"reason"`
}
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if !id.Has("@css/kick") {
fail(w, http.StatusForbidden, "Your rank can't kick players.")
return
}
if !steam.ValidID(req.SteamID) || !id.CanTarget(x, req.SteamID) {
fail(w, http.StatusForbidden, "You can't kick this player.")
return
}
p, online := s.poll.Refresh().Find(req.SteamID)
if !online {
fail(w, http.StatusConflict, "That player isn't on the server.")
return
}
reason := rcon.Arg(req.Reason, 200)
if reason == "" {
reason = "Kicked by an admin"
}
if _, err := s.rc.Exec(fmt.Sprintf(`css_kick #%d "%s"`, p.UserID, reason)); err != nil {
fail(w, http.StatusBadGateway, "The game server isn't answering.")
return
}
slog.Info("kick", "admin", id.SteamID, "player", req.SteamID)
go s.poll.Refresh()
done(w, "Kicked "+p.Name+".")
}
// console runs a command that needs no target, after a permission check.
func (s *Server) console(w http.ResponseWriter, id *Identity, perm, cmd, ok string) {
if !id.Has(perm) {
fail(w, http.StatusForbidden, "Your rank can't do that.")
return
}
if _, err := s.rc.Exec(cmd); err != nil {
fail(w, http.StatusBadGateway, "The game server isn't answering.")
return
}
slog.Info("console", "admin", id.SteamID, "cmd", cmd)
go s.poll.Refresh()
done(w, ok)
}
func (s *Server) say(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req struct {
Message string `json:"message"`
}
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
msg := rcon.Arg(req.Message, 200)
if msg == "" {
fail(w, http.StatusBadRequest, "Write a message first.")
return
}
s.console(w, id, "@css/chat", fmt.Sprintf(`css_say "%s"`, msg), "Sent.")
}
var mapName = regexp.MustCompile(`^(ws:)?[A-Za-z0-9_\-]{1,64}$`)
func (s *Server) changeMap(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req struct {
Map string `json:"map"`
}
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if !mapName.MatchString(req.Map) {
fail(w, http.StatusBadRequest, "Enter a map name like de_mirage, or ws:<workshop id>.")
return
}
s.console(w, id, "@css/changemap", "css_map "+req.Map, "Changing map to "+req.Map+".")
}
func (s *Server) restartRound(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
s.console(w, id, "@css/generic", "css_rr", "Restarting the match.")
}
func (s *Server) reloadAdmins(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
s.staff.invalidate()
s.console(w, id, "@css/root", "css_reloadadmins", "Reloaded admins.")
}
// ---------------------------------------------------------------- Ranks and staff
var (
groupName = regexp.MustCompile(`^#[A-Za-z0-9_./\-]{1,63}$`)
flagName = regexp.MustCompile(`^@[a-z0-9_./\-]{1,63}$`)
)
type groupReq struct {
Name string `json:"name"`
Immunity int `json:"immunity"`
Flags []string `json:"flags"`
}
func (req *groupReq) validate(id *Identity) string {
if !groupName.MatchString(req.Name) {
return "Rank names start with # and use letters, numbers and _ . / -, like #rank/mod."
}
if req.Immunity < 0 || req.Immunity > 9999 {
return "Immunity must be between 0 and 9999."
}
if req.Immunity > id.Immunity {
return fmt.Sprintf("You can't create a rank with more immunity than your own (%d).", id.Immunity)
}
if len(req.Flags) == 0 {
return "Give the rank at least one permission."
}
seen := map[string]bool{}
for _, f := range req.Flags {
if !flagName.MatchString(f) {
return "Permissions look like @css/kick."
}
seen[f] = true
}
req.Flags = req.Flags[:0]
for f := range seen {
req.Flags = append(req.Flags, f)
}
slices.Sort(req.Flags)
return ""
}
// afterStaffChange tells SimpleAdmin to reload admins, and says so if the server is down.
func (s *Server) afterStaffChange(w http.ResponseWriter, ok string) {
s.staff.invalidate()
if _, err := s.rc.Exec("css_reloadadmins"); err != nil {
done(w, ok+" The game server isn't answering, so it applies when SimpleAdmin next loads admins.")
return
}
done(w, ok)
}
func (s *Server) createGroup(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !id.Has("@css/root") {
fail(w, http.StatusForbidden, "Only Root can manage ranks.")
return
}
var req groupReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if msg := req.validate(id); msg != "" {
fail(w, http.StatusBadRequest, msg)
return
}
if _, err := s.st.CreateGroup(r.Context(), req.Name, req.Immunity, req.Flags); err != nil {
fail(w, http.StatusConflict, err.Error())
return
}
slog.Info("group created", "admin", id.SteamID, "group", req.Name)
s.afterStaffChange(w, "Created "+req.Name+".")
}
func (s *Server) groupByID(x *staffIndex, raw string) *groupRef {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil
}
for i := range x.groups {
if x.groups[i].ID == n {
return &groupRef{ID: n, Name: x.groups[i].Name, Immunity: x.groups[i].Immunity, Flags: x.groups[i].Flags}
}
}
return nil
}
type groupRef struct {
ID int64
Name string
Immunity int
Flags []string
}
// ownsGroup reports whether the signed-in admin holds this rank.
func ownsGroup(x *staffIndex, id *Identity, name string) bool {
a, ok := x.bySteam[id.SteamID]
return ok && slices.Contains(a.Groups, name)
}
func (s *Server) updateGroup(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !id.Has("@css/root") {
fail(w, http.StatusForbidden, "Only Root can manage ranks.")
return
}
g := s.groupByID(x, r.PathValue("id"))
if g == nil {
fail(w, http.StatusNotFound, "That rank no longer exists.")
return
}
if g.Immunity > id.Immunity {
fail(w, http.StatusForbidden, "This rank has more immunity than yours.")
return
}
var req groupReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if msg := req.validate(id); msg != "" {
fail(w, http.StatusBadRequest, msg)
return
}
if ownsGroup(x, id, g.Name) {
a := x.bySteam[id.SteamID]
if slices.Contains(g.Flags, "@css/root") && !slices.Contains(req.Flags, "@css/root") && !slices.Contains(a.Flags, "@css/root") {
fail(w, http.StatusForbidden, "That would take Root away from your own rank and lock you out. Ask another Root admin.")
return
}
}
if err := s.st.UpdateGroup(r.Context(), g.ID, req.Name, req.Immunity, req.Flags); err != nil {
if errors.Is(err, store.ErrNotFound) {
fail(w, http.StatusNotFound, "That rank no longer exists.")
return
}
fail(w, http.StatusConflict, err.Error())
return
}
slog.Info("group updated", "admin", id.SteamID, "group", req.Name, "flags", req.Flags, "immunity", req.Immunity)
s.afterStaffChange(w, "Saved "+req.Name+".")
}
func (s *Server) deleteGroup(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !id.Has("@css/root") {
fail(w, http.StatusForbidden, "Only Root can manage ranks.")
return
}
g := s.groupByID(x, r.PathValue("id"))
if g == nil {
fail(w, http.StatusNotFound, "That rank no longer exists.")
return
}
if g.Immunity > id.Immunity {
fail(w, http.StatusForbidden, "This rank has more immunity than yours.")
return
}
if ownsGroup(x, id, g.Name) {
fail(w, http.StatusForbidden, "You can't delete your own rank.")
return
}
if err := s.st.DeleteGroup(r.Context(), g.ID); err != nil {
internal(w, r, err)
return
}
slog.Info("group deleted", "admin", id.SteamID, "group", g.Name)
s.afterStaffChange(w, "Deleted "+g.Name+". Its members are regular players now.")
}
type staffReq struct {
SteamID string `json:"steamid"`
Group string `json:"group"`
Days int `json:"days"`
}
// checkStaffTarget applies the rules for giving, changing or removing someone's rank.
func (s *Server) checkStaffTarget(w http.ResponseWriter, id *Identity, x *staffIndex, steamid string) bool {
if !id.Has("@css/root") {
fail(w, http.StatusForbidden, "Only Root can manage staff.")
return false
}
if !steam.ValidID(steamid) {
fail(w, http.StatusBadRequest, "Enter the player's SteamID64 (17 digits, starting 7656119).")
return false
}
if steamid == id.SteamID {
fail(w, http.StatusForbidden, "You can't change your own rank. Ask another Root admin.")
return false
}
if x.immunity(steamid) > id.Immunity {
fail(w, http.StatusForbidden, "This person has more immunity than you.")
return false
}
return true
}
func (s *Server) applyRank(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex, req staffReq, verb string) {
grp, ok := x.byName[req.Group]
if !ok {
fail(w, http.StatusBadRequest, "Pick a rank that exists.")
return
}
if grp.Immunity > id.Immunity {
fail(w, http.StatusForbidden, "You can't give a rank with more immunity than your own.")
return
}
if req.Days < 0 || req.Days > 3650 {
fail(w, http.StatusBadRequest, "Expiry must be between 1 and 3650 days, or never.")
return
}
var ends *time.Time
if req.Days > 0 {
t := time.Now().Add(time.Duration(req.Days) * 24 * time.Hour)
ends = &t
}
// SimpleAdmin builds its admin list keyed by name, so an admin must never have an empty one.
name := s.st.LatestName(r.Context(), req.SteamID)
if name == "" {
name = req.SteamID
}
if err := s.st.SetAdminGroup(r.Context(), req.SteamID, name, req.Group, grp.Immunity, ends); err != nil {
internal(w, r, err)
return
}
slog.Info("rank set", "admin", id.SteamID, "player", req.SteamID, "group", req.Group, "days", req.Days)
s.afterStaffChange(w, verb+" "+name+" to "+s.site.rank(req.Group, x.groupIndex(req.Group)).Label+".")
}
func (s *Server) addStaff(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req staffReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if !s.checkStaffTarget(w, id, x, req.SteamID) {
return
}
s.applyRank(w, r, id, x, req, "Added")
}
func (s *Server) setStaffRank(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req staffReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
req.SteamID = r.PathValue("steamid")
if !s.checkStaffTarget(w, id, x, req.SteamID) {
return
}
if req.Group == "" {
s.removeStaff(w, r, id, x)
return
}
s.applyRank(w, r, id, x, req, "Moved")
}
func (s *Server) removeStaff(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
steamid := r.PathValue("steamid")
if !s.checkStaffTarget(w, id, x, steamid) {
return
}
if err := s.st.RemoveAdmin(r.Context(), steamid); err != nil {
internal(w, r, err)
return
}
slog.Info("staff removed", "admin", id.SteamID, "player", steamid)
s.afterStaffChange(w, "Removed their rank.")
}
// ---------------------------------------------------------------- Settings
func (s *Server) saveCvars(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !id.Has("@css/cvar") {
fail(w, http.StatusForbidden, "Your rank can't change server settings.")
return
}
var req map[string]any
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
var cmds []string
for key, raw := range req {
i := slices.IndexFunc(cvars, func(c cvarDef) bool { return c.Key == key })
if i < 0 {
fail(w, http.StatusBadRequest, key+" can't be changed here.")
return
}
def := cvars[i]
var val string
switch def.Type {
case "bool":
b, ok := raw.(bool)
if !ok {
fail(w, http.StatusBadRequest, def.Label+" must be on or off.")
return
}
val = "0"
if b {
val = "1"
}
case "int":
f, ok := raw.(float64)
if !ok || f != float64(int(f)) || f < -1 || f > 100000 {
fail(w, http.StatusBadRequest, def.Label+" must be a whole number.")
return
}
val = strconv.Itoa(int(f))
default:
str, ok := raw.(string)
if !ok {
fail(w, http.StatusBadRequest, def.Label+" must be text.")
return
}
val = rcon.Arg(str, 128)
if key == "hostname" && val == "" {
fail(w, http.StatusBadRequest, "The server needs a name.")
return
}
}
cmds = append(cmds, fmt.Sprintf(`%s "%s"`, key, val))
}
for _, c := range cmds {
if _, err := s.rc.Exec(c); err != nil {
fail(w, http.StatusBadGateway, "The game server isn't answering.")
return
}
}
slog.Info("cvars changed", "admin", id.SteamID, "cmds", cmds)
go s.poll.Refresh()
done(w, "Saved. These reset when the server restarts; put permanent values in server.cfg.")
}
func (s *Server) saveSimpleAdmin(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !id.Has("@css/root") {
fail(w, http.StatusForbidden, "Only Root can change SimpleAdmin's settings.")
return
}
if s.sacfg == nil {
fail(w, http.StatusNotFound, "The panel wasn't given CS2-SimpleAdmin.json, so these can't be changed here.")
return
}
var req map[string]any
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if err := s.sacfg.Update(req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
slog.Info("simpleadmin config changed", "admin", id.SteamID, "changes", req)
done(w, "Saved. SimpleAdmin reads its config when it loads, so this applies after the next server restart.")
}

410
internal/web/admin.go Normal file
View 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)
}

View file

@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Staff panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="app">
<nav class="nav" id="nav" aria-label="Main">
<a class="brand" href="#overview">
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span class="brand-name site-name"></span>
</a>
<div class="server-pick" id="server-pick">
<b>Loading…</b>
<small>&nbsp;</small>
</div>
<ul id="nav-links"></ul>
<a class="public-link" href="/">View the public site</a>
<div class="me" id="me">
<span class="avatar">?</span>
<div><b>&nbsp;</b><small>&nbsp;</small></div>
<button class="btn sm ghost" type="button" id="signout">Sign out</button>
</div>
</nav>
<div>
<div class="mobile-bar">
<button class="btn sm ghost" type="button" id="menu-btn" aria-controls="nav" aria-expanded="false">Menu</button>
<span class="brand-name site-name" style="font-size:19px"></span>
</div>
<main id="view" tabindex="-1"></main>
</div>
</div>
<div id="drawer-root"></div>
<dialog id="modal"></dialog>
<div id="toast-root" aria-live="polite"></div>
<script src="/static/common.js"></script>
<script src="/static/admin.js"></script>
</body>
</html>

View file

@ -0,0 +1,835 @@
// Staff panel: hash-routed pages over the /api/admin endpoints. Buttons only appear for what the
// signed-in rank may do, but the server checks every action again.
const PAGES = [
{ id: "overview", label: "Overview" },
{ id: "players", label: "Players" },
{ id: "bans", label: "Bans" },
{ id: "comms", label: "Gags & mutes" },
{ id: "staff", label: "Staff & ranks" },
{ id: "settings", label: "Server settings" },
];
const state = {
page: "overview",
banFilter: "active",
commFilter: "active",
playerFilter: "online",
query: "",
num: 1,
group: null,
me: null,
counts: {},
};
const can = (flag) => !!state.me && (state.me.perms.includes("@css/root") || state.me.perms.includes(flag));
/* ---------- Overview ---------- */
const ACTIVITY_VERB = {
ban: "banned", warn: "warned", unban: "unbanned",
comm: { GAG: "gagged", MUTE: "muted", SILENCE: "silenced" },
unmute: { GAG: "lifted the gag on", MUTE: "lifted the mute on", SILENCE: "lifted the silence on" },
};
const ACTIVITY_COLOR = { ban: "var(--accent)", comm: "var(--amber)", warn: "var(--faint)", unban: "var(--green)", unmute: "var(--green)" };
function activityLine(a) {
const verb = typeof ACTIVITY_VERB[a.kind] === "object" ? ACTIVITY_VERB[a.kind][a.type] : ACTIVITY_VERB[a.kind];
const len = (a.kind === "ban" || a.kind === "comm") ? (a.duration === 0 ? " permanently" : ` for ${fmtDuration(a.duration)}`) : "";
return `<li style="--c:${ACTIVITY_COLOR[a.kind]}"><div><p><b>${esc(a.admin)}</b> ${verb} <b>${esc(a.name || a.steamid)}</b>${len}</p>
<small>${fmtAgo(a.at)}${a.reason ? `, ${esc(a.reason)}` : ""}</small></div></li>`;
}
function liveHeader(s) {
if (!s.online) {
return `<div class="live"><div><p class="live-host">The panel can't reach the game server, so live status and in-game actions are off. Bans and blocks still save to the database.</p><div class="live-map">Offline</div></div></div>`;
}
return `<div class="live">
<div>
<p class="live-host"><span class="dot"></span>${esc(s.hostname)}${s.warmup ? ", warmup" : ""}</p>
<div class="live-map">${esc(s.map)}</div>
</div>
<div class="live-stats">
<div><b>${s.count}<span class="faint">/${s.maxPlayers}</span></b><small>Players</small></div>
<div><b><span class="team t score">${s.scoreT}</span> <span class="faint">:</span> <span class="team ct score">${s.scoreCt}</span></b><small>T vs CT</small></div>
<div><b>${s.staffOnline}</b><small>Staff online</small></div>
</div>
</div>`;
}
function tagsFor(p) {
return (p.tags || []).map((t) => {
if (t.startsWith("WARN:")) {
const n = t.slice(5);
return `<span class="tag" title="Active warnings">${esc(n)} warn${n === "1" ? "" : "s"}</span>`;
}
return `<span class="tag amber" title="${COMM_HELP[t]}">${COMM_LABEL[t]}</span>`;
}).join(" ");
}
function onlineTable(players) {
const list = players.filter((p) => !p.bot).sort((a, b) => (a.team === b.team ? b.kills - a.kills : b.team - a.team));
if (!list.length) return '<div class="empty">Nobody is on the server.</div>';
const teamName = { 2: "T", 3: "CT" };
return `<table>
<thead><tr><th>Player</th><th class="hide-sm">Rank</th><th class="num">K / D</th><th class="num hide-sm">Ping</th><th><span class="sr">Actions</span></th></tr></thead>
<tbody>
${list.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="team ${p.team === 3 ? "ct" : p.team === 2 ? "t" : ""}">${teamName[p.team] || "SPEC"}</span><div><b>${esc(p.name)}</b> ${tagsFor(p)}</div></div></td>
<td class="hide-sm">${rankTag(p.rank)}</td>
<td class="num">${p.kills} / ${p.deaths}</td>
<td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</td>
<td><div class="row-actions">${p.canTarget ? `
${can("@css/chat") ? `<button class="btn sm ghost" type="button" data-act="comm" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Gag</button>` : ""}
${can("@css/kick") ? `<button class="btn sm ghost" type="button" data-act="kick" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Kick</button>` : ""}
${can("@css/ban") ? `<button class="btn sm danger" type="button" data-act="ban" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Ban</button>` : ""}` : ""}
</div></td>
</tr>`).join("")}
</tbody>
</table>`;
}
async function overview() {
const v = $("#view");
let d;
try { d = await api("/api/admin/overview"); } catch (err) { v.innerHTML = errorBox(err); return; }
if (state.page !== "overview") return;
state.counts = { bans: d.activeBans, comms: d.activeComms, staff: d.staffCount, players: d.server.online ? `${d.server.count}/${d.server.maxPlayers}` : "" };
renderNav();
setServerPick(d.server);
const s = d.server;
v.innerHTML = `
${liveHeader(s)}
<div class="grid-2">
<section class="panel">
<div class="panel-head">
<h2>On the server</h2>
<div style="display:flex;gap:8px;flex-wrap:wrap">
${can("@css/changemap") ? '<button class="btn sm" type="button" data-act="map">Change map</button>' : ""}
${can("@css/chat") ? '<button class="btn sm" type="button" data-act="say">Message all</button>' : ""}
</div>
</div>
<div class="table-wrap">${s.online ? onlineTable(s.players) : '<div class="empty">Offline.</div>'}</div>
</section>
<div class="stack">
<section class="panel">
<div class="panel-head"><h2>Recent actions</h2><a class="muted" href="#bans" style="font-size:14px">All bans</a></div>
${d.activity.length ? `<ul class="feed">${d.activity.map(activityLine).join("")}</ul>` : '<div class="empty">Nothing yet.</div>'}
</section>
<section class="panel">
<div class="panel-head"><h2>Ending soon</h2><p>${d.activeBans} bans, ${d.activeComms} blocks active</p></div>
${d.expiring.length ? `<ul class="expiring">${d.expiring.map((p) => `<li><span><a href="#" data-player="${esc(p.steamid)}"><b>${esc(p.name || p.steamid)}</b></a> <span class="muted">${p.kind === "ban" ? "ban" : COMM_LABEL[p.type].toLowerCase()}</span></span><span class="muted">${fmtLeft(served(p).left)}</span></li>`).join("")}</ul>` : '<div class="empty">No timed bans or blocks.</div>'}
</section>
</div>
</div>`;
}
function setServerPick(s) {
const el = $("#server-pick");
el.innerHTML = s.online
? `<b><span class="dot"></span>${esc(s.hostname)}</b><small>${esc(s.map)}, ${s.count}/${s.maxPlayers} players</small>`
: `<b>Server offline</b><small>Live status unavailable</small>`;
}
/* ---------- Players ---------- */
function players() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Players</h1><p class="sub">Everyone SimpleAdmin has seen join, with their rank and record. Search by name, SteamID64${can("@css/showip") ? " or IP address" : ""}.</p></div>
</div>
<section class="panel">
<div class="toolbar">
${searchBox("Search players", state.query)}
${segmented("playerFilter", state.playerFilter, [["online", "Online"], ["ranked", "Ranked"], ["all", "All"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
function playersTable(list) {
if (!list.items.length) {
if (state.query) return `<div class="empty">No players match "${esc(state.query)}". Try a SteamID64 or part of a name.</div>`;
return `<div class="empty">${state.playerFilter === "online" ? "Nobody is on the server." : "No players yet."}</div>`;
}
return `<table>
<thead><tr><th>Player</th><th>Rank</th><th class="hide-sm">Record</th><th class="hide-sm">Last seen</th></tr></thead>
<tbody>
${list.items.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="avatar">${initial(p.name)}</span><div><b>${esc(p.name || p.steamid)}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
<td>${rankTag(p.rank)}</td>
<td class="hide-sm">
${p.banned ? '<span class="tag red">Banned</span>' : ""}
${p.bans ? `<span class="tag">${p.bans} ban${p.bans > 1 ? "s" : ""}</span>` : ""}
${p.comms ? `<span class="tag">${p.comms} block${p.comms > 1 ? "s" : ""}</span>` : ""}
${p.warns ? `<span class="tag">${p.warns} warn${p.warns > 1 ? "s" : ""}</span>` : ""}
${!p.bans && !p.comms && !p.warns ? '<span class="faint">Clean</span>' : ""}
</td>
<td class="hide-sm">${p.online ? '<span class="tag green">Online</span>' : p.lastSeen ? `<span class="muted">${fmtAgo(p.lastSeen)}</span>` : '<span class="faint">Never joined</span>'}</td>
</tr>`).join("")}
</tbody>
</table>${pager(list)}`;
}
/* ---------- Bans and comms ---------- */
function punishTable(list, kind) {
if (!list.items.length) {
return `<div class="empty">${state.query ? `Nothing matches "${esc(state.query)}".` : kind === "ban" ? "No bans in this view." : "No gags or mutes in this view."}</div>`;
}
const canLift = kind === "ban" ? can("@css/unban") : can("@css/chat");
const canAdd = kind === "ban" ? can("@css/ban") : can("@css/chat");
return `<table>
<thead><tr>
<th>Player</th>
${kind === "comm" ? "<th>Type</th>" : ""}
<th>Reason</th>
<th>Length</th>
<th class="hide-sm">Issued</th>
<th>Status</th>
<th><span class="sr">Actions</span></th>
</tr></thead>
<tbody>
${list.items.map((p) => `<tr>
<td><div class="who"><div><b>${p.steamid ? `<a href="#" data-player="${esc(p.steamid)}">${esc(p.name || p.steamid)}</a>` : esc(p.name || "Unknown")}</b><span class="sid">${esc(p.steamid)}${p.ip ? `<br>${esc(p.ip)}` : ""}</span></div></div></td>
${kind === "comm" ? `<td><span class="tag amber" title="${COMM_HELP[p.type]}">${COMM_LABEL[p.type]}</span></td>` : ""}
<td>${esc(p.reason)}${p.liftedBy || p.liftReason ? `<br><small class="muted">Lifted${p.liftedBy ? ` by ${esc(p.liftedBy)}` : ""}${p.liftReason ? `: ${esc(p.liftReason)}` : ""}</small>` : ""}</td>
<td>${termCell(p)}</td>
<td class="hide-sm"><span>${fmtDate(p.created)}</span><br><small class="muted">by ${esc(p.admin || "Console")}</small></td>
<td>${statusTag(p)}</td>
<td><div class="row-actions">
${p.status === "active" && canLift && p.steamid ? `<button class="btn sm danger" type="button" data-act="lift" data-kind="${kind}" data-id="${p.id}" data-name="${esc(p.name)}" data-type="${esc(p.type || "")}">${kind === "ban" ? "Unban" : "Lift"}</button>` : ""}
${p.status !== "active" && canAdd && p.steamid ? `<button class="btn sm ghost" type="button" data-act="${kind === "ban" ? "ban" : "comm"}" data-sid="${esc(p.steamid)}" data-type="${esc(p.type || "")}" data-reason="${esc(p.reason)}">Reapply</button>` : ""}
</div></td>
</tr>`).join("")}
</tbody>
</table>${pager(list)}`;
}
function bans() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Bans</h1><p class="sub">The bar under each length shows how much of the ban has been served. Striped bars never expire.</p></div>
${can("@css/ban") ? `<button class="btn primary" type="button" data-act="ban">${ICON.plus}Add ban</button>` : ""}
</div>
<section class="panel">
<div class="toolbar">
${searchBox(`Search by name, SteamID, ${can("@css/showip") ? "IP, " : ""}reason or admin`, state.query)}
${segmented("banFilter", state.banFilter, [["active", "Active"], ["expired", "Expired"], ["lifted", "Lifted"], ["all", "All"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
function comms() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Gags & mutes</h1><p class="sub">A gag blocks text chat, a mute blocks voice, and a silence blocks both.</p></div>
${can("@css/chat") ? `<button class="btn primary" type="button" data-act="comm">${ICON.plus}Add gag or mute</button>` : ""}
</div>
<section class="panel">
<div class="toolbar">
${searchBox("Search by name, SteamID, reason or admin", state.query)}
${segmented("commFilter", state.commFilter, [["active", "Active"], ["GAG", "Gags"], ["MUTE", "Mutes"], ["SILENCE", "Silences"], ["expired", "Expired"], ["all", "All"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
async function loadResults() {
const box = $("#results");
if (!box) return;
let path, status, render;
if (state.page === "players") {
path = "/api/admin/players"; render = playersTable;
} else if (state.page === "bans") {
path = "/api/admin/bans"; status = state.banFilter; render = (l) => punishTable(l, "ban");
} else {
path = "/api/admin/comms"; status = state.commFilter; render = (l) => punishTable(l, "comm");
}
const params = { status, q: state.query, page: state.num, filter: state.page === "players" ? state.playerFilter : undefined };
const want = JSON.stringify([state.page, params]);
loadResults.want = want;
try {
const list = await api(path + qs(params));
if (loadResults.want !== want) return;
box.innerHTML = render(list);
} catch (err) {
box.innerHTML = errorBox(err);
}
}
/* ---------- Staff & ranks ---------- */
async function staff() {
const v = $("#view");
let d;
try { d = await api("/api/admin/staff"); } catch (err) { v.innerHTML = errorBox(err); return; }
if (state.page !== "staff") return;
staff.data = d;
const manage = d.canManage;
if (!d.groups.some((g) => g.id === state.group)) state.group = d.groups[0]?.id ?? null;
const g = d.groups.find((x) => x.id === state.group);
const ladderItem = (x) => `<li><button type="button" data-group="${x.id}" aria-current="${x.id === state.group}" style="--c:${esc(x.color)}">
<i></i><span><b>${esc(x.label)}</b><small>${x.members.length} ${x.members.length === 1 ? "member" : "members"}</small></span><span class="imm">${x.immunity}</span>
</button></li>`;
const staffGroups = d.groups.filter((x) => !x.supporter);
const supporters = d.groups.filter((x) => x.supporter);
const memberRow = (p, groupName) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="avatar">${initial(p.name)}</span><div><b>${esc(p.name)}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
<td class="hide-sm">${p.online ? '<span class="tag green">Online</span>' : p.lastSeen ? `<span class="muted">Seen ${fmtAgo(p.lastSeen)}</span>` : '<span class="faint">Never joined</span>'}</td>
<td class="muted hide-sm">${p.ends ? `Until ${fmtDate(p.ends)}` : "No expiry"}</td>
<td><div class="row-actions">${manage && p.steamid !== d.me ? `<button class="btn sm ghost" type="button" data-act="rank" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}" data-group="${esc(groupName)}">Change rank</button>` : ""}</div></td>
</tr>`;
const allFlags = [...d.flags];
if (g) for (const f of g.flags) if (!allFlags.some((x) => x.flag === f)) allFlags.push({ flag: f, label: f, help: "Custom permission" });
const root = g && g.flags.includes("@css/root");
v.innerHTML = `
<div class="page-head">
<div><h1>Staff & ranks</h1><p class="sub">Ranks are listed by immunity. Staff can only act on players whose immunity isn't higher than their own.${manage ? "" : " Only Root can change ranks and staff."}</p></div>
${manage ? `<div style="display:flex;gap:8px">
<button class="btn" type="button" data-act="new-group">${ICON.plus}New rank</button>
<button class="btn primary" type="button" data-act="add-staff">${ICON.plus}Add staff</button>
</div>` : ""}
</div>
${d.groups.length ? `<div class="ranks-layout">
<section class="panel">
<div class="panel-head"><h2>Ranks</h2><p>Immunity</p></div>
<ul class="ladder">
${staffGroups.map(ladderItem).join("")}
${supporters.length ? `<li class="sep">Supporter ranks</li>${supporters.map(ladderItem).join("")}` : ""}
</ul>
</section>
<div class="stack">
<section class="panel">
<div class="panel-head">
<h2><span class="rank" style="--c:${esc(g.color)};font:inherit"><i></i>${esc(g.label)}</span></h2>
<span class="sid">${esc(g.name)}</span>
</div>
<form class="panel-body" id="group-form" style="display:grid;gap:18px" data-id="${g.id}">
<div class="form-row">
<label class="field"><span>SimpleAdmin name</span><input class="input" name="name" value="${esc(g.name)}" ${manage ? "" : "readonly"} required><small>The display name and colour come from the panel's site config.</small></label>
<label class="field"><span>Immunity</span><input class="input" name="immunity" type="number" min="0" max="9999" value="${g.immunity}" ${manage ? "" : "readonly"} required><small>0 to 9999</small></label>
</div>
<div>
<h3 style="margin-bottom:10px">Permissions</h3>
<div class="flag-grid">
${allFlags.map((f) => {
const on = g.flags.includes(f.flag);
const inherited = root && f.flag !== "@css/root";
return `<label class="flag"><input type="checkbox" name="flag" value="${esc(f.flag)}" ${on || inherited ? "checked" : ""} ${inherited || !manage ? "disabled" : ""} ${on && inherited ? 'data-kept="1"' : ""}>
<span><b>${esc(f.label)}</b><small>${inherited ? "Included by Root" : esc(f.help)}</small><br><small class="faint">${esc(f.flag)}</small></span></label>`;
}).join("")}
</div>
</div>
${manage ? `<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap">
<button class="btn danger" type="button" data-act="delete-group" data-id="${g.id}" data-name="${esc(g.label)}">Delete rank</button>
<button class="btn primary" type="submit">Save rank</button>
</div>` : ""}
</form>
</section>
<section class="panel">
<div class="panel-head"><h2>Members</h2><p>${g.members.length} ${g.members.length === 1 ? "person" : "people"}</p></div>
<div class="table-wrap">
${g.members.length ? `<table><tbody>${g.members.map((p) => memberRow(p, g.name)).join("")}</tbody></table>`
: `<div class="empty">Nobody has this rank yet.${manage ? " Use Add staff to give it to someone." : ""}</div>`}
</div>
</section>
${d.unranked.length ? `<section class="panel">
<div class="panel-head"><h2>Admins without a rank</h2><p>Permissions set directly in SimpleAdmin</p></div>
<div class="table-wrap"><table><tbody>${d.unranked.map((p) => memberRow(p, "")).join("")}</tbody></table></div>
</section>` : ""}
</div>
</div>` : `<div class="empty">SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}</div>`}`;
}
/* ---------- Settings ---------- */
async function settings() {
const v = $("#view");
let d;
try { d = await api("/api/admin/settings"); } catch (err) { v.innerHTML = errorBox(err); return; }
if (state.page !== "settings") return;
const sa = d.simpleadmin;
const cvarControl = (c) => {
if (c.type === "bool") return `<button class="switch" type="button" role="switch" aria-checked="${c.value}" aria-label="${esc(c.label)}" data-cvar="${esc(c.key)}"></button>`;
if (c.type === "int") return `<input class="input" type="number" value="${esc(c.value)}" data-cvar="${esc(c.key)}">`;
return `<input class="input" value="${esc(c.value)}" data-cvar="${esc(c.key)}" ${c.key === "sv_password" ? 'placeholder="No password"' : ""}>`;
};
const saControl = (s) => {
const val = sa.values[s.key];
const dis = d.canRoot ? "" : "disabled";
if (s.type === "bool") return `<button class="switch" type="button" role="switch" aria-checked="${val}" aria-label="${esc(s.label)}" data-sa="${esc(s.key)}" ${dis}></button>`;
if (s.type === "select") return `<select class="input" data-sa="${esc(s.key)}" ${dis}>${s.options.map((o) => `<option value="${o.value}" ${o.value === val ? "selected" : ""}>${esc(o.label)}</option>`).join("")}</select>`;
return `<input class="input" type="number" min="${s.min || 0}" value="${val}" data-sa="${esc(s.key)}" ${dis}>`;
};
const row = (label, help, key, control) => `<div class="setting">
<div><b>${esc(label)}</b>${help ? `<p>${esc(help)}</p>` : ""}${key ? `<code>${esc(key)}</code>` : ""}</div>
<div class="control">${control}</div>
</div>`;
v.innerHTML = `
<div class="page-head">
<div><h1>Server settings</h1><p class="sub">Server changes go live over RCON. SimpleAdmin's settings are saved to its config file.</p></div>
</div>
<div class="settings">
<section class="panel" id="cvars">
<div class="panel-head"><h2>Server</h2><p>Live over RCON. Resets when the server restarts.</p></div>
${!d.canCvar ? '<div class="empty">Your rank can\'t change server settings.</div>'
: !d.server.online ? '<div class="empty">The game server isn\'t answering, so these can\'t be read or changed right now.</div>'
: (d.cvars || []).map((c) => row(c.label, c.help, c.key, cvarControl(c))).join("")}
</section>
<section class="panel" id="sa">
<div class="panel-head"><h2>Punishments</h2><p>${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}</p></div>
${!sa.available ? '<div class="empty">The panel wasn\'t given CS2-SimpleAdmin.json (SAW_SA_CONFIG), so these can\'t be shown or changed here.</div>'
: sa.error ? `<div class="empty">${esc(sa.error)}</div>`
: sa.settings.map((s) => row(s.label, s.help, s.key, saControl(s))).join("")}
</section>
<section class="panel">
<div class="panel-head"><h2>Connections</h2></div>
${row("Database", "SimpleAdmin's MySQL database.", "", d.database.connected ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not connected</span>')}
${row("Game server", d.server.online ? "Live status through the WebPanelBridge plugin." : d.server.error || "", "", d.server.online ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not answering</span>')}
</section>
<section class="panel">
<div class="panel-head"><h2>Maintenance</h2><p>Each of these asks first.</p></div>
${can("@css/root") ? row("Reload admins", "Re-read ranks and staff from the database. Runs css_reloadadmins.", "", '<button class="btn" type="button" data-act="reload-admins">Reload admins</button>') : ""}
${can("@css/generic") ? row("Restart the match", "Restarts the game on the current map. Runs css_rr.", "", '<button class="btn" type="button" data-act="restart">Restart match</button>') : ""}
${can("@css/changemap") ? row("Change map", "Switch to another map, or a Workshop map with ws:<id>.", "", '<button class="btn" type="button" data-act="map">Change map</button>') : ""}
</section>
</div>
<div class="savebar" id="savebar" hidden>
<span>You have unsaved changes.</span>
<div style="display:flex;gap:8px"><button class="btn ghost" type="button" data-act="discard">Discard</button><button class="btn primary" type="button" data-act="save-settings">Save changes</button></div>
</div>`;
}
function markDirty() { const b = $("#savebar"); if (b) b.hidden = false; }
async function saveSettings() {
const cvars = {};
const sa = {};
document.querySelectorAll("[data-cvar].dirty").forEach((el) => {
const key = el.dataset.cvar;
if (el.classList.contains("switch")) cvars[key] = el.getAttribute("aria-checked") === "true";
else cvars[key] = el.type === "number" ? Number(el.value) : el.value;
});
document.querySelectorAll("[data-sa].dirty").forEach((el) => {
const key = el.dataset.sa;
if (el.classList.contains("switch")) sa[key] = el.getAttribute("aria-checked") === "true";
else sa[key] = Number(el.value);
});
const msgs = [];
try {
if (Object.keys(cvars).length) msgs.push((await api("/api/admin/settings/cvars", { method: "POST", body: cvars })).message);
if (Object.keys(sa).length) msgs.push((await api("/api/admin/settings/simpleadmin", { method: "POST", body: sa })).message);
} catch (err) {
toast(err.message);
return;
}
toast(msgs.join(" ") || "Nothing to save.");
settings();
}
/* ---------- Player drawer ---------- */
async function openPlayer(sid) {
$("#drawer-root").innerHTML = `<div class="scrim" data-close></div><aside class="drawer" role="dialog" aria-modal="true">${loading()}</aside>`;
let d;
try { d = await api(`/api/admin/players/${encodeURIComponent(sid)}`); } catch (err) {
$(".drawer").innerHTML = errorBox(err);
return;
}
const p = d.player;
const name = p.name || p.steamid;
const history = [
...d.bans.map((b) => ({ ...b, label: "Ban" })),
...d.comms.map((c) => ({ ...c, label: COMM_LABEL[c.type] })),
...d.warns.map((w) => ({ ...w, label: "Warning" })),
].sort((a, b) => new Date(b.created) - new Date(a.created));
const t = d.canTarget;
const btn = (act, label, perm, extra = "", cls = "") => can(perm)
? `<button class="btn ${cls}" type="button" data-act="${act}" data-sid="${esc(p.steamid)}" data-name="${esc(name)}" ${extra} ${t ? "" : "disabled"}>${label}</button>` : "";
const manage = can("@css/root") && p.steamid !== state.me.steamid;
$("#drawer-root").innerHTML = `
<div class="scrim" data-close></div>
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
<div class="drawer-head">
<span class="avatar lg">${initial(name)}</span>
<div><h2>${esc(name)}</h2><div>${rankTag(d.rank)} ${d.online ? '<span class="tag green" style="margin-left:6px">Online</span>' : ""}</div></div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
<section>
${t ? "" : `<p class="muted" style="margin:0 0 12px">${p.steamid === state.me.steamid ? "This is you." : "This player's immunity is higher than yours, so you can't act on them."}</p>`}
<div class="action-grid">
${btn("ban", "Ban", "@css/ban", "", "danger")}
${btn("comm", "Gag", "@css/chat", 'data-type="GAG"')}
${btn("comm", "Mute", "@css/chat", 'data-type="MUTE"')}
${btn("comm", "Silence", "@css/chat", 'data-type="SILENCE"')}
${d.online ? btn("warn", "Warn", "@css/kick") : ""}
${d.online ? btn("kick", "Kick", "@css/kick") : ""}
</div>
</section>
<section>
<h3>Details</h3>
<dl class="kv">
<dt>SteamID64</dt><dd>${esc(p.steamid)}</dd>
<dt>Profile</dt><dd><a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></dd>
${p.ips ? `<dt>Known IPs</dt><dd>${p.ips.map(esc).join(", ") || '<span class="faint">None</span>'}</dd>` : ""}
<dt>Known names</dt><dd>${(p.names || []).map(esc).join(", ") || '<span class="faint">None</span>'}</dd>
<dt>Last seen</dt><dd>${d.online ? "On the server now" : p.lastSeen ? fmtAgo(p.lastSeen) : "Never joined"}</dd>
${d.immunity ? `<dt>Immunity</dt><dd>${d.immunity}</dd>` : ""}
${d.adminEnds ? `<dt>Rank expires</dt><dd>${fmtDate(d.adminEnds)}</dd>` : ""}
</dl>
</section>
${manage ? `<section>
<h3>Rank</h3>
<div style="display:flex;gap:8px">
<button class="btn" type="button" data-act="rank" data-sid="${esc(p.steamid)}" data-name="${esc(name)}" data-group="${esc(d.rank?.name || "")}" ${t ? "" : "disabled"}>${d.rank ? "Change rank" : "Give a rank"}</button>
</div>
</section>` : ""}
<section>
<h3>History</h3>
${history.length ? `<ul class="history">${history.map((h) => `<li>
<div class="top"><span><b>${h.label}</b> <span class="muted">by ${esc(h.admin || "Console")}, ${fmtAgo(h.created)}</span></span>${statusTag(h)}</div>
<div>${esc(h.reason)}</div>
${h.kind === "warn" ? "" : termCell(h)}
</li>`).join("")}</ul>` : '<p class="muted" style="margin:0">No bans, gags, mutes or warnings on record.</p>'}
</section>
</aside>`;
$(".drawer .close").focus();
}
/* ---------- Dialogs ---------- */
const DURATIONS = [[30, "30 min"], [60, "1 hour"], [1440, "1 day"], [10080, "1 week"], [43200, "30 days"], [0, "Permanent"]];
const REASONS = {
ban: ["Cheating", "Ban evasion", "Toxicity", "Griefing", "Exploiting"],
comm: ["Spam", "Toxicity", "Mic spam", "Soundboard", "Advertising"],
warn: ["Toxicity", "Spam", "Griefing", "Ignoring staff"],
};
function punishDialog(kind, { sid = "", name = "", type = "GAG", reason = "" } = {}) {
const title = { ban: "Ban player", comm: "Gag or mute player", warn: "Warn player" }[kind];
const submit = { ban: "Ban player", comm: "Apply block", warn: "Warn player" }[kind];
const perm = kind === "ban" ? "@css/permban" : "@css/permmute";
openModal(`
<form method="dialog" data-submit="penalty" data-kind="${kind}">
<div class="modal-head"><h2>${title}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body">
<label class="field"><span>Player</span>
<input class="input" name="steamid" required pattern="7656119[0-9]{10}" value="${esc(sid)}" placeholder="SteamID64, like 76561198012345678" ${sid ? "readonly" : ""}>
<small>${name ? esc(name) : kind === "warn" ? "Warnings only work on players who are on the server." : "Works for players who are offline too; it applies when they next join."}</small>
</label>
${kind === "comm" ? `<div class="field"><span>Block</span>${segmented("commType", type || "GAG", [["GAG", "Text (gag)"], ["MUTE", "Voice (mute)"], ["SILENCE", "Both (silence)"]])}</div>` : ""}
<div class="field"><span>${kind === "warn" ? "Warning lasts" : "Length"}</span>
<div class="chips" data-chips="dur">${DURATIONS.map(([v, l], i) => `<button type="button" class="chip-btn" data-value="${v}" aria-pressed="${i === (kind === "ban" ? 2 : kind === "warn" ? 4 : 1)}">${l}</button>`).join("")}</div>
${kind === "warn" ? "" : `<small>${can(perm) ? "Your rank can use any length." : "Your rank's limit is set in SimpleAdmin's settings; permanent isn't allowed."}</small>`}
</div>
<div class="field"><span>Reason</span>
<div class="chips" data-chips="reason">${REASONS[kind].map((r) => `<button type="button" class="chip-btn" data-value="${esc(r)}" aria-pressed="false">${esc(r)}</button>`).join("")}</div>
<textarea class="input" name="reason" id="reason" required maxlength="200" placeholder="Shown to the player and in the log">${esc(reason)}</textarea>
</div>
</div>
<div class="modal-foot">
<button class="btn ghost" type="button" data-modal-close>Cancel</button>
<button class="btn primary" type="submit">${submit}</button>
</div>
</form>`);
}
async function rankDialog(sid, name, current) {
if (!staff.data) {
try { staff.data = await api("/api/admin/staff"); } catch (err) { toast(err.message); return; }
}
const groups = staff.data.groups;
const opts = (groups || []).map((g) => `<option value="${esc(g.name)}" ${g.name === current ? "selected" : ""}>${esc(g.label)} (${g.immunity})</option>`).join("");
openModal(`
<form method="dialog" data-submit="rank" data-sid="${esc(sid)}" data-had="${esc(current)}">
<div class="modal-head"><h2>${current ? "Change rank" : "Add staff"}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body">
<label class="field"><span>Player</span>
<input class="input" name="steamid" required pattern="7656119[0-9]{10}" value="${esc(sid)}" placeholder="SteamID64, like 76561198012345678" ${sid ? "readonly" : ""}>
${name ? `<small>${esc(name)}</small>` : ""}
</label>
<label class="field"><span>Rank</span><select class="input" name="group">${current ? '<option value="">No rank (remove from staff)</option>' : ""}${opts}</select></label>
<label class="field"><span>Expires</span><select class="input" name="days"><option value="0">Never</option><option value="7">In 7 days</option><option value="30">In 30 days</option><option value="90">In 90 days</option></select><small>Trial ranks usually expire.</small></label>
</div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Save</button></div>
</form>`);
}
function newGroupDialog() {
openModal(`
<form method="dialog" data-submit="new-group">
<div class="modal-head"><h2>New rank</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body">
<label class="field"><span>SimpleAdmin name</span><input class="input" name="name" required pattern="#[A-Za-z0-9_./\\-]{1,63}" placeholder="#rank/mod"><small>Starts with #. Add a display name and colour for it in the site config.</small></label>
<label class="field"><span>Immunity</span><input class="input" name="immunity" type="number" min="0" max="${state.me.immunity}" value="0" required><small>Up to your own immunity, ${state.me.immunity}.</small></label>
<p class="muted" style="margin:0">It starts with Generic admin only. Pick its other permissions after creating it.</p>
</div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Create rank</button></div>
</form>`);
}
function textDialog(kind) {
const cfg = {
map: { title: "Change map", label: "Map", placeholder: "de_mirage, or ws:<workshop id>", submit: "Change map", pattern: "(ws:)?[A-Za-z0-9_\\-]{1,64}" },
say: { title: "Message everyone", label: "Message", placeholder: "Shown in chat to everyone on the server", submit: "Send", pattern: ".{1,200}" },
kick: { title: "Kick player", label: "Reason", placeholder: "Shown to the player", submit: "Kick", pattern: ".{0,200}" },
}[kind];
return cfg;
}
function inputDialog(kind, data = {}) {
const c = textDialog(kind);
openModal(`
<form method="dialog" data-submit="${kind}" data-sid="${esc(data.sid || "")}">
<div class="modal-head"><h2>${c.title}${data.name ? `: ${esc(data.name)}` : ""}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body"><label class="field"><span>${c.label}</span><input class="input" name="value" pattern="${c.pattern}" placeholder="${esc(c.placeholder)}" ${kind === "kick" ? "" : "required"} maxlength="200"></label></div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">${c.submit}</button></div>
</form>`);
}
function confirmDialog(title, body, label, run) {
confirmDialog.run = run;
openModal(`
<form method="dialog" data-submit="confirm">
<div class="modal-head"><h2>${title}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body"><p style="margin:0">${body}</p>${confirmDialog.reason ? '<label class="field"><span>Reason</span><input class="input" name="reason" maxlength="200" placeholder="Optional, kept in the log"></label>' : ""}</div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">${label}</button></div>
</form>`);
}
function openModal(html) {
const m = $("#modal");
m.innerHTML = html;
m.showModal();
}
// act runs an API call, shows its message, and refreshes the page and any open drawer.
async function act(path, method, body) {
try {
const res = await api(path, { method, body });
toast(res.message || "Done.");
} catch (err) {
toast(err.message);
return false;
}
const drawerSid = $(".drawer [data-sid]")?.dataset.sid;
route();
if (drawerSid) openPlayer(drawerSid);
return true;
}
/* ---------- Routing & events ---------- */
const RENDER = { overview, players, bans, comms, staff, settings };
function renderNav() {
const c = state.counts;
const count = { players: c.players, bans: c.bans, comms: c.comms, staff: c.staff };
$("#nav-links").innerHTML = PAGES.map((p) => `<li><a class="link" href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}${count[p.id] !== undefined && count[p.id] !== "" ? `<span class="count">${esc(count[p.id])}</span>` : ""}</a></li>`).join("");
}
function route() {
const page = location.hash.slice(1);
state.page = RENDER[page] ? page : "overview";
renderNav();
$("#nav").classList.remove("open");
$("#menu-btn").setAttribute("aria-expanded", "false");
if (!$("#view").innerHTML) $("#view").innerHTML = loading();
RENDER[state.page]();
}
window.addEventListener("hashchange", () => { state.query = ""; state.num = 1; $("#view").innerHTML = loading(); route(); window.scrollTo(0, 0); });
const search = debounce(() => loadResults(), 250);
document.addEventListener("input", (e) => {
const t = e.target;
if (t.id === "q") { state.query = t.value; state.num = 1; search(); return; }
if (t.matches("[data-cvar], [data-sa]")) { t.classList.add("dirty"); markDirty(); }
});
document.addEventListener("change", (e) => {
if (e.target.matches("select[data-sa]")) { e.target.classList.add("dirty"); markDirty(); }
});
document.addEventListener("click", (e) => {
const t = e.target;
if (t.closest("#menu-btn")) {
const open = $("#nav").classList.toggle("open");
$("#menu-btn").setAttribute("aria-expanded", String(open));
return;
}
if (t.closest("#signout")) {
api("/auth/logout", { method: "POST" }).finally(() => { location.href = "/"; });
return;
}
const seg = t.closest("[data-seg] button");
if (seg) {
const group = seg.parentElement;
group.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg)));
const key = group.dataset.seg;
if (key in state) { state[key] = seg.dataset.value; state.num = 1; loadResults(); }
return;
}
const pg = t.closest("[data-page]");
if (pg && !pg.disabled) { state.num = Number(pg.dataset.page); loadResults(); return; }
const chip = t.closest("[data-chips] .chip-btn");
if (chip) {
const box = chip.parentElement;
if (box.dataset.chips === "reason") $("#reason").value = chip.dataset.value;
box.querySelectorAll(".chip-btn").forEach((b) => b.setAttribute("aria-pressed", String(b === chip)));
return;
}
const sw = t.closest(".switch");
if (sw && !sw.disabled) {
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
sw.classList.add("dirty");
markDirty();
return;
}
const grp = t.closest("[data-group]:not([data-act])");
if (grp) { state.group = Number(grp.dataset.group); staff(); return; }
if (t.closest("[data-close]")) { closeDrawer(); return; }
if (t.closest("[data-modal-close]")) { $("#modal").close(); return; }
const a = t.closest("[data-act]");
if (a) {
e.preventDefault();
e.stopPropagation();
if (a.disabled) return;
const d = a.dataset;
switch (d.act) {
case "ban": return punishDialog("ban", { sid: d.sid, name: d.name, reason: d.reason });
case "comm": return punishDialog("comm", { sid: d.sid, name: d.name, type: d.type, reason: d.reason });
case "warn": return punishDialog("warn", { sid: d.sid, name: d.name });
case "kick": return inputDialog("kick", { sid: d.sid, name: d.name });
case "map": return inputDialog("map");
case "say": return inputDialog("say");
case "lift": {
const what = d.kind === "ban" ? "Unban" : `Lift ${COMM_LABEL[d.type].toLowerCase()}`;
confirmDialog.reason = true;
return confirmDialog(what, d.kind === "ban"
? `Unban <b>${esc(d.name || "this player")}</b>? They can join again straight away. This lifts every active ban on their SteamID.`
: `Lift the ${COMM_LABEL[d.type].toLowerCase()} on <b>${esc(d.name || "this player")}</b>?`,
d.kind === "ban" ? "Unban" : "Lift", (form) => act("/api/admin/penalties/lift", "POST", { kind: d.kind, id: Number(d.id), reason: form.reason?.value || "" }));
}
case "rank": return rankDialog(d.sid, d.name, d.group);
case "add-staff": return rankDialog("", "", "");
case "new-group": return newGroupDialog();
case "delete-group":
confirmDialog.reason = false;
return confirmDialog("Delete rank", `Delete <b>${esc(d.name)}</b>? Its members become regular players.`, "Delete rank",
() => act(`/api/admin/groups/${d.id}`, "DELETE"));
case "reload-admins":
confirmDialog.reason = false;
return confirmDialog("Reload admins", "Re-read all ranks and staff from the database?", "Reload admins", () => act("/api/admin/reload-admins", "POST", {}));
case "restart":
confirmDialog.reason = false;
return confirmDialog("Restart the match", "Restart the game on the current map? The current match ends.", "Restart match", () => act("/api/admin/restart", "POST", {}));
case "save-settings": return saveSettings();
case "discard": return settings();
}
return;
}
const row = t.closest("[data-player]");
if (row) { e.preventDefault(); openPlayer(row.dataset.player); }
});
document.addEventListener("submit", async (e) => {
const form = e.target;
e.preventDefault();
const kind = form.dataset.submit;
const f = form.elements;
if (form.id === "group-form") {
const flags = [...form.querySelectorAll('input[name="flag"]')].filter((i) => i.checked && (!i.disabled || i.dataset.kept)).map((i) => i.value);
await act(`/api/admin/groups/${form.dataset.id}`, "PUT", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags });
return;
}
$("#modal").close();
switch (kind) {
case "penalty": {
const dur = form.querySelector('[data-chips="dur"] [aria-pressed="true"]');
const type = form.querySelector('[data-seg="commType"] [aria-pressed="true"]')?.dataset.value;
await act("/api/admin/penalties", "POST", {
kind: form.dataset.kind, type: type || "", steamid: f.steamid.value.trim(),
duration: Number(dur?.dataset.value ?? 60), reason: f.reason.value,
});
break;
}
case "rank": {
const sid = f.steamid.value.trim();
const group = f.group.value;
if (form.dataset.had) await act(`/api/admin/staff/${encodeURIComponent(sid)}`, "PUT", { group, days: Number(f.days.value) });
else await act("/api/admin/staff", "POST", { steamid: sid, group, days: Number(f.days.value) });
break;
}
case "new-group":
if (await act("/api/admin/groups", "POST", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags: ["@css/generic"] })) {
state.group = null;
}
break;
case "map": await act("/api/admin/map", "POST", { map: f.value.value.trim() }); break;
case "say": await act("/api/admin/say", "POST", { message: f.value.value }); break;
case "kick": await act("/api/admin/kick", "POST", { steamid: form.dataset.sid, reason: f.value.value }); break;
case "confirm": await confirmDialog.run?.(f); break;
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("#drawer-root").innerHTML && !$("#modal").open) closeDrawer();
});
(async () => {
try {
state.me = await api("/api/me");
} catch (err) {
$("#view").innerHTML = errorBox(err);
return;
}
if (!state.me.staff) { location.href = "/admin"; return; }
const name = state.me.site;
const i = name.indexOf(".");
document.querySelectorAll(".site-name").forEach((el) => {
el.innerHTML = i > 0 ? `${esc(name.slice(0, i))}<span>.</span>${esc(name.slice(i + 1))}` : esc(name);
});
$("#me").innerHTML = `<span class="avatar">${initial(state.me.name)}</span>
<div><b>${esc(state.me.name)}</b><small>${esc(state.me.rank?.label || "Staff")}</small></div>
<button class="btn sm ghost" type="button" id="signout">Sign out</button>`;
// Fill the sidebar counts once, even when starting on another page.
api("/api/admin/overview").then((d) => {
state.counts = { bans: d.activeBans, comms: d.activeComms, staff: d.staffCount, players: d.server.online ? `${d.server.count}/${d.server.maxPlayers}` : "" };
setServerPick(d.server);
renderNav();
}).catch(() => {});
route();
})();

View file

@ -0,0 +1,152 @@
// Helpers shared by the staff panel (admin.js) and the public site (public.js).
const $ = (sel, root = document) => root.querySelector(sel);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const MIN = 60 * 1000;
const ICON = {
search: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3"/></svg>',
plus: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>',
};
// api calls the panel's JSON API and throws an Error carrying the server's message on failure.
async function api(path, { method = "GET", body } = {}) {
const opts = { method, headers: { "X-Requested-With": "simpleadmin-web" }, credentials: "same-origin" };
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(path, opts);
} catch {
throw new Error("Couldn't reach the panel. Check your connection.");
}
let data = null;
try { data = await res.json(); } catch { /* empty or non-JSON body */ }
if (!res.ok) throw new Error(data?.error || `The panel answered ${res.status}.`);
return data;
}
function qs(params) {
const u = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") u.set(k, v);
const s = u.toString();
return s ? `?${s}` : "";
}
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
function fmtDuration(min) {
if (min === 0) return "Permanent";
if (min < 60) return `${min} min`;
if (min < 1440) return `${Math.round(min / 60)} h`;
const d = Math.round(min / 1440);
return d === 1 ? "1 day" : `${d} days`;
}
function fmtAgo(iso) {
const m = Math.round((Date.now() - new Date(iso).getTime()) / MIN);
if (m < 1) return "now";
if (m < 60) return `${m} min ago`;
if (m < 1440) return `${Math.round(m / 60)} h ago`;
const d = Math.round(m / 1440);
return d === 1 ? "yesterday" : `${d} days ago`;
}
function fmtLeft(min) {
if (min < 60) return `${Math.max(1, Math.round(min))} min left`;
if (min < 1440) return `${Math.round(min / 60)} h left`;
return `${Math.round(min / 1440)} d left`;
}
function fmtDate(iso) {
return new Date(iso).toLocaleString(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
}
// Minutes served and left on a timed penalty. With SimpleAdmin's online-only time mode, "passed"
// counts minutes served.
function served(p) {
if (p.passed != null && p.status === "active" && !p.ends) return { done: p.passed, left: p.duration - p.passed };
const start = new Date(p.created).getTime();
const end = p.ends ? new Date(p.ends).getTime() : start + p.duration * MIN;
const done = (Math.min(Date.now(), end) - start) / MIN;
return { done, left: (end - Date.now()) / MIN };
}
function rankTag(rank) {
if (!rank) return '<span class="faint">Player</span>';
return `<span class="rank" style="--c:${esc(rank.color)}"><i></i>${esc(rank.label)}</span>`;
}
function initial(name) { return esc(String(name || "").replace(/[^a-z0-9]/gi, "").charAt(0).toUpperCase() || "?"); }
// Time served against the full term: the bar under each length.
function termCell(p) {
const cls = ["term", p.kind === "comm" ? "comm" : ""];
let right;
let pct = 100;
if (p.duration === 0) {
cls.push("perm");
right = p.status === "lifted" ? "Lifted" : "Never expires";
if (p.status === "lifted") cls.push("lifted");
} else {
const s = served(p);
pct = Math.max(0, Math.min(100, (s.done / p.duration) * 100));
if (p.status === "active") right = fmtLeft(s.left);
else if (p.status === "expired") { right = "Served"; cls.push("done"); }
else { right = "Lifted"; cls.push("lifted"); }
}
const bar = p.duration === 0 ? "" : `<span style="width:${pct.toFixed(1)}%"></span>`;
return `<div class="${cls.join(" ")}">
<div class="term-top"><b>${fmtDuration(p.duration)}</b><span class="muted">${right}</span></div>
<div class="term-bar" role="img" aria-label="${esc(right)}">${bar}</div>
</div>`;
}
function statusTag(p) {
if (p.status === "active") return '<span class="tag red">Active</span>';
if (p.status === "expired") return '<span class="tag">Expired</span>';
return '<span class="tag green">Lifted</span>';
}
const COMM_LABEL = { GAG: "Gag", MUTE: "Mute", SILENCE: "Silence" };
const COMM_HELP = { GAG: "Text chat blocked", MUTE: "Voice blocked", SILENCE: "Text and voice blocked" };
function segmented(name, value, options) {
return `<div class="segmented" role="group" data-seg="${name}">
${options.map(([v, l]) => `<button type="button" data-value="${v}" aria-pressed="${v === value}">${l}</button>`).join("")}
</div>`;
}
function searchBox(placeholder, value) {
return `<label class="search">${ICON.search}<input type="search" id="q" placeholder="${esc(placeholder)}" value="${esc(value)}" aria-label="${esc(placeholder)}" autocomplete="off"></label>`;
}
function pager(list) {
const pages = Math.max(1, Math.ceil(list.total / list.pageSize));
if (pages <= 1) return "";
return `<div class="pager">
<button class="btn sm ghost" type="button" data-page="${list.page - 1}" ${list.page <= 1 ? "disabled" : ""}>Newer</button>
<span class="muted">Page ${list.page} of ${pages}</span>
<button class="btn sm ghost" type="button" data-page="${list.page + 1}" ${list.page >= pages ? "disabled" : ""}>Older</button>
</div>`;
}
function loading() { return '<div class="empty">Loading…</div>'; }
function errorBox(err) { return `<div class="empty">${esc(err.message)}</div>`; }
function closeDrawer() { $("#drawer-root").innerHTML = ""; }
function toast(msg) {
const root = $("#toast-root");
root.innerHTML = `<div class="toast">${esc(msg)}</div>`;
clearTimeout(toast.t);
toast.t = setTimeout(() => (root.innerHTML = ""), 3600);
}
const steamProfile = (sid) => `https://steamcommunity.com/profiles/${encodeURIComponent(sid)}`;

View file

@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bans and staff</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body class="public">
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="#server">
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span class="brand-name" id="site-name"></span>
</a>
<nav aria-label="Main"><ul class="tabs" id="nav-links"></ul></nav>
<a class="btn sm ghost signin" href="/admin"><span class="hide-xs">Staff sign in</span><span class="show-xs">Sign in</span></a>
</div>
</header>
<main id="view" class="public-main" tabindex="-1"></main>
<div id="drawer-root"></div>
<div id="toast-root" aria-live="polite"></div>
<script src="/static/common.js"></script>
<script src="/static/public.js"></script>
</body>
</html>

View file

@ -0,0 +1,264 @@
// Public, read-only site: the live server, bans, gags/mutes and staff. The API leaves out admin
// names and IPs; nothing here can change anything.
const PAGES = [
{ id: "server", label: "Server" },
{ id: "bans", label: "Bans" },
{ id: "comms", label: "Gags & mutes" },
{ id: "staff", label: "Staff" },
];
const state = { page: "server", banFilter: "active", commFilter: "active", query: "", num: 1 };
function siteName(title) {
const i = title.indexOf(".");
return i > 0 ? `${esc(title.slice(0, i))}<span>.</span>${esc(title.slice(i + 1))}` : esc(title);
}
/* ---------- Server ---------- */
async function server() {
const v = $("#view");
let s;
try { s = await api("/api/public/server"); } catch (err) { v.innerHTML = errorBox(err); return; }
if (state.page !== "server") return;
if (!s.online) {
v.innerHTML = `<div class="live"><div><p class="live-host">The server is offline or restarting.</p><div class="live-map">Offline</div></div></div>`;
return;
}
const side = (team, name, score) => {
const list = s.players.filter((p) => p.team === team).sort((a, b) => b.kills - a.kills);
return `<section class="panel side ${team === 3 ? "ct" : "t"}">
<div class="panel-head"><h2>${name}</h2><b class="side-score">${score}</b></div>
${list.length ? `<table>
<thead><tr><th>Player</th><th class="num">Kills</th><th class="num">Deaths</th><th class="num hide-sm">Ping</th></tr></thead>
<tbody>
${list.map((p) => `<tr ${p.bot ? "" : `class="clickable" data-player="${esc(p.steamid)}"`}>
<td><div class="who"><div><b>${esc(p.name)}</b>${p.bot ? '<span class="sid">Bot</span>' : p.rank ? rankTag(p.rank) : ""}</div></div></td>
<td class="num">${p.kills}</td>
<td class="num">${p.deaths}</td>
<td class="num hide-sm muted">${p.bot ? "" : p.ping}</td>
</tr>`).join("")}
</tbody>
</table>` : '<div class="empty">Nobody on this team.</div>'}
</section>`;
};
const spectators = s.players.filter((p) => p.team !== 2 && p.team !== 3 && !p.bot);
v.innerHTML = `
<div class="live">
<div>
<p class="live-host"><span class="dot"></span>${esc(s.hostname)}${s.warmup ? ", warmup" : ""}</p>
<div class="live-map">${esc(s.map)}</div>
</div>
<div class="live-stats">
<div><b>${s.count}<span class="faint">/${s.maxPlayers}</span></b><small>Players</small></div>
<div><b><span class="team t score">${s.scoreT}</span> <span class="faint">:</span> <span class="team ct score">${s.scoreCt}</span></b><small>T vs CT</small></div>
<div><b>${s.staffOnline}</b><small>Staff online</small></div>
</div>
${s.address ? `<div class="live-join">
<a class="btn primary" href="steam://connect/${esc(s.address)}">Join server</a>
<small class="muted">or type <b>connect ${esc(s.address)}</b> in the console</small>
</div>` : ""}
</div>
<div class="sides">
${side(3, "Counter-Terrorists", s.scoreCt)}
${side(2, "Terrorists", s.scoreT)}
</div>
${spectators.length ? `<p class="muted" style="margin-top:16px">Spectating: ${spectators.map((p) => esc(p.name)).join(", ")}</p>` : ""}`;
}
/* ---------- Bans and comms ---------- */
function publicTable(rows, kind) {
if (!rows.length) {
return `<div class="empty">${state.query ? `Nobody matches "${esc(state.query)}". Try the full SteamID64.` : "Nothing in this view."}</div>`;
}
return `<table>
<thead><tr>
<th>Player</th>
${kind === "comm" ? "<th>Type</th>" : ""}
<th>Reason</th>
<th>Length</th>
<th class="hide-sm">Issued</th>
<th>Status</th>
</tr></thead>
<tbody>
${rows.map((p) => `<tr ${p.steamid ? `class="clickable" data-player="${esc(p.steamid)}"` : ""}>
<td><div class="who"><div><b>${esc(p.name || "Unknown")}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
${kind === "comm" ? `<td><span class="tag amber" title="${COMM_HELP[p.type]}">${COMM_LABEL[p.type]}</span></td>` : ""}
<td>${esc(p.reason)}</td>
<td>${termCell(p)}</td>
<td class="hide-sm">${fmtDate(p.created)}</td>
<td>${statusTag(p)}</td>
</tr>`).join("")}
</tbody>
</table>`;
}
async function loadResults() {
const box = $("#results");
if (!box) return;
const bans = state.page === "bans";
const path = bans ? "/api/public/bans" : "/api/public/comms";
const status = bans ? state.banFilter : state.commFilter;
const want = `${state.page}|${status}|${state.query}|${state.num}`;
loadResults.want = want;
try {
const list = await api(path + qs({ status, q: state.query, page: state.num }));
if (loadResults.want !== want) return;
box.innerHTML = publicTable(list.items, bans ? "ban" : "comm") + pager(list);
const lead = $("#lead");
if (lead && bans) lead.textContent = `${list.active} ${list.active === 1 ? "player is" : "players are"} banned right now. Search for a name or SteamID64 to check a ban and when it ends.`;
} catch (err) {
box.innerHTML = errorBox(err);
}
}
function bans() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Bans</h1><p class="sub" id="lead">Search for a name or SteamID64 to check a ban and when it ends.</p></div>
</div>
<section class="panel">
<div class="toolbar">
${searchBox("Search by name, SteamID64 or reason", state.query)}
${segmented("banFilter", state.banFilter, [["active", "Active"], ["expired", "Expired"], ["lifted", "Lifted"], ["all", "All"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
function comms() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Gags & mutes</h1><p class="sub">A gag blocks text chat, a mute blocks voice, and a silence blocks both. Blocks carry over between maps and reconnects.</p></div>
</div>
<section class="panel">
<div class="toolbar">
${searchBox("Search by name, SteamID64 or reason", state.query)}
${segmented("commFilter", state.commFilter, [["active", "Active"], ["GAG", "Gags"], ["MUTE", "Mutes"], ["SILENCE", "Silences"], ["expired", "Expired"], ["all", "All"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
/* ---------- Staff ---------- */
async function staff() {
const v = $("#view");
v.innerHTML = loading();
let data;
try { data = await api("/api/public/staff"); } catch (err) { v.innerHTML = errorBox(err); return; }
if (state.page !== "staff") return;
const rankRow = (g) => `<div class="crew-row" style="--c:${esc(g.color)}">
<div class="crew-rank"><h2><span class="rank" style="font:inherit"><i></i>${esc(g.label)}</span></h2>${g.about ? `<p>${esc(g.about)}</p>` : ""}</div>
<ul class="crew">
${g.members.map((p) => `<li>
<a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">
<span class="avatar">${initial(p.name)}</span>
<span><b>${esc(p.name)}</b><small>${p.online ? '<span class="dot"></span>On the server' : "Steam profile"}</small></span>
</a>
</li>`).join("")}
</ul>
</div>`;
const staffGroups = data.groups.filter((g) => !g.supporter);
const supporters = data.groups.filter((g) => g.supporter);
const online = data.staffOnline;
v.innerHTML = `
<div class="page-head">
<div><h1>Staff</h1><p class="sub">${online ? `${online} staff ${online === 1 ? "is" : "are"} on the server right now.` : "No staff are on the server right now."}</p></div>
</div>
${staffGroups.length ? `<section class="panel crew-panel">${staffGroups.map(rankRow).join("")}</section>` : '<div class="empty">No staff are listed yet.</div>'}
${supporters.length ? `<h2 class="crew-sep">Supporters</h2><section class="panel crew-panel">${supporters.map(rankRow).join("")}</section>` : ""}`;
}
/* ---------- Player lookup (read-only) ---------- */
async function openPlayer(sid) {
$("#drawer-root").innerHTML = `<div class="scrim" data-close></div><aside class="drawer" role="dialog" aria-modal="true">${loading()}</aside>`;
let p;
try { p = await api(`/api/public/players/${encodeURIComponent(sid)}`); } catch (err) {
$(".drawer").innerHTML = errorBox(err);
return;
}
const history = [...p.bans, ...p.comms].sort((a, b) => new Date(b.created) - new Date(a.created));
const now = history.filter((h) => h.status === "active");
const label = (h) => (h.kind === "ban" ? "Ban" : COMM_LABEL[h.type]);
const name = p.name || "Unknown player";
$("#drawer-root").innerHTML = `
<div class="scrim" data-close></div>
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
<div class="drawer-head">
<span class="avatar lg">${initial(name)}</span>
<div><h2>${esc(name)}</h2><span class="sid">${esc(p.steamid)}</span>${p.rank ? `<div>${rankTag(p.rank)}</div>` : ""}</div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
<section>
<h3>Right now</h3>
${now.length ? `<div class="chips">${now.map((h) => `<span class="tag ${h.kind === "ban" ? "red" : "amber"}">${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"}</span>`).join("")}</div>`
: `<p class="muted" style="margin:0">${p.online ? "On the server, free to play and talk." : "Free to play and talk."}</p>`}
<p style="margin:12px 0 0"><a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></p>
</section>
<section>
<h3>History</h3>
${history.length ? `<ul class="history">${history.map((h) => `<li>
<div class="top"><span><b>${label(h)}</b> <span class="muted">${fmtDate(h.created)}</span></span>${statusTag(h)}</div>
<div>${esc(h.reason)}</div>
${termCell(h)}
</li>`).join("")}</ul>` : '<p class="muted" style="margin:0">No bans, gags or mutes on record.</p>'}
</section>
</aside>`;
$(".drawer .close").focus();
}
/* ---------- Routing & events ---------- */
const RENDER = { server, bans, comms, staff };
function route() {
const page = location.hash.slice(1);
state.page = RENDER[page] ? page : "server";
$("#nav-links").innerHTML = PAGES.map((p) => `<li><a href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}</a></li>`).join("");
RENDER[state.page]();
}
window.addEventListener("hashchange", () => { state.query = ""; state.num = 1; route(); window.scrollTo(0, 0); });
const search = debounce(() => loadResults(), 250);
document.addEventListener("input", (e) => {
if (e.target.id === "q") { state.query = e.target.value; state.num = 1; search(); }
});
document.addEventListener("click", (e) => {
const t = e.target;
const seg = t.closest("[data-seg] button");
if (seg) {
state[seg.parentElement.dataset.seg] = seg.dataset.value;
state.num = 1;
seg.parentElement.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg)));
loadResults();
return;
}
const pg = t.closest("[data-page]");
if (pg) { state.num = Number(pg.dataset.page); loadResults(); return; }
if (t.closest("[data-close]")) { closeDrawer(); return; }
const row = t.closest("[data-player]");
if (row) openPlayer(row.dataset.player);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("#drawer-root").innerHTML) closeDrawer();
});
api("/api/me").then((me) => {
$("#site-name").innerHTML = siteName(me.site);
document.title = `${me.site} bans and staff`;
if (me.staff) $(".signin").innerHTML = "Staff panel";
}).catch(() => {});
// Keep the scoreboard current while it's on screen.
setInterval(() => { if (state.page === "server" && !document.hidden && !$("#drawer-root").innerHTML) server(); }, 15000);
route();

View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Staff sign in</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body class="signin-page">
<main class="signin-card panel">
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<h1>Staff panel</h1>
<p class="sub" id="signin-msg">Sign in with the Steam account that has your rank on the server.</p>
<p class="signin-error" id="signin-error" hidden></p>
<a class="btn primary" href="/auth/login">Sign in through Steam</a>
<a class="muted signin-back" href="/">Back to the public site</a>
</main>
<script src="/static/common.js"></script>
<script src="/static/signin.js"></script>
</body>
</html>

View file

@ -0,0 +1,13 @@
// Explains why someone landed on the sign-in page: a failed Steam sign-in, or an account without a
// staff rank.
const err = new URLSearchParams(location.search).get("signin");
if (err) {
const box = $("#signin-error");
box.textContent = `Steam sign-in didn't work: ${err.replace(/^steam: /, "")}`;
box.hidden = false;
}
api("/api/me").then((me) => {
if (me.signedIn && !me.staff) {
$("#signin-msg").textContent = `You're signed in as ${me.steamid}, but that account has no staff rank on this server. Sign in with a different Steam account, or ask an admin to add you.`;
}
}).catch(() => {});

File diff suppressed because it is too large Load diff

202
internal/web/auth.go Normal file
View file

@ -0,0 +1,202 @@
package web
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"io/fs"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.zio.sh/cs2/simpleadmin-web/internal/steam"
)
const (
sessionCookie = "saw_session"
sessionTTL = 7 * 24 * time.Hour
// Browsers can't send this header cross-site without a CORS preflight, which is never granted.
csrfHeader = "X-Requested-With"
csrfValue = "simpleadmin-web"
)
func (s *Server) sign(payload string) string {
m := hmac.New(sha256.New, s.cfg.SessionSecret)
m.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}
func (s *Server) setSession(w http.ResponseWriter, steamid string) {
payload := steamid + "|" + strconv.FormatInt(time.Now().Add(sessionTTL).Unix(), 10)
enc := base64.RawURLEncoding.EncodeToString([]byte(payload))
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: enc + "." + s.sign(payload),
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: s.secure,
// Lax, not Strict: the sign-in redirect chain starts on steamcommunity.com.
SameSite: http.SameSiteLaxMode,
})
}
// sessionSteamID returns the signed-in SteamID64, or "".
func (s *Server) sessionSteamID(r *http.Request) string {
c, err := r.Cookie(sessionCookie)
if err != nil {
return ""
}
enc, sig, ok := strings.Cut(c.Value, ".")
if !ok {
return ""
}
raw, err := base64.RawURLEncoding.DecodeString(enc)
if err != nil {
return ""
}
payload := string(raw)
if !hmac.Equal([]byte(sig), []byte(s.sign(payload))) {
return ""
}
steamid, exp, ok := strings.Cut(payload, "|")
if !ok {
return ""
}
unix, err := strconv.ParseInt(exp, 10, 64)
if err != nil || time.Now().Unix() > unix || !steam.ValidID(steamid) {
return ""
}
return steamid
}
func (s *Server) callbackURL() string { return s.cfg.BaseURL + "/auth/callback" }
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, steam.LoginURL(s.cfg.BaseURL+"/", s.callbackURL()), http.StatusFound)
}
func (s *Server) callback(w http.ResponseWriter, r *http.Request) {
id, err := steam.Verify(r.Context(), s.http, s.callbackURL(), r.URL.Query())
if err != nil {
slog.Info("steam sign-in failed", "err", err)
http.Redirect(w, r, "/admin?signin="+url.QueryEscape(err.Error()), http.StatusFound)
return
}
s.setSession(w, id)
http.Redirect(w, r, "/admin", http.StatusFound)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
fail(w, http.StatusForbidden, "Sign out from the panel itself.")
return
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode})
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// sameOrigin rejects cross-site writes: the custom header must be present, and so must a matching
// Origin when the browser sends one.
func (s *Server) sameOrigin(r *http.Request) bool {
if r.Header.Get(csrfHeader) != csrfValue {
return false
}
if o := r.Header.Get("Origin"); o != "" && o != strings.TrimRight(s.cfg.BaseURL, "/") {
return false
}
return true
}
// identity returns the signed-in staff member, or nil for anyone else. Staff means an unexpired
// SimpleAdmin admin on this server with @css/generic (or @css/root), read fresh every 15 seconds,
// so removing someone in SimpleAdmin signs them out of the panel too.
func (s *Server) identity(r *http.Request) (*Identity, *staffIndex, error) {
steamid := s.sessionSteamID(r)
if steamid == "" {
return nil, nil, nil
}
x, err := s.staffIdx(r.Context())
if err != nil {
return nil, nil, err
}
a, ok := x.bySteam[steamid]
if !ok {
return nil, x, nil
}
id := &Identity{SteamID: steamid, Name: a.Name, Flags: x.flags(a), Immunity: x.immunity(steamid)}
if len(a.RowIDs) > 0 {
id.RowID = a.RowIDs[0]
}
if !id.Has("@css/generic") {
return nil, x, nil
}
return id, x, nil
}
type adminHandler func(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex)
func (s *Server) requireStaff(h adminHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && !s.sameOrigin(r) {
fail(w, http.StatusForbidden, "This request didn't come from the panel.")
return
}
id, x, err := s.identity(r)
if err != nil {
internal(w, r, err)
return
}
if id == nil {
fail(w, http.StatusUnauthorized, "Sign in with a staff account to do this.")
return
}
h(w, r, id, x)
}
}
// adminPage serves the staff panel to staff, and sends everyone else to sign in.
func (s *Server) adminPage(static fs.FS) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _, err := s.identity(r)
if err != nil {
slog.Error("admin page", "err", err)
}
if id == nil {
http.ServeFileFS(w, r, static, "signin.html")
return
}
http.ServeFileFS(w, r, static, "admin.html")
}
}
type meView struct {
SignedIn bool `json:"signedIn"`
Staff bool `json:"staff"`
SteamID string `json:"steamid,omitempty"`
Name string `json:"name,omitempty"`
Rank *rankView `json:"rank,omitempty"`
Perms []string `json:"perms,omitempty"`
Immunity int `json:"immunity,omitempty"`
Site string `json:"site"`
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
v := meView{Site: s.site.Title}
id, x, err := s.identity(r)
if err != nil {
internal(w, r, err)
return
}
if steamid := s.sessionSteamID(r); steamid != "" {
v.SignedIn, v.SteamID = true, steamid
}
if id != nil {
v.Staff, v.Name, v.Rank, v.Immunity = true, id.Name, s.rankOf(x, id.SteamID), id.Immunity
v.Perms = id.Flags
}
writeJSON(w, http.StatusOK, v)
}

244
internal/web/public.go Normal file
View file

@ -0,0 +1,244 @@
package web
import (
"errors"
"net/http"
"slices"
"time"
"git.zio.sh/cs2/simpleadmin-web/internal/live"
"git.zio.sh/cs2/simpleadmin-web/internal/steam"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
)
// publicPenalty is a penalty with everything about staff and IPs removed.
type publicPenalty struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Type string `json:"type,omitempty"`
Name string `json:"name"`
SteamID string `json:"steamid"`
Reason string `json:"reason"`
Duration int `json:"duration"`
Passed *int `json:"passed,omitempty"`
Ends *time.Time `json:"ends,omitempty"`
Created time.Time `json:"created"`
Status string `json:"status"`
}
func toPublic(ps []store.Penalty) []publicPenalty {
out := make([]publicPenalty, 0, len(ps))
for _, p := range ps {
out = append(out, publicPenalty{ID: p.ID, Kind: p.Kind, Type: p.Type, Name: p.Name, SteamID: p.SteamID,
Reason: p.Reason, Duration: p.Duration, Passed: p.Passed, Ends: p.Ends, Created: p.Created, Status: p.Status})
}
return out
}
type livePlayer struct {
live.Player
Rank *rankView `json:"rank,omitempty"`
// Staff-only fields, left empty on the public page.
Tags []string `json:"tags,omitempty"`
CanTarget bool `json:"canTarget,omitempty"`
}
type serverView struct {
Online bool `json:"online"`
Hostname string `json:"hostname"`
Map string `json:"map"`
Count int `json:"count"`
MaxPlayers int `json:"maxPlayers"`
ScoreT int `json:"scoreT"`
ScoreCT int `json:"scoreCt"`
Warmup bool `json:"warmup"`
StaffOnline int `json:"staffOnline"`
Address string `json:"address"`
Players []livePlayer `json:"players"`
Updated time.Time `json:"updated"`
}
func (s *Server) serverView(x *staffIndex, st live.Status) serverView {
v := serverView{Online: st.Online, Hostname: st.Hostname, Map: st.Map, MaxPlayers: st.MaxPlayers,
ScoreT: st.ScoreT, ScoreCT: st.ScoreCT, Warmup: st.Warmup, Address: s.cfg.ServerAddress,
Players: []livePlayer{}, Updated: st.Updated}
for _, p := range st.Players {
lp := livePlayer{Player: p}
if !p.Bot {
v.Count++
lp.Rank = s.rankOf(x, p.SteamID)
if s.isStaff(x, p.SteamID) {
v.StaffOnline++
}
}
v.Players = append(v.Players, lp)
}
return v
}
func (s *Server) publicServer(w http.ResponseWriter, r *http.Request) {
x, err := s.staffIdx(r.Context())
if err != nil {
internal(w, r, err)
return
}
writeJSON(w, http.StatusOK, s.serverView(x, s.poll.Current()))
}
// penaltyQuery reads ?status=&q=&page= (and for comms, a type in place of the status).
func penaltyQuery(r *http.Request, comms bool) (store.Query, int) {
lim, off, num := page(r)
q := store.Query{Search: r.URL.Query().Get("q"), Limit: lim, Offset: off}
switch st := r.URL.Query().Get("status"); st {
case store.StatusActive, store.StatusExpired, store.StatusLifted:
q.Status = st
case "GAG", "MUTE", "SILENCE":
if comms {
q.Status, q.Type = store.StatusActive, st
}
case "all":
default:
q.Status = store.StatusActive
}
return q, num
}
type penaltyPage struct {
list[publicPenalty]
Active int `json:"active"`
}
func (s *Server) publicBans(w http.ResponseWriter, r *http.Request) {
q, num := penaltyQuery(r, false)
items, total, err := s.st.Bans(r.Context(), q)
if err != nil {
internal(w, r, err)
return
}
_, active, err := s.st.Bans(r.Context(), store.Query{Status: store.StatusActive, Limit: 1})
if err != nil {
internal(w, r, err)
return
}
writeJSON(w, http.StatusOK, penaltyPage{newList(toPublic(items), total, num), active})
}
func (s *Server) publicComms(w http.ResponseWriter, r *http.Request) {
q, num := penaltyQuery(r, true)
items, total, err := s.st.Comms(r.Context(), q)
if err != nil {
internal(w, r, err)
return
}
_, active, err := s.st.Comms(r.Context(), store.Query{Status: store.StatusActive, Limit: 1})
if err != nil {
internal(w, r, err)
return
}
writeJSON(w, http.StatusOK, penaltyPage{newList(toPublic(items), total, num), active})
}
type memberView struct {
SteamID string `json:"steamid"`
Name string `json:"name"`
Online bool `json:"online"`
Ends *time.Time `json:"ends,omitempty"`
LastSeen *time.Time `json:"lastSeen,omitempty"`
}
type groupView struct {
ID int64 `json:"id"`
Name string `json:"name"`
Label string `json:"label"`
Color string `json:"color"`
About string `json:"about,omitempty"`
Supporter bool `json:"supporter"`
Immunity int `json:"immunity,omitempty"`
Flags []string `json:"flags,omitempty"`
Members []memberView `json:"members"`
}
// groupViews lists groups by immunity with their members. Staff get flags and expiry dates.
func (s *Server) groupViews(x *staffIndex, st live.Status, forStaff bool) []groupView {
out := make([]groupView, 0, len(x.groups))
for i, g := range x.groups {
r := s.site.rank(g.Name, i)
gv := groupView{ID: g.ID, Name: g.Name, Label: r.Label, Color: r.Color, About: r.About,
Supporter: r.Supporter, Immunity: g.Immunity, Members: []memberView{}}
if forStaff {
gv.Flags = g.Flags
} else {
gv.Immunity = 0
}
for _, a := range x.admins {
if x.primaryGroup(&a) != g.Name {
continue
}
_, online := st.Find(a.SteamID)
m := memberView{SteamID: a.SteamID, Name: a.Name, Online: online}
if forStaff {
m.Ends = a.Ends
}
gv.Members = append(gv.Members, m)
}
slices.SortStableFunc(gv.Members, func(a, b memberView) int {
if a.Online != b.Online {
if a.Online {
return -1
}
return 1
}
return 0
})
out = append(out, gv)
}
return out
}
func (s *Server) publicStaff(w http.ResponseWriter, r *http.Request) {
x, err := s.staffIdx(r.Context())
if err != nil {
internal(w, r, err)
return
}
st := s.poll.Current()
groups := s.groupViews(x, st, false)
// Hide empty ranks from the public.
groups = slices.DeleteFunc(groups, func(g groupView) bool { return len(g.Members) == 0 })
writeJSON(w, http.StatusOK, map[string]any{"groups": groups, "staffOnline": s.serverView(x, st).StaffOnline})
}
func (s *Server) publicPlayer(w http.ResponseWriter, r *http.Request) {
steamid := r.PathValue("steamid")
if !steam.ValidID(steamid) {
fail(w, http.StatusBadRequest, "That isn't a SteamID64.")
return
}
d, err := s.st.Player(r.Context(), steamid)
if errors.Is(err, store.ErrNotFound) {
d = store.PlayerDetail{SteamID: steamid}
} else if err != nil {
internal(w, r, err)
return
}
bans, _, err := s.st.Bans(r.Context(), store.Query{SteamID: steamid, Limit: 200})
if err != nil {
internal(w, r, err)
return
}
comms, _, err := s.st.Comms(r.Context(), store.Query{SteamID: steamid, Limit: 200})
if err != nil {
internal(w, r, err)
return
}
x, err := s.staffIdx(r.Context())
if err != nil {
internal(w, r, err)
return
}
_, online := s.poll.Current().Find(steamid)
writeJSON(w, http.StatusOK, map[string]any{
"steamid": steamid, "name": d.Name, "online": online, "rank": s.rankOf(x, steamid),
"bans": toPublic(bans), "comms": toPublic(comms),
})
}

189
internal/web/server.go Normal file
View file

@ -0,0 +1,189 @@
// Package web serves the public site, the staff panel and their JSON APIs.
package web
import (
"context"
"embed"
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
"git.zio.sh/cs2/simpleadmin-web/internal/live"
"git.zio.sh/cs2/simpleadmin-web/internal/rcon"
"git.zio.sh/cs2/simpleadmin-web/internal/saconfig"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
)
//go:embed assets
var assets embed.FS
type Config struct {
BaseURL string // e.g. https://bans.example.com, no trailing slash
SessionSecret []byte
SiteFile string
// ServerAddress is shown on the public page for "Join server".
ServerAddress string
// Limits used when SimpleAdmin's config file isn't available.
MaxBanDuration int
MaxMuteDuration int
}
type Server struct {
cfg Config
site Site
st *store.Store
rc *rcon.Client
poll *live.Poller
sacfg *saconfig.File // nil when no config path is set
staff *staffCache
http *http.Client
secure bool
}
func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg *saconfig.File) (*Server, error) {
site, err := loadSite(cfg.SiteFile)
if err != nil {
return nil, err
}
u, err := url.Parse(cfg.BaseURL)
if err != nil || u.Host == "" {
return nil, errors.New("base URL must be absolute, like https://bans.example.com")
}
return &Server{
cfg: cfg,
site: site,
st: st,
rc: rc,
poll: poll,
sacfg: sacfg,
staff: &staffCache{st: st, ttl: 15 * time.Second},
http: &http.Client{Timeout: 10 * time.Second},
secure: u.Scheme == "https",
}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
static, _ := fs.Sub(assets, "assets")
files := http.FileServerFS(static)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, static, "public.html")
})
mux.HandleFunc("GET /admin", s.adminPage(static))
mux.Handle("GET /static/", http.StripPrefix("/static/", files))
mux.HandleFunc("GET /auth/login", s.login)
mux.HandleFunc("GET /auth/callback", s.callback)
mux.HandleFunc("POST /auth/logout", s.logout)
mux.HandleFunc("GET /api/me", s.me)
mux.HandleFunc("GET /api/public/server", s.publicServer)
mux.HandleFunc("GET /api/public/bans", s.publicBans)
mux.HandleFunc("GET /api/public/comms", s.publicComms)
mux.HandleFunc("GET /api/public/staff", s.publicStaff)
mux.HandleFunc("GET /api/public/players/{steamid}", s.publicPlayer)
admin := func(pattern string, h adminHandler) { mux.HandleFunc(pattern, s.requireStaff(h)) }
admin("GET /api/admin/overview", s.adminOverview)
admin("GET /api/admin/players", s.adminPlayers)
admin("GET /api/admin/players/{steamid}", s.adminPlayer)
admin("GET /api/admin/bans", s.adminBans)
admin("GET /api/admin/comms", s.adminComms)
admin("GET /api/admin/staff", s.adminStaff)
admin("GET /api/admin/settings", s.adminSettings)
admin("POST /api/admin/penalties", s.addPenalty)
admin("POST /api/admin/penalties/lift", s.liftPenalty)
admin("POST /api/admin/kick", s.kick)
admin("POST /api/admin/say", s.say)
admin("POST /api/admin/map", s.changeMap)
admin("POST /api/admin/restart", s.restartRound)
admin("POST /api/admin/reload-admins", s.reloadAdmins)
admin("POST /api/admin/groups", s.createGroup)
admin("PUT /api/admin/groups/{id}", s.updateGroup)
admin("DELETE /api/admin/groups/{id}", s.deleteGroup)
admin("POST /api/admin/staff", s.addStaff)
admin("PUT /api/admin/staff/{steamid}", s.setStaffRank)
admin("DELETE /api/admin/staff/{steamid}", s.removeStaff)
admin("POST /api/admin/settings/cvars", s.saveCvars)
admin("POST /api/admin/settings/simpleadmin", s.saveSimpleAdmin)
return s.headers(mux)
}
func (s *Server) headers(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
// ---------------------------------------------------------------- JSON helpers
type apiError struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func fail(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, apiError{Error: msg})
}
// internal logs the real error and tells the browser something generic.
func internal(w http.ResponseWriter, r *http.Request, err error) {
slog.Error("request failed", "path", r.URL.Path, "err", err)
fail(w, http.StatusInternalServerError, "Something went wrong on the server. The error has been logged.")
}
func readJSON(r *http.Request, v any) error {
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<10))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return errors.New("the request wasn't valid JSON")
}
return nil
}
func page(r *http.Request) (limit, offset, num int) {
num, _ = strconv.Atoi(r.URL.Query().Get("page"))
if num < 1 {
num = 1
}
return pageSize, (num - 1) * pageSize, num
}
const pageSize = 50
type list[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
func newList[T any](items []T, total, num int) list[T] {
if items == nil {
items = []T{}
}
return list[T]{Items: items, Total: total, Page: num, PageSize: pageSize}
}
func (s *Server) staffIdx(ctx context.Context) (*staffIndex, error) {
return s.staff.get(ctx)
}

89
internal/web/site.go Normal file
View file

@ -0,0 +1,89 @@
package web
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// Site holds display settings SimpleAdmin has no place for: rank labels, colours and the plain
// descriptions on the public staff page. Ranks are keyed by SimpleAdmin group name.
type Site struct {
Title string `json:"title"`
Ranks map[string]Rank `json:"ranks"`
}
type Rank struct {
Label string `json:"label"`
Color string `json:"color"`
About string `json:"about"`
// Supporter ranks (VIPs) are listed apart from staff and don't count as staff online.
Supporter bool `json:"supporter"`
}
func defaultSite() Site {
return Site{
Title: "zio.sh",
Ranks: map[string]Rank{
"#rank/owner": {Label: "Owner", Color: "#fe113d", About: "Runs the server."},
"#rank/senioradmin": {Label: "Senior admin", Color: "#ff7a45", About: "Manages the staff team and server settings."},
"#rank/admin": {Label: "Admin", Color: "#f0a63a", About: "Handles appeals and longer bans."},
"#rank/trialadmin": {Label: "Trial admin", Color: "#e8d25a", About: "Admins on trial."},
"#rank/mod": {Label: "Moderator", Color: "#37c58f", About: "Keeps matches fair. Can kick, gag and issue short bans."},
"#rank/trialmod": {Label: "Trial mod", Color: "#4fb3d9", About: "Moderators on trial."},
"#rank/helper": {Label: "Helper", Color: "#7c8cf5", About: "Answers questions and handles chat."},
"#rank/guardian": {Label: "Guardian", Color: "#c77dff", About: "Supporters of the server. Not staff, and can't punish anyone.", Supporter: true},
},
}
}
func loadSite(path string) (Site, error) {
s := defaultSite()
if path == "" {
return s, nil
}
data, err := os.ReadFile(path)
if err != nil {
return s, err
}
var file Site
if err := json.Unmarshal(data, &file); err != nil {
return s, fmt.Errorf("%s: %w", path, err)
}
if file.Title != "" {
s.Title = file.Title
}
if file.Ranks != nil {
s.Ranks = file.Ranks
}
return s, nil
}
var fallbackColors = []string{"#7c8cf5", "#4fb3d9", "#37c58f", "#e8d25a", "#f0a63a", "#ff7a45", "#c77dff"}
// rank returns display settings for a group, deriving a label ("#rank/mod" -> "Mod") and a colour
// for groups the site config doesn't list.
func (s Site) rank(group string, index int) Rank {
if r, ok := s.Ranks[group]; ok {
if r.Label == "" {
r.Label = defaultLabel(group)
}
if r.Color == "" {
r.Color = fallbackColors[index%len(fallbackColors)]
}
return r
}
return Rank{Label: defaultLabel(group), Color: fallbackColors[index%len(fallbackColors)]}
}
func defaultLabel(group string) string {
name := strings.TrimPrefix(group, "#")
if i := strings.LastIndex(name, "/"); i >= 0 {
name = name[i+1:]
}
if name == "" {
return group
}
return strings.ToUpper(name[:1]) + name[1:]
}

161
internal/web/staff.go Normal file
View file

@ -0,0 +1,161 @@
package web
import (
"context"
"slices"
"sync"
"time"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
)
// staffIndex is SimpleAdmin's admins and groups for this server, merged the way CounterStrikeSharp's
// AdminManager sees them: an admin's permissions are their own @flags plus their groups' flags, and
// their immunity is the highest of their own and their groups'.
type staffIndex struct {
groups []store.Group
byName map[string]*store.Group
admins []store.Admin
bySteam map[string]*store.Admin
}
type staffCache struct {
st *store.Store
ttl time.Duration
mu sync.Mutex
idx *staffIndex
fetched time.Time
}
func (c *staffCache) get(ctx context.Context) (*staffIndex, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.idx != nil && time.Since(c.fetched) < c.ttl {
return c.idx, nil
}
groups, err := c.st.Groups(ctx)
if err != nil {
return nil, err
}
admins, err := c.st.Admins(ctx)
if err != nil {
return nil, err
}
idx := &staffIndex{groups: groups, admins: admins, byName: map[string]*store.Group{}, bySteam: map[string]*store.Admin{}}
for i := range idx.groups {
idx.byName[idx.groups[i].Name] = &idx.groups[i]
}
for i := range idx.admins {
idx.bySteam[idx.admins[i].SteamID] = &idx.admins[i]
}
c.idx, c.fetched = idx, time.Now()
return idx, nil
}
func (c *staffCache) invalidate() {
c.mu.Lock()
c.idx = nil
c.mu.Unlock()
}
// flags returns an admin's effective permissions.
func (x *staffIndex) flags(a *store.Admin) []string {
out := slices.Clone(a.Flags)
for _, g := range a.Groups {
if grp, ok := x.byName[g]; ok {
for _, f := range grp.Flags {
if !slices.Contains(out, f) {
out = append(out, f)
}
}
}
}
return out
}
// immunity returns a player's effective immunity, 0 for non-staff.
func (x *staffIndex) immunity(steamid string) int {
a, ok := x.bySteam[steamid]
if !ok {
return 0
}
imm := a.Immunity
for _, g := range a.Groups {
if grp, ok := x.byName[g]; ok {
imm = max(imm, grp.Immunity)
}
}
return imm
}
// primaryGroup is the admin's highest-immunity group that exists, or "".
func (x *staffIndex) primaryGroup(a *store.Admin) string {
best, bestImm := "", -1
for _, g := range a.Groups {
if grp, ok := x.byName[g]; ok && grp.Immunity > bestImm {
best, bestImm = g, grp.Immunity
}
}
return best
}
// groupIndex is a group's position, used to pick a stable fallback colour.
func (x *staffIndex) groupIndex(name string) int {
for i, g := range x.groups {
if g.Name == name {
return i
}
}
return 0
}
// Identity is a signed-in staff member.
type Identity struct {
SteamID string
Name string
Flags []string
Immunity int
RowID int64
}
// Has reports whether the identity holds a permission. @css/root grants everything.
func (id *Identity) Has(flag string) bool {
return slices.Contains(id.Flags, "@css/root") || slices.Contains(id.Flags, flag)
}
// CanTarget follows CounterStrikeSharp: you can act on players whose immunity isn't above yours.
// Nobody acts on themselves through the panel.
func (id *Identity) CanTarget(x *staffIndex, steamid string) bool {
if steamid == id.SteamID {
return false
}
return x.immunity(steamid) <= id.Immunity
}
// rankView is how a rank is shown to the browser.
type rankView struct {
Name string `json:"name"`
Label string `json:"label"`
Color string `json:"color"`
Supporter bool `json:"supporter,omitempty"`
}
func (s *Server) rankOf(x *staffIndex, steamid string) *rankView {
a, ok := x.bySteam[steamid]
if !ok {
return nil
}
g := x.primaryGroup(a)
if g == "" {
return &rankView{Label: "Staff", Color: "#8a91a0"}
}
r := s.site.rank(g, x.groupIndex(g))
return &rankView{Name: g, Label: r.Label, Color: r.Color, Supporter: r.Supporter}
}
// isStaff is true for admins whose rank isn't a supporter rank.
func (s *Server) isStaff(x *staffIndex, steamid string) bool {
r := s.rankOf(x, steamid)
return r != nil && !r.Supporter
}