Bans and comms saved without a player name (SimpleAdmin's offline css_addban etc.) show the player's latest name from sa_players_ips, and the panel writes that name onto new rows it claims. The mobile sidebar closes from a Close button, the backdrop or Escape, since it covers the Menu button. The public player drawer drops "Right now" and "Free to play and talk." and puts the Steam profile link after the SteamID. Adds @css/bypassipban to the permission checkboxes.
713 lines
21 KiB
Go
713 lines
21 KiB
Go
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, s.st.LatestName(ctx, req.SteamID))
|
|
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.")
|
|
}
|