simpleadmin-web 0.1.0: web panel for CS2-SimpleAdmin
This commit is contained in:
commit
4f38daf0e6
31 changed files with 6870 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
simpleadmin-web
|
||||||
|
*.env
|
||||||
|
!example.env
|
||||||
13
Containerfile
Normal file
13
Containerfile
Normal 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
107
README.md
Normal 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
30
example.env
Normal 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
7
go.mod
Normal 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
4
go.sum
Normal 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
171
internal/live/live.go
Normal 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
|
||||||
|
}
|
||||||
38
internal/live/live_test.go
Normal file
38
internal/live/live_test.go
Normal 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
230
internal/rcon/rcon.go
Normal 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
132
internal/rcon/rcon_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
242
internal/saconfig/saconfig.go
Normal file
242
internal/saconfig/saconfig.go
Normal 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
145
internal/steam/steam.go
Normal 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
|
||||||
|
}
|
||||||
35
internal/steam/steam_test.go
Normal file
35
internal/steam/steam_test.go
Normal 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
1069
internal/store/store.go
Normal file
File diff suppressed because it is too large
Load diff
713
internal/web/actions.go
Normal file
713
internal/web/actions.go
Normal 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
410
internal/web/admin.go
Normal 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)
|
||||||
|
}
|
||||||
55
internal/web/assets/admin.html
Normal file
55
internal/web/assets/admin.html
Normal 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> </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> </b><small> </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>
|
||||||
835
internal/web/assets/admin.js
Normal file
835
internal/web/assets/admin.js
Normal 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();
|
||||||
|
})();
|
||||||
152
internal/web/assets/common.js
Normal file
152
internal/web/assets/common.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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)}`;
|
||||||
35
internal/web/assets/public.html
Normal file
35
internal/web/assets/public.html
Normal 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>
|
||||||
264
internal/web/assets/public.js
Normal file
264
internal/web/assets/public.js
Normal 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();
|
||||||
27
internal/web/assets/signin.html
Normal file
27
internal/web/assets/signin.html
Normal 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>
|
||||||
13
internal/web/assets/signin.js
Normal file
13
internal/web/assets/signin.js
Normal 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(() => {});
|
||||||
1021
internal/web/assets/styles.css
Normal file
1021
internal/web/assets/styles.css
Normal file
File diff suppressed because it is too large
Load diff
202
internal/web/auth.go
Normal file
202
internal/web/auth.go
Normal 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
244
internal/web/public.go
Normal 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
189
internal/web/server.go
Normal 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
89
internal/web/site.go
Normal 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
161
internal/web/staff.go
Normal 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
221
main.go
Normal 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
13
site.example.json
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue