simpleadmin-web 0.1.0: web panel for CS2-SimpleAdmin
This commit is contained in:
commit
4f38daf0e6
31 changed files with 6870 additions and 0 deletions
713
internal/web/actions.go
Normal file
713
internal/web/actions.go
Normal 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.")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue