commit 4f38daf0e68454c1c08d9fba3e86e6e78513204b Author: Astra Date: Fri Sep 25 20:08:14 2026 +0100 simpleadmin-web 0.1.0: web panel for CS2-SimpleAdmin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..851a14e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +simpleadmin-web +*.env +!example.env diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..85d12be --- /dev/null +++ b/Containerfile @@ -0,0 +1,13 @@ +# Build: podman build -t simpleadmin-web . +FROM docker.io/library/golang:1.25 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /simpleadmin-web . + +# distroless/static has CA certificates (for Steam sign-in) and runs as a non-root user. +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /simpleadmin-web /simpleadmin-web +EXPOSE 8080 +ENTRYPOINT ["/simpleadmin-web"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9994ce --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# simpleadmin-web + +A SourceBans-style web panel for [CS2-SimpleAdmin](https://github.com/daffyyyy/CS2-SimpleAdmin) +(built against 1.8.2b's MySQL schema). + +- **Public site** (`/`): the live server (name, map, players, team score and a scoreboard), bans, + gags and mutes, and the staff list. Read-only. It never shows admin names, IP addresses or + internal immunity values. +- **Staff panel** (`/admin`): sign in through Steam. Ban, gag, mute, silence, warn and kick; lift + bans and blocks; manage ranks, permissions and staff; change server cvars and SimpleAdmin's + punishment settings. + +Live data comes from the **WebPanelBridge** plugin (`plugins/WebPanelBridge` in this repo) over +RCON. Without it the panel still works, but shows the server as offline and can't kick or warn. + +## How it works + +- **Reading**: straight from SimpleAdmin's MySQL tables (`sa_bans`, `sa_mutes`, `sa_warns`, + `sa_admins`, `sa_groups` and their flag, unban and unmute tables, `sa_players_ips`). +- **Penalties**: sent over RCON as SimpleAdmin's own commands (`css_addban`, `css_addgag`, + `css_unban`, `css_warn`, …), so they take effect in game at once, with SimpleAdmin's usual + announcements, Discord messages and IP handling. SimpleAdmin records console commands as + "Console", so the panel then rewrites that row to credit the staff member who did it. +- **Game server unreachable**: bans and blocks are written to the database directly, the way + SimpleAdmin writes them. They apply when the player next joins (bans are also picked up by + SimpleAdmin within about a minute). Kicks and warnings need the server. +- **Ranks and staff**: written to the database, then `css_reloadadmins`. A rank is a SimpleAdmin + group (`#rank/mod`); staff are admins holding exactly one such group. +- **Permissions**: commands sent over RCON run as Console, which skips all of SimpleAdmin's own + checks, so the panel enforces them itself with the same flags: + + | Action | Needs | + |---|---| + | Use the panel | `@css/generic` | + | Ban | `@css/ban`; permanent or longer than `MaxBanDuration` also `@css/permban` | + | Unban | `@css/unban` | + | Gag, mute, silence, lift them | `@css/chat`; permanent or longer than `MaxMuteDuration` also `@css/permmute` | + | Kick, warn | `@css/kick` | + | See IP addresses | `@css/showip` | + | Change map | `@css/changemap` | + | Server cvars | `@css/cvar` | + | Ranks, staff, SimpleAdmin settings, reload admins | `@css/root` | + + `@css/root` grants everything. Nobody can act on a player whose immunity is higher than theirs, + or on themselves, or give a rank with more immunity than their own. + +## Setup + +1. Install WebPanelBridge on the game server (see its README). +2. Make sure RCON works: `rcon_password` set, and the panel can reach the game port over TCP. + SimpleAdmin stores the server's address and RCON password in `sa_servers`, and the panel uses + those unless `SAW_RCON_ADDR` / `SAW_RCON_PASSWORD` are set. +3. Copy `example.env` to `simpleadmin-web.env` and fill it in. `SAW_BASE_URL` must be the exact + public address; Steam returns signed-in users there. +4. Put the panel behind HTTPS (a reverse proxy). Session cookies are marked Secure when + `SAW_BASE_URL` is https. + +Run it with podman: + +``` +podman build -t simpleadmin-web . +podman run -d --name simpleadmin-web --env-file simpleadmin-web.env \ + --user 1000:1000 \ + -v /srv/cs2/game/csgo/addons/counterstrikesharp/configs/plugins/CS2-SimpleAdmin:/data:Z \ + -p 127.0.0.1:8080:8080 simpleadmin-web +``` + +`--user 1000:1000` matches the game container's user, which owns `CS2-SimpleAdmin.json`, so the +panel can edit it. Mount it with `:ro` instead to make those settings read-only. Changes to that +file apply the next time SimpleAdmin loads (a server restart). The panel keeps the previous +version as `CS2-SimpleAdmin.json.bak`. It writes keys in alphabetical order, so expect a reordered +file after the first save. + +Or build and run it directly: `go build && ./simpleadmin-web` (Go 1.25, settings from the +environment). + +### Rank names and colours + +SimpleAdmin groups have no display name or colour, so those come from a site config +(`SAW_SITE_CONFIG`, see `site.example.json`). The built-in defaults cover `#rank/owner`, +`senioradmin`, `admin`, `trialadmin`, `mod`, `trialmod`, `helper` and `guardian`. Ranks marked +`"supporter": true` are listed apart from staff and can't sign in unless they hold +`@css/generic`. + +## Limitations and caveats + +- **Tested against a real MySQL 8.4** with SimpleAdmin 1.8.2b's migrations applied, and a fake game + server that speaks Source RCON and imitates SimpleAdmin's console commands. **Not yet tested + against a live CS2 server or live Steam sign-in.** Things to check on first run: that RCON + returns WebPanelBridge's output (it prints through `Server.PrintToConsole`, like `css version`), + and that the cvar values on the settings page read correctly. +- In-game announcements for panel actions name "Console", because that's who SimpleAdmin sees. + The database records the real staff member. +- Unban and lift act on the player: `css_unban ` lifts every active ban on that SteamID, + as it does in game. +- Bans without a SteamID (IP-only bans from `css_banip`) are listed but can only be lifted in game. +- One server per panel. Set `SAW_SERVER_ID` if `sa_servers` lists several. +- Times are read in SimpleAdmin's `Timezone` (UTC by default), matching how it writes them. + +## Development + +``` +go vet ./... && go test ./... +``` + +The pages are plain HTML, CSS and JS in `internal/web/assets`, embedded into the binary. The +design mockup they came from is in `docs/mockup` at the repo root. diff --git a/example.env b/example.env new file mode 100644 index 0000000..c65a4b0 --- /dev/null +++ b/example.env @@ -0,0 +1,30 @@ +# Copy to simpleadmin-web.env and fill in. Never commit the real file. + +# Where people reach the panel. Steam sends signed-in users back here, so it must match exactly. +SAW_BASE_URL=https://bans.example.com +# At least 32 random characters: openssl rand -hex 32. Changing it signs everyone out. +SAW_SESSION_SECRET= + +# SimpleAdmin's config file. The panel reads the database login from it, and edits the +# Punishments settings in it. Mount it read-only to turn editing off. +SAW_SA_CONFIG=/data/CS2-SimpleAdmin.json +# Or give the database directly instead (then SAW_SA_CONFIG is optional): +# SAW_DB_DSN=user:password@tcp(db-host:3306)/simpleadmin +# If the host in SimpleAdmin's config isn't reachable from the panel (e.g. a container name): +# SAW_DB_HOST=127.0.0.1:3306 + +# RCON. By default the address and password come from SimpleAdmin's sa_servers table. +# SAW_RCON_ADDR=127.0.0.1:27015 +# SAW_RCON_PASSWORD= +# Needed only if sa_servers lists more than one server. +# SAW_SERVER_ID=1 + +# The address shown under "Join server" on the public page. Defaults to sa_servers.address. +# SAW_JOIN_ADDRESS=cs2.example.com:27015 + +# Rank labels, colours and public descriptions. Built-in defaults cover the #rank/* groups. +# SAW_SITE_CONFIG=/data/site.json + +# SAW_LISTEN=:8080 +# SAW_POLL_SECONDS=5 +# SAW_TIMEZONE=UTC (defaults to SimpleAdmin's Timezone setting) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..21e7dd8 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.zio.sh/cs2/simpleadmin-web + +go 1.25.3 + +require github.com/go-sql-driver/mysql v1.10.1 + +require filippo.io/edwards25519 v1.2.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a95e9a --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= +github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= diff --git a/internal/live/live.go b/internal/live/live.go new file mode 100644 index 0000000..4a39a18 --- /dev/null +++ b/internal/live/live.go @@ -0,0 +1,171 @@ +// Package live polls the game server through the WebPanelBridge plugin and caches the result, so +// page views never wait on RCON. +package live + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "strings" + "sync" + "time" + + "git.zio.sh/cs2/simpleadmin-web/internal/rcon" +) + +const ( + TeamSpectator = 1 + TeamT = 2 + TeamCT = 3 +) + +type Player struct { + UserID int `json:"userid"` + Name string `json:"name"` + SteamID string `json:"steamid"` + Team int `json:"team"` + Kills int `json:"kills"` + Deaths int `json:"deaths"` + Ping int `json:"ping"` + Bot bool `json:"bot"` +} + +type Status struct { + Online bool `json:"online"` + Error string `json:"-"` + Hostname string `json:"hostname"` + Map string `json:"map"` + MaxPlayers int `json:"maxPlayers"` + ScoreT int `json:"scoreT"` + ScoreCT int `json:"scoreCt"` + Warmup bool `json:"warmup"` + Players []Player `json:"players"` + Updated time.Time `json:"updated"` +} + +// Humans returns the connected players that aren't bots. +func (s Status) Humans() []Player { + out := make([]Player, 0, len(s.Players)) + for _, p := range s.Players { + if !p.Bot { + out = append(out, p) + } + } + return out +} + +// Find returns the online player with this SteamID64. +func (s Status) Find(steamid string) (Player, bool) { + for _, p := range s.Players { + if !p.Bot && p.SteamID == steamid { + return p, true + } + } + return Player{}, false +} + +var errNoBridge = errors.New("WebPanelBridge didn't answer; is the plugin installed?") + +// Parse reads css_webpanel_status output. +func Parse(out string) (Status, error) { + var st Status + gotServer, gotEnd := false, false + for line := range strings.SplitSeq(out, "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "wpb ") + if !ok { + continue + } + if rest == "end" { + gotEnd = true + continue + } + if !gotServer { + var srv struct { + V int `json:"v"` + Hostname string `json:"hostname"` + Map string `json:"map"` + MaxPlayers int `json:"maxPlayers"` + ScoreT int `json:"scoreT"` + ScoreCt int `json:"scoreCt"` + Warmup bool `json:"warmup"` + } + if err := json.Unmarshal([]byte(rest), &srv); err != nil || srv.V != 1 { + return st, errors.New("unrecognised WebPanelBridge output") + } + st.Hostname, st.Map, st.MaxPlayers = srv.Hostname, srv.Map, srv.MaxPlayers + st.ScoreT, st.ScoreCT, st.Warmup = srv.ScoreT, srv.ScoreCt, srv.Warmup + gotServer = true + continue + } + var p Player + if err := json.Unmarshal([]byte(rest), &p); err == nil { + st.Players = append(st.Players, p) + } + } + if !gotServer { + return st, errNoBridge + } + if !gotEnd { + return st, errors.New("WebPanelBridge output was cut off") + } + st.Online = true + return st, nil +} + +type Poller struct { + rc *rcon.Client + interval time.Duration + + mu sync.RWMutex + current Status +} + +func NewPoller(rc *rcon.Client, interval time.Duration) *Poller { + return &Poller{rc: rc, interval: interval} +} + +// Run polls until ctx is done. +func (p *Poller) Run(ctx context.Context) { + t := time.NewTicker(p.interval) + defer t.Stop() + p.Refresh() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + p.Refresh() + } + } +} + +// Refresh polls now. Actions call it so the next page load shows their effect. +func (p *Poller) Refresh() Status { + out, err := p.rc.Exec("css_webpanel_status") + var st Status + if err == nil { + st, err = Parse(out) + } + st.Updated = time.Now() + if err != nil { + st = Status{Error: err.Error(), Updated: st.Updated} + p.mu.RLock() + was := p.current.Online || p.current.Updated.IsZero() + p.mu.RUnlock() + if was && !errors.Is(err, rcon.ErrDown) { + slog.Warn("game server status unavailable", "err", err) + } + } + p.mu.Lock() + p.current = st + p.mu.Unlock() + return st +} + +func (p *Poller) Current() Status { + p.mu.RLock() + defer p.mu.RUnlock() + return p.current +} diff --git a/internal/live/live_test.go b/internal/live/live_test.go new file mode 100644 index 0000000..b7a86c1 --- /dev/null +++ b/internal/live/live_test.go @@ -0,0 +1,38 @@ +package live + +import "testing" + +const sample = `wpb {"v":1,"hostname":"zio.sh | Random Skills","map":"de_mirage","maxPlayers":24,"scoreT":7,"scoreCt":9,"warmup":false} +L 09/25/2026 - 20:00:00: something else the server logged +wpb {"userid":2,"name":"astra","steamid":"76561198012345601","team":3,"kills":21,"deaths":9,"ping":12,"bot":false} +wpb {"userid":5,"name":"BOT Kurt","steamid":"0","team":2,"kills":3,"deaths":10,"ping":0,"bot":true} +wpb end +` + +func TestParse(t *testing.T) { + st, err := Parse(sample) + if err != nil { + t.Fatal(err) + } + if !st.Online || st.Map != "de_mirage" || st.ScoreT != 7 || st.ScoreCT != 9 || len(st.Players) != 2 { + t.Fatalf("unexpected %+v", st) + } + if h := st.Humans(); len(h) != 1 || h[0].UserID != 2 { + t.Fatalf("humans %+v", h) + } + if p, ok := st.Find("76561198012345601"); !ok || p.Kills != 21 { + t.Fatalf("find %+v %v", p, ok) + } + if _, ok := st.Find("0"); ok { + t.Fatal("bots must not be found by SteamID") + } +} + +func TestParseErrors(t *testing.T) { + if _, err := Parse("Unknown command \"css_webpanel_status\"\n"); err == nil { + t.Fatal("missing plugin should be an error") + } + if _, err := Parse(`wpb {"v":1,"map":"x"}` + "\n"); err == nil { + t.Fatal("output without end marker should be an error") + } +} diff --git a/internal/rcon/rcon.go b/internal/rcon/rcon.go new file mode 100644 index 0000000..0630df5 --- /dev/null +++ b/internal/rcon/rcon.go @@ -0,0 +1,230 @@ +// Package rcon is a Source RCON client (the TCP protocol CS2 still speaks). +// +// One connection is kept open and shared; commands are serialised with a mutex. A response can +// span several packets, so every command is followed by an empty SERVERDATA_RESPONSE_VALUE packet: +// the server answers packets in order, so seeing that packet's echo means the command's output is +// complete. If the server never echoes it, reading stops after a short idle timeout instead. +package rcon + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "strings" + "sync" + "time" +) + +const ( + typeResponseValue = 0 + typeExecCommand = 2 + typeAuthResponse = 2 + typeAuth = 3 + + maxPacket = 4096 + 14 +) + +// ErrAuth means the server rejected the RCON password. +var ErrAuth = errors.New("rcon: wrong password") + +// ErrDown means a recent attempt to reach the server failed, so this one wasn't tried. +var ErrDown = errors.New("rcon: server unreachable, retrying shortly") + +// backoff is how long Exec fails fast after the server couldn't be reached, so page loads and +// actions don't each wait out a connection timeout while the game server is down. +const backoff = 10 * time.Second + +type Client struct { + addr string + password string + timeout time.Duration + + mu sync.Mutex + conn net.Conn + nextID int32 + downUntil time.Time + lastErr error +} + +func New(addr, password string) *Client { + return &Client{addr: addr, password: password, timeout: 5 * time.Second, nextID: 1} +} + +// Exec runs a console command and returns everything it printed. +func (c *Client) Exec(cmd string) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if time.Now().Before(c.downUntil) { + return "", fmt.Errorf("%w: %v", ErrDown, c.lastErr) + } + hadConn := c.conn != nil + out, err := c.exec(cmd) + if err != nil && hadConn && !errors.Is(err, ErrAuth) { + // The kept-open connection may have gone stale (server restart, idle timeout): retry once + // on a fresh one. A failed fresh connection isn't retried. + c.close() + out, err = c.exec(cmd) + } + if err != nil { + c.close() + c.downUntil, c.lastErr = time.Now().Add(backoff), err + } + return out, err +} + +func (c *Client) exec(cmd string) (string, error) { + if c.conn == nil { + if err := c.connect(); err != nil { + return "", err + } + } + id := c.id() + endID := c.id() + if err := c.write(id, typeExecCommand, cmd); err != nil { + return "", err + } + if err := c.write(endID, typeResponseValue, ""); err != nil { + return "", err + } + + var out strings.Builder + deadline := time.Now().Add(c.timeout) + idle := 400 * time.Millisecond + got := false + for { + wait := deadline + if got { + // Once output has started, stop after a quiet gap even without the end marker. + if d := time.Now().Add(idle); d.Before(wait) { + wait = d + } + } + _ = c.conn.SetReadDeadline(wait) + pid, _, body, err := c.read() + if err != nil { + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() && got { + return out.String(), nil + } + return "", err + } + switch pid { + case id: + out.WriteString(body) + got = true + case endID: + // Some servers answer the empty packet with a second, odd-looking packet. Drain it. + _ = c.conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond)) + _, _, _, _ = c.read() + return out.String(), nil + } + } +} + +func (c *Client) connect() error { + conn, err := net.DialTimeout("tcp", c.addr, 2*time.Second) + if err != nil { + return fmt.Errorf("rcon: connect %s: %w", c.addr, err) + } + c.conn = conn + id := c.id() + if err := c.write(id, typeAuth, c.password); err != nil { + c.close() + return err + } + _ = conn.SetReadDeadline(time.Now().Add(c.timeout)) + for { + pid, ptype, _, err := c.read() + if err != nil { + c.close() + return fmt.Errorf("rcon: auth: %w", err) + } + if ptype != typeAuthResponse { + continue // an empty RESPONSE_VALUE precedes the auth response + } + if pid == -1 { + c.close() + return ErrAuth + } + return nil + } +} + +func (c *Client) id() int32 { + c.nextID++ + if c.nextID > 1<<30 { + c.nextID = 1 + } + return c.nextID +} + +func (c *Client) write(id int32, ptype int32, body string) error { + var buf bytes.Buffer + size := int32(len(body) + 10) + _ = binary.Write(&buf, binary.LittleEndian, size) + _ = binary.Write(&buf, binary.LittleEndian, id) + _ = binary.Write(&buf, binary.LittleEndian, ptype) + buf.WriteString(body) + buf.Write([]byte{0, 0}) + _ = c.conn.SetWriteDeadline(time.Now().Add(c.timeout)) + _, err := c.conn.Write(buf.Bytes()) + return err +} + +func (c *Client) read() (id int32, ptype int32, body string, err error) { + var size int32 + if err = binary.Read(c.conn, binary.LittleEndian, &size); err != nil { + return + } + if size < 10 || size > maxPacket { + err = fmt.Errorf("rcon: bad packet size %d", size) + return + } + data := make([]byte, size) + if _, err = io.ReadFull(c.conn, data); err != nil { + return + } + id = int32(binary.LittleEndian.Uint32(data[0:4])) + ptype = int32(binary.LittleEndian.Uint32(data[4:8])) + body = string(bytes.TrimRight(data[8:], "\x00")) + return +} + +func (c *Client) close() { + if c.conn != nil { + _ = c.conn.Close() + c.conn = nil + } +} + +// Close drops the connection. The next Exec reconnects. +func (c *Client) Close() { + c.mu.Lock() + defer c.mu.Unlock() + c.close() +} + +// Arg makes s safe to pass as one quoted console argument: the console splits commands on ';' and +// newlines and has no escape for '"', so those are removed along with other control characters. +func Arg(s string, max int) string { + var b strings.Builder + for _, r := range s { + switch { + case r == '"' || r == ';' || r == '\\': + continue + case r < 0x20 || r == 0x7f: + b.WriteRune(' ') + default: + b.WriteRune(r) + } + } + out := strings.TrimSpace(b.String()) + if max > 0 && len([]rune(out)) > max { + out = string([]rune(out)[:max]) + } + return out +} diff --git a/internal/rcon/rcon_test.go b/internal/rcon/rcon_test.go new file mode 100644 index 0000000..112a77d --- /dev/null +++ b/internal/rcon/rcon_test.go @@ -0,0 +1,132 @@ +package rcon + +import ( + "encoding/binary" + "errors" + "io" + "net" + "strings" + "testing" +) + +func TestArg(t *testing.T) { + cases := map[string]string{ + `plain reason`: "plain reason", + `quote" ; quit`: "quote quit", + "new\nline": "new line", + `back\slash`: "backslash", + " padded ": "padded", + `"; css_addadmin 1 x @css/root`: " css_addadmin 1 x @css/root", + } + for in, want := range cases { + if got := Arg(in, 0); got != strings.TrimSpace(want) { + t.Errorf("Arg(%q) = %q, want %q", in, got, strings.TrimSpace(want)) + } + } + if got := Arg("abcdef", 3); got != "abc" { + t.Errorf("max length: got %q", got) + } +} + +// fakeServer answers auth, then replies to each command with its text split over two packets, +// and mirrors the empty end-marker packet the way srcds does. +func fakeServer(t *testing.T, password string) string { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go serve(c, password) + } + }() + return ln.Addr().String() +} + +func readPkt(r io.Reader) (int32, int32, string, error) { + var size int32 + if err := binary.Read(r, binary.LittleEndian, &size); err != nil { + return 0, 0, "", err + } + b := make([]byte, size) + if _, err := io.ReadFull(r, b); err != nil { + return 0, 0, "", err + } + return int32(binary.LittleEndian.Uint32(b)), int32(binary.LittleEndian.Uint32(b[4:])), strings.TrimRight(string(b[8:]), "\x00"), nil +} + +func writePkt(w io.Writer, id, typ int32, body string) { + buf := make([]byte, 12+len(body)+2) + binary.LittleEndian.PutUint32(buf, uint32(len(body)+10)) + binary.LittleEndian.PutUint32(buf[4:], uint32(id)) + binary.LittleEndian.PutUint32(buf[8:], uint32(typ)) + copy(buf[12:], body) + w.Write(buf) +} + +func serve(c net.Conn, password string) { + defer c.Close() + for { + id, typ, body, err := readPkt(c) + if err != nil { + return + } + switch typ { + case typeAuth: + writePkt(c, id, typeResponseValue, "") + if body == password { + writePkt(c, id, typeAuthResponse, "") + } else { + writePkt(c, -1, typeAuthResponse, "") + } + case typeExecCommand: + out := "echo:" + body + "\n" + strings.Repeat("x", 5000) + writePkt(c, id, typeResponseValue, out[:4000]) + writePkt(c, id, typeResponseValue, out[4000:]) + case typeResponseValue: + writePkt(c, id, typeResponseValue, "") + writePkt(c, id, typeResponseValue, "\x00\x01\x00\x00") + } + } +} + +func TestExecMultiPacket(t *testing.T) { + addr := fakeServer(t, "pw") + c := New(addr, "pw") + defer c.Close() + for i := 0; i < 3; i++ { + out, err := c.Exec("status") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(out, "echo:status\n") || len(out) != len("echo:status\n")+5000 { + t.Fatalf("got %d bytes: %.40q", len(out), out) + } + } +} + +func TestWrongPassword(t *testing.T) { + addr := fakeServer(t, "pw") + c := New(addr, "nope") + if _, err := c.Exec("status"); err != ErrAuth { + t.Fatalf("got %v, want ErrAuth", err) + } +} + +func TestBackoff(t *testing.T) { + ln, _ := net.Listen("tcp", "127.0.0.1:0") + addr := ln.Addr().String() + ln.Close() // nothing listens here now + c := New(addr, "pw") + if _, err := c.Exec("status"); err == nil { + t.Fatal("expected a connection error") + } + if _, err := c.Exec("status"); !errors.Is(err, ErrDown) { + t.Fatalf("second call should fail fast with ErrDown, got %v", err) + } +} diff --git a/internal/saconfig/saconfig.go b/internal/saconfig/saconfig.go new file mode 100644 index 0000000..65ab1cb --- /dev/null +++ b/internal/saconfig/saconfig.go @@ -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) +} diff --git a/internal/steam/steam.go b/internal/steam/steam.go new file mode 100644 index 0000000..284a352 --- /dev/null +++ b/internal/steam/steam.go @@ -0,0 +1,145 @@ +// Package steam handles "Sign in through Steam" (OpenID 2.0) and SteamID64 validation. +package steam + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +const ( + opEndpoint = "https://steamcommunity.com/openid/login" + openIDNS = "http://specs.openid.net/auth/2.0" + identifierSel = "http://specs.openid.net/auth/2.0/identifier_select" + claimedPrefix = "https://steamcommunity.com/openid/id/" + nonceMaxAge = 5 * time.Minute + steamID64Base = 76561197960265728 + steamID64Limit = steamID64Base + 1<<32 +) + +var steamID64Re = regexp.MustCompile(`^7656119[0-9]{10}$`) + +// ValidID reports whether s is an individual account's SteamID64. +func ValidID(s string) bool { + if !steamID64Re.MatchString(s) { + return false + } + n, err := strconv.ParseUint(s, 10, 64) + return err == nil && n > steamID64Base && n < steamID64Limit +} + +// LoginURL is where to send the browser to sign in. returnTo must be under realm. +func LoginURL(realm, returnTo string) string { + q := url.Values{ + "openid.ns": {openIDNS}, + "openid.mode": {"checkid_setup"}, + "openid.return_to": {returnTo}, + "openid.realm": {realm}, + "openid.identity": {identifierSel}, + "openid.claimed_id": {identifierSel}, + } + return opEndpoint + "?" + q.Encode() +} + +// Verify checks a callback from Steam with Steam itself and returns the signed-in SteamID64. +func Verify(ctx context.Context, client *http.Client, returnTo string, q url.Values) (string, error) { + if q.Get("openid.mode") != "id_res" { + return "", errors.New("steam: sign-in was cancelled") + } + if q.Get("openid.op_endpoint") != opEndpoint { + return "", errors.New("steam: wrong OpenID provider") + } + if q.Get("openid.return_to") != returnTo { + return "", errors.New("steam: return address doesn't match") + } + claimed := q.Get("openid.claimed_id") + if claimed != q.Get("openid.identity") || !strings.HasPrefix(claimed, claimedPrefix) { + return "", errors.New("steam: unexpected identity") + } + id := strings.TrimPrefix(claimed, claimedPrefix) + if !ValidID(id) { + return "", errors.New("steam: identity isn't a SteamID64") + } + if err := checkNonce(q.Get("openid.response_nonce")); err != nil { + return "", err + } + // A callback URL is a bearer credential until the nonce expires, so accept each one once. + if !seen.claim(q.Get("openid.response_nonce")) { + return "", errors.New("steam: this sign-in link was already used") + } + + check := url.Values{} + for k, v := range q { + if strings.HasPrefix(k, "openid.") { + check[k] = v + } + } + check.Set("openid.mode", "check_authentication") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, opEndpoint, strings.NewReader(check.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("steam: verify: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return "", fmt.Errorf("steam: verify: %w", err) + } + for line := range strings.SplitSeq(string(body), "\n") { + if strings.TrimSpace(line) == "is_valid:true" { + return id, nil + } + } + return "", errors.New("steam: Steam didn't confirm the sign-in") +} + +// The nonce starts with its UTC creation time, e.g. 2026-09-25T20:00:00Z0a1b2c. +func checkNonce(nonce string) error { + if len(nonce) < 20 { + return errors.New("steam: missing nonce") + } + t, err := time.Parse(time.RFC3339, nonce[:20]) + if err != nil { + return errors.New("steam: bad nonce") + } + if age := time.Since(t); age > nonceMaxAge || age < -nonceMaxAge { + return errors.New("steam: sign-in link expired, try again") + } + return nil +} + +type nonceSet struct { + mu sync.Mutex + used map[string]time.Time +} + +var seen = &nonceSet{used: map[string]time.Time{}} + +func (n *nonceSet) claim(nonce string) bool { + n.mu.Lock() + defer n.mu.Unlock() + now := time.Now() + for k, t := range n.used { + if now.Sub(t) > 2*nonceMaxAge { + delete(n.used, k) + } + } + if _, ok := n.used[nonce]; ok { + return false + } + n.used[nonce] = now + return true +} diff --git a/internal/steam/steam_test.go b/internal/steam/steam_test.go new file mode 100644 index 0000000..99103a2 --- /dev/null +++ b/internal/steam/steam_test.go @@ -0,0 +1,35 @@ +package steam + +import ( + "testing" + "time" +) + +func TestValidID(t *testing.T) { + for id, want := range map[string]bool{ + "76561198012345601": true, + "76561197960265728": false, // account 0 + "7656119801234560": false, + "76561198012345601 ": false, + "86561198012345601": false, + "7656119801234560a": false, + } { + if got := ValidID(id); got != want { + t.Errorf("ValidID(%q) = %v", id, got) + } + } +} + +func TestNonce(t *testing.T) { + fresh := time.Now().UTC().Format(time.RFC3339) + "abc" + if err := checkNonce(fresh); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-10*time.Minute).UTC().Format(time.RFC3339) + "abc" + if checkNonce(old) == nil { + t.Fatal("old nonce accepted") + } + if !seen.claim(fresh) || seen.claim(fresh) { + t.Fatal("nonce replay not caught") + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..ebdc845 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,1069 @@ +// Package store reads and writes CS2-SimpleAdmin's MySQL tables (schema as of 1.8.2b, migrations +// 001-016). Times are stored by SimpleAdmin as wall-clock values in its configured Timezone +// (UTC unless changed), so every time here is read and written in that location. +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" +) + +// Normalised penalty states. SimpleAdmin uses UNBANNED for bans and UNMUTED for mutes. +const ( + StatusActive = "active" + StatusExpired = "expired" + StatusLifted = "lifted" +) + +var ErrNotFound = errors.New("not found") + +type Store struct { + db *sql.DB + loc *time.Location + serverID sql.NullInt64 + // TimeMode 0 counts mute time only while the player is online (sa_mutes.passed). + timeMode int +} + +func New(db *sql.DB, loc *time.Location, serverID int64, timeMode int) *Store { + s := &Store{db: db, loc: loc, timeMode: timeMode} + if serverID > 0 { + s.serverID = sql.NullInt64{Int64: serverID, Valid: true} + } + return s +} + +func (s *Store) DB() *sql.DB { return s.db } + +// wall turns a time into the naive wall-clock form SimpleAdmin stores. +func (s *Store) wall(t time.Time) time.Time { + t = t.In(s.loc) + return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, time.UTC) +} + +// fromWall reads a stored wall-clock value back as a real instant. +func (s *Store) fromWall(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, s.loc) +} + +func (s *Store) nullWall(t sql.NullTime) *time.Time { + if !t.Valid { + return nil + } + v := s.fromWall(t.Time) + return &v +} + +func sid(n sql.NullInt64) string { + if !n.Valid || n.Int64 == 0 { + return "" + } + return strconv.FormatInt(n.Int64, 10) +} + +func parseSID(steamid string) (int64, error) { + return strconv.ParseInt(steamid, 10, 64) +} + +// Server is a row of sa_servers. +type Server struct { + ID int64 + Hostname string + Address string + RconPassword string +} + +// Servers lists sa_servers. SimpleAdmin fills it in, including each server's rcon_password. +func (s *Store) Servers(ctx context.Context) ([]Server, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, COALESCE(hostname,''), address, COALESCE(rcon_password,'') FROM sa_servers ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Server + for rows.Next() { + var v Server + if err := rows.Scan(&v.ID, &v.Hostname, &v.Address, &v.RconPassword); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// ---------------------------------------------------------------- Penalties + +// Penalty is a ban, gag/mute/silence or warning. +type Penalty struct { + ID int64 `json:"id"` + Kind string `json:"kind"` // ban, comm, warn + Type string `json:"type,omitempty"` + Name string `json:"name"` + SteamID string `json:"steamid"` + IP string `json:"ip,omitempty"` + AdminSteamID string `json:"adminSteamid,omitempty"` + AdminName string `json:"admin,omitempty"` + Reason string `json:"reason"` + Duration int `json:"duration"` + Passed *int `json:"passed,omitempty"` + Ends *time.Time `json:"ends,omitempty"` + Created time.Time `json:"created"` + Status string `json:"status"` + LiftedBy string `json:"liftedBy,omitempty"` + LiftReason string `json:"liftReason,omitempty"` +} + +// Remaining returns minutes left on an active timed penalty. +func (p Penalty) Remaining(now time.Time) float64 { + if p.Duration == 0 { + return 0 + } + if p.Passed != nil && p.Ends == nil { + return float64(p.Duration - *p.Passed) + } + if p.Ends == nil { + return 0 + } + return p.Ends.Sub(now).Minutes() +} + +// status normalises SimpleAdmin's status, treating a timed penalty whose end has passed as expired +// even before SimpleAdmin's 61-second sweep marks it. +func (s *Store) status(raw string, duration int, ends *time.Time, passed *int) string { + switch raw { + case "UNBANNED", "UNMUTED": + return StatusLifted + case "EXPIRED", "": + return StatusExpired + } + if duration == 0 { + return StatusActive + } + if s.timeMode == 0 && passed != nil { + if *passed >= duration { + return StatusExpired + } + return StatusActive + } + if ends != nil && !ends.After(time.Now()) { + return StatusExpired + } + return StatusActive +} + +// Query filters a penalty list. +type Query struct { + Status string // active, expired, lifted, all + Type string // GAG, MUTE, SILENCE (comms only) + Search string + SteamID string // exact player + // Which fields Search may match. Admin names and IPs are only searchable by staff. + SearchAdmin bool + SearchIP bool + Limit int + Offset int +} + +// statusSQL builds the WHERE condition for a status. Only sa_mutes has "passed" (withPassed). +func (s *Store) statusSQL(status string, withPassed bool) (string, []any) { + now := s.wall(time.Now()) + online := withPassed && s.timeMode == 0 + switch status { + case StatusActive: + if online { + return `status = 'ACTIVE' AND (duration = 0 OR (passed IS NOT NULL AND passed < duration) OR (passed IS NULL AND ends > ?))`, []any{now} + } + return `status = 'ACTIVE' AND (duration = 0 OR ends > ?)`, []any{now} + case StatusExpired: + if online { + return `(status IN ('EXPIRED','') OR (status = 'ACTIVE' AND duration > 0 AND ((passed IS NOT NULL AND passed >= duration) OR (passed IS NULL AND ends <= ?))))`, []any{now} + } + return `(status IN ('EXPIRED','') OR (status = 'ACTIVE' AND duration > 0 AND ends <= ?))`, []any{now} + case StatusLifted: + return `status IN ('UNBANNED','UNMUTED')`, nil + } + return "1=1", nil +} + +func (s *Store) where(table string, q Query) (string, []any) { + var conds []string + var args []any + if q.Status != "" { + c, a := s.statusSQL(q.Status, table == "sa_mutes") + conds, args = append(conds, c), append(args, a...) + } + if q.Type != "" { + conds, args = append(conds, "type = ?"), append(args, q.Type) + } + if q.SteamID != "" { + n, _ := parseSID(q.SteamID) + conds, args = append(conds, "player_steamid = ?"), append(args, n) + } + if t := strings.TrimSpace(q.Search); t != "" { + like := "%" + escapeLike(t) + "%" + or := []string{"player_name LIKE ?", "reason LIKE ?"} + or2 := []any{like, like} + if n, err := strconv.ParseInt(t, 10, 64); err == nil { + or, or2 = append(or, "player_steamid = ?"), append(or2, n) + } + if q.SearchAdmin { + or, or2 = append(or, "admin_name LIKE ?"), append(or2, like) + } + if q.SearchIP && table == "sa_bans" { + or, or2 = append(or, "player_ip = ?"), append(or2, t) + } + conds = append(conds, "("+strings.Join(or, " OR ")+")") + args = append(args, or2...) + } + if len(conds) == 0 { + return "", nil + } + return " WHERE " + strings.Join(conds, " AND "), args +} + +func escapeLike(s string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) +} + +// Bans lists sa_bans, newest first, with the total matching count. +func (s *Store) Bans(ctx context.Context, q Query) ([]Penalty, int, error) { + where, args := s.where("sa_bans", q) + var total int + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sa_bans"+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + sqlq := `SELECT b.id, COALESCE(b.player_name,''), b.player_steamid, COALESCE(b.player_ip,''), b.admin_steamid, + b.admin_name, b.reason, b.duration, b.ends, b.created, b.status, + COALESCE(ua.player_name,''), COALESCE(u.reason,'') + FROM (SELECT * FROM sa_bans` + where + ` ORDER BY id DESC LIMIT ? OFFSET ?) b + LEFT JOIN sa_unbans u ON u.id = b.unban_id + LEFT JOIN sa_admins ua ON ua.id = u.admin_id + ORDER BY b.id DESC` + rows, err := s.db.QueryContext(ctx, sqlq, append(args, limit(q.Limit), q.Offset)...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var out []Penalty + for rows.Next() { + var p Penalty + var psid, asid sql.NullInt64 + var ends, created sql.NullTime + var raw string + if err := rows.Scan(&p.ID, &p.Name, &psid, &p.IP, &asid, &p.AdminName, &p.Reason, &p.Duration, &ends, + &created, &raw, &p.LiftedBy, &p.LiftReason); err != nil { + return nil, 0, err + } + p.Kind, p.SteamID, p.AdminSteamID = "ban", sid(psid), sid(asid) + p.Ends, p.Created = s.nullWall(ends), s.fromWall(created.Time) + p.Status = s.status(raw, p.Duration, p.Ends, nil) + out = append(out, p) + } + return out, total, rows.Err() +} + +// Comms lists sa_mutes (gags, mutes and silences), newest first. +func (s *Store) Comms(ctx context.Context, q Query) ([]Penalty, int, error) { + where, args := s.where("sa_mutes", q) + var total int + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sa_mutes"+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + sqlq := `SELECT m.id, m.type, COALESCE(m.player_name,''), m.player_steamid, m.admin_steamid, m.admin_name, + m.reason, m.duration, m.passed, m.ends, m.created, m.status, + COALESCE(ua.player_name,''), COALESCE(u.reason,'') + FROM (SELECT * FROM sa_mutes` + where + ` ORDER BY id DESC LIMIT ? OFFSET ?) m + LEFT JOIN sa_unmutes u ON u.id = m.unmute_id + LEFT JOIN sa_admins ua ON ua.id = u.admin_id + ORDER BY m.id DESC` + rows, err := s.db.QueryContext(ctx, sqlq, append(args, limit(q.Limit), q.Offset)...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var out []Penalty + for rows.Next() { + var p Penalty + var psid, asid sql.NullInt64 + var passed sql.NullInt64 + var ends, created sql.NullTime + var raw string + if err := rows.Scan(&p.ID, &p.Type, &p.Name, &psid, &asid, &p.AdminName, &p.Reason, &p.Duration, &passed, + &ends, &created, &raw, &p.LiftedBy, &p.LiftReason); err != nil { + return nil, 0, err + } + p.Kind, p.SteamID, p.AdminSteamID = "comm", sid(psid), sid(asid) + if passed.Valid { + v := int(passed.Int64) + p.Passed = &v + } + p.Ends, p.Created = s.nullWall(ends), s.fromWall(created.Time) + p.Status = s.status(raw, p.Duration, p.Ends, p.Passed) + out = append(out, p) + } + return out, total, rows.Err() +} + +// Warns lists sa_warns for one player, newest first. +func (s *Store) Warns(ctx context.Context, steamid string) ([]Penalty, error) { + n, _ := parseSID(steamid) + rows, err := s.db.QueryContext(ctx, `SELECT id, COALESCE(player_name,''), admin_steamid, admin_name, reason, duration, ends, created, status + FROM sa_warns WHERE player_steamid = ? ORDER BY id DESC LIMIT 100`, n) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Penalty + for rows.Next() { + var p Penalty + var asid sql.NullInt64 + var ends, created sql.NullTime + var raw string + if err := rows.Scan(&p.ID, &p.Name, &asid, &p.AdminName, &p.Reason, &p.Duration, &ends, &created, &raw); err != nil { + return nil, err + } + p.Kind, p.SteamID, p.AdminSteamID = "warn", steamid, sid(asid) + p.Ends, p.Created = s.nullWall(ends), s.fromWall(created.Time) + p.Status = s.status(raw, p.Duration, p.Ends, nil) + out = append(out, p) + } + return out, rows.Err() +} + +// ActiveWarnCounts returns active warnings per player for the given SteamIDs. +func (s *Store) ActiveWarnCounts(ctx context.Context, steamids []string) (map[string]int, error) { + out := map[string]int{} + if len(steamids) == 0 { + return out, nil + } + in, args := inList(steamids) + args = append(args, s.wall(time.Now())) + rows, err := s.db.QueryContext(ctx, `SELECT player_steamid, COUNT(*) FROM sa_warns + WHERE player_steamid IN (`+in+`) AND status = 'ACTIVE' AND (duration = 0 OR ends > ?) GROUP BY player_steamid`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id sql.NullInt64 + var n int + if err := rows.Scan(&id, &n); err != nil { + return nil, err + } + out[sid(id)] = n + } + return out, rows.Err() +} + +// RecordCounts returns how many bans and comm blocks (any status) each player has. +func (s *Store) RecordCounts(ctx context.Context, steamids []string) (bans, comms map[string]int, err error) { + bans, comms = map[string]int{}, map[string]int{} + if len(steamids) == 0 { + return + } + in, args := inList(steamids) + for table, m := range map[string]map[string]int{"sa_bans": bans, "sa_mutes": comms} { + rows, qerr := s.db.QueryContext(ctx, `SELECT player_steamid, COUNT(*) FROM `+table+` WHERE player_steamid IN (`+in+`) GROUP BY player_steamid`, args...) + if qerr != nil { + return nil, nil, qerr + } + for rows.Next() { + var id sql.NullInt64 + var n int + if err = rows.Scan(&id, &n); err != nil { + rows.Close() + return + } + m[sid(id)] = n + } + rows.Close() + } + return +} + +func inList(steamids []string) (string, []any) { + args := make([]any, 0, len(steamids)) + for _, v := range steamids { + n, _ := parseSID(v) + args = append(args, n) + } + return strings.TrimSuffix(strings.Repeat("?,", len(args)), ","), args +} + +func limit(n int) int { + if n <= 0 || n > 200 { + return 50 + } + return n +} + +// Activity returns the most recent bans, comm blocks, warnings and unbans, newest first. +type Activity struct { + Kind string `json:"kind"` // ban, comm, warn, unban, unmute + Type string `json:"type,omitempty"` + Name string `json:"name"` + SteamID string `json:"steamid"` + Admin string `json:"admin"` + Reason string `json:"reason"` + Minutes int `json:"duration"` + At time.Time `json:"at"` +} + +func (s *Store) Activity(ctx context.Context, n int) ([]Activity, error) { + q := ` + (SELECT 'ban' kind, '' type, COALESCE(player_name,'') name, player_steamid, admin_name, reason, duration, created FROM sa_bans ORDER BY id DESC LIMIT ?) + UNION ALL + (SELECT 'comm', type, COALESCE(player_name,''), player_steamid, admin_name, reason, duration, created FROM sa_mutes ORDER BY id DESC LIMIT ?) + UNION ALL + (SELECT 'warn', '', COALESCE(player_name,''), player_steamid, admin_name, reason, duration, created FROM sa_warns ORDER BY id DESC LIMIT ?) + UNION ALL + (SELECT 'unban', '', COALESCE(b.player_name,''), b.player_steamid, COALESCE(a.player_name,'Console'), u.reason, 0, u.date + FROM sa_unbans u JOIN sa_bans b ON b.id = u.ban_id LEFT JOIN sa_admins a ON a.id = u.admin_id ORDER BY u.id DESC LIMIT ?) + UNION ALL + (SELECT 'unmute', m.type, COALESCE(m.player_name,''), m.player_steamid, COALESCE(a.player_name,'Console'), u.reason, 0, u.date + FROM sa_unmutes u JOIN sa_mutes m ON m.id = u.mute_id LEFT JOIN sa_admins a ON a.id = u.admin_id ORDER BY u.id DESC LIMIT ?) + ORDER BY 8 DESC LIMIT ?` + rows, err := s.db.QueryContext(ctx, q, n, n, n, n, n, n) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Activity + for rows.Next() { + var a Activity + var id sql.NullInt64 + var at sql.NullTime + if err := rows.Scan(&a.Kind, &a.Type, &a.Name, &id, &a.Admin, &a.Reason, &a.Minutes, &at); err != nil { + return nil, err + } + a.SteamID, a.At = sid(id), s.fromWall(at.Time) + out = append(out, a) + } + return out, rows.Err() +} + +// ---------------------------------------------------------------- Players + +// Player is someone SimpleAdmin has seen connect (sa_players_ips). +type Player struct { + SteamID string `json:"steamid"` + Name string `json:"name"` + LastSeen time.Time `json:"lastSeen"` +} + +// Players lists known players, most recently seen first. Search matches name, SteamID64 or, when +// byIP is set, an exact IPv4 address. +func (s *Store) Players(ctx context.Context, search string, byIP bool, onlyIDs []string, lim, offset int) ([]Player, int, error) { + var conds []string + var args []any + if t := strings.TrimSpace(search); t != "" { + or := []string{"name LIKE ?"} + or2 := []any{"%" + escapeLike(t) + "%"} + if n, err := strconv.ParseInt(t, 10, 64); err == nil { + or, or2 = append(or, "steamid = ?"), append(or2, n) + } + if byIP && strings.Count(t, ".") == 3 { + or, or2 = append(or, "address = INET_ATON(?)"), append(or2, t) + } + conds = append(conds, "steamid IN (SELECT steamid FROM sa_players_ips WHERE "+strings.Join(or, " OR ")+")") + args = append(args, or2...) + } + if onlyIDs != nil { + if len(onlyIDs) == 0 { + return nil, 0, nil + } + in, a := inList(onlyIDs) + conds = append(conds, "steamid IN ("+in+")") + args = append(args, a...) + } + where := "" + if len(conds) > 0 { + where = " WHERE " + strings.Join(conds, " AND ") + } + var total int + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(DISTINCT steamid) FROM sa_players_ips"+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + q := `SELECT steamid, MAX(used_at) AS seen, + SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(name,'') ORDER BY used_at DESC SEPARATOR '\n'), '\n', 1) + FROM sa_players_ips` + where + ` GROUP BY steamid ORDER BY seen DESC LIMIT ? OFFSET ?` + rows, err := s.db.QueryContext(ctx, q, append(args, limit(lim), offset)...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var out []Player + for rows.Next() { + var p Player + var id sql.NullInt64 + var seen sql.NullTime + if err := rows.Scan(&id, &seen, &p.Name); err != nil { + return nil, 0, err + } + p.SteamID, p.LastSeen = sid(id), s.fromWall(seen.Time) + out = append(out, p) + } + return out, total, rows.Err() +} + +// PlayerDetail is everything known about one SteamID. +type PlayerDetail struct { + SteamID string `json:"steamid"` + Name string `json:"name"` + Names []string `json:"names"` + IPs []string `json:"ips,omitempty"` + LastSeen *time.Time `json:"lastSeen,omitempty"` +} + +func (s *Store) Player(ctx context.Context, steamid string) (PlayerDetail, error) { + n, err := parseSID(steamid) + if err != nil { + return PlayerDetail{}, ErrNotFound + } + d := PlayerDetail{SteamID: steamid} + rows, err := s.db.QueryContext(ctx, `SELECT COALESCE(name,''), INET_NTOA(address), used_at FROM sa_players_ips WHERE steamid = ? ORDER BY used_at DESC`, n) + if err != nil { + return d, err + } + defer rows.Close() + for rows.Next() { + var name string + var ip sql.NullString + var at sql.NullTime + if err := rows.Scan(&name, &ip, &at); err != nil { + return d, err + } + if d.LastSeen == nil && at.Valid { + t := s.fromWall(at.Time) + d.LastSeen = &t + } + if name != "" && !slices.Contains(d.Names, name) { + d.Names = append(d.Names, name) + } + if ip.Valid && !slices.Contains(d.IPs, ip.String) { + d.IPs = append(d.IPs, ip.String) + } + } + if err := rows.Err(); err != nil { + return d, err + } + // Players who were banned offline may never have joined; fall back to names on their records. + for _, table := range []string{"sa_bans", "sa_mutes", "sa_admins"} { + var name sql.NullString + err := s.db.QueryRowContext(ctx, `SELECT player_name FROM `+table+` WHERE player_steamid = ? AND player_name IS NOT NULL AND player_name <> '' ORDER BY id DESC LIMIT 1`, n).Scan(&name) + if err == nil && name.Valid && !slices.Contains(d.Names, name.String) { + d.Names = append(d.Names, name.String) + } + } + if len(d.Names) == 0 { + return d, ErrNotFound + } + d.Name = d.Names[0] + return d, nil +} + +// LatestName returns the last name SimpleAdmin saw for a SteamID, or "". +func (s *Store) LatestName(ctx context.Context, steamid string) string { + d, err := s.Player(ctx, steamid) + if err != nil { + return "" + } + return d.Name +} + +// ---------------------------------------------------------------- Admins and groups + +// Group is a SimpleAdmin group ("rank"), e.g. #rank/mod. +type Group struct { + ID int64 `json:"id"` + Name string `json:"name"` + Immunity int `json:"immunity"` + Flags []string `json:"flags"` +} + +// Admin merges every applicable sa_admins row for one SteamID. +type Admin struct { + RowIDs []int64 `json:"-"` + SteamID string `json:"steamid"` + Name string `json:"name"` + Immunity int `json:"immunity"` + Flags []string `json:"flags"` // @css/... set directly on the admin + Groups []string `json:"groups"` // #group names + Ends *time.Time `json:"ends,omitempty"` + Global bool `json:"global"` +} + +func (s *Store) serverFilter(col string) (string, []any) { + if s.serverID.Valid { + return "(" + col + " IS NULL OR " + col + " = ?)", []any{s.serverID.Int64} + } + return "1=1", nil +} + +// Groups returns the groups that apply to this server, the same set SimpleAdmin loads. +func (s *Store) Groups(ctx context.Context) ([]Group, error) { + cond, args := s.serverFilter("gs.server_id") + rows, err := s.db.QueryContext(ctx, `SELECT g.id, g.name, g.immunity, COALESCE(f.flag,'') + FROM sa_groups g + JOIN sa_groups_servers gs ON gs.group_id = g.id AND `+cond+` + LEFT JOIN sa_groups_flags f ON f.group_id = g.id + ORDER BY g.immunity DESC, g.id`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Group + idx := map[int64]int{} + for rows.Next() { + var g Group + var flag string + if err := rows.Scan(&g.ID, &g.Name, &g.Immunity, &flag); err != nil { + return nil, err + } + i, ok := idx[g.ID] + if !ok { + g.Flags = []string{} + out = append(out, g) + i = len(out) - 1 + idx[g.ID] = i + } + if flag != "" && !slices.Contains(out[i].Flags, flag) { + out[i].Flags = append(out[i].Flags, flag) + } + } + return out, rows.Err() +} + +// Admins returns every unexpired admin that applies to this server. +func (s *Store) Admins(ctx context.Context) ([]Admin, error) { + cond, args := s.serverFilter("a.server_id") + args = append([]any{s.wall(time.Now())}, args...) + rows, err := s.db.QueryContext(ctx, `SELECT a.id, a.player_steamid, COALESCE(a.player_name,''), a.immunity, a.ends, a.server_id IS NULL, COALESCE(f.flag,'') + FROM sa_admins a + LEFT JOIN sa_admins_flags f ON f.admin_id = a.id + WHERE a.player_steamid IS NOT NULL AND a.player_steamid <> 0 + AND (a.ends IS NULL OR a.ends > ?) AND `+cond+` + ORDER BY a.server_id IS NULL DESC, a.id`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Admin + idx := map[string]int{} + for rows.Next() { + var rowID int64 + var id sql.NullInt64 + var name, flag string + var imm int + var ends sql.NullTime + var global bool + if err := rows.Scan(&rowID, &id, &name, &imm, &ends, &global, &flag); err != nil { + return nil, err + } + key := sid(id) + i, ok := idx[key] + if !ok { + out = append(out, Admin{SteamID: key, Name: name, Flags: []string{}, Groups: []string{}, Ends: s.nullWall(ends)}) + i = len(out) - 1 + idx[key] = i + } + a := &out[i] + if !slices.Contains(a.RowIDs, rowID) { + a.RowIDs = append(a.RowIDs, rowID) + } + a.Immunity = max(a.Immunity, imm) + a.Global = a.Global || global + switch { + case strings.HasPrefix(flag, "#") && !slices.Contains(a.Groups, flag): + a.Groups = append(a.Groups, flag) + case strings.HasPrefix(flag, "@") && !slices.Contains(a.Flags, flag): + a.Flags = append(a.Flags, flag) + } + } + // SimpleAdmin ignores admin rows with no flags at all (its admin query joins the flags table). + out = slices.DeleteFunc(out, func(a Admin) bool { return len(a.Flags) == 0 && len(a.Groups) == 0 }) + return out, rows.Err() +} + +// AdminRowID returns the sa_admins.id to credit for unbans by this SteamID, or 0 (Console). +func (s *Store) AdminRowID(ctx context.Context, steamid string) int64 { + n, _ := parseSID(steamid) + var id int64 + _ = s.db.QueryRowContext(ctx, `SELECT id FROM sa_admins WHERE player_steamid = ? ORDER BY server_id IS NULL DESC, id LIMIT 1`, n).Scan(&id) + return id +} + +// CreateGroup adds a global group, like css_addgroup ... -g. +func (s *Store) CreateGroup(ctx context.Context, name string, immunity int, flags []string) (int64, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + var exists int + _ = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM sa_groups WHERE name = ?`, name).Scan(&exists) + if exists > 0 { + return 0, fmt.Errorf("A rank named %s already exists.", name) + } + res, err := tx.ExecContext(ctx, `INSERT INTO sa_groups (name, immunity) VALUES (?, ?)`, name, immunity) + if err != nil { + return 0, err + } + id, _ := res.LastInsertId() + if err := insertGroupFlags(ctx, tx, id, flags); err != nil { + return 0, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO sa_groups_servers (group_id, server_id) VALUES (?, NULL)`, id); err != nil { + return 0, err + } + return id, tx.Commit() +} + +func insertGroupFlags(ctx context.Context, tx *sql.Tx, id int64, flags []string) error { + for _, f := range flags { + if _, err := tx.ExecContext(ctx, `INSERT INTO sa_groups_flags (group_id, flag) VALUES (?, ?)`, id, f); err != nil { + return err + } + } + return nil +} + +// UpdateGroup changes a group's name, immunity and flags. Renaming also updates every admin's +// "#name" reference so members keep the rank. +func (s *Store) UpdateGroup(ctx context.Context, id int64, name string, immunity int, flags []string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var old string + if err := tx.QueryRowContext(ctx, `SELECT name FROM sa_groups WHERE id = ?`, id).Scan(&old); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if name != old { + var exists int + _ = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM sa_groups WHERE name = ? AND id <> ?`, name, id).Scan(&exists) + if exists > 0 { + return fmt.Errorf("A rank named %s already exists.", name) + } + if _, err := tx.ExecContext(ctx, `UPDATE sa_admins_flags SET flag = ? WHERE flag = ?`, name, old); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE sa_groups SET name = ?, immunity = ? WHERE id = ?`, name, immunity, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM sa_groups_flags WHERE group_id = ?`, id); err != nil { + return err + } + if err := insertGroupFlags(ctx, tx, id, flags); err != nil { + return err + } + return tx.Commit() +} + +// DeleteGroup removes a group and every admin's reference to it. Admins left with no flags or +// groups are removed too, which is what makes them regular players again. +func (s *Store) DeleteGroup(ctx context.Context, id int64) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var name string + if err := tx.QueryRowContext(ctx, `SELECT name FROM sa_groups WHERE id = ?`, id).Scan(&name); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM sa_admins_flags WHERE flag = ?`, name); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM sa_groups WHERE id = ?`, id); err != nil { + return err + } + if err := deleteEmptyAdmins(ctx, tx); err != nil { + return err + } + return tx.Commit() +} + +func deleteEmptyAdmins(ctx context.Context, tx *sql.Tx) error { + // id 0 is SimpleAdmin's "Console" row, which unbans reference. + _, err := tx.ExecContext(ctx, `DELETE FROM sa_admins WHERE id <> 0 AND player_steamid <> 0 + AND NOT EXISTS (SELECT 1 FROM sa_admins_flags f WHERE f.admin_id = sa_admins.id) + AND (flags IS NULL OR flags = '')`) + return err +} + +// SetAdminGroup gives a player exactly one rank (replacing any #group they had), creating a +// global admin row if they have none. immunity is stored on the admin row, as css_addadmin does. +// ends is nil for no expiry. +func (s *Store) SetAdminGroup(ctx context.Context, steamid, name, group string, immunity int, ends *time.Time) error { + n, err := parseSID(steamid) + if err != nil { + return err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + var rowID int64 + err = tx.QueryRowContext(ctx, `SELECT id FROM sa_admins WHERE player_steamid = ? ORDER BY server_id IS NULL DESC, id LIMIT 1`, n).Scan(&rowID) + var endsArg any + if ends != nil { + endsArg = s.wall(*ends) + } + switch { + case errors.Is(err, sql.ErrNoRows): + res, err := tx.ExecContext(ctx, `INSERT INTO sa_admins (player_steamid, player_name, immunity, ends, created, server_id) VALUES (?, ?, ?, ?, ?, NULL)`, + n, name, immunity, endsArg, s.wall(time.Now())) + if err != nil { + return err + } + rowID, _ = res.LastInsertId() + case err != nil: + return err + default: + if _, err := tx.ExecContext(ctx, `UPDATE sa_admins SET immunity = ?, ends = ?, player_name = COALESCE(NULLIF(?, ''), player_name) WHERE player_steamid = ?`, + immunity, endsArg, name, n); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `DELETE f FROM sa_admins_flags f JOIN sa_admins a ON a.id = f.admin_id WHERE a.player_steamid = ? AND f.flag LIKE '#%'`, n); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO sa_admins_flags (admin_id, flag) VALUES (?, ?)`, rowID, group); err != nil { + return err + } + return tx.Commit() +} + +// RemoveAdmin deletes every sa_admins row for a SteamID, like css_deladmin ... -g. +func (s *Store) RemoveAdmin(ctx context.Context, steamid string) error { + n, err := parseSID(steamid) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `DELETE FROM sa_admins WHERE player_steamid = ? AND id <> 0`, n) + return err +} + +// ---------------------------------------------------------------- Writes for penalties + +// MaxID returns the highest id in a penalty table, to find rows a console command creates. +func (s *Store) MaxID(ctx context.Context, table string) int64 { + var id sql.NullInt64 + _ = s.db.QueryRowContext(ctx, `SELECT MAX(id) FROM `+table).Scan(&id) + return id.Int64 +} + +// ClaimConsoleRow finds a row created after afterID for the player by "Console" (admin_steamid 0) +// and credits it to the web admin instead. SimpleAdmin writes the row asynchronously, so this polls +// for a few seconds. It returns the row id, or 0 if none appeared. +func (s *Store) ClaimConsoleRow(ctx context.Context, table string, afterID int64, steamid, commType, adminSteamID, adminName string) int64 { + n, _ := parseSID(steamid) + a, _ := parseSID(adminSteamID) + q := `SELECT id FROM ` + table + ` WHERE id > ? AND player_steamid = ? AND admin_steamid = 0` + args := []any{afterID, n} + if commType != "" { + q += ` AND type = ?` + args = append(args, commType) + } + q += ` ORDER BY id DESC LIMIT 1` + for range 20 { + var id int64 + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&id); err == nil { + _, _ = s.db.ExecContext(ctx, `UPDATE `+table+` SET admin_steamid = ?, admin_name = ? WHERE id = ?`, a, adminName, id) + return id + } + select { + case <-ctx.Done(): + return 0 + case <-time.After(250 * time.Millisecond): + } + } + return 0 +} + +// ActivePenaltyIDs lists a player's active bans (table sa_bans) or active comm blocks of one type. +func (s *Store) ActivePenaltyIDs(ctx context.Context, table, steamid, commType string) []int64 { + n, _ := parseSID(steamid) + q := `SELECT id FROM ` + table + ` WHERE player_steamid = ? AND status = 'ACTIVE'` + args := []any{n} + if commType != "" { + q += ` AND type = ?` + args = append(args, commType) + } + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + return ids +} + +// ClaimLift credits the unban/unmute rows that a console command wrote for these penalties, which +// were active before the command. SimpleAdmin gives console lifts the id of its "Console" admin row, +// which isn't reliably 0, so the rows are found through the penalties instead. +func (s *Store) ClaimLift(ctx context.Context, table string, ids []int64, adminRowID int64) { + if len(ids) == 0 || adminRowID == 0 { + return + } + liftTable, col := "sa_unbans", "unban_id" + if table == "sa_mutes" { + liftTable, col = "sa_unmutes", "unmute_id" + } + in := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + args := make([]any, 0, len(ids)+1) + args = append(args, adminRowID) + for _, id := range ids { + args = append(args, id) + } + for range 20 { + res, err := s.db.ExecContext(ctx, `UPDATE `+liftTable+` SET admin_id = ? WHERE id IN (SELECT `+col+` FROM `+table+` WHERE id IN (`+in+`) AND `+col+` IS NOT NULL)`, args...) + if err == nil { + if n, _ := res.RowsAffected(); n > 0 { + return + } + } + select { + case <-ctx.Done(): + return + case <-time.After(250 * time.Millisecond): + } + } +} + +// NewPenalty is a ban or comm block written directly, used when the game server is unreachable. +type NewPenalty struct { + SteamID string + Name string + Type string // GAG/MUTE/SILENCE for comms + AdminSteamID string + AdminName string + Reason string + Duration int +} + +func (s *Store) serverArg() any { + if s.serverID.Valid { + return s.serverID.Int64 + } + return nil +} + +// InsertBan writes a ban the way SimpleAdmin's AddBanBySteamid does. +func (s *Store) InsertBan(ctx context.Context, p NewPenalty) error { + n, _ := parseSID(p.SteamID) + a, _ := parseSID(p.AdminSteamID) + now := time.Now() + var ends any + if p.Duration > 0 { + ends = s.wall(now.Add(time.Duration(p.Duration) * time.Minute)) + } + _, err := s.db.ExecContext(ctx, `INSERT INTO sa_bans (player_steamid, player_name, admin_steamid, admin_name, reason, duration, ends, created, server_id) + VALUES (?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)`, n, p.Name, a, p.AdminName, p.Reason, p.Duration, ends, s.wall(now), s.serverArg()) + return err +} + +// InsertComm writes a gag, mute or silence the way SimpleAdmin's AddMuteBySteamid does. +func (s *Store) InsertComm(ctx context.Context, p NewPenalty) error { + n, _ := parseSID(p.SteamID) + a, _ := parseSID(p.AdminSteamID) + now := time.Now() + var ends any + if p.Duration > 0 { + ends = s.wall(now.Add(time.Duration(p.Duration) * time.Minute)) + } + _, err := s.db.ExecContext(ctx, `INSERT INTO sa_mutes (player_steamid, player_name, admin_steamid, admin_name, reason, duration, ends, created, type, server_id, passed) + VALUES (?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?, ?, 0)`, n, p.Name, a, p.AdminName, p.Reason, p.Duration, ends, s.wall(now), p.Type, s.serverArg()) + return err +} + +// LiftDirect marks a player's active bans (or comm blocks of one type) lifted and records who did +// it, the way SimpleAdmin's unban/unmute do. Used when the game server is unreachable. +func (s *Store) LiftDirect(ctx context.Context, table, steamid, commType string, adminRowID int64, reason string) (int, error) { + ids := s.ActivePenaltyIDs(ctx, table, steamid, commType) + liftTable, fk, col, status := "sa_unbans", "ban_id", "unban_id", "UNBANNED" + if table == "sa_mutes" { + liftTable, fk, col, status = "sa_unmutes", "mute_id", "unmute_id", "UNMUTED" + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + for _, id := range ids { + res, err := tx.ExecContext(ctx, `INSERT INTO `+liftTable+` (`+fk+`, admin_id, reason) VALUES (?, ?, ?)`, id, adminRowID, reason) + if err != nil { + return 0, err + } + liftID, _ := res.LastInsertId() + if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET status = ?, `+col+` = ? WHERE id = ?`, status, liftID, id); err != nil { + return 0, err + } + } + return len(ids), tx.Commit() +} + +// PenaltyOwner returns the player SteamID and (for comms) type of a penalty row. +func (s *Store) PenaltyOwner(ctx context.Context, table string, id int64) (steamid, commType string, err error) { + var n sql.NullInt64 + if table == "sa_mutes" { + err = s.db.QueryRowContext(ctx, `SELECT player_steamid, type FROM sa_mutes WHERE id = ?`, id).Scan(&n, &commType) + } else { + err = s.db.QueryRowContext(ctx, `SELECT player_steamid FROM sa_bans WHERE id = ?`, id).Scan(&n) + } + if errors.Is(err, sql.ErrNoRows) { + return "", "", ErrNotFound + } + return sid(n), commType, err +} + +// Expiring returns active timed bans and comm blocks, soonest end first. +func (s *Store) Expiring(ctx context.Context, n int) ([]Penalty, error) { + bans, _, err := s.Bans(ctx, Query{Status: StatusActive, Limit: 200}) + if err != nil { + return nil, err + } + comms, _, err := s.Comms(ctx, Query{Status: StatusActive, Limit: 200}) + if err != nil { + return nil, err + } + now := time.Now() + all := slices.DeleteFunc(append(bans, comms...), func(p Penalty) bool { return p.Duration == 0 }) + slices.SortFunc(all, func(a, b Penalty) int { + ra, rb := a.Remaining(now), b.Remaining(now) + switch { + case ra < rb: + return -1 + case ra > rb: + return 1 + } + return 0 + }) + if len(all) > n { + all = all[:n] + } + return all, nil +} diff --git a/internal/web/actions.go b/internal/web/actions.go new file mode 100644 index 0000000..10d2327 --- /dev/null +++ b/internal/web/actions.go @@ -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:.") + 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.") +} diff --git a/internal/web/admin.go b/internal/web/admin.go new file mode 100644 index 0000000..e2d1de2 --- /dev/null +++ b/internal/web/admin.go @@ -0,0 +1,410 @@ +package web + +import ( + "errors" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "git.zio.sh/cs2/simpleadmin-web/internal/saconfig" + "git.zio.sh/cs2/simpleadmin-web/internal/steam" + "git.zio.sh/cs2/simpleadmin-web/internal/store" +) + +// scrubIPs removes IP addresses for staff without @css/showip, like SimpleAdmin's css_players does. +func scrubIPs(id *Identity, ps []store.Penalty) []store.Penalty { + if id.Has("@css/showip") { + return ps + } + for i := range ps { + ps[i].IP = "" + } + return ps +} + +func nonNil[T any](v []T) []T { + if v == nil { + return []T{} + } + return v +} + +func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { + ctx := r.Context() + view := s.serverView(x, s.poll.Current()) + + var ids []string + for _, p := range view.Players { + if !p.Bot { + ids = append(ids, p.SteamID) + } + } + warns, err := s.st.ActiveWarnCounts(ctx, ids) + if err != nil { + internal(w, r, err) + return + } + for i, p := range view.Players { + if p.Bot { + continue + } + view.Players[i].CanTarget = id.CanTarget(x, p.SteamID) + comms, _, err := s.st.Comms(ctx, store.Query{SteamID: p.SteamID, Status: store.StatusActive, Limit: 5}) + if err != nil { + internal(w, r, err) + return + } + for _, c := range comms { + view.Players[i].Tags = append(view.Players[i].Tags, c.Type) + } + if n := warns[p.SteamID]; n > 0 { + view.Players[i].Tags = append(view.Players[i].Tags, "WARN:"+strconv.Itoa(n)) + } + } + + activity, err := s.st.Activity(ctx, 12) + if err != nil { + internal(w, r, err) + return + } + expiring, err := s.st.Expiring(ctx, 6) + if err != nil { + internal(w, r, err) + return + } + _, activeBans, err := s.st.Bans(ctx, store.Query{Status: store.StatusActive, Limit: 1}) + if err != nil { + internal(w, r, err) + return + } + _, activeComms, err := s.st.Comms(ctx, store.Query{Status: store.StatusActive, Limit: 1}) + if err != nil { + internal(w, r, err) + return + } + staff := 0 + for _, a := range x.admins { + if s.isStaff(x, a.SteamID) { + staff++ + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "server": view, "serverError": s.poll.Current().Error, + "activity": nonNil(activity), "expiring": nonNil(scrubIPs(id, expiring)), + "activeBans": activeBans, "activeComms": activeComms, "staffCount": staff, + }) +} + +type playerRow struct { + SteamID string `json:"steamid"` + Name string `json:"name"` + Online bool `json:"online"` + LastSeen *time.Time `json:"lastSeen,omitempty"` + Rank *rankView `json:"rank,omitempty"` + Bans int `json:"bans"` + Comms int `json:"comms"` + Warns int `json:"warns"` + Banned bool `json:"banned"` +} + +func (s *Server) adminPlayers(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { + ctx := r.Context() + search := strings.TrimSpace(r.URL.Query().Get("q")) + lim, off, num := page(r) + st := s.poll.Current() + + var rows []playerRow + total := 0 + switch r.URL.Query().Get("filter") { + case "all": + players, n, err := s.st.Players(ctx, search, id.Has("@css/showip"), nil, lim, off) + if err != nil { + internal(w, r, err) + return + } + total = n + for _, p := range players { + seen := p.LastSeen + rows = append(rows, playerRow{SteamID: p.SteamID, Name: p.Name, LastSeen: &seen}) + } + case "ranked": + var ids []string + for _, a := range x.admins { + ids = append(ids, a.SteamID) + } + seen := map[string]time.Time{} + if players, _, err := s.st.Players(ctx, "", false, ids, 200, 0); err == nil { + for _, p := range players { + seen[p.SteamID] = p.LastSeen + } + } + for _, a := range x.admins { + if search != "" && !strings.Contains(strings.ToLower(a.Name), strings.ToLower(search)) && a.SteamID != search { + continue + } + row := playerRow{SteamID: a.SteamID, Name: a.Name} + if t, ok := seen[a.SteamID]; ok { + row.LastSeen = &t + } + rows = append(rows, row) + } + slices.SortStableFunc(rows, func(a, b playerRow) int { return x.immunity(b.SteamID) - x.immunity(a.SteamID) }) + total = len(rows) + rows = rows[min(off, len(rows)):min(off+lim, len(rows))] + default: // online + for _, p := range st.Humans() { + if search != "" && !strings.Contains(strings.ToLower(p.Name), strings.ToLower(search)) && p.SteamID != search { + continue + } + rows = append(rows, playerRow{SteamID: p.SteamID, Name: p.Name}) + } + total = len(rows) + } + + var ids []string + for _, p := range rows { + ids = append(ids, p.SteamID) + } + bans, comms, err := s.st.RecordCounts(ctx, ids) + if err != nil { + internal(w, r, err) + return + } + warns, err := s.st.ActiveWarnCounts(ctx, ids) + if err != nil { + internal(w, r, err) + return + } + for i := range rows { + p := &rows[i] + _, p.Online = st.Find(p.SteamID) + p.Rank = s.rankOf(x, p.SteamID) + p.Bans, p.Comms, p.Warns = bans[p.SteamID], comms[p.SteamID], warns[p.SteamID] + if p.Bans > 0 { + _, active, err := s.st.Bans(ctx, store.Query{SteamID: p.SteamID, Status: store.StatusActive, Limit: 1}) + if err == nil { + p.Banned = active > 0 + } + } + } + writeJSON(w, http.StatusOK, newList(rows, total, num)) +} + +func (s *Server) adminPlayer(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { + ctx := r.Context() + steamid := r.PathValue("steamid") + if !steam.ValidID(steamid) { + fail(w, http.StatusBadRequest, "That isn't a SteamID64.") + return + } + d, err := s.st.Player(ctx, steamid) + if errors.Is(err, store.ErrNotFound) { + d = store.PlayerDetail{SteamID: steamid, Names: []string{}} + } else if err != nil { + internal(w, r, err) + return + } + if !id.Has("@css/showip") { + d.IPs = nil + } + bans, _, err := s.st.Bans(ctx, store.Query{SteamID: steamid, Limit: 200}) + if err != nil { + internal(w, r, err) + return + } + comms, _, err := s.st.Comms(ctx, store.Query{SteamID: steamid, Limit: 200}) + if err != nil { + internal(w, r, err) + return + } + warns, err := s.st.Warns(ctx, steamid) + if err != nil { + internal(w, r, err) + return + } + lp, online := s.poll.Current().Find(steamid) + resp := map[string]any{ + "player": d, "online": online, "rank": s.rankOf(x, steamid), + "immunity": x.immunity(steamid), "canTarget": id.CanTarget(x, steamid), + "bans": nonNil(scrubIPs(id, bans)), "comms": nonNil(comms), "warns": nonNil(warns), + } + if online { + resp["live"] = lp + } + if a, ok := x.bySteam[steamid]; ok { + resp["groups"] = a.Groups + resp["adminEnds"] = a.Ends + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) adminBans(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { + q, num := penaltyQuery(r, false) + q.SearchAdmin, q.SearchIP = true, id.Has("@css/showip") + items, total, err := s.st.Bans(r.Context(), q) + if err != nil { + internal(w, r, err) + return + } + writeJSON(w, http.StatusOK, newList(scrubIPs(id, items), total, num)) +} + +func (s *Server) adminComms(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { + q, num := penaltyQuery(r, true) + q.SearchAdmin = true + items, total, err := s.st.Comms(r.Context(), q) + if err != nil { + internal(w, r, err) + return + } + writeJSON(w, http.StatusOK, newList(items, total, num)) +} + +// knownFlags are the permissions offered as checkboxes. Groups may hold others; those are kept. +var knownFlags = [][3]string{ + {"@css/reservation", "Reserved slot", "Joins when the server is full"}, + {"@css/generic", "Generic admin", "Required for any admin command and for this panel"}, + {"@css/chat", "Chat", "Admin chat, gag, mute and silence"}, + {"@css/permmute", "Permanent blocks", "Gag and mute longer than the limit, or permanently"}, + {"@css/kick", "Kick", "Kick and warn players"}, + {"@css/slay", "Slay", "Slay, slap and respawn players"}, + {"@css/ban", "Ban", "Ban players, up to the longest ban setting"}, + {"@css/permban", "Permanent bans", "Ban longer than the limit, or permanently"}, + {"@css/unban", "Unban", "Lift bans"}, + {"@css/showip", "Show IPs", "See players' IP addresses"}, + {"@css/changemap", "Change map", "Change or restart the map"}, + {"@css/vote", "Votes", "Start votes"}, + {"@css/cvar", "Cvars", "Change server cvars"}, + {"@css/config", "Config", "Exec config files"}, + {"@css/rcon", "RCON", "Run raw console commands"}, + {"@css/cheats", "Cheats", "sv_cheats-protected commands"}, + {"@css/vip", "VIP", "VIP perks in other plugins"}, + {"@css/root", "Root", "Every permission, and managing ranks and staff"}, +} + +func (s *Server) adminStaff(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { + st := s.poll.Current() + groups := s.groupViews(x, st, true) + + var unranked []memberView + for _, a := range x.admins { + if x.primaryGroup(&a) == "" { + _, online := st.Find(a.SteamID) + unranked = append(unranked, memberView{SteamID: a.SteamID, Name: a.Name, Online: online, Ends: a.Ends}) + } + } + // Last-seen times for members, from SimpleAdmin's connection log. + var ids []string + for _, a := range x.admins { + ids = append(ids, a.SteamID) + } + if players, _, err := s.st.Players(r.Context(), "", false, ids, 200, 0); err == nil { + seen := map[string]time.Time{} + for _, p := range players { + seen[p.SteamID] = p.LastSeen + } + for gi := range groups { + for mi := range groups[gi].Members { + if t, ok := seen[groups[gi].Members[mi].SteamID]; ok { + groups[gi].Members[mi].LastSeen = &t + } + } + } + for mi := range unranked { + if t, ok := seen[unranked[mi].SteamID]; ok { + unranked[mi].LastSeen = &t + } + } + } + + flags := make([]map[string]string, 0, len(knownFlags)) + for _, f := range knownFlags { + flags = append(flags, map[string]string{"flag": f[0], "label": f[1], "help": f[2]}) + } + writeJSON(w, http.StatusOK, map[string]any{ + "groups": groups, "unranked": nonNil(unranked), "flags": flags, + "canManage": id.Has("@css/root"), "myImmunity": id.Immunity, "me": id.SteamID, + }) +} + +// ---------------------------------------------------------------- Settings + +type cvarDef struct { + Key string `json:"key"` + Label string `json:"label"` + Help string `json:"help"` + Type string `json:"type"` // text, int, bool + Value any `json:"value"` +} + +var cvars = []cvarDef{ + {Key: "hostname", Label: "Server name", Help: "Shown in the server browser.", Type: "text"}, + {Key: "sv_password", Label: "Join password", Help: "Leave empty for a public server.", Type: "text"}, + {Key: "sv_visiblemaxplayers", Label: "Visible slots", Help: "Slots shown in the server browser. -1 shows them all.", Type: "int"}, + {Key: "mp_autoteambalance", Label: "Auto team balance", Help: "Move players to keep teams even.", Type: "bool"}, + {Key: "mp_friendlyfire", Label: "Friendly fire", Help: "Let teammates damage each other.", Type: "bool"}, + {Key: "mp_timelimit", Label: "Map time limit", Help: "Minutes. 0 for no limit.", Type: "int"}, + {Key: "mp_maxrounds", Label: "Rounds per map", Help: "The map ends after this many rounds.", Type: "int"}, +} + +var cvarLine = regexp.MustCompile(`(?m)^\s*"?([A-Za-z0-9_]+)"?\s*=\s*"?([^"\r\n]*?)"?\s*(?:\(|$)`) + +// readCvar asks the server for a cvar's value. CS2 prints `name = value` (sometimes quoted, sometimes +// followed by flags and help text). +func (s *Server) readCvar(key string) (string, bool) { + out, err := s.rc.Exec(key) + if err != nil { + return "", false + } + for _, m := range cvarLine.FindAllStringSubmatch(out, -1) { + if strings.EqualFold(m[1], key) { + return strings.TrimSpace(m[2]), true + } + } + return "", false +} + +func (s *Server) adminSettings(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { + resp := map[string]any{"canCvar": id.Has("@css/cvar"), "canRoot": id.Has("@css/root")} + + if id.Has("@css/cvar") && s.poll.Current().Online { + defs := make([]cvarDef, 0, len(cvars)) + for _, c := range cvars { + v, ok := s.readCvar(c.Key) + if !ok { + continue + } + switch c.Type { + case "bool": + c.Value = v == "1" || strings.EqualFold(v, "true") + case "int": + c.Value = strings.TrimSuffix(strings.SplitN(v, ".", 2)[0], " ") + default: + c.Value = v + } + defs = append(defs, c) + } + resp["cvars"] = defs + } + + sa := map[string]any{"available": s.sacfg != nil} + if s.sacfg != nil { + if id.Has("@css/root") { + sa["path"] = s.sacfg.Path() + } + if vals, err := s.sacfg.Values(); err == nil { + sa["settings"], sa["values"] = saconfig.Settings, vals + } else { + sa["error"] = "Couldn't read CS2-SimpleAdmin.json. Check the panel's log." + } + } + resp["simpleadmin"] = sa + + dbOK := s.st.DB().PingContext(r.Context()) == nil + resp["database"] = map[string]any{"connected": dbOK} + resp["server"] = map[string]any{"online": s.poll.Current().Online, "error": s.poll.Current().Error} + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/web/assets/admin.html b/internal/web/assets/admin.html new file mode 100644 index 0000000..bd8201b --- /dev/null +++ b/internal/web/assets/admin.html @@ -0,0 +1,55 @@ + + + + + + Staff panel + + + + + + +
+ + +
+
+ + +
+
+
+
+ +
+ +
+ + + + + diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js new file mode 100644 index 0000000..2130f84 --- /dev/null +++ b/internal/web/assets/admin.js @@ -0,0 +1,835 @@ +// Staff panel: hash-routed pages over the /api/admin endpoints. Buttons only appear for what the +// signed-in rank may do, but the server checks every action again. + +const PAGES = [ + { id: "overview", label: "Overview" }, + { id: "players", label: "Players" }, + { id: "bans", label: "Bans" }, + { id: "comms", label: "Gags & mutes" }, + { id: "staff", label: "Staff & ranks" }, + { id: "settings", label: "Server settings" }, +]; + +const state = { + page: "overview", + banFilter: "active", + commFilter: "active", + playerFilter: "online", + query: "", + num: 1, + group: null, + me: null, + counts: {}, +}; + +const can = (flag) => !!state.me && (state.me.perms.includes("@css/root") || state.me.perms.includes(flag)); + +/* ---------- Overview ---------- */ + +const ACTIVITY_VERB = { + ban: "banned", warn: "warned", unban: "unbanned", + comm: { GAG: "gagged", MUTE: "muted", SILENCE: "silenced" }, + unmute: { GAG: "lifted the gag on", MUTE: "lifted the mute on", SILENCE: "lifted the silence on" }, +}; +const ACTIVITY_COLOR = { ban: "var(--accent)", comm: "var(--amber)", warn: "var(--faint)", unban: "var(--green)", unmute: "var(--green)" }; + +function activityLine(a) { + const verb = typeof ACTIVITY_VERB[a.kind] === "object" ? ACTIVITY_VERB[a.kind][a.type] : ACTIVITY_VERB[a.kind]; + const len = (a.kind === "ban" || a.kind === "comm") ? (a.duration === 0 ? " permanently" : ` for ${fmtDuration(a.duration)}`) : ""; + return `
  • ${esc(a.admin)} ${verb} ${esc(a.name || a.steamid)}${len}

    + ${fmtAgo(a.at)}${a.reason ? `, ${esc(a.reason)}` : ""}
  • `; +} + +function liveHeader(s) { + if (!s.online) { + return `

    The panel can't reach the game server, so live status and in-game actions are off. Bans and blocks still save to the database.

    Offline
    `; + } + return `
    +
    +

    ${esc(s.hostname)}${s.warmup ? ", warmup" : ""}

    +
    ${esc(s.map)}
    +
    +
    +
    ${s.count}/${s.maxPlayers}Players
    +
    ${s.scoreT} : ${s.scoreCt}T vs CT
    +
    ${s.staffOnline}Staff online
    +
    +
    `; +} + +function tagsFor(p) { + return (p.tags || []).map((t) => { + if (t.startsWith("WARN:")) { + const n = t.slice(5); + return `${esc(n)} warn${n === "1" ? "" : "s"}`; + } + return `${COMM_LABEL[t]}`; + }).join(" "); +} + +function onlineTable(players) { + const list = players.filter((p) => !p.bot).sort((a, b) => (a.team === b.team ? b.kills - a.kills : b.team - a.team)); + if (!list.length) return '
    Nobody is on the server.
    '; + const teamName = { 2: "T", 3: "CT" }; + return ` + + + ${list.map((p) => ` + + + + + + `).join("")} + +
    PlayerRankK / DPingActions
    ${teamName[p.team] || "SPEC"}
    ${esc(p.name)} ${tagsFor(p)}
    ${rankTag(p.rank)}${p.kills} / ${p.deaths}${p.ping}
    ${p.canTarget ? ` + ${can("@css/chat") ? `` : ""} + ${can("@css/kick") ? `` : ""} + ${can("@css/ban") ? `` : ""}` : ""} +
    `; +} + +async function overview() { + const v = $("#view"); + let d; + try { d = await api("/api/admin/overview"); } catch (err) { v.innerHTML = errorBox(err); return; } + if (state.page !== "overview") return; + state.counts = { bans: d.activeBans, comms: d.activeComms, staff: d.staffCount, players: d.server.online ? `${d.server.count}/${d.server.maxPlayers}` : "" }; + renderNav(); + setServerPick(d.server); + const s = d.server; + v.innerHTML = ` + ${liveHeader(s)} +
    +
    +
    +

    On the server

    +
    + ${can("@css/changemap") ? '' : ""} + ${can("@css/chat") ? '' : ""} +
    +
    +
    ${s.online ? onlineTable(s.players) : '
    Offline.
    '}
    +
    + +
    +
    +

    Recent actions

    All bans
    + ${d.activity.length ? `
      ${d.activity.map(activityLine).join("")}
    ` : '
    Nothing yet.
    '} +
    +
    +

    Ending soon

    ${d.activeBans} bans, ${d.activeComms} blocks active

    + ${d.expiring.length ? `
      ${d.expiring.map((p) => `
    • ${esc(p.name || p.steamid)} ${p.kind === "ban" ? "ban" : COMM_LABEL[p.type].toLowerCase()}${fmtLeft(served(p).left)}
    • `).join("")}
    ` : '
    No timed bans or blocks.
    '} +
    +
    +
    `; +} + +function setServerPick(s) { + const el = $("#server-pick"); + el.innerHTML = s.online + ? `${esc(s.hostname)}${esc(s.map)}, ${s.count}/${s.maxPlayers} players` + : `Server offlineLive status unavailable`; +} + +/* ---------- Players ---------- */ + +function players() { + $("#view").innerHTML = ` +
    +

    Players

    Everyone SimpleAdmin has seen join, with their rank and record. Search by name, SteamID64${can("@css/showip") ? " or IP address" : ""}.

    +
    +
    +
    + ${searchBox("Search players", state.query)} + ${segmented("playerFilter", state.playerFilter, [["online", "Online"], ["ranked", "Ranked"], ["all", "All"]])} +
    +
    ${loading()}
    +
    `; + loadResults(); +} + +function playersTable(list) { + if (!list.items.length) { + if (state.query) return `
    No players match "${esc(state.query)}". Try a SteamID64 or part of a name.
    `; + return `
    ${state.playerFilter === "online" ? "Nobody is on the server." : "No players yet."}
    `; + } + return ` + + + ${list.items.map((p) => ` + + + + + `).join("")} + +
    PlayerRankRecordLast seen
    ${initial(p.name)}
    ${esc(p.name || p.steamid)}${esc(p.steamid)}
    ${rankTag(p.rank)} + ${p.banned ? 'Banned' : ""} + ${p.bans ? `${p.bans} ban${p.bans > 1 ? "s" : ""}` : ""} + ${p.comms ? `${p.comms} block${p.comms > 1 ? "s" : ""}` : ""} + ${p.warns ? `${p.warns} warn${p.warns > 1 ? "s" : ""}` : ""} + ${!p.bans && !p.comms && !p.warns ? 'Clean' : ""} + ${p.online ? 'Online' : p.lastSeen ? `${fmtAgo(p.lastSeen)}` : 'Never joined'}
    ${pager(list)}`; +} + +/* ---------- Bans and comms ---------- */ + +function punishTable(list, kind) { + if (!list.items.length) { + return `
    ${state.query ? `Nothing matches "${esc(state.query)}".` : kind === "ban" ? "No bans in this view." : "No gags or mutes in this view."}
    `; + } + const canLift = kind === "ban" ? can("@css/unban") : can("@css/chat"); + const canAdd = kind === "ban" ? can("@css/ban") : can("@css/chat"); + return ` + + + ${kind === "comm" ? "" : ""} + + + + + + + + ${list.items.map((p) => ` + + ${kind === "comm" ? `` : ""} + + + + + + `).join("")} + +
    PlayerTypeReasonLengthIssuedStatusActions
    ${p.steamid ? `${esc(p.name || p.steamid)}` : esc(p.name || "Unknown")}${esc(p.steamid)}${p.ip ? `
    ${esc(p.ip)}` : ""}
    ${COMM_LABEL[p.type]}${esc(p.reason)}${p.liftedBy || p.liftReason ? `
    Lifted${p.liftedBy ? ` by ${esc(p.liftedBy)}` : ""}${p.liftReason ? `: ${esc(p.liftReason)}` : ""}` : ""}
    ${termCell(p)}${fmtDate(p.created)}
    by ${esc(p.admin || "Console")}
    ${statusTag(p)}
    + ${p.status === "active" && canLift && p.steamid ? `` : ""} + ${p.status !== "active" && canAdd && p.steamid ? `` : ""} +
    ${pager(list)}`; +} + +function bans() { + $("#view").innerHTML = ` +
    +

    Bans

    The bar under each length shows how much of the ban has been served. Striped bars never expire.

    + ${can("@css/ban") ? `` : ""} +
    +
    +
    + ${searchBox(`Search by name, SteamID, ${can("@css/showip") ? "IP, " : ""}reason or admin`, state.query)} + ${segmented("banFilter", state.banFilter, [["active", "Active"], ["expired", "Expired"], ["lifted", "Lifted"], ["all", "All"]])} +
    +
    ${loading()}
    +
    `; + loadResults(); +} + +function comms() { + $("#view").innerHTML = ` +
    +

    Gags & mutes

    A gag blocks text chat, a mute blocks voice, and a silence blocks both.

    + ${can("@css/chat") ? `` : ""} +
    +
    +
    + ${searchBox("Search by name, SteamID, reason or admin", state.query)} + ${segmented("commFilter", state.commFilter, [["active", "Active"], ["GAG", "Gags"], ["MUTE", "Mutes"], ["SILENCE", "Silences"], ["expired", "Expired"], ["all", "All"]])} +
    +
    ${loading()}
    +
    `; + loadResults(); +} + +async function loadResults() { + const box = $("#results"); + if (!box) return; + let path, status, render; + if (state.page === "players") { + path = "/api/admin/players"; render = playersTable; + } else if (state.page === "bans") { + path = "/api/admin/bans"; status = state.banFilter; render = (l) => punishTable(l, "ban"); + } else { + path = "/api/admin/comms"; status = state.commFilter; render = (l) => punishTable(l, "comm"); + } + const params = { status, q: state.query, page: state.num, filter: state.page === "players" ? state.playerFilter : undefined }; + const want = JSON.stringify([state.page, params]); + loadResults.want = want; + try { + const list = await api(path + qs(params)); + if (loadResults.want !== want) return; + box.innerHTML = render(list); + } catch (err) { + box.innerHTML = errorBox(err); + } +} + +/* ---------- Staff & ranks ---------- */ + +async function staff() { + const v = $("#view"); + let d; + try { d = await api("/api/admin/staff"); } catch (err) { v.innerHTML = errorBox(err); return; } + if (state.page !== "staff") return; + staff.data = d; + const manage = d.canManage; + if (!d.groups.some((g) => g.id === state.group)) state.group = d.groups[0]?.id ?? null; + const g = d.groups.find((x) => x.id === state.group); + + const ladderItem = (x) => `
  • `; + const staffGroups = d.groups.filter((x) => !x.supporter); + const supporters = d.groups.filter((x) => x.supporter); + + const memberRow = (p, groupName) => ` +
    ${initial(p.name)}
    ${esc(p.name)}${esc(p.steamid)}
    + ${p.online ? 'Online' : p.lastSeen ? `Seen ${fmtAgo(p.lastSeen)}` : 'Never joined'} + ${p.ends ? `Until ${fmtDate(p.ends)}` : "No expiry"} +
    ${manage && p.steamid !== d.me ? `` : ""}
    + `; + + const allFlags = [...d.flags]; + if (g) for (const f of g.flags) if (!allFlags.some((x) => x.flag === f)) allFlags.push({ flag: f, label: f, help: "Custom permission" }); + const root = g && g.flags.includes("@css/root"); + + v.innerHTML = ` +
    +

    Staff & ranks

    Ranks are listed by immunity. Staff can only act on players whose immunity isn't higher than their own.${manage ? "" : " Only Root can change ranks and staff."}

    + ${manage ? `
    + + +
    ` : ""} +
    + + ${d.groups.length ? `
    +
    +

    Ranks

    Immunity

    +
      + ${staffGroups.map(ladderItem).join("")} + ${supporters.length ? `
    • Supporter ranks
    • ${supporters.map(ladderItem).join("")}` : ""} +
    +
    + +
    +
    +
    +

    ${esc(g.label)}

    + ${esc(g.name)} +
    +
    +
    + + +
    +
    +

    Permissions

    +
    + ${allFlags.map((f) => { + const on = g.flags.includes(f.flag); + const inherited = root && f.flag !== "@css/root"; + return ``; + }).join("")} +
    +
    + ${manage ? `
    + + +
    ` : ""} +
    +
    + +
    +

    Members

    ${g.members.length} ${g.members.length === 1 ? "person" : "people"}

    +
    + ${g.members.length ? `${g.members.map((p) => memberRow(p, g.name)).join("")}
    ` + : `
    Nobody has this rank yet.${manage ? " Use Add staff to give it to someone." : ""}
    `} +
    +
    + + ${d.unranked.length ? `
    +

    Admins without a rank

    Permissions set directly in SimpleAdmin

    +
    ${d.unranked.map((p) => memberRow(p, "")).join("")}
    +
    ` : ""} +
    +
    ` : `
    SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}
    `}`; +} + +/* ---------- Settings ---------- */ + +async function settings() { + const v = $("#view"); + let d; + try { d = await api("/api/admin/settings"); } catch (err) { v.innerHTML = errorBox(err); return; } + if (state.page !== "settings") return; + const sa = d.simpleadmin; + + const cvarControl = (c) => { + if (c.type === "bool") return ``; + if (c.type === "int") return ``; + return ``; + }; + const saControl = (s) => { + const val = sa.values[s.key]; + const dis = d.canRoot ? "" : "disabled"; + if (s.type === "bool") return ``; + if (s.type === "select") return ``; + return ``; + }; + const row = (label, help, key, control) => `
    +
    ${esc(label)}${help ? `

    ${esc(help)}

    ` : ""}${key ? `${esc(key)}` : ""}
    +
    ${control}
    +
    `; + + v.innerHTML = ` +
    +

    Server settings

    Server changes go live over RCON. SimpleAdmin's settings are saved to its config file.

    +
    +
    +
    +

    Server

    Live over RCON. Resets when the server restarts.

    + ${!d.canCvar ? '
    Your rank can\'t change server settings.
    ' + : !d.server.online ? '
    The game server isn\'t answering, so these can\'t be read or changed right now.
    ' + : (d.cvars || []).map((c) => row(c.label, c.help, c.key, cvarControl(c))).join("")} +
    + +
    +

    Punishments

    ${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}

    + ${!sa.available ? '
    The panel wasn\'t given CS2-SimpleAdmin.json (SAW_SA_CONFIG), so these can\'t be shown or changed here.
    ' + : sa.error ? `
    ${esc(sa.error)}
    ` + : sa.settings.map((s) => row(s.label, s.help, s.key, saControl(s))).join("")} +
    + +
    +

    Connections

    + ${row("Database", "SimpleAdmin's MySQL database.", "", d.database.connected ? 'Connected' : 'Not connected')} + ${row("Game server", d.server.online ? "Live status through the WebPanelBridge plugin." : d.server.error || "", "", d.server.online ? 'Connected' : 'Not answering')} +
    + +
    +

    Maintenance

    Each of these asks first.

    + ${can("@css/root") ? row("Reload admins", "Re-read ranks and staff from the database. Runs css_reloadadmins.", "", '') : ""} + ${can("@css/generic") ? row("Restart the match", "Restarts the game on the current map. Runs css_rr.", "", '') : ""} + ${can("@css/changemap") ? row("Change map", "Switch to another map, or a Workshop map with ws:.", "", '') : ""} +
    +
    + `; +} + +function markDirty() { const b = $("#savebar"); if (b) b.hidden = false; } + +async function saveSettings() { + const cvars = {}; + const sa = {}; + document.querySelectorAll("[data-cvar].dirty").forEach((el) => { + const key = el.dataset.cvar; + if (el.classList.contains("switch")) cvars[key] = el.getAttribute("aria-checked") === "true"; + else cvars[key] = el.type === "number" ? Number(el.value) : el.value; + }); + document.querySelectorAll("[data-sa].dirty").forEach((el) => { + const key = el.dataset.sa; + if (el.classList.contains("switch")) sa[key] = el.getAttribute("aria-checked") === "true"; + else sa[key] = Number(el.value); + }); + const msgs = []; + try { + if (Object.keys(cvars).length) msgs.push((await api("/api/admin/settings/cvars", { method: "POST", body: cvars })).message); + if (Object.keys(sa).length) msgs.push((await api("/api/admin/settings/simpleadmin", { method: "POST", body: sa })).message); + } catch (err) { + toast(err.message); + return; + } + toast(msgs.join(" ") || "Nothing to save."); + settings(); +} + +/* ---------- Player drawer ---------- */ + +async function openPlayer(sid) { + $("#drawer-root").innerHTML = `
    `; + let d; + try { d = await api(`/api/admin/players/${encodeURIComponent(sid)}`); } catch (err) { + $(".drawer").innerHTML = errorBox(err); + return; + } + const p = d.player; + const name = p.name || p.steamid; + const history = [ + ...d.bans.map((b) => ({ ...b, label: "Ban" })), + ...d.comms.map((c) => ({ ...c, label: COMM_LABEL[c.type] })), + ...d.warns.map((w) => ({ ...w, label: "Warning" })), + ].sort((a, b) => new Date(b.created) - new Date(a.created)); + const t = d.canTarget; + const btn = (act, label, perm, extra = "", cls = "") => can(perm) + ? `` : ""; + const manage = can("@css/root") && p.steamid !== state.me.steamid; + + $("#drawer-root").innerHTML = ` +
    + `; + $(".drawer .close").focus(); +} + +/* ---------- Dialogs ---------- */ + +const DURATIONS = [[30, "30 min"], [60, "1 hour"], [1440, "1 day"], [10080, "1 week"], [43200, "30 days"], [0, "Permanent"]]; +const REASONS = { + ban: ["Cheating", "Ban evasion", "Toxicity", "Griefing", "Exploiting"], + comm: ["Spam", "Toxicity", "Mic spam", "Soundboard", "Advertising"], + warn: ["Toxicity", "Spam", "Griefing", "Ignoring staff"], +}; + +function punishDialog(kind, { sid = "", name = "", type = "GAG", reason = "" } = {}) { + const title = { ban: "Ban player", comm: "Gag or mute player", warn: "Warn player" }[kind]; + const submit = { ban: "Ban player", comm: "Apply block", warn: "Warn player" }[kind]; + const perm = kind === "ban" ? "@css/permban" : "@css/permmute"; + openModal(` +
    + + + +
    `); +} + +async function rankDialog(sid, name, current) { + if (!staff.data) { + try { staff.data = await api("/api/admin/staff"); } catch (err) { toast(err.message); return; } + } + const groups = staff.data.groups; + const opts = (groups || []).map((g) => ``).join(""); + openModal(` +
    + + + +
    `); +} + +function newGroupDialog() { + openModal(` +
    + + + +
    `); +} + +function textDialog(kind) { + const cfg = { + map: { title: "Change map", label: "Map", placeholder: "de_mirage, or ws:", submit: "Change map", pattern: "(ws:)?[A-Za-z0-9_\\-]{1,64}" }, + say: { title: "Message everyone", label: "Message", placeholder: "Shown in chat to everyone on the server", submit: "Send", pattern: ".{1,200}" }, + kick: { title: "Kick player", label: "Reason", placeholder: "Shown to the player", submit: "Kick", pattern: ".{0,200}" }, + }[kind]; + return cfg; +} + +function inputDialog(kind, data = {}) { + const c = textDialog(kind); + openModal(` +
    + + + +
    `); +} + +function confirmDialog(title, body, label, run) { + confirmDialog.run = run; + openModal(` +
    + + + +
    `); +} + +function openModal(html) { + const m = $("#modal"); + m.innerHTML = html; + m.showModal(); +} + +// act runs an API call, shows its message, and refreshes the page and any open drawer. +async function act(path, method, body) { + try { + const res = await api(path, { method, body }); + toast(res.message || "Done."); + } catch (err) { + toast(err.message); + return false; + } + const drawerSid = $(".drawer [data-sid]")?.dataset.sid; + route(); + if (drawerSid) openPlayer(drawerSid); + return true; +} + +/* ---------- Routing & events ---------- */ + +const RENDER = { overview, players, bans, comms, staff, settings }; + +function renderNav() { + const c = state.counts; + const count = { players: c.players, bans: c.bans, comms: c.comms, staff: c.staff }; + $("#nav-links").innerHTML = PAGES.map((p) => `
  • ${p.label}${count[p.id] !== undefined && count[p.id] !== "" ? `${esc(count[p.id])}` : ""}
  • `).join(""); +} + +function route() { + const page = location.hash.slice(1); + state.page = RENDER[page] ? page : "overview"; + renderNav(); + $("#nav").classList.remove("open"); + $("#menu-btn").setAttribute("aria-expanded", "false"); + if (!$("#view").innerHTML) $("#view").innerHTML = loading(); + RENDER[state.page](); +} + +window.addEventListener("hashchange", () => { state.query = ""; state.num = 1; $("#view").innerHTML = loading(); route(); window.scrollTo(0, 0); }); + +const search = debounce(() => loadResults(), 250); + +document.addEventListener("input", (e) => { + const t = e.target; + if (t.id === "q") { state.query = t.value; state.num = 1; search(); return; } + if (t.matches("[data-cvar], [data-sa]")) { t.classList.add("dirty"); markDirty(); } +}); + +document.addEventListener("change", (e) => { + if (e.target.matches("select[data-sa]")) { e.target.classList.add("dirty"); markDirty(); } +}); + +document.addEventListener("click", (e) => { + const t = e.target; + + if (t.closest("#menu-btn")) { + const open = $("#nav").classList.toggle("open"); + $("#menu-btn").setAttribute("aria-expanded", String(open)); + return; + } + if (t.closest("#signout")) { + api("/auth/logout", { method: "POST" }).finally(() => { location.href = "/"; }); + return; + } + + const seg = t.closest("[data-seg] button"); + if (seg) { + const group = seg.parentElement; + group.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg))); + const key = group.dataset.seg; + if (key in state) { state[key] = seg.dataset.value; state.num = 1; loadResults(); } + return; + } + + const pg = t.closest("[data-page]"); + if (pg && !pg.disabled) { state.num = Number(pg.dataset.page); loadResults(); return; } + + const chip = t.closest("[data-chips] .chip-btn"); + if (chip) { + const box = chip.parentElement; + if (box.dataset.chips === "reason") $("#reason").value = chip.dataset.value; + box.querySelectorAll(".chip-btn").forEach((b) => b.setAttribute("aria-pressed", String(b === chip))); + return; + } + + const sw = t.closest(".switch"); + if (sw && !sw.disabled) { + sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true")); + sw.classList.add("dirty"); + markDirty(); + return; + } + + const grp = t.closest("[data-group]:not([data-act])"); + if (grp) { state.group = Number(grp.dataset.group); staff(); return; } + + if (t.closest("[data-close]")) { closeDrawer(); return; } + if (t.closest("[data-modal-close]")) { $("#modal").close(); return; } + + const a = t.closest("[data-act]"); + if (a) { + e.preventDefault(); + e.stopPropagation(); + if (a.disabled) return; + const d = a.dataset; + switch (d.act) { + case "ban": return punishDialog("ban", { sid: d.sid, name: d.name, reason: d.reason }); + case "comm": return punishDialog("comm", { sid: d.sid, name: d.name, type: d.type, reason: d.reason }); + case "warn": return punishDialog("warn", { sid: d.sid, name: d.name }); + case "kick": return inputDialog("kick", { sid: d.sid, name: d.name }); + case "map": return inputDialog("map"); + case "say": return inputDialog("say"); + case "lift": { + const what = d.kind === "ban" ? "Unban" : `Lift ${COMM_LABEL[d.type].toLowerCase()}`; + confirmDialog.reason = true; + return confirmDialog(what, d.kind === "ban" + ? `Unban ${esc(d.name || "this player")}? They can join again straight away. This lifts every active ban on their SteamID.` + : `Lift the ${COMM_LABEL[d.type].toLowerCase()} on ${esc(d.name || "this player")}?`, + d.kind === "ban" ? "Unban" : "Lift", (form) => act("/api/admin/penalties/lift", "POST", { kind: d.kind, id: Number(d.id), reason: form.reason?.value || "" })); + } + case "rank": return rankDialog(d.sid, d.name, d.group); + case "add-staff": return rankDialog("", "", ""); + case "new-group": return newGroupDialog(); + case "delete-group": + confirmDialog.reason = false; + return confirmDialog("Delete rank", `Delete ${esc(d.name)}? Its members become regular players.`, "Delete rank", + () => act(`/api/admin/groups/${d.id}`, "DELETE")); + case "reload-admins": + confirmDialog.reason = false; + return confirmDialog("Reload admins", "Re-read all ranks and staff from the database?", "Reload admins", () => act("/api/admin/reload-admins", "POST", {})); + case "restart": + confirmDialog.reason = false; + return confirmDialog("Restart the match", "Restart the game on the current map? The current match ends.", "Restart match", () => act("/api/admin/restart", "POST", {})); + case "save-settings": return saveSettings(); + case "discard": return settings(); + } + return; + } + + const row = t.closest("[data-player]"); + if (row) { e.preventDefault(); openPlayer(row.dataset.player); } +}); + +document.addEventListener("submit", async (e) => { + const form = e.target; + e.preventDefault(); + const kind = form.dataset.submit; + const f = form.elements; + + if (form.id === "group-form") { + const flags = [...form.querySelectorAll('input[name="flag"]')].filter((i) => i.checked && (!i.disabled || i.dataset.kept)).map((i) => i.value); + await act(`/api/admin/groups/${form.dataset.id}`, "PUT", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags }); + return; + } + + $("#modal").close(); + switch (kind) { + case "penalty": { + const dur = form.querySelector('[data-chips="dur"] [aria-pressed="true"]'); + const type = form.querySelector('[data-seg="commType"] [aria-pressed="true"]')?.dataset.value; + await act("/api/admin/penalties", "POST", { + kind: form.dataset.kind, type: type || "", steamid: f.steamid.value.trim(), + duration: Number(dur?.dataset.value ?? 60), reason: f.reason.value, + }); + break; + } + case "rank": { + const sid = f.steamid.value.trim(); + const group = f.group.value; + if (form.dataset.had) await act(`/api/admin/staff/${encodeURIComponent(sid)}`, "PUT", { group, days: Number(f.days.value) }); + else await act("/api/admin/staff", "POST", { steamid: sid, group, days: Number(f.days.value) }); + break; + } + case "new-group": + if (await act("/api/admin/groups", "POST", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags: ["@css/generic"] })) { + state.group = null; + } + break; + case "map": await act("/api/admin/map", "POST", { map: f.value.value.trim() }); break; + case "say": await act("/api/admin/say", "POST", { message: f.value.value }); break; + case "kick": await act("/api/admin/kick", "POST", { steamid: form.dataset.sid, reason: f.value.value }); break; + case "confirm": await confirmDialog.run?.(f); break; + } +}); + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && $("#drawer-root").innerHTML && !$("#modal").open) closeDrawer(); +}); + +(async () => { + try { + state.me = await api("/api/me"); + } catch (err) { + $("#view").innerHTML = errorBox(err); + return; + } + if (!state.me.staff) { location.href = "/admin"; return; } + const name = state.me.site; + const i = name.indexOf("."); + document.querySelectorAll(".site-name").forEach((el) => { + el.innerHTML = i > 0 ? `${esc(name.slice(0, i))}.${esc(name.slice(i + 1))}` : esc(name); + }); + $("#me").innerHTML = `${initial(state.me.name)} +
    ${esc(state.me.name)}${esc(state.me.rank?.label || "Staff")}
    + `; + // Fill the sidebar counts once, even when starting on another page. + api("/api/admin/overview").then((d) => { + state.counts = { bans: d.activeBans, comms: d.activeComms, staff: d.staffCount, players: d.server.online ? `${d.server.count}/${d.server.maxPlayers}` : "" }; + setServerPick(d.server); + renderNav(); + }).catch(() => {}); + route(); +})(); diff --git a/internal/web/assets/common.js b/internal/web/assets/common.js new file mode 100644 index 0000000..130ee86 --- /dev/null +++ b/internal/web/assets/common.js @@ -0,0 +1,152 @@ +// Helpers shared by the staff panel (admin.js) and the public site (public.js). + +const $ = (sel, root = document) => root.querySelector(sel); +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +const MIN = 60 * 1000; + +const ICON = { + search: '', + plus: '', +}; + +// api calls the panel's JSON API and throws an Error carrying the server's message on failure. +async function api(path, { method = "GET", body } = {}) { + const opts = { method, headers: { "X-Requested-With": "simpleadmin-web" }, credentials: "same-origin" }; + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + let res; + try { + res = await fetch(path, opts); + } catch { + throw new Error("Couldn't reach the panel. Check your connection."); + } + let data = null; + try { data = await res.json(); } catch { /* empty or non-JSON body */ } + if (!res.ok) throw new Error(data?.error || `The panel answered ${res.status}.`); + return data; +} + +function qs(params) { + const u = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") u.set(k, v); + const s = u.toString(); + return s ? `?${s}` : ""; +} + +function debounce(fn, ms) { + let t; + return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; +} + +function fmtDuration(min) { + if (min === 0) return "Permanent"; + if (min < 60) return `${min} min`; + if (min < 1440) return `${Math.round(min / 60)} h`; + const d = Math.round(min / 1440); + return d === 1 ? "1 day" : `${d} days`; +} + +function fmtAgo(iso) { + const m = Math.round((Date.now() - new Date(iso).getTime()) / MIN); + if (m < 1) return "now"; + if (m < 60) return `${m} min ago`; + if (m < 1440) return `${Math.round(m / 60)} h ago`; + const d = Math.round(m / 1440); + return d === 1 ? "yesterday" : `${d} days ago`; +} + +function fmtLeft(min) { + if (min < 60) return `${Math.max(1, Math.round(min))} min left`; + if (min < 1440) return `${Math.round(min / 60)} h left`; + return `${Math.round(min / 1440)} d left`; +} + +function fmtDate(iso) { + return new Date(iso).toLocaleString(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); +} + +// Minutes served and left on a timed penalty. With SimpleAdmin's online-only time mode, "passed" +// counts minutes served. +function served(p) { + if (p.passed != null && p.status === "active" && !p.ends) return { done: p.passed, left: p.duration - p.passed }; + const start = new Date(p.created).getTime(); + const end = p.ends ? new Date(p.ends).getTime() : start + p.duration * MIN; + const done = (Math.min(Date.now(), end) - start) / MIN; + return { done, left: (end - Date.now()) / MIN }; +} + +function rankTag(rank) { + if (!rank) return 'Player'; + return `${esc(rank.label)}`; +} + +function initial(name) { return esc(String(name || "").replace(/[^a-z0-9]/gi, "").charAt(0).toUpperCase() || "?"); } + +// Time served against the full term: the bar under each length. +function termCell(p) { + const cls = ["term", p.kind === "comm" ? "comm" : ""]; + let right; + let pct = 100; + if (p.duration === 0) { + cls.push("perm"); + right = p.status === "lifted" ? "Lifted" : "Never expires"; + if (p.status === "lifted") cls.push("lifted"); + } else { + const s = served(p); + pct = Math.max(0, Math.min(100, (s.done / p.duration) * 100)); + if (p.status === "active") right = fmtLeft(s.left); + else if (p.status === "expired") { right = "Served"; cls.push("done"); } + else { right = "Lifted"; cls.push("lifted"); } + } + const bar = p.duration === 0 ? "" : ``; + return `
    +
    ${fmtDuration(p.duration)}${right}
    + +
    `; +} + +function statusTag(p) { + if (p.status === "active") return 'Active'; + if (p.status === "expired") return 'Expired'; + return 'Lifted'; +} + +const COMM_LABEL = { GAG: "Gag", MUTE: "Mute", SILENCE: "Silence" }; +const COMM_HELP = { GAG: "Text chat blocked", MUTE: "Voice blocked", SILENCE: "Text and voice blocked" }; + +function segmented(name, value, options) { + return `
    + ${options.map(([v, l]) => ``).join("")} +
    `; +} + +function searchBox(placeholder, value) { + return ``; +} + +function pager(list) { + const pages = Math.max(1, Math.ceil(list.total / list.pageSize)); + if (pages <= 1) return ""; + return `
    + + Page ${list.page} of ${pages} + +
    `; +} + +function loading() { return '
    Loading…
    '; } + +function errorBox(err) { return `
    ${esc(err.message)}
    `; } + +function closeDrawer() { $("#drawer-root").innerHTML = ""; } + +function toast(msg) { + const root = $("#toast-root"); + root.innerHTML = `
    ${esc(msg)}
    `; + clearTimeout(toast.t); + toast.t = setTimeout(() => (root.innerHTML = ""), 3600); +} + +const steamProfile = (sid) => `https://steamcommunity.com/profiles/${encodeURIComponent(sid)}`; diff --git a/internal/web/assets/public.html b/internal/web/assets/public.html new file mode 100644 index 0000000..18d7d3c --- /dev/null +++ b/internal/web/assets/public.html @@ -0,0 +1,35 @@ + + + + + + Bans and staff + + + + + + +
    + +
    + +
    + +
    +
    + + + + + diff --git a/internal/web/assets/public.js b/internal/web/assets/public.js new file mode 100644 index 0000000..44ff170 --- /dev/null +++ b/internal/web/assets/public.js @@ -0,0 +1,264 @@ +// Public, read-only site: the live server, bans, gags/mutes and staff. The API leaves out admin +// names and IPs; nothing here can change anything. + +const PAGES = [ + { id: "server", label: "Server" }, + { id: "bans", label: "Bans" }, + { id: "comms", label: "Gags & mutes" }, + { id: "staff", label: "Staff" }, +]; + +const state = { page: "server", banFilter: "active", commFilter: "active", query: "", num: 1 }; + +function siteName(title) { + const i = title.indexOf("."); + return i > 0 ? `${esc(title.slice(0, i))}.${esc(title.slice(i + 1))}` : esc(title); +} + +/* ---------- Server ---------- */ + +async function server() { + const v = $("#view"); + let s; + try { s = await api("/api/public/server"); } catch (err) { v.innerHTML = errorBox(err); return; } + if (state.page !== "server") return; + if (!s.online) { + v.innerHTML = `

    The server is offline or restarting.

    Offline
    `; + return; + } + const side = (team, name, score) => { + const list = s.players.filter((p) => p.team === team).sort((a, b) => b.kills - a.kills); + return `
    +

    ${name}

    ${score}
    + ${list.length ? ` + + + ${list.map((p) => ` + + + + + `).join("")} + +
    PlayerKillsDeathsPing
    ${esc(p.name)}${p.bot ? 'Bot' : p.rank ? rankTag(p.rank) : ""}
    ${p.kills}${p.deaths}${p.bot ? "" : p.ping}
    ` : '
    Nobody on this team.
    '} +
    `; + }; + const spectators = s.players.filter((p) => p.team !== 2 && p.team !== 3 && !p.bot); + v.innerHTML = ` +
    +
    +

    ${esc(s.hostname)}${s.warmup ? ", warmup" : ""}

    +
    ${esc(s.map)}
    +
    +
    +
    ${s.count}/${s.maxPlayers}Players
    +
    ${s.scoreT} : ${s.scoreCt}T vs CT
    +
    ${s.staffOnline}Staff online
    +
    + ${s.address ? `
    + Join server + or type connect ${esc(s.address)} in the console +
    ` : ""} +
    +
    + ${side(3, "Counter-Terrorists", s.scoreCt)} + ${side(2, "Terrorists", s.scoreT)} +
    + ${spectators.length ? `

    Spectating: ${spectators.map((p) => esc(p.name)).join(", ")}

    ` : ""}`; +} + +/* ---------- Bans and comms ---------- */ + +function publicTable(rows, kind) { + if (!rows.length) { + return `
    ${state.query ? `Nobody matches "${esc(state.query)}". Try the full SteamID64.` : "Nothing in this view."}
    `; + } + return ` + + + ${kind === "comm" ? "" : ""} + + + + + + + ${rows.map((p) => ` + + ${kind === "comm" ? `` : ""} + + + + + `).join("")} + +
    PlayerTypeReasonLengthIssuedStatus
    ${esc(p.name || "Unknown")}${esc(p.steamid)}
    ${COMM_LABEL[p.type]}${esc(p.reason)}${termCell(p)}${fmtDate(p.created)}${statusTag(p)}
    `; +} + +async function loadResults() { + const box = $("#results"); + if (!box) return; + const bans = state.page === "bans"; + const path = bans ? "/api/public/bans" : "/api/public/comms"; + const status = bans ? state.banFilter : state.commFilter; + const want = `${state.page}|${status}|${state.query}|${state.num}`; + loadResults.want = want; + try { + const list = await api(path + qs({ status, q: state.query, page: state.num })); + if (loadResults.want !== want) return; + box.innerHTML = publicTable(list.items, bans ? "ban" : "comm") + pager(list); + const lead = $("#lead"); + if (lead && bans) lead.textContent = `${list.active} ${list.active === 1 ? "player is" : "players are"} banned right now. Search for a name or SteamID64 to check a ban and when it ends.`; + } catch (err) { + box.innerHTML = errorBox(err); + } +} + +function bans() { + $("#view").innerHTML = ` +
    +

    Bans

    Search for a name or SteamID64 to check a ban and when it ends.

    +
    +
    +
    + ${searchBox("Search by name, SteamID64 or reason", state.query)} + ${segmented("banFilter", state.banFilter, [["active", "Active"], ["expired", "Expired"], ["lifted", "Lifted"], ["all", "All"]])} +
    +
    ${loading()}
    +
    `; + loadResults(); +} + +function comms() { + $("#view").innerHTML = ` +
    +

    Gags & mutes

    A gag blocks text chat, a mute blocks voice, and a silence blocks both. Blocks carry over between maps and reconnects.

    +
    +
    +
    + ${searchBox("Search by name, SteamID64 or reason", state.query)} + ${segmented("commFilter", state.commFilter, [["active", "Active"], ["GAG", "Gags"], ["MUTE", "Mutes"], ["SILENCE", "Silences"], ["expired", "Expired"], ["all", "All"]])} +
    +
    ${loading()}
    +
    `; + loadResults(); +} + +/* ---------- Staff ---------- */ + +async function staff() { + const v = $("#view"); + v.innerHTML = loading(); + let data; + try { data = await api("/api/public/staff"); } catch (err) { v.innerHTML = errorBox(err); return; } + if (state.page !== "staff") return; + const rankRow = (g) => `
    +

    ${esc(g.label)}

    ${g.about ? `

    ${esc(g.about)}

    ` : ""}
    + +
    `; + const staffGroups = data.groups.filter((g) => !g.supporter); + const supporters = data.groups.filter((g) => g.supporter); + const online = data.staffOnline; + v.innerHTML = ` +
    +

    Staff

    ${online ? `${online} staff ${online === 1 ? "is" : "are"} on the server right now.` : "No staff are on the server right now."}

    +
    + ${staffGroups.length ? `
    ${staffGroups.map(rankRow).join("")}
    ` : '
    No staff are listed yet.
    '} + ${supporters.length ? `

    Supporters

    ${supporters.map(rankRow).join("")}
    ` : ""}`; +} + +/* ---------- Player lookup (read-only) ---------- */ + +async function openPlayer(sid) { + $("#drawer-root").innerHTML = `
    `; + let p; + try { p = await api(`/api/public/players/${encodeURIComponent(sid)}`); } catch (err) { + $(".drawer").innerHTML = errorBox(err); + return; + } + const history = [...p.bans, ...p.comms].sort((a, b) => new Date(b.created) - new Date(a.created)); + const now = history.filter((h) => h.status === "active"); + const label = (h) => (h.kind === "ban" ? "Ban" : COMM_LABEL[h.type]); + const name = p.name || "Unknown player"; + $("#drawer-root").innerHTML = ` +
    + `; + $(".drawer .close").focus(); +} + +/* ---------- Routing & events ---------- */ + +const RENDER = { server, bans, comms, staff }; + +function route() { + const page = location.hash.slice(1); + state.page = RENDER[page] ? page : "server"; + $("#nav-links").innerHTML = PAGES.map((p) => `
  • ${p.label}
  • `).join(""); + RENDER[state.page](); +} + +window.addEventListener("hashchange", () => { state.query = ""; state.num = 1; route(); window.scrollTo(0, 0); }); + +const search = debounce(() => loadResults(), 250); +document.addEventListener("input", (e) => { + if (e.target.id === "q") { state.query = e.target.value; state.num = 1; search(); } +}); + +document.addEventListener("click", (e) => { + const t = e.target; + const seg = t.closest("[data-seg] button"); + if (seg) { + state[seg.parentElement.dataset.seg] = seg.dataset.value; + state.num = 1; + seg.parentElement.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg))); + loadResults(); + return; + } + const pg = t.closest("[data-page]"); + if (pg) { state.num = Number(pg.dataset.page); loadResults(); return; } + if (t.closest("[data-close]")) { closeDrawer(); return; } + const row = t.closest("[data-player]"); + if (row) openPlayer(row.dataset.player); +}); + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && $("#drawer-root").innerHTML) closeDrawer(); +}); + +api("/api/me").then((me) => { + $("#site-name").innerHTML = siteName(me.site); + document.title = `${me.site} bans and staff`; + if (me.staff) $(".signin").innerHTML = "Staff panel"; +}).catch(() => {}); + +// Keep the scoreboard current while it's on screen. +setInterval(() => { if (state.page === "server" && !document.hidden && !$("#drawer-root").innerHTML) server(); }, 15000); + +route(); diff --git a/internal/web/assets/signin.html b/internal/web/assets/signin.html new file mode 100644 index 0000000..68638dd --- /dev/null +++ b/internal/web/assets/signin.html @@ -0,0 +1,27 @@ + + + + + + Staff sign in + + + + + + +
    + +

    Staff panel

    +

    Sign in with the Steam account that has your rank on the server.

    + + Sign in through Steam + +
    + + + + diff --git a/internal/web/assets/signin.js b/internal/web/assets/signin.js new file mode 100644 index 0000000..563fceb --- /dev/null +++ b/internal/web/assets/signin.js @@ -0,0 +1,13 @@ +// Explains why someone landed on the sign-in page: a failed Steam sign-in, or an account without a +// staff rank. +const err = new URLSearchParams(location.search).get("signin"); +if (err) { + const box = $("#signin-error"); + box.textContent = `Steam sign-in didn't work: ${err.replace(/^steam: /, "")}`; + box.hidden = false; +} +api("/api/me").then((me) => { + if (me.signedIn && !me.staff) { + $("#signin-msg").textContent = `You're signed in as ${me.steamid}, but that account has no staff rank on this server. Sign in with a different Steam account, or ask an admin to add you.`; + } +}).catch(() => {}); diff --git a/internal/web/assets/styles.css b/internal/web/assets/styles.css new file mode 100644 index 0000000..5bce718 --- /dev/null +++ b/internal/web/assets/styles.css @@ -0,0 +1,1021 @@ +:root { + --bg: #14161b; + --surface: #1b1e25; + --raised: #242832; + --line: #2e333f; + --line-soft: #252a34; + --text: #e6e8ee; + --muted: #8a91a0; + --faint: #5d6472; + --accent: #fe113d; + --accent-soft: rgba(254, 17, 61, 0.14); + --accent-line: rgba(254, 17, 61, 0.45); + --amber: #f0a63a; + --amber-soft: rgba(240, 166, 58, 0.14); + --green: #37c58f; + --green-soft: rgba(55, 197, 143, 0.13); + + --display: "Barlow Condensed", "Arial Narrow", sans-serif; + --body: "Barlow", system-ui, sans-serif; + + --r-sm: 4px; + --r-md: 8px; + --r-lg: 12px; + + color-scheme: dark; +} + +* { box-sizing: border-box; } + +html, body { margin: 0; height: 100%; } + +body { + background: var(--bg); + color: var(--text); + font: 400 15px/1.5 var(--body); + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; +} + +button, input, select, textarea { font: inherit; color: inherit; } + +a { color: inherit; } + +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* ---------- Shell ---------- */ + +.app { + display: grid; + grid-template-columns: 232px 1fr; + min-height: 100vh; + background: linear-gradient(90deg, var(--surface) 231px, var(--line) 231px 232px, transparent 232px); +} + +.app > div { min-width: 0; } + +.nav { + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 24px; + padding: 22px 14px 18px; + background: var(--surface); + border-right: 1px solid var(--line); +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 0 8px; + text-decoration: none; +} + +.brand-mark { + width: 30px; + height: 30px; + flex: none; +} + +.brand-name { + font: 700 22px/1 var(--display); + letter-spacing: 0.01em; +} + +.brand-name span { color: var(--accent); } + +.server-pick { + display: grid; + gap: 2px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--bg); + text-align: left; + cursor: pointer; +} + +.server-pick b { font-weight: 600; font-size: 14px; } +.server-pick small { color: var(--muted); font-size: 13px; } + +.dot { + display: inline-block; + width: 7px; + height: 7px; + margin-right: 6px; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 0 3px var(--green-soft); + vertical-align: 1px; +} + +.nav ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; } + +.nav a.link { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px 8px 12px; + border-radius: var(--r-sm); + color: var(--muted); + text-decoration: none; + font: 600 17px/1.2 var(--display); + letter-spacing: 0.01em; + border-left: 2px solid transparent; +} + +.nav a.link:hover { color: var(--text); background: var(--raised); } + +.nav a.link[aria-current="page"] { + color: var(--text); + background: var(--accent-soft); + border-left-color: var(--accent); +} + +.nav .count { + font: 500 13px/1 var(--body); + color: var(--muted); +} + +.nav a.link[aria-current="page"] .count { color: var(--text); } + +.me { + margin-top: auto; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 8px 0; + border-top: 1px solid var(--line); +} + +.me b { display: block; font-weight: 600; font-size: 14px; } +.me small { color: var(--muted); font-size: 13px; } + +.avatar { + flex: none; + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--raised); + color: var(--muted); + font: 700 14px/1 var(--display); +} + +.avatar.lg { width: 56px; height: 56px; font-size: 24px; } + +/* ---------- Main ---------- */ + +main { + min-width: 0; + padding: 28px 36px 64px; +} + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 20px; +} + +h1 { + margin: 0; + font: 700 38px/1 var(--display); + letter-spacing: 0.005em; +} + +.sub { margin: 6px 0 0; color: var(--muted); max-width: 64ch; } + +h2 { + margin: 0; + font: 600 21px/1.2 var(--display); + letter-spacing: 0.01em; +} + +h3 { margin: 0; font: 600 17px/1.2 var(--display); letter-spacing: 0.01em; } + +.panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-lg); +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--line); + flex-wrap: wrap; +} + +.panel-head p { margin: 0; color: var(--muted); font-size: 14px; } + +.panel-body { padding: 18px; } + +/* ---------- Controls ---------- */ + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--raised); + font-weight: 600; + font-size: 14px; + cursor: pointer; + white-space: nowrap; +} + +.btn:hover { border-color: var(--faint); } + +.btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.btn.primary:hover { background: #e60a33; } + +.btn.ghost { background: transparent; } + +.btn.danger { color: var(--accent); border-color: var(--accent-line); background: transparent; } +.btn.danger:hover { background: var(--accent-soft); } + +.btn.sm { height: 30px; padding: 0 10px; font-size: 13px; } + +.btn svg { width: 16px; height: 16px; } + +.toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 12px 18px; + border-bottom: 1px solid var(--line); +} + +.search { + flex: 1 1 260px; + display: flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--bg); + color: var(--muted); +} + +.search input { flex: 1; min-width: 0; border: 0; background: none; outline: none; color: var(--text); } +.search:focus-within { border-color: var(--accent-line); } +.search svg { width: 16px; height: 16px; flex: none; } + +.segmented { + display: inline-flex; + padding: 3px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--bg); + max-width: 100%; + overflow-x: auto; +} + +.segmented button { + height: 28px; + padding: 0 12px; + border: 0; + border-radius: 5px; + background: none; + color: var(--muted); + font-weight: 500; + font-size: 14px; + cursor: pointer; + flex: none; +} + +.segmented button[aria-pressed="true"] { background: var(--raised); color: var(--text); } + +.field { display: grid; gap: 6px; align-content: start; } +.field > span { font-size: 14px; font-weight: 500; } +.field small { color: var(--muted); font-size: 13px; } + +.input, select.input, textarea.input { + width: 100%; + height: 38px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--bg); + outline: none; +} + +textarea.input { height: auto; min-height: 72px; padding: 10px 12px; resize: vertical; } + +.input:focus { border-color: var(--accent-line); } + +.input[readonly] { color: var(--muted); } + +.chips { display: flex; flex-wrap: wrap; gap: 6px; } + +.chip-btn { + height: 30px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 15px; + background: var(--bg); + color: var(--muted); + font-size: 14px; + cursor: pointer; +} + +.chip-btn[aria-pressed="true"] { border-color: var(--accent); color: var(--text); background: var(--accent-soft); } + +.switch { + position: relative; + flex: none; + width: 38px; + height: 22px; + border-radius: 11px; + border: 1px solid var(--line); + background: var(--bg); + cursor: pointer; +} + +.switch::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--faint); + transition: transform 0.15s ease; +} + +.switch[aria-checked="true"] { background: var(--accent); border-color: var(--accent); } +.switch[aria-checked="true"]::after { background: #fff; transform: translateX(16px); } + +/* ---------- Tables ---------- */ + +.table-wrap { position: relative; overflow-x: auto; } + +table { width: 100%; border-collapse: collapse; } + +th { + padding: 10px 18px; + color: var(--muted); + font-size: 13px; + font-weight: 500; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid var(--line); +} + +td { + padding: 12px 18px; + border-bottom: 1px solid var(--line-soft); + vertical-align: middle; +} + +tbody tr:last-child td { border-bottom: 0; } + +tbody tr.clickable { cursor: pointer; } +tbody tr.clickable:hover td { background: rgba(255, 255, 255, 0.02); } + +td.num, th.num { text-align: right; white-space: nowrap; } + +.who { display: flex; align-items: center; gap: 10px; min-width: 180px; } +.who b { display: block; font-weight: 600; } +.sid { color: var(--muted); font-size: 13px; letter-spacing: 0.01em; } + +.muted { color: var(--muted); } +.faint { color: var(--faint); } + +.row-actions { display: flex; gap: 6px; justify-content: flex-end; } + +/* Status tags */ + +.tag { + display: inline-flex; + align-items: center; + gap: 5px; + height: 22px; + padding: 0 8px; + border-radius: 11px; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + background: var(--raised); + color: var(--muted); +} + +.tag.red { background: var(--accent-soft); color: #ff5a78; } +.tag.amber { background: var(--amber-soft); color: var(--amber); } +.tag.green { background: var(--green-soft); color: var(--green); } + +.rank { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 500; + font-size: 14px; + white-space: nowrap; +} + +.rank i { + width: 8px; + height: 8px; + border-radius: 2px; + transform: rotate(45deg); + background: var(--c, var(--faint)); +} + +/* ---------- The term bar: time served vs. remaining ---------- */ + +.term { min-width: 190px; } + +.term-top { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 14px; + margin-bottom: 6px; +} + +.term-top b { font-weight: 600; } + +.term-bar { + position: relative; + height: 6px; + border-radius: 3px; + background: var(--raised); + overflow: hidden; +} + +.term-bar > span { + position: absolute; + inset: 0 auto 0 0; + border-radius: 3px; + background: var(--accent); +} + +.term.comm .term-bar > span { background: var(--amber); } + +.term.perm .term-bar { + background: repeating-linear-gradient(-45deg, var(--accent) 0 6px, #b30b2b 6px 12px); +} + +.term.perm.comm .term-bar { + background: repeating-linear-gradient(-45deg, var(--amber) 0 6px, #b87a22 6px 12px); +} + +.term.done .term-bar > span { background: var(--faint); } + +.term.lifted .term-top b { text-decoration: line-through; text-decoration-color: var(--faint); color: var(--muted); } +.term.lifted .term-bar > span { background: var(--faint); opacity: 0.5; } + +/* ---------- Overview ---------- */ + +.live { + display: grid; + grid-template-columns: 1fr auto; + gap: 24px; + align-items: end; + padding: 22px 24px; + margin-bottom: 20px; + border: 1px solid var(--line); + border-radius: var(--r-lg); + background: + linear-gradient(90deg, var(--surface) 35%, rgba(27, 30, 37, 0.7)), + repeating-linear-gradient(0deg, transparent 0 23px, var(--line-soft) 23px 24px), + repeating-linear-gradient(90deg, transparent 0 23px, var(--line-soft) 23px 24px), + var(--surface); +} + +.live-map { + font: 700 56px/0.9 var(--display); + letter-spacing: -0.005em; +} + +.live-host { margin: 0 0 10px; color: var(--muted); } + +.live-stats { display: flex; gap: 28px; } +.live-stats div { display: grid; gap: 2px; } +.live-stats b { font: 600 28px/1 var(--display); } +.live-stats small { color: var(--muted); font-size: 13px; } + +.grid-2 { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr); + gap: 20px; + align-items: start; +} + +.stack { display: grid; gap: 20px; } + +.team { + display: inline-block; + width: 26px; + font: 700 13px/1 var(--display); + letter-spacing: 0.03em; +} +.live-stats .score { font: inherit; width: auto; } +.team.t { color: #e3b353; } +.team.ct { color: #6fa3e8; } + +.feed { list-style: none; margin: 0; padding: 6px 0; } + +.feed li { + display: grid; + grid-template-columns: 10px 1fr; + gap: 12px; + padding: 10px 18px; +} + +.feed li::before { + content: ""; + width: 8px; + height: 8px; + margin-top: 7px; + border-radius: 50%; + background: var(--c, var(--faint)); +} + +.feed p { margin: 0; font-size: 14px; } +.feed small { color: var(--muted); font-size: 13px; } + +.expiring li { display: flex; justify-content: space-between; gap: 12px; padding: 10px 18px; font-size: 14px; } +.expiring { list-style: none; margin: 0; padding: 6px 0; } + +/* ---------- Ranks ---------- */ + +.ranks-layout { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +.ladder { list-style: none; margin: 0; padding: 8px; display: grid; gap: 2px; } + +.ladder button { + display: grid; + grid-template-columns: 14px 1fr auto; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 12px; + border: 0; + border-radius: var(--r-md); + background: none; + text-align: left; + cursor: pointer; +} + +.ladder button:hover { background: var(--raised); } +.ladder button[aria-current="true"] { background: var(--raised); box-shadow: inset 2px 0 0 var(--c); } + +.ladder i { + width: 10px; + height: 10px; + border-radius: 2px; + transform: rotate(45deg); + background: var(--c); +} + +.ladder b { font: 600 17px/1.1 var(--display); letter-spacing: 0.01em; } +.ladder small { display: block; color: var(--muted); font-size: 13px; } +.ladder .imm { color: var(--muted); font-size: 13px; text-align: right; } + +.ladder .sep { + margin: 8px 12px 4px; + padding-top: 10px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 13px; +} + +.flag-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px; +} + +.flag { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--bg); + cursor: pointer; +} + +.flag input { margin: 3px 0 0; accent-color: var(--accent); } +.flag b { display: block; font-weight: 600; font-size: 14px; } +.flag small { color: var(--muted); font-size: 13px; } +.flag:has(input:checked) { border-color: var(--accent-line); } +.flag:has(input:disabled) { opacity: 0.55; cursor: not-allowed; } + +.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; } + +/* ---------- Settings ---------- */ + +.settings { display: grid; gap: 20px; max-width: 900px; } + +.setting { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(200px, 300px); + gap: 24px; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid var(--line-soft); +} + +.setting:last-child { border-bottom: 0; } +.setting b { display: block; font-weight: 600; } +.setting p { margin: 2px 0 0; color: var(--muted); font-size: 14px; } +.setting .control { display: flex; justify-content: flex-end; } +.setting code { font: 13px ui-monospace, "Cascadia Mono", Menlo, monospace; color: var(--faint); } + +.savebar { + position: sticky; + bottom: 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + max-width: 900px; + margin-top: 20px; + padding: 12px 16px; + border: 1px solid var(--accent-line); + border-radius: var(--r-lg); + background: #1f1519; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45); +} + +.savebar[hidden] { display: none; } + +/* ---------- Drawer & modal ---------- */ + +.scrim { + position: fixed; + inset: 0; + background: rgba(5, 6, 9, 0.6); + z-index: 20; +} + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(520px, 100vw); + overflow-y: auto; + background: var(--surface); + border-left: 1px solid var(--line); + z-index: 21; + animation: slide 0.2s ease-out; +} + +@keyframes slide { from { transform: translateX(24px); opacity: 0; } } + +.drawer-head { + display: flex; + gap: 14px; + align-items: center; + padding: 22px 22px 18px; + border-bottom: 1px solid var(--line); +} + +.drawer-head h2 { font-size: 28px; } + +.close { + margin-left: auto; + align-self: flex-start; + width: 32px; + height: 32px; + border: 0; + border-radius: var(--r-md); + background: none; + color: var(--muted); + font-size: 22px; + line-height: 1; + cursor: pointer; +} + +.close:hover { background: var(--raised); color: var(--text); } + +.drawer section { padding: 18px 22px; border-bottom: 1px solid var(--line); } +.drawer section:last-child { border-bottom: 0; } +.drawer section > h3 { margin-bottom: 12px; } + +.kv { display: grid; grid-template-columns: 120px 1fr; gap: 8px 16px; margin: 0; font-size: 14px; } +.kv dt { color: var(--muted); } +.kv dd { margin: 0; word-break: break-all; } + +.action-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.action-grid .btn { justify-content: center; } + +.history { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } +.history li { display: grid; gap: 6px; padding: 12px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg); } +.history .top { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; } + +dialog { + width: min(560px, calc(100vw - 32px)); + padding: 0; + border: 1px solid var(--line); + border-radius: var(--r-lg); + background: var(--surface); + color: var(--text); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55); +} + +dialog::backdrop { background: rgba(5, 6, 9, 0.65); } + +.modal-head { display: flex; align-items: center; padding: 18px 20px; border-bottom: 1px solid var(--line); } +.modal-head h2 { font-size: 24px; } +.modal-body { display: grid; gap: 16px; padding: 20px; } +.modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--line); } + +.toast { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + padding: 10px 16px; + border-radius: var(--r-md); + background: var(--raised); + border: 1px solid var(--line); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); + font-weight: 500; + z-index: 40; + animation: rise 0.2s ease-out; +} + +@keyframes rise { from { transform: translate(-50%, 8px); opacity: 0; } } + +.empty { padding: 40px 18px; text-align: center; color: var(--muted); } + +.mobile-bar { display: none; } + +/* ---------- Responsive ---------- */ + +@media (max-width: 1100px) { + .grid-2, .ranks-layout { grid-template-columns: minmax(0, 1fr); } +} + +@media (max-width: 820px) { + .app { grid-template-columns: 1fr; background: none; } + .nav { + position: fixed; + inset: 0 auto 0 0; + width: 260px; + z-index: 30; + transform: translateX(-100%); + transition: transform 0.2s ease; + } + .nav.open { transform: none; box-shadow: 0 0 60px rgba(0, 0, 0, 0.6); } + .mobile-bar { + display: flex; + align-items: center; + gap: 12px; + position: sticky; + top: 0; + z-index: 10; + padding: 10px 16px; + background: var(--surface); + border-bottom: 1px solid var(--line); + } + main { padding: 20px 16px 48px; } + h1 { font-size: 32px; } + .live { grid-template-columns: 1fr; padding: 18px; } + .live-map { font-size: 42px; } + .setting { grid-template-columns: 1fr; gap: 10px; } + .setting .control { justify-content: flex-start; } + .action-grid { grid-template-columns: repeat(2, 1fr); } + .hide-sm { display: none; } + th, td { padding-left: 12px; padding-right: 12px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } +} + +.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +/* ---------- Public (guest) pages ---------- */ + +.public-link { + padding: 0 12px; + color: var(--muted); + font-size: 14px; +} + +.public-link:hover { color: var(--text); } + +.topbar { + position: sticky; + top: 0; + z-index: 10; + background: var(--surface); + border-bottom: 1px solid var(--line); +} + +.topbar-inner { + display: flex; + align-items: center; + gap: 28px; + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + height: 60px; +} + +.topbar .brand { padding: 0; } + +.tabs { list-style: none; display: flex; gap: 4px; margin: 0; padding: 0; height: 60px; } + +.tabs a { + display: flex; + align-items: center; + height: 100%; + padding: 0 12px; + color: var(--muted); + text-decoration: none; + font: 600 18px/1 var(--display); + letter-spacing: 0.01em; + border-bottom: 2px solid transparent; +} + +.tabs a:hover { color: var(--text); } +.tabs a[aria-current="page"] { color: var(--text); border-bottom-color: var(--accent); } + +.signin { margin-left: auto; text-decoration: none; } +.show-xs { display: none; } + +.public-main { + max-width: 1200px; + margin: 0 auto; + padding: 32px 24px 40px; +} + +.public-foot { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px 48px; + color: var(--muted); + font-size: 14px; +} + +.public-foot p { margin: 0; max-width: 70ch; } + +.crew-panel { overflow: hidden; } + +.crew-row { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 24px; + padding: 18px 20px; + border-bottom: 1px solid var(--line-soft); + box-shadow: inset 3px 0 0 var(--c); +} + +.crew-row:last-child { border-bottom: 0; } +.crew-row .rank { --c: inherit; } +.crew-rank p { margin: 4px 0 0; color: var(--muted); font-size: 14px; } + +.crew { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; align-content: start; } + +.crew a { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 14px 6px 6px; + border: 1px solid var(--line); + border-radius: 24px; + background: var(--bg); + text-decoration: none; +} + +.crew a:hover { border-color: var(--faint); } +.crew b { display: block; font-weight: 600; font-size: 14px; line-height: 1.2; } +.crew small { color: var(--muted); font-size: 13px; } + +.crew-sep { margin: 28px 0 12px; } + +@media (max-width: 820px) { + .topbar-inner { gap: 12px; padding: 0 16px; } + .topbar .brand-name { display: none; } + .tabs a { padding: 0 8px; font-size: 16px; } + .public-main { padding: 20px 16px 32px; } + .public-foot { padding: 0 16px 32px; } + .crew-row { grid-template-columns: 1fr; gap: 12px; } +} + +.live-join { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + padding-top: 18px; + border-top: 1px solid var(--line); + font-size: 14px; +} + +.live-join .btn { text-decoration: none; } + +.sides { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + align-items: start; +} + +.side { overflow: hidden; } +.side.ct { box-shadow: inset 0 3px 0 #6fa3e8; } +.side.t { box-shadow: inset 0 3px 0 #e3b353; } +.side-score { font: 700 32px/1 var(--display); } +.side.ct .side-score { color: #6fa3e8; } +.side.t .side-score { color: #e3b353; } +.side .who .rank { display: flex; margin-top: 2px; font-size: 13px; } + +@media (max-width: 900px) { + .sides { grid-template-columns: minmax(0, 1fr); } +} + +.live-join small { font-size: 14px; } +.tabs a { white-space: nowrap; } + +@media (max-width: 480px) { + .topbar-inner { gap: 8px; padding: 0 12px; } + .tabs { gap: 0; } + .tabs a { padding: 0 6px; font-size: 15px; } + .signin { padding: 0 8px; } + .hide-xs { display: none; } + .show-xs { display: inline; } +} + +/* ---------- Live panel additions ---------- */ + +.btn:disabled { opacity: 0.45; cursor: not-allowed; } +.btn:disabled:hover { border-color: var(--line); } +a.btn { text-decoration: none; } + +.pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 18px; + border-top: 1px solid var(--line); + font-size: 14px; +} + +.server-pick { cursor: default; } +.me #signout { margin-left: auto; } +.me > div { min-width: 0; } +.me b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.signin-page { + display: grid; + place-items: center; + min-height: 100vh; + padding: 16px; +} + +.signin-card { + display: grid; + justify-items: start; + gap: 14px; + width: min(440px, 100%); + padding: 28px; +} + +.signin-card .brand-mark { width: 40px; height: 40px; } +.signin-card .sub { margin: 0; } +.signin-back { font-size: 14px; } + +.signin-error { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--accent-line); + border-radius: var(--r-md); + background: var(--accent-soft); + font-size: 14px; +} diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..294a58c --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,202 @@ +package web + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "io/fs" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "git.zio.sh/cs2/simpleadmin-web/internal/steam" +) + +const ( + sessionCookie = "saw_session" + sessionTTL = 7 * 24 * time.Hour + // Browsers can't send this header cross-site without a CORS preflight, which is never granted. + csrfHeader = "X-Requested-With" + csrfValue = "simpleadmin-web" +) + +func (s *Server) sign(payload string) string { + m := hmac.New(sha256.New, s.cfg.SessionSecret) + m.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} + +func (s *Server) setSession(w http.ResponseWriter, steamid string) { + payload := steamid + "|" + strconv.FormatInt(time.Now().Add(sessionTTL).Unix(), 10) + enc := base64.RawURLEncoding.EncodeToString([]byte(payload)) + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: enc + "." + s.sign(payload), + Path: "/", + MaxAge: int(sessionTTL.Seconds()), + HttpOnly: true, + Secure: s.secure, + // Lax, not Strict: the sign-in redirect chain starts on steamcommunity.com. + SameSite: http.SameSiteLaxMode, + }) +} + +// sessionSteamID returns the signed-in SteamID64, or "". +func (s *Server) sessionSteamID(r *http.Request) string { + c, err := r.Cookie(sessionCookie) + if err != nil { + return "" + } + enc, sig, ok := strings.Cut(c.Value, ".") + if !ok { + return "" + } + raw, err := base64.RawURLEncoding.DecodeString(enc) + if err != nil { + return "" + } + payload := string(raw) + if !hmac.Equal([]byte(sig), []byte(s.sign(payload))) { + return "" + } + steamid, exp, ok := strings.Cut(payload, "|") + if !ok { + return "" + } + unix, err := strconv.ParseInt(exp, 10, 64) + if err != nil || time.Now().Unix() > unix || !steam.ValidID(steamid) { + return "" + } + return steamid +} + +func (s *Server) callbackURL() string { return s.cfg.BaseURL + "/auth/callback" } + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, steam.LoginURL(s.cfg.BaseURL+"/", s.callbackURL()), http.StatusFound) +} + +func (s *Server) callback(w http.ResponseWriter, r *http.Request) { + id, err := steam.Verify(r.Context(), s.http, s.callbackURL(), r.URL.Query()) + if err != nil { + slog.Info("steam sign-in failed", "err", err) + http.Redirect(w, r, "/admin?signin="+url.QueryEscape(err.Error()), http.StatusFound) + return + } + s.setSession(w, id) + http.Redirect(w, r, "/admin", http.StatusFound) +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + if !s.sameOrigin(r) { + fail(w, http.StatusForbidden, "Sign out from the panel itself.") + return + } + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode}) + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// sameOrigin rejects cross-site writes: the custom header must be present, and so must a matching +// Origin when the browser sends one. +func (s *Server) sameOrigin(r *http.Request) bool { + if r.Header.Get(csrfHeader) != csrfValue { + return false + } + if o := r.Header.Get("Origin"); o != "" && o != strings.TrimRight(s.cfg.BaseURL, "/") { + return false + } + return true +} + +// identity returns the signed-in staff member, or nil for anyone else. Staff means an unexpired +// SimpleAdmin admin on this server with @css/generic (or @css/root), read fresh every 15 seconds, +// so removing someone in SimpleAdmin signs them out of the panel too. +func (s *Server) identity(r *http.Request) (*Identity, *staffIndex, error) { + steamid := s.sessionSteamID(r) + if steamid == "" { + return nil, nil, nil + } + x, err := s.staffIdx(r.Context()) + if err != nil { + return nil, nil, err + } + a, ok := x.bySteam[steamid] + if !ok { + return nil, x, nil + } + id := &Identity{SteamID: steamid, Name: a.Name, Flags: x.flags(a), Immunity: x.immunity(steamid)} + if len(a.RowIDs) > 0 { + id.RowID = a.RowIDs[0] + } + if !id.Has("@css/generic") { + return nil, x, nil + } + return id, x, nil +} + +type adminHandler func(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) + +func (s *Server) requireStaff(h adminHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && !s.sameOrigin(r) { + fail(w, http.StatusForbidden, "This request didn't come from the panel.") + return + } + id, x, err := s.identity(r) + if err != nil { + internal(w, r, err) + return + } + if id == nil { + fail(w, http.StatusUnauthorized, "Sign in with a staff account to do this.") + return + } + h(w, r, id, x) + } +} + +// adminPage serves the staff panel to staff, and sends everyone else to sign in. +func (s *Server) adminPage(static fs.FS) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, _, err := s.identity(r) + if err != nil { + slog.Error("admin page", "err", err) + } + if id == nil { + http.ServeFileFS(w, r, static, "signin.html") + return + } + http.ServeFileFS(w, r, static, "admin.html") + } +} + +type meView struct { + SignedIn bool `json:"signedIn"` + Staff bool `json:"staff"` + SteamID string `json:"steamid,omitempty"` + Name string `json:"name,omitempty"` + Rank *rankView `json:"rank,omitempty"` + Perms []string `json:"perms,omitempty"` + Immunity int `json:"immunity,omitempty"` + Site string `json:"site"` +} + +func (s *Server) me(w http.ResponseWriter, r *http.Request) { + v := meView{Site: s.site.Title} + id, x, err := s.identity(r) + if err != nil { + internal(w, r, err) + return + } + if steamid := s.sessionSteamID(r); steamid != "" { + v.SignedIn, v.SteamID = true, steamid + } + if id != nil { + v.Staff, v.Name, v.Rank, v.Immunity = true, id.Name, s.rankOf(x, id.SteamID), id.Immunity + v.Perms = id.Flags + } + writeJSON(w, http.StatusOK, v) +} diff --git a/internal/web/public.go b/internal/web/public.go new file mode 100644 index 0000000..cc2d947 --- /dev/null +++ b/internal/web/public.go @@ -0,0 +1,244 @@ +package web + +import ( + "errors" + "net/http" + "slices" + "time" + + "git.zio.sh/cs2/simpleadmin-web/internal/live" + "git.zio.sh/cs2/simpleadmin-web/internal/steam" + "git.zio.sh/cs2/simpleadmin-web/internal/store" +) + +// publicPenalty is a penalty with everything about staff and IPs removed. +type publicPenalty struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Type string `json:"type,omitempty"` + Name string `json:"name"` + SteamID string `json:"steamid"` + Reason string `json:"reason"` + Duration int `json:"duration"` + Passed *int `json:"passed,omitempty"` + Ends *time.Time `json:"ends,omitempty"` + Created time.Time `json:"created"` + Status string `json:"status"` +} + +func toPublic(ps []store.Penalty) []publicPenalty { + out := make([]publicPenalty, 0, len(ps)) + for _, p := range ps { + out = append(out, publicPenalty{ID: p.ID, Kind: p.Kind, Type: p.Type, Name: p.Name, SteamID: p.SteamID, + Reason: p.Reason, Duration: p.Duration, Passed: p.Passed, Ends: p.Ends, Created: p.Created, Status: p.Status}) + } + return out +} + +type livePlayer struct { + live.Player + Rank *rankView `json:"rank,omitempty"` + // Staff-only fields, left empty on the public page. + Tags []string `json:"tags,omitempty"` + CanTarget bool `json:"canTarget,omitempty"` +} + +type serverView struct { + Online bool `json:"online"` + Hostname string `json:"hostname"` + Map string `json:"map"` + Count int `json:"count"` + MaxPlayers int `json:"maxPlayers"` + ScoreT int `json:"scoreT"` + ScoreCT int `json:"scoreCt"` + Warmup bool `json:"warmup"` + StaffOnline int `json:"staffOnline"` + Address string `json:"address"` + Players []livePlayer `json:"players"` + Updated time.Time `json:"updated"` +} + +func (s *Server) serverView(x *staffIndex, st live.Status) serverView { + v := serverView{Online: st.Online, Hostname: st.Hostname, Map: st.Map, MaxPlayers: st.MaxPlayers, + ScoreT: st.ScoreT, ScoreCT: st.ScoreCT, Warmup: st.Warmup, Address: s.cfg.ServerAddress, + Players: []livePlayer{}, Updated: st.Updated} + for _, p := range st.Players { + lp := livePlayer{Player: p} + if !p.Bot { + v.Count++ + lp.Rank = s.rankOf(x, p.SteamID) + if s.isStaff(x, p.SteamID) { + v.StaffOnline++ + } + } + v.Players = append(v.Players, lp) + } + return v +} + +func (s *Server) publicServer(w http.ResponseWriter, r *http.Request) { + x, err := s.staffIdx(r.Context()) + if err != nil { + internal(w, r, err) + return + } + writeJSON(w, http.StatusOK, s.serverView(x, s.poll.Current())) +} + +// penaltyQuery reads ?status=&q=&page= (and for comms, a type in place of the status). +func penaltyQuery(r *http.Request, comms bool) (store.Query, int) { + lim, off, num := page(r) + q := store.Query{Search: r.URL.Query().Get("q"), Limit: lim, Offset: off} + switch st := r.URL.Query().Get("status"); st { + case store.StatusActive, store.StatusExpired, store.StatusLifted: + q.Status = st + case "GAG", "MUTE", "SILENCE": + if comms { + q.Status, q.Type = store.StatusActive, st + } + case "all": + default: + q.Status = store.StatusActive + } + return q, num +} + +type penaltyPage struct { + list[publicPenalty] + Active int `json:"active"` +} + +func (s *Server) publicBans(w http.ResponseWriter, r *http.Request) { + q, num := penaltyQuery(r, false) + items, total, err := s.st.Bans(r.Context(), q) + if err != nil { + internal(w, r, err) + return + } + _, active, err := s.st.Bans(r.Context(), store.Query{Status: store.StatusActive, Limit: 1}) + if err != nil { + internal(w, r, err) + return + } + writeJSON(w, http.StatusOK, penaltyPage{newList(toPublic(items), total, num), active}) +} + +func (s *Server) publicComms(w http.ResponseWriter, r *http.Request) { + q, num := penaltyQuery(r, true) + items, total, err := s.st.Comms(r.Context(), q) + if err != nil { + internal(w, r, err) + return + } + _, active, err := s.st.Comms(r.Context(), store.Query{Status: store.StatusActive, Limit: 1}) + if err != nil { + internal(w, r, err) + return + } + writeJSON(w, http.StatusOK, penaltyPage{newList(toPublic(items), total, num), active}) +} + +type memberView struct { + SteamID string `json:"steamid"` + Name string `json:"name"` + Online bool `json:"online"` + Ends *time.Time `json:"ends,omitempty"` + LastSeen *time.Time `json:"lastSeen,omitempty"` +} + +type groupView struct { + ID int64 `json:"id"` + Name string `json:"name"` + Label string `json:"label"` + Color string `json:"color"` + About string `json:"about,omitempty"` + Supporter bool `json:"supporter"` + Immunity int `json:"immunity,omitempty"` + Flags []string `json:"flags,omitempty"` + Members []memberView `json:"members"` +} + +// groupViews lists groups by immunity with their members. Staff get flags and expiry dates. +func (s *Server) groupViews(x *staffIndex, st live.Status, forStaff bool) []groupView { + out := make([]groupView, 0, len(x.groups)) + for i, g := range x.groups { + r := s.site.rank(g.Name, i) + gv := groupView{ID: g.ID, Name: g.Name, Label: r.Label, Color: r.Color, About: r.About, + Supporter: r.Supporter, Immunity: g.Immunity, Members: []memberView{}} + if forStaff { + gv.Flags = g.Flags + } else { + gv.Immunity = 0 + } + for _, a := range x.admins { + if x.primaryGroup(&a) != g.Name { + continue + } + _, online := st.Find(a.SteamID) + m := memberView{SteamID: a.SteamID, Name: a.Name, Online: online} + if forStaff { + m.Ends = a.Ends + } + gv.Members = append(gv.Members, m) + } + slices.SortStableFunc(gv.Members, func(a, b memberView) int { + if a.Online != b.Online { + if a.Online { + return -1 + } + return 1 + } + return 0 + }) + out = append(out, gv) + } + return out +} + +func (s *Server) publicStaff(w http.ResponseWriter, r *http.Request) { + x, err := s.staffIdx(r.Context()) + if err != nil { + internal(w, r, err) + return + } + st := s.poll.Current() + groups := s.groupViews(x, st, false) + // Hide empty ranks from the public. + groups = slices.DeleteFunc(groups, func(g groupView) bool { return len(g.Members) == 0 }) + writeJSON(w, http.StatusOK, map[string]any{"groups": groups, "staffOnline": s.serverView(x, st).StaffOnline}) +} + +func (s *Server) publicPlayer(w http.ResponseWriter, r *http.Request) { + steamid := r.PathValue("steamid") + if !steam.ValidID(steamid) { + fail(w, http.StatusBadRequest, "That isn't a SteamID64.") + return + } + d, err := s.st.Player(r.Context(), steamid) + if errors.Is(err, store.ErrNotFound) { + d = store.PlayerDetail{SteamID: steamid} + } else if err != nil { + internal(w, r, err) + return + } + bans, _, err := s.st.Bans(r.Context(), store.Query{SteamID: steamid, Limit: 200}) + if err != nil { + internal(w, r, err) + return + } + comms, _, err := s.st.Comms(r.Context(), store.Query{SteamID: steamid, Limit: 200}) + if err != nil { + internal(w, r, err) + return + } + x, err := s.staffIdx(r.Context()) + if err != nil { + internal(w, r, err) + return + } + _, online := s.poll.Current().Find(steamid) + writeJSON(w, http.StatusOK, map[string]any{ + "steamid": steamid, "name": d.Name, "online": online, "rank": s.rankOf(x, steamid), + "bans": toPublic(bans), "comms": toPublic(comms), + }) +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..42d9cad --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,189 @@ +// Package web serves the public site, the staff panel and their JSON APIs. +package web + +import ( + "context" + "embed" + "encoding/json" + "errors" + "io/fs" + "log/slog" + "net/http" + "net/url" + "strconv" + "time" + + "git.zio.sh/cs2/simpleadmin-web/internal/live" + "git.zio.sh/cs2/simpleadmin-web/internal/rcon" + "git.zio.sh/cs2/simpleadmin-web/internal/saconfig" + "git.zio.sh/cs2/simpleadmin-web/internal/store" +) + +//go:embed assets +var assets embed.FS + +type Config struct { + BaseURL string // e.g. https://bans.example.com, no trailing slash + SessionSecret []byte + SiteFile string + // ServerAddress is shown on the public page for "Join server". + ServerAddress string + // Limits used when SimpleAdmin's config file isn't available. + MaxBanDuration int + MaxMuteDuration int +} + +type Server struct { + cfg Config + site Site + st *store.Store + rc *rcon.Client + poll *live.Poller + sacfg *saconfig.File // nil when no config path is set + staff *staffCache + http *http.Client + secure bool +} + +func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg *saconfig.File) (*Server, error) { + site, err := loadSite(cfg.SiteFile) + if err != nil { + return nil, err + } + u, err := url.Parse(cfg.BaseURL) + if err != nil || u.Host == "" { + return nil, errors.New("base URL must be absolute, like https://bans.example.com") + } + return &Server{ + cfg: cfg, + site: site, + st: st, + rc: rc, + poll: poll, + sacfg: sacfg, + staff: &staffCache{st: st, ttl: 15 * time.Second}, + http: &http.Client{Timeout: 10 * time.Second}, + secure: u.Scheme == "https", + }, nil +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + static, _ := fs.Sub(assets, "assets") + files := http.FileServerFS(static) + + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, static, "public.html") + }) + mux.HandleFunc("GET /admin", s.adminPage(static)) + mux.Handle("GET /static/", http.StripPrefix("/static/", files)) + + mux.HandleFunc("GET /auth/login", s.login) + mux.HandleFunc("GET /auth/callback", s.callback) + mux.HandleFunc("POST /auth/logout", s.logout) + + mux.HandleFunc("GET /api/me", s.me) + mux.HandleFunc("GET /api/public/server", s.publicServer) + mux.HandleFunc("GET /api/public/bans", s.publicBans) + mux.HandleFunc("GET /api/public/comms", s.publicComms) + mux.HandleFunc("GET /api/public/staff", s.publicStaff) + mux.HandleFunc("GET /api/public/players/{steamid}", s.publicPlayer) + + admin := func(pattern string, h adminHandler) { mux.HandleFunc(pattern, s.requireStaff(h)) } + admin("GET /api/admin/overview", s.adminOverview) + admin("GET /api/admin/players", s.adminPlayers) + admin("GET /api/admin/players/{steamid}", s.adminPlayer) + admin("GET /api/admin/bans", s.adminBans) + admin("GET /api/admin/comms", s.adminComms) + admin("GET /api/admin/staff", s.adminStaff) + admin("GET /api/admin/settings", s.adminSettings) + + admin("POST /api/admin/penalties", s.addPenalty) + admin("POST /api/admin/penalties/lift", s.liftPenalty) + admin("POST /api/admin/kick", s.kick) + admin("POST /api/admin/say", s.say) + admin("POST /api/admin/map", s.changeMap) + admin("POST /api/admin/restart", s.restartRound) + admin("POST /api/admin/reload-admins", s.reloadAdmins) + admin("POST /api/admin/groups", s.createGroup) + admin("PUT /api/admin/groups/{id}", s.updateGroup) + admin("DELETE /api/admin/groups/{id}", s.deleteGroup) + admin("POST /api/admin/staff", s.addStaff) + admin("PUT /api/admin/staff/{steamid}", s.setStaffRank) + admin("DELETE /api/admin/staff/{steamid}", s.removeStaff) + admin("POST /api/admin/settings/cvars", s.saveCvars) + admin("POST /api/admin/settings/simpleadmin", s.saveSimpleAdmin) + + return s.headers(mux) +} + +func (s *Server) headers(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "same-origin") + h.Set("X-Frame-Options", "DENY") + h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; frame-ancestors 'none'") + next.ServeHTTP(w, r) + }) +} + +// ---------------------------------------------------------------- JSON helpers + +type apiError struct { + Error string `json:"error"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func fail(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, apiError{Error: msg}) +} + +// internal logs the real error and tells the browser something generic. +func internal(w http.ResponseWriter, r *http.Request, err error) { + slog.Error("request failed", "path", r.URL.Path, "err", err) + fail(w, http.StatusInternalServerError, "Something went wrong on the server. The error has been logged.") +} + +func readJSON(r *http.Request, v any) error { + dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<10)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return errors.New("the request wasn't valid JSON") + } + return nil +} + +func page(r *http.Request) (limit, offset, num int) { + num, _ = strconv.Atoi(r.URL.Query().Get("page")) + if num < 1 { + num = 1 + } + return pageSize, (num - 1) * pageSize, num +} + +const pageSize = 50 + +type list[T any] struct { + Items []T `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +func newList[T any](items []T, total, num int) list[T] { + if items == nil { + items = []T{} + } + return list[T]{Items: items, Total: total, Page: num, PageSize: pageSize} +} + +func (s *Server) staffIdx(ctx context.Context) (*staffIndex, error) { + return s.staff.get(ctx) +} diff --git a/internal/web/site.go b/internal/web/site.go new file mode 100644 index 0000000..4636430 --- /dev/null +++ b/internal/web/site.go @@ -0,0 +1,89 @@ +package web + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// Site holds display settings SimpleAdmin has no place for: rank labels, colours and the plain +// descriptions on the public staff page. Ranks are keyed by SimpleAdmin group name. +type Site struct { + Title string `json:"title"` + Ranks map[string]Rank `json:"ranks"` +} + +type Rank struct { + Label string `json:"label"` + Color string `json:"color"` + About string `json:"about"` + // Supporter ranks (VIPs) are listed apart from staff and don't count as staff online. + Supporter bool `json:"supporter"` +} + +func defaultSite() Site { + return Site{ + Title: "zio.sh", + Ranks: map[string]Rank{ + "#rank/owner": {Label: "Owner", Color: "#fe113d", About: "Runs the server."}, + "#rank/senioradmin": {Label: "Senior admin", Color: "#ff7a45", About: "Manages the staff team and server settings."}, + "#rank/admin": {Label: "Admin", Color: "#f0a63a", About: "Handles appeals and longer bans."}, + "#rank/trialadmin": {Label: "Trial admin", Color: "#e8d25a", About: "Admins on trial."}, + "#rank/mod": {Label: "Moderator", Color: "#37c58f", About: "Keeps matches fair. Can kick, gag and issue short bans."}, + "#rank/trialmod": {Label: "Trial mod", Color: "#4fb3d9", About: "Moderators on trial."}, + "#rank/helper": {Label: "Helper", Color: "#7c8cf5", About: "Answers questions and handles chat."}, + "#rank/guardian": {Label: "Guardian", Color: "#c77dff", About: "Supporters of the server. Not staff, and can't punish anyone.", Supporter: true}, + }, + } +} + +func loadSite(path string) (Site, error) { + s := defaultSite() + if path == "" { + return s, nil + } + data, err := os.ReadFile(path) + if err != nil { + return s, err + } + var file Site + if err := json.Unmarshal(data, &file); err != nil { + return s, fmt.Errorf("%s: %w", path, err) + } + if file.Title != "" { + s.Title = file.Title + } + if file.Ranks != nil { + s.Ranks = file.Ranks + } + return s, nil +} + +var fallbackColors = []string{"#7c8cf5", "#4fb3d9", "#37c58f", "#e8d25a", "#f0a63a", "#ff7a45", "#c77dff"} + +// rank returns display settings for a group, deriving a label ("#rank/mod" -> "Mod") and a colour +// for groups the site config doesn't list. +func (s Site) rank(group string, index int) Rank { + if r, ok := s.Ranks[group]; ok { + if r.Label == "" { + r.Label = defaultLabel(group) + } + if r.Color == "" { + r.Color = fallbackColors[index%len(fallbackColors)] + } + return r + } + return Rank{Label: defaultLabel(group), Color: fallbackColors[index%len(fallbackColors)]} +} + +func defaultLabel(group string) string { + name := strings.TrimPrefix(group, "#") + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[i+1:] + } + if name == "" { + return group + } + return strings.ToUpper(name[:1]) + name[1:] +} diff --git a/internal/web/staff.go b/internal/web/staff.go new file mode 100644 index 0000000..e3a1f46 --- /dev/null +++ b/internal/web/staff.go @@ -0,0 +1,161 @@ +package web + +import ( + "context" + "slices" + "sync" + "time" + + "git.zio.sh/cs2/simpleadmin-web/internal/store" +) + +// staffIndex is SimpleAdmin's admins and groups for this server, merged the way CounterStrikeSharp's +// AdminManager sees them: an admin's permissions are their own @flags plus their groups' flags, and +// their immunity is the highest of their own and their groups'. +type staffIndex struct { + groups []store.Group + byName map[string]*store.Group + admins []store.Admin + bySteam map[string]*store.Admin +} + +type staffCache struct { + st *store.Store + ttl time.Duration + + mu sync.Mutex + idx *staffIndex + fetched time.Time +} + +func (c *staffCache) get(ctx context.Context) (*staffIndex, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.idx != nil && time.Since(c.fetched) < c.ttl { + return c.idx, nil + } + groups, err := c.st.Groups(ctx) + if err != nil { + return nil, err + } + admins, err := c.st.Admins(ctx) + if err != nil { + return nil, err + } + idx := &staffIndex{groups: groups, admins: admins, byName: map[string]*store.Group{}, bySteam: map[string]*store.Admin{}} + for i := range idx.groups { + idx.byName[idx.groups[i].Name] = &idx.groups[i] + } + for i := range idx.admins { + idx.bySteam[idx.admins[i].SteamID] = &idx.admins[i] + } + c.idx, c.fetched = idx, time.Now() + return idx, nil +} + +func (c *staffCache) invalidate() { + c.mu.Lock() + c.idx = nil + c.mu.Unlock() +} + +// flags returns an admin's effective permissions. +func (x *staffIndex) flags(a *store.Admin) []string { + out := slices.Clone(a.Flags) + for _, g := range a.Groups { + if grp, ok := x.byName[g]; ok { + for _, f := range grp.Flags { + if !slices.Contains(out, f) { + out = append(out, f) + } + } + } + } + return out +} + +// immunity returns a player's effective immunity, 0 for non-staff. +func (x *staffIndex) immunity(steamid string) int { + a, ok := x.bySteam[steamid] + if !ok { + return 0 + } + imm := a.Immunity + for _, g := range a.Groups { + if grp, ok := x.byName[g]; ok { + imm = max(imm, grp.Immunity) + } + } + return imm +} + +// primaryGroup is the admin's highest-immunity group that exists, or "". +func (x *staffIndex) primaryGroup(a *store.Admin) string { + best, bestImm := "", -1 + for _, g := range a.Groups { + if grp, ok := x.byName[g]; ok && grp.Immunity > bestImm { + best, bestImm = g, grp.Immunity + } + } + return best +} + +// groupIndex is a group's position, used to pick a stable fallback colour. +func (x *staffIndex) groupIndex(name string) int { + for i, g := range x.groups { + if g.Name == name { + return i + } + } + return 0 +} + +// Identity is a signed-in staff member. +type Identity struct { + SteamID string + Name string + Flags []string + Immunity int + RowID int64 +} + +// Has reports whether the identity holds a permission. @css/root grants everything. +func (id *Identity) Has(flag string) bool { + return slices.Contains(id.Flags, "@css/root") || slices.Contains(id.Flags, flag) +} + +// CanTarget follows CounterStrikeSharp: you can act on players whose immunity isn't above yours. +// Nobody acts on themselves through the panel. +func (id *Identity) CanTarget(x *staffIndex, steamid string) bool { + if steamid == id.SteamID { + return false + } + return x.immunity(steamid) <= id.Immunity +} + +// rankView is how a rank is shown to the browser. +type rankView struct { + Name string `json:"name"` + Label string `json:"label"` + Color string `json:"color"` + Supporter bool `json:"supporter,omitempty"` +} + +func (s *Server) rankOf(x *staffIndex, steamid string) *rankView { + a, ok := x.bySteam[steamid] + if !ok { + return nil + } + g := x.primaryGroup(a) + if g == "" { + return &rankView{Label: "Staff", Color: "#8a91a0"} + } + r := s.site.rank(g, x.groupIndex(g)) + return &rankView{Name: g, Label: r.Label, Color: r.Color, Supporter: r.Supporter} +} + +// isStaff is true for admins whose rank isn't a supporter rank. +func (s *Server) isStaff(x *staffIndex, steamid string) bool { + r := s.rankOf(x, steamid) + return r != nil && !r.Supporter +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a540ab7 --- /dev/null +++ b/main.go @@ -0,0 +1,221 @@ +// simpleadmin-web is a web panel for CS2-SimpleAdmin: a public ban list, staff list and live +// scoreboard, and a staff panel for bans, gags and mutes, ranks and server settings. +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + _ "time/tzdata" // SimpleAdmin's Timezone must load even in a minimal container + + "github.com/go-sql-driver/mysql" + + "git.zio.sh/cs2/simpleadmin-web/internal/live" + "git.zio.sh/cs2/simpleadmin-web/internal/rcon" + "git.zio.sh/cs2/simpleadmin-web/internal/saconfig" + "git.zio.sh/cs2/simpleadmin-web/internal/store" + "git.zio.sh/cs2/simpleadmin-web/internal/web" +) + +func env(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func envInt(key string, def int) (int, error) { + v := env(key, "") + if v == "" { + return def, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("%s must be a number", key) + } + return n, nil +} + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) + if err := run(); err != nil { + slog.Error("simpleadmin-web", "err", err) + os.Exit(1) + } +} + +func run() error { + baseURL := strings.TrimRight(env("SAW_BASE_URL", ""), "/") + if baseURL == "" { + return errors.New("set SAW_BASE_URL to the panel's public address, like https://bans.example.com") + } + secret := env("SAW_SESSION_SECRET", "") + if len(secret) < 32 { + return errors.New("set SAW_SESSION_SECRET to at least 32 random characters (e.g. openssl rand -hex 32)") + } + + var sacfg *saconfig.File + general := saconfig.General{Timezone: "UTC", MultiServerMode: true} + if path := env("SAW_SA_CONFIG", ""); path != "" { + sacfg = saconfig.Open(path) + g, err := sacfg.General() + if err != nil { + return fmt.Errorf("SAW_SA_CONFIG: %w", err) + } + general = g + } + + tzName := env("SAW_TIMEZONE", general.Timezone) + loc, err := time.LoadLocation(tzName) + if err != nil { + return fmt.Errorf("timezone %q: %w", tzName, err) + } + + db, err := openDB(sacfg) + if err != nil { + return err + } + defer db.Close() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + serverID, err := envInt("SAW_SERVER_ID", 0) + if err != nil { + return err + } + timeMode := 1 + maxBan, maxMute := 60*24*7, 60*24*7 + if sacfg != nil { + if v, err := sacfg.Values(); err == nil { + timeMode, maxBan, maxMute = v["TimeMode"].(int), v["MaxBanDuration"].(int), v["MaxMuteDuration"].(int) + } + } + + // Find this server in sa_servers. SimpleAdmin records its address and RCON password there. + st := store.New(db, loc, 0, timeMode) + servers, err := st.Servers(ctx) + if err != nil { + return fmt.Errorf("read sa_servers (is this SimpleAdmin's database?): %w", err) + } + var srv store.Server + switch { + case serverID > 0: + for _, s := range servers { + if s.ID == int64(serverID) { + srv = s + } + } + if srv.ID == 0 { + return fmt.Errorf("SAW_SERVER_ID %d isn't in sa_servers", serverID) + } + case len(servers) == 1: + srv = servers[0] + case len(servers) == 0: + slog.Warn("sa_servers is empty; SimpleAdmin hasn't registered the server yet") + default: + return errors.New("sa_servers lists several servers; set SAW_SERVER_ID to this server's id") + } + st = store.New(db, loc, srv.ID, timeMode) + + rconAddr := env("SAW_RCON_ADDR", srv.Address) + rconPass := env("SAW_RCON_PASSWORD", srv.RconPassword) + if rconAddr == "" || rconPass == "" { + slog.Warn("no RCON address or password; live status and in-game actions are off. Set SAW_RCON_ADDR and SAW_RCON_PASSWORD.") + } + rc := rcon.New(rconAddr, rconPass) + defer rc.Close() + + pollSecs, err := envInt("SAW_POLL_SECONDS", 5) + if err != nil { + return err + } + poller := live.NewPoller(rc, time.Duration(max(pollSecs, 2))*time.Second) + go poller.Run(ctx) + + joinAddr := env("SAW_JOIN_ADDRESS", srv.Address) + app, err := web.New(web.Config{ + BaseURL: baseURL, + SessionSecret: []byte(secret), + SiteFile: env("SAW_SITE_CONFIG", ""), + ServerAddress: joinAddr, + MaxBanDuration: maxBan, + MaxMuteDuration: maxMute, + }, st, rc, poller, sacfg) + if err != nil { + return err + } + + listen := env("SAW_LISTEN", ":8080") + hs := &http.Server{ + Addr: listen, + Handler: app.Handler(), + ReadHeaderTimeout: 10 * time.Second, + BaseContext: func(net.Listener) context.Context { return ctx }, + } + go func() { + <-ctx.Done() + shut, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = hs.Shutdown(shut) + }() + slog.Info("listening", "addr", listen, "base", baseURL, "server", srv.ID, "rcon", rconAddr, "timezone", loc.String()) + if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +// openDB connects with SAW_DB_DSN, or with the DatabaseConfig in CS2-SimpleAdmin.json. +func openDB(sacfg *saconfig.File) (*sql.DB, error) { + var cfg *mysql.Config + if dsn := env("SAW_DB_DSN", ""); dsn != "" { + c, err := mysql.ParseDSN(dsn) + if err != nil { + return nil, fmt.Errorf("SAW_DB_DSN: %w", err) + } + cfg = c + } else if sacfg != nil { + d, err := sacfg.Database() + if err != nil { + return nil, err + } + if !strings.EqualFold(d.Type, "mysql") { + return nil, fmt.Errorf("SimpleAdmin uses %s; the panel only supports MySQL", d.Type) + } + cfg = mysql.NewConfig() + cfg.User, cfg.Passwd, cfg.DBName = d.User, d.Password, d.Name + cfg.Net, cfg.Addr = "tcp", net.JoinHostPort(d.Host, strconv.Itoa(d.Port)) + if h := env("SAW_DB_HOST", ""); h != "" { + cfg.Addr = h + } + } else { + return nil, errors.New("set SAW_DB_DSN, or SAW_SA_CONFIG to CS2-SimpleAdmin.json to use its database settings") + } + // Timestamps are SimpleAdmin's wall-clock values; the store interprets them in SimpleAdmin's + // Timezone, so the driver must pass them through unconverted. + cfg.ParseTime = true + cfg.Loc = time.UTC + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(10) + db.SetConnMaxLifetime(5 * time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("connect to the database at %s: %w", cfg.Addr, err) + } + return db, nil +} diff --git a/site.example.json b/site.example.json new file mode 100644 index 0000000..13cf66e --- /dev/null +++ b/site.example.json @@ -0,0 +1,13 @@ +{ + "title": "zio.sh", + "ranks": { + "#rank/owner": { "label": "Owner", "color": "#fe113d", "about": "Runs the server." }, + "#rank/senioradmin": { "label": "Senior admin", "color": "#ff7a45", "about": "Manages the staff team and server settings." }, + "#rank/admin": { "label": "Admin", "color": "#f0a63a", "about": "Handles appeals and longer bans." }, + "#rank/trialadmin": { "label": "Trial admin", "color": "#e8d25a", "about": "Admins on trial." }, + "#rank/mod": { "label": "Moderator", "color": "#37c58f", "about": "Keeps matches fair. Can kick, gag and issue short bans." }, + "#rank/trialmod": { "label": "Trial mod", "color": "#4fb3d9", "about": "Moderators on trial." }, + "#rank/helper": { "label": "Helper", "color": "#7c8cf5", "about": "Answers questions and handles chat." }, + "#rank/guardian": { "label": "Guardian", "color": "#c77dff", "about": "Supporters of the server. Not staff, and can't punish anyone.", "supporter": true } + } +}