simpleadmin-web/internal/web/actions.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)
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.")
}