simpleadmin-web 0.1.0: web panel for CS2-SimpleAdmin

This commit is contained in:
Astra 2026-09-25 20:08:14 +01:00
commit 4f38daf0e6
31 changed files with 6870 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
simpleadmin-web
*.env
!example.env

13
Containerfile Normal file
View file

@ -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"]

107
README.md Normal file
View file

@ -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 <steamid>` 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.

30
example.env Normal file
View file

@ -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)

7
go.mod Normal file
View file

@ -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

4
go.sum Normal file
View file

@ -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=

171
internal/live/live.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

230
internal/rcon/rcon.go Normal file
View file

@ -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
}

132
internal/rcon/rcon_test.go Normal file
View file

@ -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)
}
}

View file

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

145
internal/steam/steam.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

1069
internal/store/store.go Normal file

File diff suppressed because it is too large Load diff

713
internal/web/actions.go Normal file
View file

@ -0,0 +1,713 @@
package web
import (
"errors"
"fmt"
"log/slog"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"time"
"git.zio.sh/cs2/simpleadmin-web/internal/rcon"
"git.zio.sh/cs2/simpleadmin-web/internal/steam"
"git.zio.sh/cs2/simpleadmin-web/internal/store"
)
// Commands sent over RCON run as SimpleAdmin's "Console", which skips every permission, immunity
// and length check SimpleAdmin would apply to a player. Each handler here enforces them instead,
// using the same flags SimpleAdmin checks.
type result struct {
OK bool `json:"ok"`
Message string `json:"message"`
}
func done(w http.ResponseWriter, msg string) {
writeJSON(w, http.StatusOK, result{OK: true, Message: msg})
}
func (s *Server) limits() (maxBan, maxMute int) {
maxBan, maxMute = s.cfg.MaxBanDuration, s.cfg.MaxMuteDuration
if s.sacfg != nil {
if v, err := s.sacfg.Values(); err == nil {
maxBan, maxMute = v["MaxBanDuration"].(int), v["MaxMuteDuration"].(int)
}
}
return
}
func fmtMinutes(m int) string {
switch {
case m%(60*24) == 0:
d := m / (60 * 24)
if d == 1 {
return "1 day"
}
return strconv.Itoa(d) + " days"
case m%60 == 0:
return strconv.Itoa(m/60) + " h"
}
return strconv.Itoa(m) + " min"
}
var commCommands = map[string]struct{ add, lift, label string }{
"GAG": {"css_addgag", "css_ungag", "gag"},
"MUTE": {"css_addmute", "css_unmute", "mute"},
"SILENCE": {"css_addsilence", "css_unsilence", "silence"},
}
// consoleRejected spots SimpleAdmin's replies to a command it refused.
func consoleRejected(out string) string {
for _, bad := range []string{"Invalid SteamID64", "Invalid player", "Unknown command", "not found"} {
if strings.Contains(out, bad) {
return strings.TrimSpace(out)
}
}
return ""
}
type penaltyReq struct {
Kind string `json:"kind"` // ban, comm, warn
Type string `json:"type"` // GAG, MUTE, SILENCE
SteamID string `json:"steamid"`
Duration int `json:"duration"`
Reason string `json:"reason"`
}
func (s *Server) addPenalty(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req penaltyReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if !steam.ValidID(req.SteamID) {
fail(w, http.StatusBadRequest, "Enter the player's SteamID64 (17 digits, starting 7656119).")
return
}
reason := rcon.Arg(req.Reason, 200)
if reason == "" {
fail(w, http.StatusBadRequest, "Give a reason. The player sees it.")
return
}
if req.Duration < 0 || req.Duration > 60*24*365*10 {
fail(w, http.StatusBadRequest, "Pick a length between 1 minute and 10 years, or permanent.")
return
}
if !id.CanTarget(x, req.SteamID) {
if req.SteamID == id.SteamID {
fail(w, http.StatusForbidden, "You can't punish yourself from the panel.")
} else {
fail(w, http.StatusForbidden, "This player's rank has higher immunity than yours.")
}
return
}
maxBan, maxMute := s.limits()
var table, cmd, what string
switch req.Kind {
case "ban":
if !id.Has("@css/ban") {
fail(w, http.StatusForbidden, "Your rank can't ban players.")
return
}
if (req.Duration == 0 || req.Duration > maxBan) && !id.Has("@css/permban") {
fail(w, http.StatusForbidden, fmt.Sprintf("Your rank can ban for up to %s, and not permanently.", fmtMinutes(maxBan)))
return
}
table, what = "sa_bans", "Ban"
cmd = fmt.Sprintf(`css_addban %s %d "%s"`, req.SteamID, req.Duration, reason)
case "comm":
c, ok := commCommands[req.Type]
if !ok {
fail(w, http.StatusBadRequest, "Choose gag, mute or silence.")
return
}
if !id.Has("@css/chat") {
fail(w, http.StatusForbidden, "Your rank can't gag or mute players.")
return
}
if (req.Duration == 0 || req.Duration > maxMute) && !id.Has("@css/permmute") {
fail(w, http.StatusForbidden, fmt.Sprintf("Your rank can block for up to %s, and not permanently.", fmtMinutes(maxMute)))
return
}
table, what = "sa_mutes", strings.ToUpper(c.label[:1])+c.label[1:]
cmd = fmt.Sprintf(`%s %s %d "%s"`, c.add, req.SteamID, req.Duration, reason)
case "warn":
if !id.Has("@css/kick") {
fail(w, http.StatusForbidden, "Your rank can't warn players.")
return
}
p, online := s.poll.Refresh().Find(req.SteamID)
if !online {
fail(w, http.StatusConflict, "Warnings can only be given to players on the server.")
return
}
table, what = "sa_warns", "Warning"
cmd = fmt.Sprintf(`css_warn #%d %d "%s"`, p.UserID, req.Duration, reason)
default:
fail(w, http.StatusBadRequest, "Unknown penalty.")
return
}
ctx := r.Context()
before := s.st.MaxID(ctx, table)
out, err := s.rc.Exec(cmd)
if err != nil {
slog.Warn("rcon failed, writing penalty to the database", "err", err)
if req.Kind == "warn" {
fail(w, http.StatusBadGateway, "The game server isn't answering, and warnings can only be given in-game.")
return
}
np := store.NewPenalty{SteamID: req.SteamID, Name: s.st.LatestName(ctx, req.SteamID), Type: req.Type,
AdminSteamID: id.SteamID, AdminName: id.Name, Reason: reason, Duration: req.Duration}
if req.Kind == "ban" {
err = s.st.InsertBan(ctx, np)
} else {
err = s.st.InsertComm(ctx, np)
}
if err != nil {
internal(w, r, err)
return
}
done(w, what+" saved. The game server isn't answering, so it applies when the player next joins.")
return
}
if msg := consoleRejected(out); msg != "" {
fail(w, http.StatusBadGateway, "SimpleAdmin refused: "+msg)
return
}
commType := ""
if req.Kind == "comm" {
commType = req.Type
}
claimed := s.st.ClaimConsoleRow(ctx, table, before, req.SteamID, commType, id.SteamID, id.Name)
go s.poll.Refresh()
slog.Info("penalty issued", "admin", id.SteamID, "kind", req.Kind, "type", req.Type, "player", req.SteamID, "minutes", req.Duration)
if claimed == 0 {
done(w, what+" sent to the server, but its record hasn't appeared yet. Check the list in a moment.")
return
}
done(w, what+" applied.")
}
type liftReq struct {
Kind string `json:"kind"` // ban, comm
ID int64 `json:"id"`
Reason string `json:"reason"`
}
func (s *Server) liftPenalty(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req liftReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
reason := rcon.Arg(req.Reason, 200)
if reason == "" {
reason = "Lifted from the web panel"
}
table := "sa_bans"
switch req.Kind {
case "ban":
if !id.Has("@css/unban") {
fail(w, http.StatusForbidden, "Your rank can't unban players.")
return
}
case "comm":
if !id.Has("@css/chat") {
fail(w, http.StatusForbidden, "Your rank can't lift gags or mutes.")
return
}
table = "sa_mutes"
default:
fail(w, http.StatusBadRequest, "Unknown penalty.")
return
}
ctx := r.Context()
steamid, commType, err := s.st.PenaltyOwner(ctx, table, req.ID)
if errors.Is(err, store.ErrNotFound) {
fail(w, http.StatusNotFound, "That record no longer exists.")
return
} else if err != nil {
internal(w, r, err)
return
}
if !steam.ValidID(steamid) {
fail(w, http.StatusConflict, "This record has no SteamID, so it can only be lifted in-game.")
return
}
ids := s.st.ActivePenaltyIDs(ctx, table, steamid, commType)
if !slices.Contains(ids, req.ID) {
fail(w, http.StatusConflict, "That's already expired or lifted.")
return
}
var cmd, what string
if req.Kind == "ban" {
cmd, what = fmt.Sprintf(`css_unban %s "%s"`, steamid, reason), "Unbanned"
} else {
c := commCommands[commType]
cmd, what = fmt.Sprintf(`%s %s "%s"`, c.lift, steamid, reason), "Lifted the "+c.label
}
if _, err := s.rc.Exec(cmd); err != nil {
slog.Warn("rcon failed, lifting in the database", "err", err)
if _, err := s.st.LiftDirect(ctx, table, steamid, commType, id.RowID, reason); err != nil {
internal(w, r, err)
return
}
done(w, what+". The game server isn't answering, so it applies when the player next joins.")
return
}
s.st.ClaimLift(ctx, table, ids, id.RowID)
go s.poll.Refresh()
slog.Info("penalty lifted", "admin", id.SteamID, "kind", req.Kind, "player", steamid)
done(w, what+".")
}
func (s *Server) kick(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req struct {
SteamID string `json:"steamid"`
Reason string `json:"reason"`
}
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if !id.Has("@css/kick") {
fail(w, http.StatusForbidden, "Your rank can't kick players.")
return
}
if !steam.ValidID(req.SteamID) || !id.CanTarget(x, req.SteamID) {
fail(w, http.StatusForbidden, "You can't kick this player.")
return
}
p, online := s.poll.Refresh().Find(req.SteamID)
if !online {
fail(w, http.StatusConflict, "That player isn't on the server.")
return
}
reason := rcon.Arg(req.Reason, 200)
if reason == "" {
reason = "Kicked by an admin"
}
if _, err := s.rc.Exec(fmt.Sprintf(`css_kick #%d "%s"`, p.UserID, reason)); err != nil {
fail(w, http.StatusBadGateway, "The game server isn't answering.")
return
}
slog.Info("kick", "admin", id.SteamID, "player", req.SteamID)
go s.poll.Refresh()
done(w, "Kicked "+p.Name+".")
}
// console runs a command that needs no target, after a permission check.
func (s *Server) console(w http.ResponseWriter, id *Identity, perm, cmd, ok string) {
if !id.Has(perm) {
fail(w, http.StatusForbidden, "Your rank can't do that.")
return
}
if _, err := s.rc.Exec(cmd); err != nil {
fail(w, http.StatusBadGateway, "The game server isn't answering.")
return
}
slog.Info("console", "admin", id.SteamID, "cmd", cmd)
go s.poll.Refresh()
done(w, ok)
}
func (s *Server) say(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req struct {
Message string `json:"message"`
}
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
msg := rcon.Arg(req.Message, 200)
if msg == "" {
fail(w, http.StatusBadRequest, "Write a message first.")
return
}
s.console(w, id, "@css/chat", fmt.Sprintf(`css_say "%s"`, msg), "Sent.")
}
var mapName = regexp.MustCompile(`^(ws:)?[A-Za-z0-9_\-]{1,64}$`)
func (s *Server) changeMap(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req struct {
Map string `json:"map"`
}
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if !mapName.MatchString(req.Map) {
fail(w, http.StatusBadRequest, "Enter a map name like de_mirage, or ws:<workshop id>.")
return
}
s.console(w, id, "@css/changemap", "css_map "+req.Map, "Changing map to "+req.Map+".")
}
func (s *Server) restartRound(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
s.console(w, id, "@css/generic", "css_rr", "Restarting the match.")
}
func (s *Server) reloadAdmins(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
s.staff.invalidate()
s.console(w, id, "@css/root", "css_reloadadmins", "Reloaded admins.")
}
// ---------------------------------------------------------------- Ranks and staff
var (
groupName = regexp.MustCompile(`^#[A-Za-z0-9_./\-]{1,63}$`)
flagName = regexp.MustCompile(`^@[a-z0-9_./\-]{1,63}$`)
)
type groupReq struct {
Name string `json:"name"`
Immunity int `json:"immunity"`
Flags []string `json:"flags"`
}
func (req *groupReq) validate(id *Identity) string {
if !groupName.MatchString(req.Name) {
return "Rank names start with # and use letters, numbers and _ . / -, like #rank/mod."
}
if req.Immunity < 0 || req.Immunity > 9999 {
return "Immunity must be between 0 and 9999."
}
if req.Immunity > id.Immunity {
return fmt.Sprintf("You can't create a rank with more immunity than your own (%d).", id.Immunity)
}
if len(req.Flags) == 0 {
return "Give the rank at least one permission."
}
seen := map[string]bool{}
for _, f := range req.Flags {
if !flagName.MatchString(f) {
return "Permissions look like @css/kick."
}
seen[f] = true
}
req.Flags = req.Flags[:0]
for f := range seen {
req.Flags = append(req.Flags, f)
}
slices.Sort(req.Flags)
return ""
}
// afterStaffChange tells SimpleAdmin to reload admins, and says so if the server is down.
func (s *Server) afterStaffChange(w http.ResponseWriter, ok string) {
s.staff.invalidate()
if _, err := s.rc.Exec("css_reloadadmins"); err != nil {
done(w, ok+" The game server isn't answering, so it applies when SimpleAdmin next loads admins.")
return
}
done(w, ok)
}
func (s *Server) createGroup(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !id.Has("@css/root") {
fail(w, http.StatusForbidden, "Only Root can manage ranks.")
return
}
var req groupReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if msg := req.validate(id); msg != "" {
fail(w, http.StatusBadRequest, msg)
return
}
if _, err := s.st.CreateGroup(r.Context(), req.Name, req.Immunity, req.Flags); err != nil {
fail(w, http.StatusConflict, err.Error())
return
}
slog.Info("group created", "admin", id.SteamID, "group", req.Name)
s.afterStaffChange(w, "Created "+req.Name+".")
}
func (s *Server) groupByID(x *staffIndex, raw string) *groupRef {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil
}
for i := range x.groups {
if x.groups[i].ID == n {
return &groupRef{ID: n, Name: x.groups[i].Name, Immunity: x.groups[i].Immunity, Flags: x.groups[i].Flags}
}
}
return nil
}
type groupRef struct {
ID int64
Name string
Immunity int
Flags []string
}
// ownsGroup reports whether the signed-in admin holds this rank.
func ownsGroup(x *staffIndex, id *Identity, name string) bool {
a, ok := x.bySteam[id.SteamID]
return ok && slices.Contains(a.Groups, name)
}
func (s *Server) updateGroup(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !id.Has("@css/root") {
fail(w, http.StatusForbidden, "Only Root can manage ranks.")
return
}
g := s.groupByID(x, r.PathValue("id"))
if g == nil {
fail(w, http.StatusNotFound, "That rank no longer exists.")
return
}
if g.Immunity > id.Immunity {
fail(w, http.StatusForbidden, "This rank has more immunity than yours.")
return
}
var req groupReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if msg := req.validate(id); msg != "" {
fail(w, http.StatusBadRequest, msg)
return
}
if ownsGroup(x, id, g.Name) {
a := x.bySteam[id.SteamID]
if slices.Contains(g.Flags, "@css/root") && !slices.Contains(req.Flags, "@css/root") && !slices.Contains(a.Flags, "@css/root") {
fail(w, http.StatusForbidden, "That would take Root away from your own rank and lock you out. Ask another Root admin.")
return
}
}
if err := s.st.UpdateGroup(r.Context(), g.ID, req.Name, req.Immunity, req.Flags); err != nil {
if errors.Is(err, store.ErrNotFound) {
fail(w, http.StatusNotFound, "That rank no longer exists.")
return
}
fail(w, http.StatusConflict, err.Error())
return
}
slog.Info("group updated", "admin", id.SteamID, "group", req.Name, "flags", req.Flags, "immunity", req.Immunity)
s.afterStaffChange(w, "Saved "+req.Name+".")
}
func (s *Server) deleteGroup(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !id.Has("@css/root") {
fail(w, http.StatusForbidden, "Only Root can manage ranks.")
return
}
g := s.groupByID(x, r.PathValue("id"))
if g == nil {
fail(w, http.StatusNotFound, "That rank no longer exists.")
return
}
if g.Immunity > id.Immunity {
fail(w, http.StatusForbidden, "This rank has more immunity than yours.")
return
}
if ownsGroup(x, id, g.Name) {
fail(w, http.StatusForbidden, "You can't delete your own rank.")
return
}
if err := s.st.DeleteGroup(r.Context(), g.ID); err != nil {
internal(w, r, err)
return
}
slog.Info("group deleted", "admin", id.SteamID, "group", g.Name)
s.afterStaffChange(w, "Deleted "+g.Name+". Its members are regular players now.")
}
type staffReq struct {
SteamID string `json:"steamid"`
Group string `json:"group"`
Days int `json:"days"`
}
// checkStaffTarget applies the rules for giving, changing or removing someone's rank.
func (s *Server) checkStaffTarget(w http.ResponseWriter, id *Identity, x *staffIndex, steamid string) bool {
if !id.Has("@css/root") {
fail(w, http.StatusForbidden, "Only Root can manage staff.")
return false
}
if !steam.ValidID(steamid) {
fail(w, http.StatusBadRequest, "Enter the player's SteamID64 (17 digits, starting 7656119).")
return false
}
if steamid == id.SteamID {
fail(w, http.StatusForbidden, "You can't change your own rank. Ask another Root admin.")
return false
}
if x.immunity(steamid) > id.Immunity {
fail(w, http.StatusForbidden, "This person has more immunity than you.")
return false
}
return true
}
func (s *Server) applyRank(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex, req staffReq, verb string) {
grp, ok := x.byName[req.Group]
if !ok {
fail(w, http.StatusBadRequest, "Pick a rank that exists.")
return
}
if grp.Immunity > id.Immunity {
fail(w, http.StatusForbidden, "You can't give a rank with more immunity than your own.")
return
}
if req.Days < 0 || req.Days > 3650 {
fail(w, http.StatusBadRequest, "Expiry must be between 1 and 3650 days, or never.")
return
}
var ends *time.Time
if req.Days > 0 {
t := time.Now().Add(time.Duration(req.Days) * 24 * time.Hour)
ends = &t
}
// SimpleAdmin builds its admin list keyed by name, so an admin must never have an empty one.
name := s.st.LatestName(r.Context(), req.SteamID)
if name == "" {
name = req.SteamID
}
if err := s.st.SetAdminGroup(r.Context(), req.SteamID, name, req.Group, grp.Immunity, ends); err != nil {
internal(w, r, err)
return
}
slog.Info("rank set", "admin", id.SteamID, "player", req.SteamID, "group", req.Group, "days", req.Days)
s.afterStaffChange(w, verb+" "+name+" to "+s.site.rank(req.Group, x.groupIndex(req.Group)).Label+".")
}
func (s *Server) addStaff(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req staffReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if !s.checkStaffTarget(w, id, x, req.SteamID) {
return
}
s.applyRank(w, r, id, x, req, "Added")
}
func (s *Server) setStaffRank(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
var req staffReq
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
req.SteamID = r.PathValue("steamid")
if !s.checkStaffTarget(w, id, x, req.SteamID) {
return
}
if req.Group == "" {
s.removeStaff(w, r, id, x)
return
}
s.applyRank(w, r, id, x, req, "Moved")
}
func (s *Server) removeStaff(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
steamid := r.PathValue("steamid")
if !s.checkStaffTarget(w, id, x, steamid) {
return
}
if err := s.st.RemoveAdmin(r.Context(), steamid); err != nil {
internal(w, r, err)
return
}
slog.Info("staff removed", "admin", id.SteamID, "player", steamid)
s.afterStaffChange(w, "Removed their rank.")
}
// ---------------------------------------------------------------- Settings
func (s *Server) saveCvars(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !id.Has("@css/cvar") {
fail(w, http.StatusForbidden, "Your rank can't change server settings.")
return
}
var req map[string]any
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
var cmds []string
for key, raw := range req {
i := slices.IndexFunc(cvars, func(c cvarDef) bool { return c.Key == key })
if i < 0 {
fail(w, http.StatusBadRequest, key+" can't be changed here.")
return
}
def := cvars[i]
var val string
switch def.Type {
case "bool":
b, ok := raw.(bool)
if !ok {
fail(w, http.StatusBadRequest, def.Label+" must be on or off.")
return
}
val = "0"
if b {
val = "1"
}
case "int":
f, ok := raw.(float64)
if !ok || f != float64(int(f)) || f < -1 || f > 100000 {
fail(w, http.StatusBadRequest, def.Label+" must be a whole number.")
return
}
val = strconv.Itoa(int(f))
default:
str, ok := raw.(string)
if !ok {
fail(w, http.StatusBadRequest, def.Label+" must be text.")
return
}
val = rcon.Arg(str, 128)
if key == "hostname" && val == "" {
fail(w, http.StatusBadRequest, "The server needs a name.")
return
}
}
cmds = append(cmds, fmt.Sprintf(`%s "%s"`, key, val))
}
for _, c := range cmds {
if _, err := s.rc.Exec(c); err != nil {
fail(w, http.StatusBadGateway, "The game server isn't answering.")
return
}
}
slog.Info("cvars changed", "admin", id.SteamID, "cmds", cmds)
go s.poll.Refresh()
done(w, "Saved. These reset when the server restarts; put permanent values in server.cfg.")
}
func (s *Server) saveSimpleAdmin(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
if !id.Has("@css/root") {
fail(w, http.StatusForbidden, "Only Root can change SimpleAdmin's settings.")
return
}
if s.sacfg == nil {
fail(w, http.StatusNotFound, "The panel wasn't given CS2-SimpleAdmin.json, so these can't be changed here.")
return
}
var req map[string]any
if err := readJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
if err := s.sacfg.Update(req); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
slog.Info("simpleadmin config changed", "admin", id.SteamID, "changes", req)
done(w, "Saved. SimpleAdmin reads its config when it loads, so this applies after the next server restart.")
}

