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

View file

@ -0,0 +1,242 @@
// 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)
}