242 lines
7.1 KiB
Go
242 lines
7.1 KiB
Go
// Package saconfig reads and edits CS2-SimpleAdmin.json (config version 25, SimpleAdmin 1.8.2b).
|
|
//
|
|
// Only the options listed in Settings can be changed. Everything else in the file, including keys
|
|
// this package doesn't know about, is written back unchanged. Missing options take SimpleAdmin's
|
|
// defaults, since CounterStrikeSharp fills those in when it loads the file.
|
|
package saconfig
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
)
|
|
|
|
// Setting describes one editable option under OtherSettings.
|
|
type Setting struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Help string `json:"help"`
|
|
Type string `json:"type"` // int, bool, select
|
|
Options []Option `json:"options,omitempty"`
|
|
Default any `json:"-"`
|
|
Min int `json:"min,omitempty"`
|
|
}
|
|
|
|
type Option struct {
|
|
Value int `json:"value"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
// Settings are the OtherSettings the panel exposes, with SimpleAdmin 1.8.2b's defaults.
|
|
var Settings = []Setting{
|
|
{Key: "BanType", Label: "Ban by", Type: "select", Default: 1,
|
|
Help: "What a ban matches when the banned player tries to join.",
|
|
Options: []Option{{0, "SteamID only"}, {1, "SteamID and IP address"}}},
|
|
{Key: "CheckMultiAccountsByIp", Label: "Catch alt accounts by IP", Type: "bool", Default: true,
|
|
Help: "Also refuse players who share any IP address a banned player has used."},
|
|
{Key: "ExpireOldIpBans", Label: "Stop matching IPs after", Type: "int", Default: 0,
|
|
Help: "Days. Dynamic and shared IPs make old IP matches hit the wrong people. 0 keeps them forever."},
|
|
{Key: "MaxBanDuration", Label: "Longest ban", Type: "int", Default: 60 * 24 * 7, Min: 1,
|
|
Help: "Minutes. Staff without @css/permban can't ban longer than this, or permanently."},
|
|
{Key: "MaxMuteDuration", Label: "Longest gag or mute", Type: "int", Default: 60 * 24 * 7, Min: 1,
|
|
Help: "Minutes. Staff without @css/permmute can't block longer than this, or permanently."},
|
|
{Key: "TimeMode", Label: "Count block time", Type: "select", Default: 1,
|
|
Help: "Whether gags and mutes run out in real time, or only while the player is on the server.",
|
|
Options: []Option{{1, "In real time"}, {0, "Only while online"}}},
|
|
{Key: "ShowActivityType", Label: "Announce punishments", Type: "select", Default: 2,
|
|
Help: "Tell players in chat when someone is banned, gagged or muted.",
|
|
Options: []Option{{0, "Don't announce"}, {1, "Announce, admin name shown to staff only"}, {2, "Announce with admin name"}, {3, "Announce to staff only"}}},
|
|
{Key: "DisableDangerousCommands", Label: "Block commands on several players", Type: "bool", Default: true,
|
|
Help: "Stops in-game commands from hitting more than one player at once, such as @all."},
|
|
{Key: "ReloadAdminsEveryMapChange", Label: "Reload admins on map change", Type: "bool", Default: false,
|
|
Help: "Re-read staff and ranks from the database at every map change."},
|
|
{Key: "NotifyPenaltiesToAdminOnConnect", Label: "Tell staff about joining players' records", Type: "bool", Default: true,
|
|
Help: "When a player with bans or blocks on record joins, list them to staff in chat."},
|
|
}
|
|
|
|
// Database is the DatabaseConfig block.
|
|
type Database struct {
|
|
Type string
|
|
Host string
|
|
Port int
|
|
User string
|
|
Password string
|
|
Name string
|
|
SSLMode string
|
|
}
|
|
|
|
type File struct {
|
|
path string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func Open(path string) *File { return &File{path: path} }
|
|
|
|
func (f *File) Path() string { return f.path }
|
|
|
|
func (f *File) load() (map[string]any, error) {
|
|
data, err := os.ReadFile(f.path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(data))
|
|
dec.UseNumber()
|
|
var m map[string]any
|
|
if err := dec.Decode(&m); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", f.path, err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func section(m map[string]any, key string) map[string]any {
|
|
if v, ok := m[key].(map[string]any); ok {
|
|
return v
|
|
}
|
|
return map[string]any{}
|
|
}
|
|
|
|
func str(m map[string]any, key, def string) string {
|
|
if v, ok := m[key].(string); ok {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func num(m map[string]any, key string, def int) int {
|
|
if v, ok := m[key].(json.Number); ok {
|
|
if n, err := v.Int64(); err == nil {
|
|
return int(n)
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
func boolean(m map[string]any, key string, def bool) bool {
|
|
if v, ok := m[key].(bool); ok {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// Database returns the database connection settings.
|
|
func (f *File) Database() (Database, error) {
|
|
m, err := f.load()
|
|
if err != nil {
|
|
return Database{}, err
|
|
}
|
|
d := section(m, "DatabaseConfig")
|
|
return Database{
|
|
Type: str(d, "DatabaseType", "SQLite"),
|
|
Host: str(d, "DatabaseHost", ""),
|
|
Port: num(d, "DatabasePort", 3306),
|
|
User: str(d, "DatabaseUser", ""),
|
|
Password: str(d, "DatabasePassword", ""),
|
|
Name: str(d, "DatabaseName", ""),
|
|
SSLMode: str(d, "DatabaseSSlMode", "preferred"),
|
|
}, nil
|
|
}
|
|
|
|
// General holds top-level options the panel needs to behave like SimpleAdmin.
|
|
type General struct {
|
|
Timezone string
|
|
MultiServerMode bool
|
|
}
|
|
|
|
func (f *File) General() (General, error) {
|
|
m, err := f.load()
|
|
if err != nil {
|
|
return General{}, err
|
|
}
|
|
return General{
|
|
Timezone: str(m, "Timezone", "UTC"),
|
|
MultiServerMode: boolean(m, "MultiServerMode", true),
|
|
}, nil
|
|
}
|
|
|
|
// Values returns the current value of every editable setting.
|
|
func (f *File) Values() (map[string]any, error) {
|
|
m, err := f.load()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o := section(m, "OtherSettings")
|
|
out := map[string]any{}
|
|
for _, s := range Settings {
|
|
switch def := s.Default.(type) {
|
|
case bool:
|
|
out[s.Key] = boolean(o, s.Key, def)
|
|
case int:
|
|
out[s.Key] = num(o, s.Key, def)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Update validates and writes changed settings. It keeps a copy of the previous file as .bak.
|
|
func (f *File) Update(changes map[string]any) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
m, err := f.load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o := section(m, "OtherSettings")
|
|
for key, raw := range changes {
|
|
var def *Setting
|
|
for i := range Settings {
|
|
if Settings[i].Key == key {
|
|
def = &Settings[i]
|
|
}
|
|
}
|
|
if def == nil {
|
|
return fmt.Errorf("%s can't be changed here.", key)
|
|
}
|
|
switch def.Type {
|
|
case "bool":
|
|
v, ok := raw.(bool)
|
|
if !ok {
|
|
return fmt.Errorf("%s must be on or off.", def.Label)
|
|
}
|
|
o[key] = v
|
|
default:
|
|
fv, ok := raw.(float64)
|
|
if !ok || fv != float64(int(fv)) {
|
|
return fmt.Errorf("%s must be a whole number.", def.Label)
|
|
}
|
|
v := int(fv)
|
|
if v < def.Min {
|
|
return fmt.Errorf("%s must be at least %d.", def.Label, def.Min)
|
|
}
|
|
if def.Type == "select" {
|
|
valid := false
|
|
for _, opt := range def.Options {
|
|
valid = valid || opt.Value == v
|
|
}
|
|
if !valid {
|
|
return fmt.Errorf("%s has no option %d.", def.Label, v)
|
|
}
|
|
}
|
|
o[key] = v
|
|
}
|
|
}
|
|
m["OtherSettings"] = o
|
|
|
|
old, err := os.ReadFile(f.path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetIndent("", " ")
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(m); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(f.path+".bak", old, 0o600); err != nil {
|
|
return fmt.Errorf("back up %s: %w", f.path, err)
|
|
}
|
|
// Write in place rather than rename, so the file keeps its owner (the game server's user).
|
|
return os.WriteFile(f.path, buf.Bytes(), 0o644)
|
|
}
|