410
internal/web/admin.go Normal file
View file

@ -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)
}

View file

@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Staff panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="app">
<nav class="nav" id="nav" aria-label="Main">
<a class="brand" href="#overview">
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span class="brand-name site-name"></span>
</a>
<div class="server-pick" id="server-pick">
<b>Loading…</b>
<small>&nbsp;</small>
</div>
<ul id="nav-links"></ul>
<a class="public-link" href="/">View the public site</a>
<div class="me" id="me">
<span class="avatar">?</span>
<div><b>&nbsp;</b><small>&nbsp;</small></div>
<button class="btn sm ghost" type="button" id="signout">Sign out</button>
</div>
</nav>
<div>
<div class="mobile-bar">
<button class="btn sm ghost" type="button" id="menu-btn" aria-controls="nav" aria-expanded="false">Menu</button>
<span class="brand-name site-name" style="font-size:19px"></span>
</div>
<main id="view" tabindex="-1"></main>
</div>
</div>
<div id="drawer-root"></div>
<dialog id="modal"></dialog>
<div id="toast-root" aria-live="polite"></div>
<script src="/static/common.js"></script>
<script src="/static/admin.js"></script>
</body>
</html>

View file

@ -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 `<li style="--c:${ACTIVITY_COLOR[a.kind]}"><div><p><b>${esc(a.admin)}</b> ${verb} <b>${esc(a.name || a.steamid)}</b>${len}</p>
<small>${fmtAgo(a.at)}${a.reason ? `, ${esc(a.reason)}` : ""}</small></div></li>`;
}
function liveHeader(s) {
if (!s.online) {
return `<div class="live"><div><p class="live-host">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.</p><div class="live-map">Offline</div></div></div>`;
}
return `<div class="live">
<div>
<p class="live-host"><span class="dot"></span>${esc(s.hostname)}${s.warmup ? ", warmup" : ""}</p>
<div class="live-map">${esc(s.map)}</div>
</div>
<div class="live-stats">
<div><b>${s.count}<span class="faint">/${s.maxPlayers}</span></b><small>Players</small></div>
<div><b><span class="team t score">${s.scoreT}</span> <span class="faint">:</span> <span class="team ct score">${s.scoreCt}</span></b><small>T vs CT</small></div>
<div><b>${s.staffOnline}</b><small>Staff online</small></div>
</div>
</div>`;
}
function tagsFor(p) {
return (p.tags || []).map((t) => {
if (t.startsWith("WARN:")) {
const n = t.slice(5);
return `<span class="tag" title="Active warnings">${esc(n)} warn${n === "1" ? "" : "s"}</span>`;
}
return `<span class="tag amber" title="${COMM_HELP[t]}">${COMM_LABEL[t]}</span>`;
}).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 '<div class="empty">Nobody is on the server.</div>';
const teamName = { 2: "T", 3: "CT" };
return `<table>
<thead><tr><th>Player</th><th class="hide-sm">Rank</th><th class="num">K / D</th><th class="num hide-sm">Ping</th><th><span class="sr">Actions</span></th></tr></thead>
<tbody>
${list.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="team ${p.team === 3 ? "ct" : p.team === 2 ? "t" : ""}">${teamName[p.team] || "SPEC"}</span><div><b>${esc(p.name)}</b> ${tagsFor(p)}</div></div></td>
<td class="hide-sm">${rankTag(p.rank)}</td>
<td class="num">${p.kills} / ${p.deaths}</td>
<td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</td>
<td><div class="row-actions">${p.canTarget ? `
${can("@css/chat") ? `<button class="btn sm ghost" type="button" data-act="comm" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Gag</button>` : ""}
${can("@css/kick") ? `<button class="btn sm ghost" type="button" data-act="kick" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Kick</button>` : ""}
${can("@css/ban") ? `<button class="btn sm danger" type="button" data-act="ban" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Ban</button>` : ""}` : ""}
</div></td>
</tr>`).join("")}
</tbody>
</table>`;
}
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)}
<div class="grid-2">
<section class="panel">
<div class="panel-head">
<h2>On the server</h2>
<div style="display:flex;gap:8px;flex-wrap:wrap">
${can("@css/changemap") ? '<button class="btn sm" type="button" data-act="map">Change map</button>' : ""}
${can("@css/chat") ? '<button class="btn sm" type="button" data-act="say">Message all</button>' : ""}
</div>
</div>
<div class="table-wrap">${s.online ? onlineTable(s.players) : '<div class="empty">Offline.</div>'}</div>
</section>
<div class="stack">
<section class="panel">
<div class="panel-head"><h2>Recent actions</h2><a class="muted" href="#bans" style="font-size:14px">All bans</a></div>
${d.activity.length ? `<ul class="feed">${d.activity.map(activityLine).join("")}</ul>` : '<div class="empty">Nothing yet.</div>'}
</section>
<section class="panel">
<div class="panel-head"><h2>Ending soon</h2><p>${d.activeBans} bans, ${d.activeComms} blocks active</p></div>
${d.expiring.length ? `<ul class="expiring">${d.expiring.map((p) => `<li><span><a href="#" data-player="${esc(p.steamid)}"><b>${esc(p.name || p.steamid)}</b></a> <span class="muted">${p.kind === "ban" ? "ban" : COMM_LABEL[p.type].toLowerCase()}</span></span><span class="muted">${fmtLeft(served(p).left)}</span></li>`).join("")}</ul>` : '<div class="empty">No timed bans or blocks.</div>'}
</section>
</div>
</div>`;
}
function setServerPick(s) {
const el = $("#server-pick");
el.innerHTML = s.online
? `<b><span class="dot"></span>${esc(s.hostname)}</b><small>${esc(s.map)}, ${s.count}/${s.maxPlayers} players</small>`
: `<b>Server offline</b><small>Live status unavailable</small>`;
}
/* ---------- Players ---------- */
function players() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Players</h1><p class="sub">Everyone SimpleAdmin has seen join, with their rank and record. Search by name, SteamID64${can("@css/showip") ? " or IP address" : ""}.</p></div>
</div>
<section class="panel">
<div class="toolbar">
${searchBox("Search players", state.query)}
${segmented("playerFilter", state.playerFilter, [["online", "Online"], ["ranked", "Ranked"], ["all", "All"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
function playersTable(list) {
if (!list.items.length) {
if (state.query) return `<div class="empty">No players match "${esc(state.query)}". Try a SteamID64 or part of a name.</div>`;
return `<div class="empty">${state.playerFilter === "online" ? "Nobody is on the server." : "No players yet."}</div>`;
}
return `<table>
<thead><tr><th>Player</th><th>Rank</th><th class="hide-sm">Record</th><th class="hide-sm">Last seen</th></tr></thead>
<tbody>
${list.items.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="avatar">${initial(p.name)}</span><div><b>${esc(p.name || p.steamid)}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
<td>${rankTag(p.rank)}</td>
<td class="hide-sm">
${p.banned ? '<span class="tag red">Banned</span>' : ""}
${p.bans ? `<span class="tag">${p.bans} ban${p.bans > 1 ? "s" : ""}</span>` : ""}
${p.comms ? `<span class="tag">${p.comms} block${p.comms > 1 ? "s" : ""}</span>` : ""}
${p.warns ? `<span class="tag">${p.warns} warn${p.warns > 1 ? "s" : ""}</span>` : ""}
${!p.bans && !p.comms && !p.warns ? '<span class="faint">Clean</span>' : ""}
</td>
<td class="hide-sm">${p.online ? '<span class="tag green">Online</span>' : p.lastSeen ? `<span class="muted">${fmtAgo(p.lastSeen)}</span>` : '<span class="faint">Never joined</span>'}</td>
</tr>`).join("")}
</tbody>
</table>${pager(list)}`;
}
/* ---------- Bans and comms ---------- */
function punishTable(list, kind) {
if (!list.items.length) {
return `<div class="empty">${state.query ? `Nothing matches "${esc(state.query)}".` : kind === "ban" ? "No bans in this view." : "No gags or mutes in this view."}</div>`;
}
const canLift = kind === "ban" ? can("@css/unban") : can("@css/chat");
const canAdd = kind === "ban" ? can("@css/ban") : can("@css/chat");
return `<table>
<thead><tr>
<th>Player</th>
${kind === "comm" ? "<th>Type</th>" : ""}
<th>Reason</th>
<th>Length</th>
<th class="hide-sm">Issued</th>
<th>Status</th>
<th><span class="sr">Actions</span></th>
</tr></thead>
<tbody>
${list.items.map((p) => `<tr>
<td><div class="who"><div><b>${p.steamid ? `<a href="#" data-player="${esc(p.steamid)}">${esc(p.name || p.steamid)}</a>` : esc(p.name || "Unknown")}</b><span class="sid">${esc(p.steamid)}${p.ip ? `<br>${esc(p.ip)}` : ""}</span></div></div></td>
${kind === "comm" ? `<td><span class="tag amber" title="${COMM_HELP[p.type]}">${COMM_LABEL[p.type]}</span></td>` : ""}
<td>${esc(p.reason)}${p.liftedBy || p.liftReason ? `<br><small class="muted">Lifted${p.liftedBy ? ` by ${esc(p.liftedBy)}` : ""}${p.liftReason ? `: ${esc(p.liftReason)}` : ""}</small>` : ""}</td>
<td>${termCell(p)}</td>
<td class="hide-sm"><span>${fmtDate(p.created)}</span><br><small class="muted">by ${esc(p.admin || "Console")}</small></td>
<td>${statusTag(p)}</td>
<td><div class="row-actions">
${p.status === "active" && canLift && p.steamid ? `<button class="btn sm danger" type="button" data-act="lift" data-kind="${kind}" data-id="${p.id}" data-name="${esc(p.name)}" data-type="${esc(p.type || "")}">${kind === "ban" ? "Unban" : "Lift"}</button>` : ""}
${p.status !== "active" && canAdd && p.steamid ? `<button class="btn sm ghost" type="button" data-act="${kind === "ban" ? "ban" : "comm"}" data-sid="${esc(p.steamid)}" data-type="${esc(p.type || "")}" data-reason="${esc(p.reason)}">Reapply</button>` : ""}
</div></td>
</tr>`).join("")}
</tbody>
</table>${pager(list)}`;
}
function bans() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Bans</h1><p class="sub">The bar under each length shows how much of the ban has been served. Striped bars never expire.</p></div>
${can("@css/ban") ? `<button class="btn primary" type="button" data-act="ban">${ICON.plus}Add ban</button>` : ""}
</div>
<section class="panel">
<div class="toolbar">
${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"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
function comms() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Gags & mutes</h1><p class="sub">A gag blocks text chat, a mute blocks voice, and a silence blocks both.</p></div>
${can("@css/chat") ? `<button class="btn primary" type="button" data-act="comm">${ICON.plus}Add gag or mute</button>` : ""}
</div>
<section class="panel">
<div class="toolbar">
${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"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
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) => `<li><button type="button" data-group="${x.id}" aria-current="${x.id === state.group}" style="--c:${esc(x.color)}">
<i></i><span><b>${esc(x.label)}</b><small>${x.members.length} ${x.members.length === 1 ? "member" : "members"}</small></span><span class="imm">${x.immunity}</span>
</button></li>`;
const staffGroups = d.groups.filter((x) => !x.supporter);
const supporters = d.groups.filter((x) => x.supporter);
const memberRow = (p, groupName) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="avatar">${initial(p.name)}</span><div><b>${esc(p.name)}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
<td class="hide-sm">${p.online ? '<span class="tag green">Online</span>' : p.lastSeen ? `<span class="muted">Seen ${fmtAgo(p.lastSeen)}</span>` : '<span class="faint">Never joined</span>'}</td>
<td class="muted hide-sm">${p.ends ? `Until ${fmtDate(p.ends)}` : "No expiry"}</td>
<td><div class="row-actions">${manage && p.steamid !== d.me ? `<button class="btn sm ghost" type="button" data-act="rank" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}" data-group="${esc(groupName)}">Change rank</button>` : ""}</div></td>
</tr>`;
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 = `
<div class="page-head">
<div><h1>Staff & ranks</h1><p class="sub">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."}</p></div>
${manage ? `<div style="display:flex;gap:8px">
<button class="btn" type="button" data-act="new-group">${ICON.plus}New rank</button>
<button class="btn primary" type="button" data-act="add-staff">${ICON.plus}Add staff</button>
</div>` : ""}
</div>
${d.groups.length ? `<div class="ranks-layout">
<section class="panel">
<div class="panel-head"><h2>Ranks</h2><p>Immunity</p></div>
<ul class="ladder">
${staffGroups.map(ladderItem).join("")}
${supporters.length ? `<li class="sep">Supporter ranks</li>${supporters.map(ladderItem).join("")}` : ""}
</ul>
</section>
<div class="stack">
<section class="panel">
<div class="panel-head">
<h2><span class="rank" style="--c:${esc(g.color)};font:inherit"><i></i>${esc(g.label)}</span></h2>
<span class="sid">${esc(g.name)}</span>
</div>
<form class="panel-body" id="group-form" style="display:grid;gap:18px" data-id="${g.id}">
<div class="form-row">
<label class="field"><span>SimpleAdmin name</span><input class="input" name="name" value="${esc(g.name)}" ${manage ? "" : "readonly"} required><small>The display name and colour come from the panel's site config.</small></label>
<label class="field"><span>Immunity</span><input class="input" name="immunity" type="number" min="0" max="9999" value="${g.immunity}" ${manage ? "" : "readonly"} required><small>0 to 9999</small></label>
</div>
<div>
<h3 style="margin-bottom:10px">Permissions</h3>
<div class="flag-grid">
${allFlags.map((f) => {
const on = g.flags.includes(f.flag);
const inherited = root && f.flag !== "@css/root";
return `<label class="flag"><input type="checkbox" name="flag" value="${esc(f.flag)}" ${on || inherited ? "checked" : ""} ${inherited || !manage ? "disabled" : ""} ${on && inherited ? 'data-kept="1"' : ""}>
<span><b>${esc(f.label)}</b><small>${inherited ? "Included by Root" : esc(f.help)}</small><br><small class="faint">${esc(f.flag)}</small></span></label>`;
}).join("")}
</div>
</div>
${manage ? `<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap">
<button class="btn danger" type="button" data-act="delete-group" data-id="${g.id}" data-name="${esc(g.label)}">Delete rank</button>
<button class="btn primary" type="submit">Save rank</button>
</div>` : ""}
</form>
</section>
<section class="panel">
<div class="panel-head"><h2>Members</h2><p>${g.members.length} ${g.members.length === 1 ? "person" : "people"}</p></div>
<div class="table-wrap">
${g.members.length ? `<table><tbody>${g.members.map((p) => memberRow(p, g.name)).join("")}</tbody></table>`
: `<div class="empty">Nobody has this rank yet.${manage ? " Use Add staff to give it to someone." : ""}</div>`}
</div>
</section>
${d.unranked.length ? `<section class="panel">
<div class="panel-head"><h2>Admins without a rank</h2><p>Permissions set directly in SimpleAdmin</p></div>
<div class="table-wrap"><table><tbody>${d.unranked.map((p) => memberRow(p, "")).join("")}</tbody></table></div>
</section>` : ""}
</div>
</div>` : `<div class="empty">SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}</div>`}`;
}
/* ---------- 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 `<button class="switch" type="button" role="switch" aria-checked="${c.value}" aria-label="${esc(c.label)}" data-cvar="${esc(c.key)}"></button>`;
if (c.type === "int") return `<input class="input" type="number" value="${esc(c.value)}" data-cvar="${esc(c.key)}">`;
return `<input class="input" value="${esc(c.value)}" data-cvar="${esc(c.key)}" ${c.key === "sv_password" ? 'placeholder="No password"' : ""}>`;
};
const saControl = (s) => {
const val = sa.values[s.key];
const dis = d.canRoot ? "" : "disabled";
if (s.type === "bool") return `<button class="switch" type="button" role="switch" aria-checked="${val}" aria-label="${esc(s.label)}" data-sa="${esc(s.key)}" ${dis}></button>`;
if (s.type === "select") return `<select class="input" data-sa="${esc(s.key)}" ${dis}>${s.options.map((o) => `<option value="${o.value}" ${o.value === val ? "selected" : ""}>${esc(o.label)}</option>`).join("")}</select>`;
return `<input class="input" type="number" min="${s.min || 0}" value="${val}" data-sa="${esc(s.key)}" ${dis}>`;
};
const row = (label, help, key, control) => `<div class="setting">
<div><b>${esc(label)}</b>${help ? `<p>${esc(help)}</p>` : ""}${key ? `<code>${esc(key)}</code>` : ""}</div>
<div class="control">${control}</div>
</div>`;
v.innerHTML = `
<div class="page-head">
<div><h1>Server settings</h1><p class="sub">Server changes go live over RCON. SimpleAdmin's settings are saved to its config file.</p></div>
</div>
<div class="settings">
<section class="panel" id="cvars">
<div class="panel-head"><h2>Server</h2><p>Live over RCON. Resets when the server restarts.</p></div>
${!d.canCvar ? '<div class="empty">Your rank can\'t change server settings.</div>'
: !d.server.online ? '<div class="empty">The game server isn\'t answering, so these can\'t be read or changed right now.</div>'
: (d.cvars || []).map((c) => row(c.label, c.help, c.key, cvarControl(c))).join("")}
</section>
<section class="panel" id="sa">
<div class="panel-head"><h2>Punishments</h2><p>${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}</p></div>
${!sa.available ? '<div class="empty">The panel wasn\'t given CS2-SimpleAdmin.json (SAW_SA_CONFIG), so these can\'t be shown or changed here.</div>'
: sa.error ? `<div class="empty">${esc(sa.error)}</div>`
: sa.settings.map((s) => row(s.label, s.help, s.key, saControl(s))).join("")}
</section>
<section class="panel">
<div class="panel-head"><h2>Connections</h2></div>
${row("Database", "SimpleAdmin's MySQL database.", "", d.database.connected ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not connected</span>')}
${row("Game server", d.server.online ? "Live status through the WebPanelBridge plugin." : d.server.error || "", "", d.server.online ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not answering</span>')}
</section>
<section class="panel">
<div class="panel-head"><h2>Maintenance</h2><p>Each of these asks first.</p></div>
${can("@css/root") ? row("Reload admins", "Re-read ranks and staff from the database. Runs css_reloadadmins.", "", '<button class="btn" type="button" data-act="reload-admins">Reload admins</button>') : ""}
${can("@css/generic") ? row("Restart the match", "Restarts the game on the current map. Runs css_rr.", "", '<button class="btn" type="button" data-act="restart">Restart match</button>') : ""}
${can("@css/changemap") ? row("Change map", "Switch to another map, or a Workshop map with ws:<id>.", "", '<button class="btn" type="button" data-act="map">Change map</button>') : ""}
</section>
</div>
<div class="savebar" id="savebar" hidden>
<span>You have unsaved changes.</span>
<div style="display:flex;gap:8px"><button class="btn ghost" type="button" data-act="discard">Discard</button><button class="btn primary" type="button" data-act="save-settings">Save changes</button></div>
</div>`;
}
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 = `<div class="scrim" data-close></div><aside class="drawer" role="dialog" aria-modal="true">${loading()}</aside>`;
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)
? `<button class="btn ${cls}" type="button" data-act="${act}" data-sid="${esc(p.steamid)}" data-name="${esc(name)}" ${extra} ${t ? "" : "disabled"}>${label}</button>` : "";
const manage = can("@css/root") && p.steamid !== state.me.steamid;
$("#drawer-root").innerHTML = `
<div class="scrim" data-close></div>
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
<div class="drawer-head">
<span class="avatar lg">${initial(name)}</span>
<div><h2>${esc(name)}</h2><div>${rankTag(d.rank)} ${d.online ? '<span class="tag green" style="margin-left:6px">Online</span>' : ""}</div></div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
<section>
${t ? "" : `<p class="muted" style="margin:0 0 12px">${p.steamid === state.me.steamid ? "This is you." : "This player's immunity is higher than yours, so you can't act on them."}</p>`}
<div class="action-grid">
${btn("ban", "Ban", "@css/ban", "", "danger")}
${btn("comm", "Gag", "@css/chat", 'data-type="GAG"')}
${btn("comm", "Mute", "@css/chat", 'data-type="MUTE"')}
${btn("comm", "Silence", "@css/chat", 'data-type="SILENCE"')}
${d.online ? btn("warn", "Warn", "@css/kick") : ""}
${d.online ? btn("kick", "Kick", "@css/kick") : ""}
</div>
</section>
<section>
<h3>Details</h3>
<dl class="kv">
<dt>SteamID64</dt><dd>${esc(p.steamid)}</dd>
<dt>Profile</dt><dd><a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></dd>
${p.ips ? `<dt>Known IPs</dt><dd>${p.ips.map(esc).join(", ") || '<span class="faint">None</span>'}</dd>` : ""}
<dt>Known names</dt><dd>${(p.names || []).map(esc).join(", ") || '<span class="faint">None</span>'}</dd>
<dt>Last seen</dt><dd>${d.online ? "On the server now" : p.lastSeen ? fmtAgo(p.lastSeen) : "Never joined"}</dd>
${d.immunity ? `<dt>Immunity</dt><dd>${d.immunity}</dd>` : ""}
${d.adminEnds ? `<dt>Rank expires</dt><dd>${fmtDate(d.adminEnds)}</dd>` : ""}
</dl>
</section>
${manage ? `<section>
<h3>Rank</h3>
<div style="display:flex;gap:8px">
<button class="btn" type="button" data-act="rank" data-sid="${esc(p.steamid)}" data-name="${esc(name)}" data-group="${esc(d.rank?.name || "")}" ${t ? "" : "disabled"}>${d.rank ? "Change rank" : "Give a rank"}</button>
</div>
</section>` : ""}
<section>
<h3>History</h3>
${history.length ? `<ul class="history">${history.map((h) => `<li>
<div class="top"><span><b>${h.label}</b> <span class="muted">by ${esc(h.admin || "Console")}, ${fmtAgo(h.created)}</span></span>${statusTag(h)}</div>
<div>${esc(h.reason)}</div>
${h.kind === "warn" ? "" : termCell(h)}
</li>`).join("")}</ul>` : '<p class="muted" style="margin:0">No bans, gags, mutes or warnings on record.</p>'}
</section>
</aside>`;
$(".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(`
<form method="dialog" data-submit="penalty" data-kind="${kind}">
<div class="modal-head"><h2>${title}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body">
<label class="field"><span>Player</span>
<input class="input" name="steamid" required pattern="7656119[0-9]{10}" value="${esc(sid)}" placeholder="SteamID64, like 76561198012345678" ${sid ? "readonly" : ""}>
<small>${name ? esc(name) : kind === "warn" ? "Warnings only work on players who are on the server." : "Works for players who are offline too; it applies when they next join."}</small>
</label>
${kind === "comm" ? `<div class="field"><span>Block</span>${segmented("commType", type || "GAG", [["GAG", "Text (gag)"], ["MUTE", "Voice (mute)"], ["SILENCE", "Both (silence)"]])}</div>` : ""}
<div class="field"><span>${kind === "warn" ? "Warning lasts" : "Length"}</span>
<div class="chips" data-chips="dur">${DURATIONS.map(([v, l], i) => `<button type="button" class="chip-btn" data-value="${v}" aria-pressed="${i === (kind === "ban" ? 2 : kind === "warn" ? 4 : 1)}">${l}</button>`).join("")}</div>
${kind === "warn" ? "" : `<small>${can(perm) ? "Your rank can use any length." : "Your rank's limit is set in SimpleAdmin's settings; permanent isn't allowed."}</small>`}
</div>
<div class="field"><span>Reason</span>
<div class="chips" data-chips="reason">${REASONS[kind].map((r) => `<button type="button" class="chip-btn" data-value="${esc(r)}" aria-pressed="false">${esc(r)}</button>`).join("")}</div>
<textarea class="input" name="reason" id="reason" required maxlength="200" placeholder="Shown to the player and in the log">${esc(reason)}</textarea>
</div>
</div>
<div class="modal-foot">
<button class="btn ghost" type="button" data-modal-close>Cancel</button>
<button class="btn primary" type="submit">${submit}</button>
</div>
</form>`);
}
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) => `<option value="${esc(g.name)}" ${g.name === current ? "selected" : ""}>${esc(g.label)} (${g.immunity})</option>`).join("");
openModal(`
<form method="dialog" data-submit="rank" data-sid="${esc(sid)}" data-had="${esc(current)}">
<div class="modal-head"><h2>${current ? "Change rank" : "Add staff"}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body">
<label class="field"><span>Player</span>
<input class="input" name="steamid" required pattern="7656119[0-9]{10}" value="${esc(sid)}" placeholder="SteamID64, like 76561198012345678" ${sid ? "readonly" : ""}>
${name ? `<small>${esc(name)}</small>` : ""}
</label>
<label class="field"><span>Rank</span><select class="input" name="group">${current ? '<option value="">No rank (remove from staff)</option>' : ""}${opts}</select></label>
<label class="field"><span>Expires</span><select class="input" name="days"><option value="0">Never</option><option value="7">In 7 days</option><option value="30">In 30 days</option><option value="90">In 90 days</option></select><small>Trial ranks usually expire.</small></label>
</div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Save</button></div>
</form>`);
}
function newGroupDialog() {
openModal(`
<form method="dialog" data-submit="new-group">
<div class="modal-head"><h2>New rank</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body">
<label class="field"><span>SimpleAdmin name</span><input class="input" name="name" required pattern="#[A-Za-z0-9_./\\-]{1,63}" placeholder="#rank/mod"><small>Starts with #. Add a display name and colour for it in the site config.</small></label>
<label class="field"><span>Immunity</span><input class="input" name="immunity" type="number" min="0" max="${state.me.immunity}" value="0" required><small>Up to your own immunity, ${state.me.immunity}.</small></label>
<p class="muted" style="margin:0">It starts with Generic admin only. Pick its other permissions after creating it.</p>
</div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Create rank</button></div>
</form>`);
}
function textDialog(kind) {
const cfg = {
map: { title: "Change map", label: "Map", placeholder: "de_mirage, or ws:<workshop id>", 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(`
<form method="dialog" data-submit="${kind}" data-sid="${esc(data.sid || "")}">
<div class="modal-head"><h2>${c.title}${data.name ? `: ${esc(data.name)}` : ""}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body"><label class="field"><span>${c.label}</span><input class="input" name="value" pattern="${c.pattern}" placeholder="${esc(c.placeholder)}" ${kind === "kick" ? "" : "required"} maxlength="200"></label></div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">${c.submit}</button></div>
</form>`);
}
function confirmDialog(title, body, label, run) {
confirmDialog.run = run;
openModal(`
<form method="dialog" data-submit="confirm">
<div class="modal-head"><h2>${title}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body"><p style="margin:0">${body}</p>${confirmDialog.reason ? '<label class="field"><span>Reason</span><input class="input" name="reason" maxlength="200" placeholder="Optional, kept in the log"></label>' : ""}</div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">${label}</button></div>
</form>`);
}
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) => `<li><a class="link" href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}${count[p.id] !== undefined && count[p.id] !== "" ? `<span class="count">${esc(count[p.id])}</span>` : ""}</a></li>`).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 <b>${esc(d.name || "this player")}</b>? They can join again straight away. This lifts every active ban on their SteamID.`
: `Lift the ${COMM_LABEL[d.type].toLowerCase()} on <b>${esc(d.name || "this player")}</b>?`,
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 <b>${esc(d.name)}</b>? 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))}<span>.</span>${esc(name.slice(i + 1))}` : esc(name);
});
$("#me").innerHTML = `<span class="avatar">${initial(state.me.name)}</span>
<div><b>${esc(state.me.name)}</b><small>${esc(state.me.rank?.label || "Staff")}</small></div>
<button class="btn sm ghost" type="button" id="signout">Sign out</button>`;
// 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();
})();

View file

@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const MIN = 60 * 1000;
const ICON = {
search: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3"/></svg>',
plus: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>',
};
// 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 '<span class="faint">Player</span>';
return `<span class="rank" style="--c:${esc(rank.color)}"><i></i>${esc(rank.label)}</span>`;
}
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 ? "" : `<span style="width:${pct.toFixed(1)}%"></span>`;
return `<div class="${cls.join(" ")}">
<div class="term-top"><b>${fmtDuration(p.duration)}</b><span class="muted">${right}</span></div>
<div class="term-bar" role="img" aria-label="${esc(right)}">${bar}</div>
</div>`;
}
function statusTag(p) {
if (p.status === "active") return '<span class="tag red">Active</span>';
if (p.status === "expired") return '<span class="tag">Expired</span>';
return '<span class="tag green">Lifted</span>';
}
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 `<div class="segmented" role="group" data-seg="${name}">
${options.map(([v, l]) => `<button type="button" data-value="${v}" aria-pressed="${v === value}">${l}</button>`).join("")}
</div>`;
}
function searchBox(placeholder, value) {
return `<label class="search">${ICON.search}<input type="search" id="q" placeholder="${esc(placeholder)}" value="${esc(value)}" aria-label="${esc(placeholder)}" autocomplete="off"></label>`;
}
function pager(list) {
const pages = Math.max(1, Math.ceil(list.total / list.pageSize));
if (pages <= 1) return "";
return `<div class="pager">
<button class="btn sm ghost" type="button" data-page="${list.page - 1}" ${list.page <= 1 ? "disabled" : ""}>Newer</button>
<span class="muted">Page ${list.page} of ${pages}</span>
<button class="btn sm ghost" type="button" data-page="${list.page + 1}" ${list.page >= pages ? "disabled" : ""}>Older</button>
</div>`;
}
function loading() { return '<div class="empty">Loading…</div>'; }
function errorBox(err) { return `<div class="empty">${esc(err.message)}</div>`; }
function closeDrawer() { $("#drawer-root").innerHTML = ""; }
function toast(msg) {
const root = $("#toast-root");
root.innerHTML = `<div class="toast">${esc(msg)}</div>`;
clearTimeout(toast.t);
toast.t = setTimeout(() => (root.innerHTML = ""), 3600);
}
const steamProfile = (sid) => `https://steamcommunity.com/profiles/${encodeURIComponent(sid)}`;

View file

@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bans and staff</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body class="public">
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="#server">
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span class="brand-name" id="site-name"></span>
</a>
<nav aria-label="Main"><ul class="tabs" id="nav-links"></ul></nav>
<a class="btn sm ghost signin" href="/admin"><span class="hide-xs">Staff sign in</span><span class="show-xs">Sign in</span></a>
</div>
</header>
<main id="view" class="public-main" tabindex="-1"></main>
<div id="drawer-root"></div>
<div id="toast-root" aria-live="polite"></div>
<script src="/static/common.js"></script>
<script src="/static/public.js"></script>
</body>
</html>

View file

@ -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))}<span>.</span>${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 = `<div class="live"><div><p class="live-host">The server is offline or restarting.</p><div class="live-map">Offline</div></div></div>`;
return;
}
const side = (team, name, score) => {
const list = s.players.filter((p) => p.team === team).sort((a, b) => b.kills - a.kills);
return `<section class="panel side ${team === 3 ? "ct" : "t"}">
<div class="panel-head"><h2>${name}</h2><b class="side-score">${score}</b></div>
${list.length ? `<table>
<thead><tr><th>Player</th><th class="num">Kills</th><th class="num">Deaths</th><th class="num hide-sm">Ping</th></tr></thead>
<tbody>
${list.map((p) => `<tr ${p.bot ? "" : `class="clickable" data-player="${esc(p.steamid)}"`}>
<td><div class="who"><div><b>${esc(p.name)}</b>${p.bot ? '<span class="sid">Bot</span>' : p.rank ? rankTag(p.rank) : ""}</div></div></td>
<td class="num">${p.kills}</td>
<td class="num">${p.deaths}</td>
<td class="num hide-sm muted">${p.bot ? "" : p.ping}</td>
</tr>`).join("")}
</tbody>
</table>` : '<div class="empty">Nobody on this team.</div>'}
</section>`;
};
const spectators = s.players.filter((p) => p.team !== 2 && p.team !== 3 && !p.bot);
v.innerHTML = `
<div class="live">
<div>
<p class="live-host"><span class="dot"></span>${esc(s.hostname)}${s.warmup ? ", warmup" : ""}</p>
<div class="live-map">${esc(s.map)}</div>
</div>
<div class="live-stats">
<div><b>${s.count}<span class="faint">/${s.maxPlayers}</span></b><small>Players</small></div>
<div><b><span class="team t score">${s.scoreT}</span> <span class="faint">:</span> <span class="team ct score">${s.scoreCt}</span></b><small>T vs CT</small></div>
<div><b>${s.staffOnline}</b><small>Staff online</small></div>
</div>
${s.address ? `<div class="live-join">
<a class="btn primary" href="steam://connect/${esc(s.address)}">Join server</a>
<small class="muted">or type <b>connect ${esc(s.address)}</b> in the console</small>
</div>` : ""}
</div>
<div class="sides">
${side(3, "Counter-Terrorists", s.scoreCt)}
${side(2, "Terrorists", s.scoreT)}
</div>
${spectators.length ? `<p class="muted" style="margin-top:16px">Spectating: ${spectators.map((p) => esc(p.name)).join(", ")}</p>` : ""}`;
}
/* ---------- Bans and comms ---------- */
function publicTable(rows, kind) {
if (!rows.length) {
return `<div class="empty">${state.query ? `Nobody matches "${esc(state.query)}". Try the full SteamID64.` : "Nothing in this view."}</div>`;
}
return `<table>
<thead><tr>
<th>Player</th>
${kind === "comm" ? "<th>Type</th>" : ""}
<th>Reason</th>
<th>Length</th>
<th class="hide-sm">Issued</th>
<th>Status</th>
</tr></thead>
<tbody>
${rows.map((p) => `<tr ${p.steamid ? `class="clickable" data-player="${esc(p.steamid)}"` : ""}>
<td><div class="who"><div><b>${esc(p.name || "Unknown")}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
${kind === "comm" ? `<td><span class="tag amber" title="${COMM_HELP[p.type]}">${COMM_LABEL[p.type]}</span></td>` : ""}
<td>${esc(p.reason)}</td>
<td>${termCell(p)}</td>
<td class="hide-sm">${fmtDate(p.created)}</td>
<td>${statusTag(p)}</td>
</tr>`).join("")}
</tbody>
</table>`;
}
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 = `
<div class="page-head">
<div><h1>Bans</h1><p class="sub" id="lead">Search for a name or SteamID64 to check a ban and when it ends.</p></div>
</div>
<section class="panel">
<div class="toolbar">
${searchBox("Search by name, SteamID64 or reason", state.query)}
${segmented("banFilter", state.banFilter, [["active", "Active"], ["expired", "Expired"], ["lifted", "Lifted"], ["all", "All"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
function comms() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Gags & mutes</h1><p class="sub">A gag blocks text chat, a mute blocks voice, and a silence blocks both. Blocks carry over between maps and reconnects.</p></div>
</div>
<section class="panel">
<div class="toolbar">
${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"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
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) => `<div class="crew-row" style="--c:${esc(g.color)}">
<div class="crew-rank"><h2><span class="rank" style="font:inherit"><i></i>${esc(g.label)}</span></h2>${g.about ? `<p>${esc(g.about)}</p>` : ""}</div>
<ul class="crew">
${g.members.map((p) => `<li>
<a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">
<span class="avatar">${initial(p.name)}</span>
<span><b>${esc(p.name)}</b><small>${p.online ? '<span class="dot"></span>On the server' : "Steam profile"}</small></span>
</a>
</li>`).join("")}
</ul>
</div>`;
const staffGroups = data.groups.filter((g) => !g.supporter);
const supporters = data.groups.filter((g) => g.supporter);
const online = data.staffOnline;
v.innerHTML = `
<div class="page-head">
<div><h1>Staff</h1><p class="sub">${online ? `${online} staff ${online === 1 ? "is" : "are"} on the server right now.` : "No staff are on the server right now."}</p></div>
</div>
${staffGroups.length ? `<section class="panel crew-panel">${staffGroups.map(rankRow).join("")}</section>` : '<div class="empty">No staff are listed yet.</div>'}
${supporters.length ? `<h2 class="crew-sep">Supporters</h2><section class="panel crew-panel">${supporters.map(rankRow).join("")}</section>` : ""}`;
}
/* ---------- Player lookup (read-only) ---------- */
async function openPlayer(sid) {
$("#drawer-root").innerHTML = `<div class="scrim" data-close></div><aside class="drawer" role="dialog" aria-modal="true">${loading()}</aside>`;
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 = `
<div class="scrim" data-close></div>
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
<div class="drawer-head">
<span class="avatar lg">${initial(name)}</span>
<div><h2>${esc(name)}</h2><span class="sid">${esc(p.steamid)}</span>${p.rank ? `<div>${rankTag(p.rank)}</div>` : ""}</div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
<section>
<h3>Right now</h3>
${now.length ? `<div class="chips">${now.map((h) => `<span class="tag ${h.kind === "ban" ? "red" : "amber"}">${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"}</span>`).join("")}</div>`
: `<p class="muted" style="margin:0">${p.online ? "On the server, free to play and talk." : "Free to play and talk."}</p>`}
<p style="margin:12px 0 0"><a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></p>
</section>
<section>
<h3>History</h3>
${history.length ? `<ul class="history">${history.map((h) => `<li>
<div class="top"><span><b>${label(h)}</b> <span class="muted">${fmtDate(h.created)}</span></span>${statusTag(h)}</div>
<div>${esc(h.reason)}</div>
${termCell(h)}
</li>`).join("")}</ul>` : '<p class="muted" style="margin:0">No bans, gags or mutes on record.</p>'}
</section>
</aside>`;
$(".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) => `<li><a href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}</a></li>`).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();

View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Staff sign in</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body class="signin-page">
<main class="signin-card panel">
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<h1>Staff panel</h1>
<p class="sub" id="signin-msg">Sign in with the Steam account that has your rank on the server.</p>
<p class="signin-error" id="signin-error" hidden></p>
<a class="btn primary" href="/auth/login">Sign in through Steam</a>
<a class="muted signin-back" href="/">Back to the public site</a>
</main>
<script src="/static/common.js"></script>
<script src="/static/signin.js"></script>
</body>
</html>

View file

@ -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(() => {});

File diff suppressed because it is too large Load diff

202
internal/web/auth.go Normal file
View file

@ -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)
}

244
internal/web/public.go Normal file
View file

@ -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),
})
}

189
internal/web/server.go Normal file
View file

@ -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)
}

89
internal/web/site.go Normal file
View file

@ -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:]
}

161
internal/web/staff.go Normal file
View file

@ -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
}

221
main.go Normal file
View file

@ -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
}

13
site.example.json Normal file
View file

@ -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 }
}
}