GroupVerify: Verification page, overrides and public Verified tags
Shown only where GroupVerify is: staff get the Verification page once its gv_* tables are in the database, and the public site shows "Verified" only while it's on (enabled, with a group) and not missing from css_plugins list, which the poller reads once a minute over RCON. - Verification page: plugin state and version, Steam group, saved member list, overrides. - Overrides (@zio/verify, not on yourself unless Root): give, change, remove; player drawer shows why a player is or isn't verified. - Root sets the group and turns it on/off: saved to gv_settings, and sent as css_gv_* when the plugin is loaded so it applies at once. - gv_* times use GroupVerify's own timezone setting. - MySQL integration test (SAW_TEST_DSN), unit tests for plugin list parsing and the show rule.
This commit is contained in:
parent
b2b4698776
commit
9e4bc63201
15 changed files with 1270 additions and 13 deletions
44
README.md
44
README.md
|
|
@ -9,6 +9,8 @@ A SourceBans-style web panel for [CS2-SimpleAdmin](https://github.com/daffyyyy/C
|
||||||
- **Staff panel** (`/admin`): sign in through Steam. Ban, gag, mute, silence, warn and kick; lift
|
- **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
|
bans and blocks; manage ranks, permissions and staff; change server cvars and SimpleAdmin's
|
||||||
punishment settings.
|
punishment settings.
|
||||||
|
- **Verification** (optional): who our GroupVerify plugin verifies, with overrides to give and
|
||||||
|
remove, and its settings. Only appears when GroupVerify is installed (see below).
|
||||||
|
|
||||||
Live data comes from the **WebPanelBridge** plugin (`plugins/WebPanelBridge` in this repo) over
|
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.
|
RCON. Without it the panel still works, but shows the server as offline and can't kick or warn.
|
||||||
|
|
@ -42,7 +44,8 @@ RCON. Without it the panel still works, but shows the server as offline and can'
|
||||||
| Change map | `@css/changemap` |
|
| Change map | `@css/changemap` |
|
||||||
| Server cvars | `@css/cvar` |
|
| Server cvars | `@css/cvar` |
|
||||||
| See the Staff & ranks page | `@zio/webpanel` |
|
| See the Staff & ranks page | `@zio/webpanel` |
|
||||||
| Ranks, staff, SimpleAdmin settings, reload admins | `@css/root` |
|
| Give and remove verification overrides | `@zio/verify` (not on themselves, unless Root) |
|
||||||
|
| Ranks, staff, SimpleAdmin settings, reload admins, GroupVerify's group and on/off | `@css/root` |
|
||||||
|
|
||||||
`@css/root` grants everything. Nobody can act on a player whose immunity is higher than theirs,
|
`@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.
|
or on themselves, or give a rank with more immunity than their own.
|
||||||
|
|
@ -100,6 +103,34 @@ and deleting a rank that people hold). Our SimpleAdmin fork (1.8.2b-zio10+) logs
|
||||||
that lapse when their expiry passes, `css_delgroup`, and edits made straight in the database.
|
that lapse when their expiry passes, `css_delgroup`, and edits made straight in the database.
|
||||||
History starts when the table is created.
|
History starts when the table is created.
|
||||||
|
|
||||||
|
### Verification (GroupVerify, optional)
|
||||||
|
|
||||||
|
[GroupVerify](https://git.zio.sh/cs2/GroupVerify) (`plugins/GroupVerify` in the cs2 repo) verifies
|
||||||
|
players who are in the server's Steam group or have a manual override, and other plugins give
|
||||||
|
verified players perks. It keeps everything in SimpleAdmin's database (`gv_verified`,
|
||||||
|
`gv_settings`), so the panel needs no setup for it:
|
||||||
|
|
||||||
|
- **Staff** get a Verification page as soon as GroupVerify's tables are in the database: whether
|
||||||
|
it's on, the plugin's state and version, the Steam group, its saved member list, and the
|
||||||
|
overrides. The player drawer shows why a player is or isn't verified. With `@zio/verify` (give
|
||||||
|
it to the ranks that should have it in the rank editor), staff give overrides for a day up to a
|
||||||
|
year or with no expiry, change them and remove them. Root sets the group and turns verification
|
||||||
|
on and off.
|
||||||
|
- **Everyone** sees a "Verified" tag on the scoreboard and on player pages, but only while
|
||||||
|
verification is on (enabled, with a group set) and GroupVerify isn't missing from the server's
|
||||||
|
plugin list. The public site never shows why someone is verified, or who gave an override.
|
||||||
|
- **Whether it's loaded** comes from `css_plugins list` over RCON, read once a minute alongside
|
||||||
|
the live status. While the server is down it's unknown, and that doesn't hide anything.
|
||||||
|
- **Changes**: overrides are written to `gv_verified` and GroupVerify picks them up on its next
|
||||||
|
reload (`db_reload_seconds`, 60 by default). Settings are written to `gv_settings` and, when
|
||||||
|
the plugin is loaded, also sent as its own `css_gv_group`/`css_gv_enable`/`css_gv_disable`, so
|
||||||
|
they apply at once.
|
||||||
|
- **Times** in its tables are in GroupVerify's own `timezone` setting, which the panel follows.
|
||||||
|
Keep it the same as SimpleAdmin's `Timezone`.
|
||||||
|
|
||||||
|
The panel never shows a group member list from Steam itself: "Steam group members" is the list
|
||||||
|
GroupVerify last fetched and saved.
|
||||||
|
|
||||||
### Name history (optional)
|
### Name history (optional)
|
||||||
|
|
||||||
SimpleAdmin keeps one row per SteamID + IP in `sa_players_ips` and overwrites its name on each
|
SimpleAdmin keeps one row per SteamID + IP in `sa_players_ips` and overwrites its name on each
|
||||||
|
|
@ -129,5 +160,16 @@ SimpleAdmin database; the file has the revert. Names are still only recorded at
|
||||||
go vet ./... && go test ./...
|
go vet ./... && go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`internal/web/verify_db_test.go` also runs the Verification endpoints against a real MySQL when
|
||||||
|
`SAW_TEST_DSN` is set. It empties SimpleAdmin's admin and group tables and GroupVerify's, so give
|
||||||
|
it a scratch database holding SimpleAdmin 1.8.2b's migrations and GroupVerify's
|
||||||
|
`Migrations/001_CreateTables.sql`:
|
||||||
|
|
||||||
|
```
|
||||||
|
podman run -d --rm --name sa-test -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=sa -p 127.0.0.1:33061:3306 mysql:8.4
|
||||||
|
# apply the migrations, then:
|
||||||
|
SAW_TEST_DSN='root:test@tcp(127.0.0.1:33061)/sa?parseTime=true&loc=UTC' go test ./internal/web -run VerifyDB
|
||||||
|
```
|
||||||
|
|
||||||
The pages are plain HTML, CSS and JS in `internal/web/assets`, embedded into the binary. The
|
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.
|
design mockup they came from is in `docs/mockup` at the repo root.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -114,12 +115,46 @@ func Parse(out string) (Status, error) {
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pluginLine is one plugin in `css_plugins list`: [#3:LOADED]: "GroupVerify" (0.2.0) by astra
|
||||||
|
var pluginLine = regexp.MustCompile(`\[#\d+:([A-Z]+)\]: "([^"]*)" \(([^)]*)\)`)
|
||||||
|
|
||||||
|
// ParsePlugins reads `css_plugins list` output into loaded plugins' versions by ModuleName. ok is
|
||||||
|
// false when the output isn't a plugin list at all.
|
||||||
|
func ParsePlugins(out string) (loaded map[string]string, ok bool) {
|
||||||
|
if !strings.Contains(out, "List of all plugins") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
loaded = map[string]string{}
|
||||||
|
for _, m := range pluginLine.FindAllStringSubmatch(out, -1) {
|
||||||
|
if m[1] == "LOADED" {
|
||||||
|
loaded[m[2]] = m[3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return loaded, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a CSS plugin is loaded on the server, as far as the last check knows.
|
||||||
|
type PluginState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PluginLoaded PluginState = "loaded"
|
||||||
|
PluginMissing PluginState = "missing"
|
||||||
|
PluginUnknown PluginState = "unknown" // server down, or not checked yet
|
||||||
|
)
|
||||||
|
|
||||||
|
// pluginInterval is how often the plugin list is reread; plugins rarely change.
|
||||||
|
const pluginInterval = time.Minute
|
||||||
|
|
||||||
type Poller struct {
|
type Poller struct {
|
||||||
rc *rcon.Client
|
rc *rcon.Client
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
current Status
|
current Status
|
||||||
|
|
||||||
|
// From css_plugins list, nil while unknown.
|
||||||
|
plugins map[string]string
|
||||||
|
pluginsAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPoller(rc *rcon.Client, interval time.Duration) *Poller {
|
func NewPoller(rc *rcon.Client, interval time.Duration) *Poller {
|
||||||
|
|
@ -169,10 +204,44 @@ func (p *Poller) Refresh() Status {
|
||||||
st.Error = p.current.Error
|
st.Error = p.current.Error
|
||||||
}
|
}
|
||||||
p.current = st
|
p.current = st
|
||||||
|
if !st.Online {
|
||||||
|
p.plugins = nil
|
||||||
|
}
|
||||||
|
checkPlugins := st.Online && time.Since(p.pluginsAt) >= pluginInterval
|
||||||
|
if checkPlugins {
|
||||||
|
p.pluginsAt = time.Now() // claimed, so concurrent refreshes don't all ask
|
||||||
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
if checkPlugins {
|
||||||
|
p.refreshPlugins()
|
||||||
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Poller) refreshPlugins() {
|
||||||
|
out, err := p.rc.Exec("css_plugins list")
|
||||||
|
loaded, ok := ParsePlugins(out)
|
||||||
|
if err != nil || !ok {
|
||||||
|
loaded = nil
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.plugins = loaded
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin reports whether a CSS plugin (by ModuleName) is loaded, and its version if it is.
|
||||||
|
func (p *Poller) Plugin(name string) (PluginState, string) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
if p.plugins == nil {
|
||||||
|
return PluginUnknown, ""
|
||||||
|
}
|
||||||
|
if v, ok := p.plugins[name]; ok {
|
||||||
|
return PluginLoaded, v
|
||||||
|
}
|
||||||
|
return PluginMissing, ""
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Poller) Current() Status {
|
func (p *Poller) Current() Status {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -36,3 +36,27 @@ func TestParseErrors(t *testing.T) {
|
||||||
t.Fatal("output without end marker should be an error")
|
t.Fatal("output without end marker should be an error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParsePlugins(t *testing.T) {
|
||||||
|
out := ` List of all plugins currently loaded by CounterStrikeSharp: 3 plugins loaded.
|
||||||
|
[#1:LOADED]: "CS2-SimpleAdmin (RELEASE)" (1.8.2b-zio10) by daffyy
|
||||||
|
Simple admin plugin
|
||||||
|
[#2:LOADED]: "GroupVerify" (0.2.0) by astra
|
||||||
|
Verifies players by Steam group membership or a manual override
|
||||||
|
[#3:UNLOADED]: "NoGoZones" (0.14.0) by astra
|
||||||
|
Termination Reason: whatever
|
||||||
|
`
|
||||||
|
loaded, ok := ParsePlugins(out)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("not recognised as a plugin list")
|
||||||
|
}
|
||||||
|
if loaded["GroupVerify"] != "0.2.0" || loaded["CS2-SimpleAdmin (RELEASE)"] != "1.8.2b-zio10" {
|
||||||
|
t.Fatalf("loaded %v", loaded)
|
||||||
|
}
|
||||||
|
if _, ok := loaded["NoGoZones"]; ok {
|
||||||
|
t.Fatal("unloaded plugins must not count as loaded")
|
||||||
|
}
|
||||||
|
if _, ok := ParsePlugins("Unknown command \"css_plugins\"\n"); ok {
|
||||||
|
t.Fatal("an error reply isn't a plugin list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
268
internal/store/verify.go
Normal file
268
internal/store/verify.go
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupVerify's tables (plugins/GroupVerify in the cs2 repo) live in the same database. The plugin
|
||||||
|
// creates them; the panel reads them, writes 'manual' override rows and changes gv_settings. Their
|
||||||
|
// times are wall-clock values in gv_settings' timezone, the zone GroupVerify itself uses, so they're
|
||||||
|
// read and written in that location rather than SimpleAdmin's.
|
||||||
|
|
||||||
|
// VerifySettings is gv_settings.
|
||||||
|
type VerifySettings struct {
|
||||||
|
Enabled bool
|
||||||
|
GroupID string // "" when no group is set
|
||||||
|
ReloadSeconds int // how often GroupVerify rereads its tables
|
||||||
|
Location *time.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active matches GroupVerify: it only verifies anyone when enabled with a group set.
|
||||||
|
func (v VerifySettings) Active() bool { return v.Enabled && v.GroupID != "" }
|
||||||
|
|
||||||
|
// HasVerifyTables reports whether GroupVerify's tables exist in this database.
|
||||||
|
func (s *Store) HasVerifyTables(ctx context.Context) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE() AND table_name IN ('gv_verified', 'gv_settings')`).Scan(&n)
|
||||||
|
return n == 2, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifySettings reads gv_settings, with GroupVerify's defaults for anything missing.
|
||||||
|
func (s *Store) VerifySettings(ctx context.Context) (VerifySettings, error) {
|
||||||
|
v := VerifySettings{Enabled: true, ReloadSeconds: 60, Location: time.UTC}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT name, value FROM gv_settings`)
|
||||||
|
if err != nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var name, value string
|
||||||
|
if err := rows.Scan(&name, &value); err != nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
switch name {
|
||||||
|
case "enabled":
|
||||||
|
v.Enabled = value != "0"
|
||||||
|
case "group_id64":
|
||||||
|
if n, err := strconv.ParseUint(value, 10, 64); err == nil && n != 0 {
|
||||||
|
v.GroupID = value
|
||||||
|
}
|
||||||
|
case "db_reload_seconds":
|
||||||
|
if n, err := strconv.Atoi(value); err == nil && n >= 10 {
|
||||||
|
v.ReloadSeconds = n
|
||||||
|
}
|
||||||
|
case "timezone":
|
||||||
|
if loc, err := time.LoadLocation(value); err == nil {
|
||||||
|
v.Location = loc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVerifySetting writes one gv_settings value.
|
||||||
|
func (s *Store) SetVerifySetting(ctx context.Context, name, value string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO gv_settings (name, value) VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE value = VALUES(value)`, name, value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func wallIn(t time.Time, loc *time.Location) time.Time {
|
||||||
|
t = t.In(loc)
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromWallIn(t time.Time, loc *time.Location) time.Time {
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyEntry is a row of gv_verified: an override, or a Steam group member.
|
||||||
|
type VerifyEntry struct {
|
||||||
|
SteamID string `json:"steamid"`
|
||||||
|
Name string `json:"name"` // the last name SimpleAdmin saw, "" if they never joined
|
||||||
|
Source string `json:"source"`
|
||||||
|
Expires *time.Time `json:"expires,omitempty"` // overrides only; nil never ends
|
||||||
|
AddedBy string `json:"addedBy,omitempty"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Seen time.Time `json:"seen"` // group rows: last seen in the group's member list
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyStatus is why a player is or isn't verified, as far as the database knows.
|
||||||
|
type VerifyStatus struct {
|
||||||
|
InGroup bool `json:"inGroup"`
|
||||||
|
GroupSeen *time.Time `json:"groupSeen,omitempty"`
|
||||||
|
Override *VerifyEntry `json:"override,omitempty"` // the unexpired override, if any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v VerifyStatus) Verified() bool { return v.InGroup || v.Override != nil }
|
||||||
|
|
||||||
|
// VerifyStatuses looks up several players at once. Players with no rows are left out.
|
||||||
|
func (s *Store) VerifyStatuses(ctx context.Context, loc *time.Location, steamids []string) (map[string]VerifyStatus, error) {
|
||||||
|
out := map[string]VerifyStatus{}
|
||||||
|
if len(steamids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
in, args := inList(steamids)
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT steamid64, source, `+"`override`"+`, expires_at, added_by, created_at, updated_at
|
||||||
|
FROM gv_verified WHERE steamid64 IN (`+in+`)`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
now := time.Now()
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var e VerifyEntry
|
||||||
|
var override bool
|
||||||
|
var expires sql.NullTime
|
||||||
|
if err := rows.Scan(&id, &e.Source, &override, &expires, &e.AddedBy, &e.Created, &e.Seen); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.SteamID = strconv.FormatInt(id, 10)
|
||||||
|
e.Created, e.Seen = fromWallIn(e.Created, loc), fromWallIn(e.Seen, loc)
|
||||||
|
if expires.Valid {
|
||||||
|
t := fromWallIn(expires.Time, loc)
|
||||||
|
e.Expires = &t
|
||||||
|
}
|
||||||
|
st := out[e.SteamID]
|
||||||
|
if e.Source == "group" {
|
||||||
|
st.InGroup = true
|
||||||
|
st.GroupSeen = &e.Seen
|
||||||
|
}
|
||||||
|
// The same rule as GroupVerify: any unexpired override row counts, the longest one wins.
|
||||||
|
if override && (e.Expires == nil || e.Expires.After(now)) {
|
||||||
|
if st.Override == nil || (st.Override.Expires != nil && (e.Expires == nil || e.Expires.After(*st.Override.Expires))) {
|
||||||
|
entry := e
|
||||||
|
st.Override = &entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[e.SteamID] = st
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// What VerifyList lists.
|
||||||
|
const (
|
||||||
|
VerifyOverrides = "overrides" // unexpired rows with override = 1
|
||||||
|
VerifyGroup = "group" // the Steam group's members, as of GroupVerify's last fetch
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyList lists overrides or group members, newest first. Search matches a SteamID64 or a name
|
||||||
|
// SimpleAdmin has seen them use.
|
||||||
|
func (s *Store) VerifyList(ctx context.Context, loc *time.Location, kind, search string, lim, offset int) ([]VerifyEntry, int, error) {
|
||||||
|
var conds []string
|
||||||
|
var args []any
|
||||||
|
if kind == VerifyGroup {
|
||||||
|
conds = append(conds, "v.source = 'group'")
|
||||||
|
} else {
|
||||||
|
conds = append(conds, "v.`override` = 1", "(v.expires_at IS NULL OR v.expires_at > ?)")
|
||||||
|
args = append(args, wallIn(time.Now(), loc))
|
||||||
|
}
|
||||||
|
if t := strings.TrimSpace(search); t != "" {
|
||||||
|
or := "v.steamid64 IN (SELECT steamid FROM sa_players_ips WHERE name LIKE ?)"
|
||||||
|
orArgs := []any{"%" + escapeLike(t) + "%"}
|
||||||
|
if n, err := strconv.ParseInt(t, 10, 64); err == nil {
|
||||||
|
or += " OR v.steamid64 = ?"
|
||||||
|
orArgs = append(orArgs, n)
|
||||||
|
}
|
||||||
|
conds = append(conds, "("+or+")")
|
||||||
|
args = append(args, orArgs...)
|
||||||
|
}
|
||||||
|
where := " WHERE " + strings.Join(conds, " AND ")
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM gv_verified v"+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT v.steamid64, v.source, v.expires_at, v.added_by, v.created_at, v.updated_at,
|
||||||
|
COALESCE((SELECT p.name FROM sa_players_ips p WHERE p.steamid = v.steamid64 ORDER BY p.used_at DESC LIMIT 1), '')
|
||||||
|
FROM gv_verified v`+where+` ORDER BY v.created_at DESC, v.steamid64 LIMIT ? OFFSET ?`,
|
||||||
|
append(args, limit(lim), offset)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []VerifyEntry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var e VerifyEntry
|
||||||
|
var expires sql.NullTime
|
||||||
|
if err := rows.Scan(&id, &e.Source, &expires, &e.AddedBy, &e.Created, &e.Seen, &e.Name); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
e.SteamID = strconv.FormatInt(id, 10)
|
||||||
|
e.Created, e.Seen = fromWallIn(e.Created, loc), fromWallIn(e.Seen, loc)
|
||||||
|
if expires.Valid {
|
||||||
|
t := fromWallIn(expires.Time, loc)
|
||||||
|
e.Expires = &t
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyCounts is how many group members and unexpired overrides there are, and when GroupVerify
|
||||||
|
// last saved the group's member list (nil if it never has).
|
||||||
|
func (s *Store) VerifyCounts(ctx context.Context, loc *time.Location) (group, overrides int, fetched *time.Time, err error) {
|
||||||
|
var last sql.NullTime
|
||||||
|
err = s.db.QueryRowContext(ctx, `SELECT
|
||||||
|
COALESCE(SUM(source = 'group'), 0),
|
||||||
|
COALESCE(SUM(`+"`override`"+` = 1 AND (expires_at IS NULL OR expires_at > ?)), 0),
|
||||||
|
MAX(CASE WHEN source = 'group' THEN updated_at END)
|
||||||
|
FROM gv_verified`, wallIn(time.Now(), loc)).Scan(&group, &overrides, &last)
|
||||||
|
if last.Valid {
|
||||||
|
t := fromWallIn(last.Time, loc)
|
||||||
|
fetched = &t
|
||||||
|
}
|
||||||
|
return group, overrides, fetched, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOverride gives a player a manual override ending at expires (nil: never), or changes the one
|
||||||
|
// they have. created_at keeps when it was first given.
|
||||||
|
func (s *Store) SetOverride(ctx context.Context, loc *time.Location, steamid string, expires *time.Time, addedBy string) error {
|
||||||
|
n, err := parseSID(steamid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var ends any
|
||||||
|
if expires != nil {
|
||||||
|
ends = wallIn(*expires, loc)
|
||||||
|
}
|
||||||
|
now := wallIn(time.Now(), loc)
|
||||||
|
_, err = s.db.ExecContext(ctx, `INSERT INTO gv_verified (steamid64, source, `+"`override`"+`, expires_at, added_by, created_at, updated_at)
|
||||||
|
VALUES (?, 'manual', 1, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE `+"`override`"+` = 1, expires_at = VALUES(expires_at), added_by = VALUES(added_by), updated_at = VALUES(updated_at)`,
|
||||||
|
n, ends, addedBy, now, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveOverride takes a player's override away: deletes their manual row, and clears override on
|
||||||
|
// their group row in case it was set by hand. Reports whether there was one.
|
||||||
|
func (s *Store) RemoveOverride(ctx context.Context, steamid string) (bool, error) {
|
||||||
|
n, err := parseSID(steamid)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
res, err := tx.ExecContext(ctx, `DELETE FROM gv_verified WHERE steamid64 = ? AND source = 'manual'`, n)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
deleted, _ := res.RowsAffected()
|
||||||
|
res, err = tx.ExecContext(ctx, `UPDATE gv_verified SET `+"`override`"+` = 0 WHERE steamid64 = ? AND source = 'group' AND `+"`override`"+` = 1`, n)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
cleared, _ := res.RowsAffected()
|
||||||
|
return deleted+cleared > 0, tx.Commit()
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ func nonNil[T any](v []T) []T {
|
||||||
func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
view := s.serverView(x, s.poll.Current())
|
view := s.serverView(x, s.poll.Current())
|
||||||
|
s.markVerified(ctx, view.Players)
|
||||||
|
|
||||||
var ids []string
|
var ids []string
|
||||||
for _, p := range view.Players {
|
for _, p := range view.Players {
|
||||||
|
|
@ -238,6 +239,12 @@ func (s *Server) adminPlayer(w http.ResponseWriter, r *http.Request, id *Identit
|
||||||
resp["groups"] = a.Groups
|
resp["groups"] = a.Groups
|
||||||
resp["adminEnds"] = a.Ends
|
resp["adminEnds"] = a.Ends
|
||||||
}
|
}
|
||||||
|
if v, err := s.playerVerify(ctx, id, steamid); err != nil {
|
||||||
|
internal(w, r, err)
|
||||||
|
return
|
||||||
|
} else if v != nil {
|
||||||
|
resp["verify"] = v
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,6 +306,7 @@ var knownFlags = [][3]string{
|
||||||
{"@zio/vote", "Start votes", "css_vote"},
|
{"@zio/vote", "Start votes", "css_vote"},
|
||||||
{"@zio/plugins", "Plugins", "css_plugins and css"},
|
{"@zio/plugins", "Plugins", "css_plugins and css"},
|
||||||
{"@zio/webpanel", "Staff page", "See the Staff & ranks page in this panel"},
|
{"@zio/webpanel", "Staff page", "See the Staff & ranks page in this panel"},
|
||||||
|
{"@zio/verify", "Verification overrides", "Give and remove GroupVerify overrides in this panel"},
|
||||||
{"@css/root", "Root", "Every permission, and managing ranks and staff"},
|
{"@css/root", "Root", "Every permission, and managing ranks and staff"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,19 @@ const PAGES = [
|
||||||
{ id: "bans", label: "Bans" },
|
{ id: "bans", label: "Bans" },
|
||||||
{ id: "comms", label: "Gags & mutes" },
|
{ id: "comms", label: "Gags & mutes" },
|
||||||
{ id: "staff", label: "Staff & ranks", flag: "@zio/webpanel" },
|
{ id: "staff", label: "Staff & ranks", flag: "@zio/webpanel" },
|
||||||
|
// Only when GroupVerify's tables are in the database.
|
||||||
|
{ id: "verify", label: "Verification", show: () => !!state.me?.verify },
|
||||||
{ id: "settings", label: "Server settings" },
|
{ id: "settings", label: "Server settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const pageAllowed = (p) => (!p.flag || can(p.flag)) && (!p.show || p.show());
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
page: "overview",
|
page: "overview",
|
||||||
banFilter: "active",
|
banFilter: "active",
|
||||||
commFilter: "active",
|
commFilter: "active",
|
||||||
playerFilter: "online",
|
playerFilter: "online",
|
||||||
|
verifyView: "overrides",
|
||||||
query: "",
|
query: "",
|
||||||
num: 1,
|
num: 1,
|
||||||
group: null,
|
group: null,
|
||||||
|
|
@ -75,7 +80,7 @@ function onlineTable(players) {
|
||||||
<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>
|
<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>
|
<tbody>
|
||||||
${list.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
|
${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><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> ${p.verified ? VERIFIED_TAG : ""} ${tagsFor(p)}</div></div></td>
|
||||||
<td class="hide-sm">${rankTag(p.rank)}</td>
|
<td class="hide-sm">${rankTag(p.rank)}</td>
|
||||||
<td class="num">${p.kills} / ${p.deaths}</td>
|
<td class="num">${p.kills} / ${p.deaths}</td>
|
||||||
<td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</td>
|
<td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</td>
|
||||||
|
|
@ -246,12 +251,18 @@ async function loadResults() {
|
||||||
let path, status, render;
|
let path, status, render;
|
||||||
if (state.page === "players") {
|
if (state.page === "players") {
|
||||||
path = "/api/admin/players"; render = playersTable;
|
path = "/api/admin/players"; render = playersTable;
|
||||||
|
} else if (state.page === "verify") {
|
||||||
|
path = "/api/admin/verify"; status = undefined; render = verifyResults;
|
||||||
} else if (state.page === "bans") {
|
} else if (state.page === "bans") {
|
||||||
path = "/api/admin/bans"; status = state.banFilter; render = (l) => punishTable(l, "ban");
|
path = "/api/admin/bans"; status = state.banFilter; render = (l) => punishTable(l, "ban");
|
||||||
} else {
|
} else {
|
||||||
path = "/api/admin/comms"; status = state.commFilter; render = (l) => punishTable(l, "comm");
|
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 params = {
|
||||||
|
status, q: state.query, page: state.num,
|
||||||
|
filter: state.page === "players" ? state.playerFilter : undefined,
|
||||||
|
view: state.page === "verify" ? state.verifyView : undefined,
|
||||||
|
};
|
||||||
const want = JSON.stringify([state.page, params]);
|
const want = JSON.stringify([state.page, params]);
|
||||||
loadResults.want = want;
|
loadResults.want = want;
|
||||||
try {
|
try {
|
||||||
|
|
@ -355,6 +366,141 @@ async function staff() {
|
||||||
</div>` : `<div class="empty">SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}</div>`}`;
|
</div>` : `<div class="empty">SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}</div>`}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Verification (GroupVerify) ---------- */
|
||||||
|
|
||||||
|
const VERIFY_DAYS = [[1, "1 day"], [7, "1 week"], [30, "30 days"], [90, "90 days"], [365, "1 year"], [0, "No expiry"]];
|
||||||
|
|
||||||
|
function verify() {
|
||||||
|
$("#view").innerHTML = `
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1>Verification</h1><p class="sub">GroupVerify verifies players who are in the server's Steam group, or have an override given here. Other plugins give verified players their perks.</p></div>
|
||||||
|
<div id="verify-actions"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stack" id="verify-status">${loading()}</div>
|
||||||
|
<section class="panel" style="margin-top:18px">
|
||||||
|
<div class="toolbar">
|
||||||
|
${searchBox("Search by name or SteamID64", state.query)}
|
||||||
|
${segmented("verifyView", state.verifyView, [["overrides", "Overrides"], ["group", "Steam group members"]])}
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap" id="results">${loading()}</div>
|
||||||
|
</section>`;
|
||||||
|
loadResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLUGIN_STATE = {
|
||||||
|
loaded: (v) => `<span class="tag green">Loaded</span> <span class="muted">${esc(v)}</span>`,
|
||||||
|
missing: () => '<span class="tag red">Not loaded</span>',
|
||||||
|
unknown: () => '<span class="tag">Unknown</span> <span class="muted">the game server isn\'t answering</span>',
|
||||||
|
};
|
||||||
|
|
||||||
|
// verifyStatus is the top of the page, drawn once per visit so typing in the group field survives
|
||||||
|
// searches and paging.
|
||||||
|
function verifyStatus(st) {
|
||||||
|
const box = $("#verify-status");
|
||||||
|
if (!box || box.dataset.drawn) return;
|
||||||
|
box.dataset.drawn = "1";
|
||||||
|
$("#verify-actions").innerHTML = st.canOverride ? `<button class="btn primary" type="button" data-act="override">${ICON.plus}Give override</button>` : "";
|
||||||
|
const warn = [];
|
||||||
|
if (st.plugin === "missing") warn.push("GroupVerify isn't loaded on the game server, so nobody is verified in game, and the public site doesn't show verification.");
|
||||||
|
if (!st.enabled) warn.push("Verification is turned off: nobody is verified, overrides included.");
|
||||||
|
else if (!st.groupId) warn.push("No Steam group is set, so verification is off: nobody is verified, overrides included.");
|
||||||
|
const group = st.groupId
|
||||||
|
? `<a href="https://steamcommunity.com/gid/${esc(st.groupId)}" target="_blank" rel="noopener">${esc(st.groupId)}</a>`
|
||||||
|
: '<span class="faint">Not set</span>';
|
||||||
|
box.innerHTML = `
|
||||||
|
${warn.map((w) => `<div class="panel"><div class="panel-body"><p style="margin:0"><span class="tag amber">Off</span> ${esc(w)}</p></div></div>`).join("")}
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h2>GroupVerify</h2>${st.active && st.plugin !== "missing" ? '<span class="tag green">On</span>' : '<span class="tag amber">Off</span>'}</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Plugin</dt><dd>${PLUGIN_STATE[st.plugin](st.version)}</dd>
|
||||||
|
<dt>Steam group</dt><dd>${group}</dd>
|
||||||
|
<dt>Group members</dt><dd>${st.groupMembers}${st.groupFetched ? ` <span class="muted">list saved ${fmtAgo(st.groupFetched)}</span>` : ' <span class="faint">never fetched</span>'}</dd>
|
||||||
|
<dt>Overrides</dt><dd>${st.overrides}</dd>
|
||||||
|
<dt>Changes apply</dt><dd>Within ${st.reloadSeconds} seconds <span class="muted">(times in ${esc(st.timezone)})</span></dd>
|
||||||
|
</dl>
|
||||||
|
${st.canConfigure ? `<form id="gv-form" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-top:16px">
|
||||||
|
<label class="field" style="flex:1;min-width:240px"><span>Steam group ID64</span>
|
||||||
|
<input class="input" name="group" value="${esc(st.groupId)}" inputmode="numeric" pattern="[0-9]{0,20}" placeholder="103582791…, blank or 0 turns verification off">
|
||||||
|
<small><groupID64> on https://steamcommunity.com/groups/<name>/memberslistxml/?xml=1</small>
|
||||||
|
</label>
|
||||||
|
<button class="btn" type="submit">Save group</button>
|
||||||
|
<button class="btn ${st.enabled ? "danger" : "primary"}" type="button" data-act="gv-toggle" data-on="${st.enabled ? "0" : "1"}">${st.enabled ? "Turn off" : "Turn on"}</button>
|
||||||
|
</form>` : '<p class="muted" style="margin:16px 0 0">Only Root can change the group or turn verification on and off.</p>'}
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyResults(d) {
|
||||||
|
verifyStatus(d.status);
|
||||||
|
const list = d.list;
|
||||||
|
const groupView = state.verifyView === "group";
|
||||||
|
if (!list.items.length) {
|
||||||
|
if (state.query) return `<div class="empty">Nobody matches "${esc(state.query)}".</div>`;
|
||||||
|
return `<div class="empty">${groupView ? "No group members saved yet. GroupVerify saves the list each time it fetches it." : "No overrides."}</div>`;
|
||||||
|
}
|
||||||
|
const mayEdit = (sid) => d.status.canOverride && (sid !== state.me.steamid || can("@css/root"));
|
||||||
|
const who = (e) => `<td><div class="who"><div><b><a href="#" data-player="${esc(e.steamid)}">${e.name ? esc(e.name) : '<span class="faint">Never joined</span>'}</a></b><span class="sid">${esc(e.steamid)}</span></div></div></td>`;
|
||||||
|
if (groupView) {
|
||||||
|
return `<table>
|
||||||
|
<thead><tr><th>Player</th><th>In the group since</th><th class="hide-sm">Last seen in the list</th></tr></thead>
|
||||||
|
<tbody>${list.items.map((e) => `<tr>${who(e)}<td>${fmtDate(e.created)}</td><td class="hide-sm muted">${fmtAgo(e.seen)}</td></tr>`).join("")}</tbody>
|
||||||
|
</table>${pager(list)}`;
|
||||||
|
}
|
||||||
|
return `<table>
|
||||||
|
<thead><tr><th>Player</th><th>Ends</th><th class="hide-sm">Given</th><th><span class="sr">Actions</span></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${list.items.map((e) => `<tr>
|
||||||
|
${who(e)}
|
||||||
|
<td>${e.expires ? `${fmtDate(e.expires)}<br><small class="muted">${fmtLeft((new Date(e.expires) - Date.now()) / MIN)}</small>` : "Never"}</td>
|
||||||
|
<td class="hide-sm">${fmtDate(e.created)}${e.addedBy ? `<br><small class="muted">by ${esc(e.addedBy)}</small>` : ""}</td>
|
||||||
|
<td><div class="row-actions">${mayEdit(e.steamid) ? `
|
||||||
|
<button class="btn sm ghost" type="button" data-act="override" data-sid="${esc(e.steamid)}" data-name="${esc(e.name)}">Change</button>
|
||||||
|
<button class="btn sm danger" type="button" data-act="remove-override" data-sid="${esc(e.steamid)}" data-name="${esc(e.name)}">Remove</button>` : ""}
|
||||||
|
</div></td>
|
||||||
|
</tr>`).join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>${pager(list)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifySection is the Verification part of a player's drawer.
|
||||||
|
function verifySection(v, sid, name) {
|
||||||
|
if (!v) return "";
|
||||||
|
const o = v.override;
|
||||||
|
const badge = !v.active ? '<span class="tag amber">Verification is off</span>'
|
||||||
|
: v.verified ? VERIFIED_TAG : '<span class="tag">Not verified</span>';
|
||||||
|
return `<section>
|
||||||
|
<h3>Verification</h3>
|
||||||
|
<p style="margin:0 0 10px">${badge}</p>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Steam group</dt><dd>${v.inGroup ? `Member <span class="muted">seen in the list ${fmtAgo(v.groupSeen)}</span>` : '<span class="faint">Not a member</span>'}</dd>
|
||||||
|
<dt>Override</dt><dd>${o ? `${o.expires ? `Until ${fmtDate(o.expires)}` : "No expiry"}${o.addedBy ? ` <span class="muted">from ${esc(o.addedBy)}</span>` : ""}` : '<span class="faint">None</span>'}</dd>
|
||||||
|
</dl>
|
||||||
|
${v.canOverride ? `<div style="display:flex;gap:8px;margin-top:12px">
|
||||||
|
<button class="btn" type="button" data-act="override" data-sid="${esc(sid)}" data-name="${esc(name)}">${o ? "Change override" : "Give override"}</button>
|
||||||
|
${o ? `<button class="btn danger" type="button" data-act="remove-override" data-sid="${esc(sid)}" data-name="${esc(name)}">Remove override</button>` : ""}
|
||||||
|
</div>` : ""}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overrideDialog(sid, name) {
|
||||||
|
openModal(`
|
||||||
|
<form method="dialog" data-submit="override">
|
||||||
|
<div class="modal-head"><h2>${sid ? "Override" : "Give override"}${name ? `: ${esc(name)}` : ""}</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>They count as verified whether or not they're in the Steam group, until it ends.</small>
|
||||||
|
</label>
|
||||||
|
<div class="field"><span>Length</span>
|
||||||
|
<div class="chips" data-chips="days">${VERIFY_DAYS.map(([v, l]) => `<button type="button" class="chip-btn" data-value="${v}" aria-pressed="${v === 30}">${l}</button>`).join("")}</div>
|
||||||
|
<small>Changing an override sets a new end from now.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Save override</button></div>
|
||||||
|
</form>`);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Settings ---------- */
|
/* ---------- Settings ---------- */
|
||||||
|
|
||||||
async function settings() {
|
async function settings() {
|
||||||
|
|
@ -472,7 +618,7 @@ async function openPlayer(sid) {
|
||||||
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
|
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
<span class="avatar lg">${initial(name)}</span>
|
<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>
|
<div><h2>${esc(name)}</h2><div>${rankTag(d.rank)} ${d.online ? '<span class="tag green" style="margin-left:6px">Online</span>' : ""}${d.verify?.verified ? ` ${VERIFIED_TAG}` : ""}</div></div>
|
||||||
<button class="close" type="button" data-close aria-label="Close">×</button>
|
<button class="close" type="button" data-close aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -498,6 +644,7 @@ async function openPlayer(sid) {
|
||||||
${d.adminEnds ? `<dt>Rank expires</dt><dd>${fmtDate(d.adminEnds)}</dd>` : ""}
|
${d.adminEnds ? `<dt>Rank expires</dt><dd>${fmtDate(d.adminEnds)}</dd>` : ""}
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
${verifySection(d.verify, p.steamid, name)}
|
||||||
${manage ? `<section>
|
${manage ? `<section>
|
||||||
<h3>Rank</h3>
|
<h3>Rank</h3>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
|
|
@ -640,12 +787,12 @@ async function act(path, method, body) {
|
||||||
|
|
||||||
/* ---------- Routing & events ---------- */
|
/* ---------- Routing & events ---------- */
|
||||||
|
|
||||||
const RENDER = { overview, players, bans, comms, staff, settings };
|
const RENDER = { overview, players, bans, comms, staff, verify, settings };
|
||||||
|
|
||||||
function renderNav() {
|
function renderNav() {
|
||||||
const c = state.counts;
|
const c = state.counts;
|
||||||
const count = { players: c.players, bans: c.bans, comms: c.comms, staff: c.staff };
|
const count = { players: c.players, bans: c.bans, comms: c.comms, staff: c.staff };
|
||||||
$("#nav-links").innerHTML = PAGES.filter((p) => !p.flag || can(p.flag)).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("");
|
$("#nav-links").innerHTML = PAGES.filter(pageAllowed).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("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mobile sidebar covers the Menu button, so it closes from its own Close button, the
|
// The mobile sidebar covers the Menu button, so it closes from its own Close button, the
|
||||||
|
|
@ -658,7 +805,7 @@ function setNav(open) {
|
||||||
function route() {
|
function route() {
|
||||||
const page = location.hash.slice(1);
|
const page = location.hash.slice(1);
|
||||||
const def = PAGES.find((p) => p.id === page);
|
const def = PAGES.find((p) => p.id === page);
|
||||||
state.page = def && (!def.flag || can(def.flag)) ? page : "overview";
|
state.page = def && pageAllowed(def) ? page : "overview";
|
||||||
renderNav();
|
renderNav();
|
||||||
setNav(false);
|
setNav(false);
|
||||||
if (!$("#view").innerHTML) $("#view").innerHTML = loading();
|
if (!$("#view").innerHTML) $("#view").innerHTML = loading();
|
||||||
|
|
@ -763,6 +910,19 @@ document.addEventListener("click", (e) => {
|
||||||
case "restart":
|
case "restart":
|
||||||
confirmDialog.reason = false;
|
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", {}));
|
return confirmDialog("Restart the match", "Restart the game on the current map? The current match ends.", "Restart match", () => act("/api/admin/restart", "POST", {}));
|
||||||
|
case "override": return overrideDialog(d.sid || "", d.name || "");
|
||||||
|
case "remove-override":
|
||||||
|
confirmDialog.reason = false;
|
||||||
|
return confirmDialog("Remove override", `Remove <b>${esc(d.name || d.sid)}</b>'s override? They stay verified only if they're in the Steam group.`, "Remove",
|
||||||
|
() => act(`/api/admin/verify/overrides/${encodeURIComponent(d.sid)}`, "DELETE"));
|
||||||
|
case "gv-toggle": {
|
||||||
|
const on = d.on === "1";
|
||||||
|
confirmDialog.reason = false;
|
||||||
|
return confirmDialog(on ? "Turn verification on" : "Turn verification off", on
|
||||||
|
? "Turn GroupVerify on? Players in the Steam group and with overrides become verified."
|
||||||
|
: "Turn GroupVerify off? Nobody is verified while it's off, overrides included, and verified players lose their perks.",
|
||||||
|
on ? "Turn on" : "Turn off", () => act("/api/admin/verify/settings", "POST", { enabled: on }));
|
||||||
|
}
|
||||||
case "save-settings": return saveSettings();
|
case "save-settings": return saveSettings();
|
||||||
case "discard": return settings();
|
case "discard": return settings();
|
||||||
}
|
}
|
||||||
|
|
@ -779,6 +939,11 @@ document.addEventListener("submit", async (e) => {
|
||||||
const kind = form.dataset.submit;
|
const kind = form.dataset.submit;
|
||||||
const f = form.elements;
|
const f = form.elements;
|
||||||
|
|
||||||
|
if (form.id === "gv-form") {
|
||||||
|
await act("/api/admin/verify/settings", "POST", { groupId: f.group.value.trim() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (form.id === "group-form") {
|
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);
|
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 });
|
await act(`/api/admin/groups/${form.dataset.id}`, "PUT", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags });
|
||||||
|
|
@ -811,6 +976,11 @@ document.addEventListener("submit", async (e) => {
|
||||||
case "map": await act("/api/admin/map", "POST", { map: f.value.value.trim() }); 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 "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 "kick": await act("/api/admin/kick", "POST", { steamid: form.dataset.sid, reason: f.value.value }); break;
|
||||||
|
case "override": {
|
||||||
|
const days = form.querySelector('[data-chips="days"] [aria-pressed="true"]');
|
||||||
|
await act("/api/admin/verify/overrides", "POST", { steamid: f.steamid.value.trim(), days: Number(days?.dataset.value ?? 30) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "confirm": await confirmDialog.run?.(f); break;
|
case "confirm": await confirmDialog.run?.(f); break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,9 @@ function statusTag(p) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMM_LABEL = { GAG: "Gag", MUTE: "Mute", SILENCE: "Silence" };
|
const COMM_LABEL = { GAG: "Gag", MUTE: "Mute", SILENCE: "Silence" };
|
||||||
|
|
||||||
|
// Verified by GroupVerify: in the server's Steam group, or given an override by staff.
|
||||||
|
const VERIFIED_TAG = '<span class="tag verified" title="In the server\'s Steam group, or verified by staff">Verified</span>';
|
||||||
const COMM_HELP = { GAG: "Text chat blocked", MUTE: "Voice blocked", SILENCE: "Text and voice blocked" };
|
const COMM_HELP = { GAG: "Text chat blocked", MUTE: "Voice blocked", SILENCE: "Text and voice blocked" };
|
||||||
|
|
||||||
function segmented(name, value, options) {
|
function segmented(name, value, options) {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ async function server() {
|
||||||
<thead><tr><th>Player</th><th class="num">Kills</th><th class="num">Deaths</th><th class="num hide-sm">Ping</th></tr></thead>
|
<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>
|
<tbody>
|
||||||
${list.map((p) => `<tr ${p.bot ? "" : `class="clickable" data-player="${esc(p.steamid)}"`}>
|
${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><div class="who"><div><b>${esc(p.name)}</b>${p.verified ? ` ${VERIFIED_TAG}` : ""}${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.kills}</td>
|
||||||
<td class="num">${p.deaths}</td>
|
<td class="num">${p.deaths}</td>
|
||||||
<td class="num hide-sm muted">${p.bot ? "" : p.ping}</td>
|
<td class="num hide-sm muted">${p.bot ? "" : p.ping}</td>
|
||||||
|
|
@ -238,8 +238,8 @@ async function openPlayer(sid) {
|
||||||
<div><h2>${esc(name)}</h2><span class="sid">${esc(p.steamid)} · <a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></span>${p.rank ? `<div>${rankTag(p.rank)}</div>` : ""}</div>
|
<div><h2>${esc(name)}</h2><span class="sid">${esc(p.steamid)} · <a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></span>${p.rank ? `<div>${rankTag(p.rank)}</div>` : ""}</div>
|
||||||
<button class="close" type="button" data-close aria-label="Close">×</button>
|
<button class="close" type="button" data-close aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
${now.length || p.online ? `<section>
|
${now.length || p.online || p.verified ? `<section>
|
||||||
<div class="chips">${p.online ? '<span class="tag">On the server</span>' : ""}${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>
|
<div class="chips">${p.verified ? VERIFIED_TAG : ""}${p.online ? '<span class="tag">On the server</span>' : ""}${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>
|
||||||
</section>` : ""}
|
</section>` : ""}
|
||||||
<section>
|
<section>
|
||||||
<h3>History</h3>
|
<h3>History</h3>
|
||||||
|
|
|
||||||
|
|
@ -427,6 +427,7 @@ td.num, th.num { text-align: right; white-space: nowrap; }
|
||||||
.tag.red { background: var(--accent-soft); color: #ff5a78; }
|
.tag.red { background: var(--accent-soft); color: #ff5a78; }
|
||||||
.tag.amber { background: var(--amber-soft); color: var(--amber); }
|
.tag.amber { background: var(--amber-soft); color: var(--amber); }
|
||||||
.tag.green { background: var(--green-soft); color: var(--green); }
|
.tag.green { background: var(--green-soft); color: var(--green); }
|
||||||
|
.tag.verified { background: rgba(79, 179, 217, .14); color: #4fb3d9; }
|
||||||
|
|
||||||
.rank {
|
.rank {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,8 @@ type meView struct {
|
||||||
Rank *rankView `json:"rank,omitempty"`
|
Rank *rankView `json:"rank,omitempty"`
|
||||||
Perms []string `json:"perms,omitempty"`
|
Perms []string `json:"perms,omitempty"`
|
||||||
Immunity int `json:"immunity,omitempty"`
|
Immunity int `json:"immunity,omitempty"`
|
||||||
|
// Verify is true when GroupVerify's tables are in the database, for the Verification page.
|
||||||
|
Verify bool `json:"verify,omitempty"`
|
||||||
Site string `json:"site"`
|
Site string `json:"site"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,6 +199,10 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||||
if id != nil {
|
if id != nil {
|
||||||
v.Staff, v.Name, v.Rank, v.Immunity = true, id.Name, s.rankOf(x, id.SteamID), id.Immunity
|
v.Staff, v.Name, v.Rank, v.Immunity = true, id.Name, s.rankOf(x, id.SteamID), id.Immunity
|
||||||
v.Perms = id.Flags
|
v.Perms = id.Flags
|
||||||
|
// Never fails the page: without the tables, or on an error, there's just no Verification page.
|
||||||
|
if info, err := s.verifyInfo(r.Context()); err == nil {
|
||||||
|
v.Verify = info.Tables
|
||||||
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, v)
|
writeJSON(w, http.StatusOK, v)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ func toPublic(ps []store.Penalty) []publicPenalty {
|
||||||
type livePlayer struct {
|
type livePlayer struct {
|
||||||
live.Player
|
live.Player
|
||||||
Rank *rankView `json:"rank,omitempty"`
|
Rank *rankView `json:"rank,omitempty"`
|
||||||
|
// Verified by GroupVerify, when that's shown at all (see verifyInfo.Shown).
|
||||||
|
Verified bool `json:"verified,omitempty"`
|
||||||
// Staff-only fields, left empty on the public page.
|
// Staff-only fields, left empty on the public page.
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
CanTarget bool `json:"canTarget,omitempty"`
|
CanTarget bool `json:"canTarget,omitempty"`
|
||||||
|
|
@ -82,7 +84,9 @@ func (s *Server) publicServer(w http.ResponseWriter, r *http.Request) {
|
||||||
internal(w, r, err)
|
internal(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, s.serverView(x, s.poll.Current()))
|
view := s.serverView(x, s.poll.Current())
|
||||||
|
s.markVerified(r.Context(), view.Players)
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// penaltyQuery reads ?status=&q=&page= (and for comms, a type in place of the status).
|
// penaltyQuery reads ?status=&q=&page= (and for comms, a type in place of the status).
|
||||||
|
|
@ -286,8 +290,10 @@ func (s *Server) publicPlayer(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, online := s.poll.Current().Find(steamid)
|
_, online := s.poll.Current().Find(steamid)
|
||||||
|
// Only whether they're verified: why, and overrides' expiry and who gave them, are for staff.
|
||||||
|
st, verified := s.verifiedSet(r.Context(), []string{steamid})[steamid]
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"steamid": steamid, "name": d.Name, "online": online, "rank": s.rankOf(x, steamid),
|
"steamid": steamid, "name": d.Name, "online": online, "rank": s.rankOf(x, steamid),
|
||||||
"bans": toPublic(bans), "comms": toPublic(comms),
|
"verified": verified && st.Verified(), "bans": toPublic(bans), "comms": toPublic(comms),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ type Server struct {
|
||||||
poll *live.Poller
|
poll *live.Poller
|
||||||
sacfg *saconfig.File // nil when no config path is set
|
sacfg *saconfig.File // nil when no config path is set
|
||||||
staff *staffCache
|
staff *staffCache
|
||||||
|
verify *verifyCache
|
||||||
avatars *avatarCache
|
avatars *avatarCache
|
||||||
http *http.Client
|
http *http.Client
|
||||||
secure bool
|
secure bool
|
||||||
|
|
@ -63,6 +64,7 @@ func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg
|
||||||
poll: poll,
|
poll: poll,
|
||||||
sacfg: sacfg,
|
sacfg: sacfg,
|
||||||
staff: &staffCache{st: st, ttl: 15 * time.Second},
|
staff: &staffCache{st: st, ttl: 15 * time.Second},
|
||||||
|
verify: &verifyCache{st: st, ttl: 30 * time.Second},
|
||||||
avatars: newAvatarCache(&http.Client{Timeout: 10 * time.Second}),
|
avatars: newAvatarCache(&http.Client{Timeout: 10 * time.Second}),
|
||||||
http: &http.Client{Timeout: 10 * time.Second},
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
secure: u.Scheme == "https",
|
secure: u.Scheme == "https",
|
||||||
|
|
@ -101,6 +103,7 @@ func (s *Server) Handler() http.Handler {
|
||||||
admin("GET /api/admin/comms", s.adminComms)
|
admin("GET /api/admin/comms", s.adminComms)
|
||||||
admin("GET /api/admin/staff", s.adminStaff)
|
admin("GET /api/admin/staff", s.adminStaff)
|
||||||
admin("GET /api/admin/settings", s.adminSettings)
|
admin("GET /api/admin/settings", s.adminSettings)
|
||||||
|
admin("GET /api/admin/verify", s.adminVerify)
|
||||||
|
|
||||||
admin("POST /api/admin/penalties", s.addPenalty)
|
admin("POST /api/admin/penalties", s.addPenalty)
|
||||||
admin("POST /api/admin/penalties/lift", s.liftPenalty)
|
admin("POST /api/admin/penalties/lift", s.liftPenalty)
|
||||||
|
|
@ -117,6 +120,9 @@ func (s *Server) Handler() http.Handler {
|
||||||
admin("DELETE /api/admin/staff/{steamid}", s.removeStaff)
|
admin("DELETE /api/admin/staff/{steamid}", s.removeStaff)
|
||||||
admin("POST /api/admin/settings/cvars", s.saveCvars)
|
admin("POST /api/admin/settings/cvars", s.saveCvars)
|
||||||
admin("POST /api/admin/settings/simpleadmin", s.saveSimpleAdmin)
|
admin("POST /api/admin/settings/simpleadmin", s.saveSimpleAdmin)
|
||||||
|
admin("POST /api/admin/verify/overrides", s.setOverride)
|
||||||
|
admin("DELETE /api/admin/verify/overrides/{steamid}", s.removeOverride)
|
||||||
|
admin("POST /api/admin/verify/settings", s.saveVerifySettings)
|
||||||
|
|
||||||
return s.headers(mux)
|
return s.headers(mux)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
389
internal/web/verify.go
Normal file
389
internal/web/verify.go
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupVerify (plugins/GroupVerify in the cs2 repo) verifies players who are in a Steam group or
|
||||||
|
// have a manual override. The panel shows it only where it's there: staff get the Verification
|
||||||
|
// page once its tables are in the database, and "Verified" shows on the public site only while
|
||||||
|
// it's on (enabled, with a group) and not known to be missing from the server's plugin list.
|
||||||
|
|
||||||
|
const groupVerifyPlugin = "GroupVerify"
|
||||||
|
|
||||||
|
// verifyOverridePerm lets staff give and remove overrides. Settings need @css/root, as in game.
|
||||||
|
const verifyOverridePerm = "@zio/verify"
|
||||||
|
|
||||||
|
// verifyInfo is what's known about GroupVerify right now.
|
||||||
|
type verifyInfo struct {
|
||||||
|
Tables bool
|
||||||
|
Settings store.VerifySettings
|
||||||
|
Plugin live.PluginState
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shown reports whether players' verified status should be shown at all.
|
||||||
|
func (v verifyInfo) Shown() bool {
|
||||||
|
return v.Tables && v.Settings.Active() && v.Plugin != live.PluginMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyCache keeps the table check and gv_settings for a while, so the scoreboard and every page
|
||||||
|
// view don't ask the database each time.
|
||||||
|
type verifyCache struct {
|
||||||
|
st *store.Store
|
||||||
|
ttl time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
tables bool
|
||||||
|
settings store.VerifySettings
|
||||||
|
fetched time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *verifyCache) get(ctx context.Context) (bool, store.VerifySettings, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if !c.fetched.IsZero() && time.Since(c.fetched) < c.ttl {
|
||||||
|
return c.tables, c.settings, nil
|
||||||
|
}
|
||||||
|
tables, err := c.st.HasVerifyTables(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, store.VerifySettings{}, err
|
||||||
|
}
|
||||||
|
settings := store.VerifySettings{}
|
||||||
|
if tables {
|
||||||
|
if settings, err = c.st.VerifySettings(ctx); err != nil {
|
||||||
|
return false, store.VerifySettings{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.tables, c.settings, c.fetched = tables, settings, time.Now()
|
||||||
|
return tables, settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *verifyCache) invalidate() {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.fetched = time.Time{}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) verifyInfo(ctx context.Context) (verifyInfo, error) {
|
||||||
|
tables, settings, err := s.verify.get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return verifyInfo{}, err
|
||||||
|
}
|
||||||
|
state, version := s.poll.Plugin(groupVerifyPlugin)
|
||||||
|
return verifyInfo{Tables: tables, Settings: settings, Plugin: state, Version: version}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifiedSet returns which of these players are verified, or nil when verification isn't shown.
|
||||||
|
// Errors are logged and treated as not shown: verification is extra, never worth failing a page.
|
||||||
|
func (s *Server) verifiedSet(ctx context.Context, steamids []string) map[string]store.VerifyStatus {
|
||||||
|
info, err := s.verifyInfo(ctx)
|
||||||
|
if err == nil && info.Shown() {
|
||||||
|
var st map[string]store.VerifyStatus
|
||||||
|
if st, err = s.st.VerifyStatuses(ctx, info.Settings.Location, steamids); err == nil {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("GroupVerify status unavailable", "err", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markVerified sets Verified on the live players who are.
|
||||||
|
func (s *Server) markVerified(ctx context.Context, players []livePlayer) {
|
||||||
|
var ids []string
|
||||||
|
for _, p := range players {
|
||||||
|
if !p.Bot {
|
||||||
|
ids = append(ids, p.SteamID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set := s.verifiedSet(ctx, ids)
|
||||||
|
for i, p := range players {
|
||||||
|
if st, ok := set[p.SteamID]; ok && st.Verified() {
|
||||||
|
players[i].Verified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Staff API
|
||||||
|
|
||||||
|
type verifyStatusView struct {
|
||||||
|
Tables bool `json:"tables"`
|
||||||
|
Plugin live.PluginState `json:"plugin"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
GroupID string `json:"groupId"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Shown bool `json:"shown"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
ReloadSeconds int `json:"reloadSeconds"`
|
||||||
|
GroupMembers int `json:"groupMembers"`
|
||||||
|
Overrides int `json:"overrides"`
|
||||||
|
GroupFetched *time.Time `json:"groupFetched,omitempty"`
|
||||||
|
CanOverride bool `json:"canOverride"`
|
||||||
|
CanConfigure bool `json:"canConfigure"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminVerify is the Verification page: GroupVerify's state, and a page of overrides or group
|
||||||
|
// members (?view=overrides|group&q=&page=).
|
||||||
|
func (s *Server) adminVerify(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||||
|
ctx := r.Context()
|
||||||
|
info, err := s.verifyInfo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
internal(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !info.Tables {
|
||||||
|
fail(w, http.StatusNotFound, "GroupVerify's tables aren't in the database. Install GroupVerify on the server first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loc := info.Settings.Location
|
||||||
|
view := verifyStatusView{
|
||||||
|
Tables: true, Plugin: info.Plugin, Version: info.Version,
|
||||||
|
Enabled: info.Settings.Enabled, GroupID: info.Settings.GroupID, Active: info.Settings.Active(), Shown: info.Shown(),
|
||||||
|
Timezone: loc.String(), ReloadSeconds: info.Settings.ReloadSeconds,
|
||||||
|
CanOverride: id.Has(verifyOverridePerm), CanConfigure: id.Has("@css/root"),
|
||||||
|
}
|
||||||
|
if view.GroupMembers, view.Overrides, view.GroupFetched, err = s.st.VerifyCounts(ctx, loc); err != nil {
|
||||||
|
internal(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := store.VerifyOverrides
|
||||||
|
if r.URL.Query().Get("view") == store.VerifyGroup {
|
||||||
|
kind = store.VerifyGroup
|
||||||
|
}
|
||||||
|
lim, off, num := page(r)
|
||||||
|
items, total, err := s.st.VerifyList(ctx, loc, kind, r.URL.Query().Get("q"), lim, off)
|
||||||
|
if err != nil {
|
||||||
|
internal(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": view, "list": newList(items, total, num)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// playerVerify is the Verification section of a player's drawer, or nil without GroupVerify.
|
||||||
|
func (s *Server) playerVerify(ctx context.Context, id *Identity, steamid string) (map[string]any, error) {
|
||||||
|
info, err := s.verifyInfo(ctx)
|
||||||
|
if err != nil || !info.Tables {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set, err := s.st.VerifyStatuses(ctx, info.Settings.Location, []string{steamid})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st := set[steamid]
|
||||||
|
return map[string]any{
|
||||||
|
"active": info.Settings.Active(), "verified": info.Settings.Active() && st.Verified(),
|
||||||
|
"inGroup": st.InGroup, "groupSeen": st.GroupSeen, "override": st.Override,
|
||||||
|
"canOverride": canOverride(id, steamid),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// canOverride: staff with @zio/verify, and not on themselves unless they're Root.
|
||||||
|
func canOverride(id *Identity, steamid string) bool {
|
||||||
|
return id.Has(verifyOverridePerm) && (steamid != id.SteamID || id.Has("@css/root"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyReady fails the request unless GroupVerify's tables exist, and returns its settings.
|
||||||
|
func (s *Server) verifyReady(w http.ResponseWriter, r *http.Request) (store.VerifySettings, bool) {
|
||||||
|
info, err := s.verifyInfo(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
internal(w, r, err)
|
||||||
|
return store.VerifySettings{}, false
|
||||||
|
}
|
||||||
|
if !info.Tables {
|
||||||
|
fail(w, http.StatusNotFound, "GroupVerify's tables aren't in the database.")
|
||||||
|
return store.VerifySettings{}, false
|
||||||
|
}
|
||||||
|
return info.Settings, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickedUp says when GroupVerify will notice a change to its tables.
|
||||||
|
func pickedUp(v store.VerifySettings) string {
|
||||||
|
return fmt.Sprintf("GroupVerify picks it up within %d seconds.", v.ReloadSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setOverride gives or changes an override: {steamid, days}, days 0 for one that never ends.
|
||||||
|
func (s *Server) setOverride(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||||
|
var req struct {
|
||||||
|
SteamID string `json:"steamid"`
|
||||||
|
Days int `json:"days"`
|
||||||
|
}
|
||||||
|
if err := readJSON(r, &req); err != nil {
|
||||||
|
fail(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !steam.ValidID(req.SteamID) {
|
||||||
|
fail(w, http.StatusBadRequest, "That isn't a SteamID64.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !id.Has(verifyOverridePerm) {
|
||||||
|
fail(w, http.StatusForbidden, "You need "+verifyOverridePerm+" to give overrides.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canOverride(id, req.SteamID) {
|
||||||
|
fail(w, http.StatusForbidden, "Only Root can give themselves an override.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Days < 0 || req.Days > 3650 {
|
||||||
|
fail(w, http.StatusBadRequest, "Pick a length between 1 day and 10 years, or no expiry.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, ok := s.verifyReady(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var expires *time.Time
|
||||||
|
if req.Days > 0 {
|
||||||
|
t := time.Now().Add(time.Duration(req.Days) * 24 * time.Hour)
|
||||||
|
expires = &t
|
||||||
|
}
|
||||||
|
addedBy := fmt.Sprintf("%s (%s)", id.Name, id.SteamID)
|
||||||
|
if err := s.st.SetOverride(r.Context(), settings.Location, req.SteamID, expires, addedBy); err != nil {
|
||||||
|
internal(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("verify override", "admin", id.SteamID, "steamid", req.SteamID, "days", req.Days)
|
||||||
|
name := s.st.LatestName(r.Context(), req.SteamID)
|
||||||
|
if name == "" {
|
||||||
|
name = req.SteamID
|
||||||
|
}
|
||||||
|
length := "with no expiry"
|
||||||
|
if expires != nil {
|
||||||
|
length = "for " + strconv.Itoa(req.Days) + " days"
|
||||||
|
}
|
||||||
|
msg := "Verified " + name + " " + length + ". " + pickedUp(settings)
|
||||||
|
if !settings.Active() {
|
||||||
|
msg = "Saved the override for " + name + ", but verification is off, so it does nothing until it's turned on."
|
||||||
|
}
|
||||||
|
done(w, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) removeOverride(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||||
|
steamid := r.PathValue("steamid")
|
||||||
|
if !steam.ValidID(steamid) {
|
||||||
|
fail(w, http.StatusBadRequest, "That isn't a SteamID64.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canOverride(id, steamid) {
|
||||||
|
fail(w, http.StatusForbidden, "You need "+verifyOverridePerm+" to remove overrides.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, ok := s.verifyReady(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removed, err := s.st.RemoveOverride(r.Context(), steamid)
|
||||||
|
if err != nil {
|
||||||
|
internal(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !removed {
|
||||||
|
fail(w, http.StatusNotFound, "That player has no override.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("verify override removed", "admin", id.SteamID, "steamid", steamid)
|
||||||
|
done(w, "Removed the override. "+pickedUp(settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// isClanID is a Steam group's 64-bit ID: universe Public, account type Clan, a non-zero account.
|
||||||
|
func isClanID(n uint64) bool {
|
||||||
|
return n>>56 == 1 && (n>>52)&0xF == 7 && n&0xFFFFFFFF != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveVerifySettings turns GroupVerify on or off or changes its group: {enabled?, groupId?}. Saved
|
||||||
|
// to gv_settings, then, when the plugin is loaded, sent as its own command so it applies at once.
|
||||||
|
func (s *Server) saveVerifySettings(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||||
|
var req struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
GroupID *string `json:"groupId"`
|
||||||
|
}
|
||||||
|
if err := readJSON(r, &req); err != nil {
|
||||||
|
fail(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !id.Has("@css/root") {
|
||||||
|
fail(w, http.StatusForbidden, "Only Root can change GroupVerify's settings.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Enabled == nil && req.GroupID == nil {
|
||||||
|
fail(w, http.StatusBadRequest, "Nothing to change.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := s.verifyReady(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type change struct{ name, value, cmd string }
|
||||||
|
var changes []change
|
||||||
|
if req.GroupID != nil {
|
||||||
|
g := strings.TrimSpace(*req.GroupID)
|
||||||
|
if g == "" {
|
||||||
|
g = "0"
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseUint(g, 10, 64)
|
||||||
|
if err != nil || (n != 0 && !isClanID(n)) {
|
||||||
|
fail(w, http.StatusBadRequest, "That isn't a Steam group ID64 (103582791…). 0 or blank clears it.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g = strconv.FormatUint(n, 10)
|
||||||
|
changes = append(changes, change{"group_id64", g, "css_gv_group " + g})
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
c := change{"enabled", "0", "css_gv_disable"}
|
||||||
|
if *req.Enabled {
|
||||||
|
c = change{"enabled", "1", "css_gv_enable"}
|
||||||
|
}
|
||||||
|
changes = append(changes, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
for _, c := range changes {
|
||||||
|
if err := s.st.SetVerifySetting(ctx, c.name, c.value); err != nil {
|
||||||
|
internal(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("verify setting", "admin", id.SteamID, "name", c.name, "value", c.value)
|
||||||
|
}
|
||||||
|
s.verify.invalidate()
|
||||||
|
|
||||||
|
applied := false
|
||||||
|
if state, _ := s.poll.Plugin(groupVerifyPlugin); state == live.PluginLoaded {
|
||||||
|
applied = true
|
||||||
|
for _, c := range changes {
|
||||||
|
if out, err := s.rc.Exec(c.cmd); err != nil || consoleRejected(out) != "" {
|
||||||
|
applied = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings, err := s.st.VerifySettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
internal(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state := "Verification is on."
|
||||||
|
switch {
|
||||||
|
case !settings.Enabled:
|
||||||
|
state = "Verification is off."
|
||||||
|
case settings.GroupID == "":
|
||||||
|
state = "Verification is off until a Steam group is set."
|
||||||
|
}
|
||||||
|
if applied {
|
||||||
|
done(w, "Saved and applied. "+state)
|
||||||
|
} else {
|
||||||
|
done(w, "Saved. "+state+" "+pickedUp(settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
222
internal/web/verify_db_test.go
Normal file
222
internal/web/verify_db_test.go
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "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/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestVerifyDB runs the GroupVerify endpoints against a real MySQL holding SimpleAdmin 1.8.2b's
|
||||||
|
// migrations and GroupVerify's Migrations/001_CreateTables.sql. It empties those tables first, so
|
||||||
|
// only point it at a scratch database:
|
||||||
|
//
|
||||||
|
// SAW_TEST_DSN='root:test@tcp(127.0.0.1:33061)/sa?parseTime=true&loc=UTC' go test ./internal/web -run VerifyDB
|
||||||
|
func TestVerifyDB(t *testing.T) {
|
||||||
|
dsn := os.Getenv("SAW_TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("SAW_TEST_DSN not set")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
exec := func(q string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.Exec(q, args...); err != nil {
|
||||||
|
t.Fatalf("%s: %v", q, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
owner = "76561198000000001"
|
||||||
|
mod = "76561198000000002"
|
||||||
|
charlie = "76561198000000003"
|
||||||
|
member = "76561198000000004"
|
||||||
|
expired = "76561198000000005"
|
||||||
|
group = "103582791429521412"
|
||||||
|
)
|
||||||
|
for _, table := range []string{"gv_verified", "gv_settings", "sa_admins_flags", "sa_admins", "sa_groups_flags", "sa_groups_servers", "sa_groups", "sa_players_ips"} {
|
||||||
|
exec("DELETE FROM " + table)
|
||||||
|
}
|
||||||
|
exec("INSERT INTO sa_groups (id, name, immunity) VALUES (1, '#rank/owner', 9999), (2, '#rank/mod', 30)")
|
||||||
|
exec("INSERT INTO sa_groups_servers (group_id, server_id) VALUES (1, NULL), (2, NULL)")
|
||||||
|
exec("INSERT INTO sa_groups_flags (group_id, flag) VALUES (1, '@css/root'), (2, '@css/generic')")
|
||||||
|
exec("INSERT INTO sa_admins (id, player_name, player_steamid, immunity, server_id) VALUES (1, 'Owner', ?, 0, NULL), (2, 'Mod', ?, 0, NULL)", owner, mod)
|
||||||
|
exec("INSERT INTO sa_admins_flags (admin_id, flag) VALUES (1, '#rank/owner'), (2, '#rank/mod')")
|
||||||
|
exec("INSERT INTO sa_players_ips (steamid, name, address, used_at) VALUES (?, 'Charlie', INET_ATON('10.0.0.3'), NOW())", charlie)
|
||||||
|
|
||||||
|
st := store.New(db, time.UTC, 0, 1)
|
||||||
|
rc := rcon.New("127.0.0.1:1", "unused") // nothing listens: the plugin's state stays unknown
|
||||||
|
srv, err := New(Config{BaseURL: "http://panel.test", SessionSecret: []byte("secret")}, st, rc, live.NewPoller(rc, time.Hour), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := srv.Handler()
|
||||||
|
|
||||||
|
call := func(method, path, as string, body any) (int, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
var rd *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
rd = bytes.NewReader(b)
|
||||||
|
} else {
|
||||||
|
rd = bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, rd)
|
||||||
|
req.Header.Set(csrfHeader, csrfValue)
|
||||||
|
if as != "" {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.setSession(rec, as)
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||||
|
return rec.Code, out
|
||||||
|
}
|
||||||
|
expect := func(want, got int, out map[string]any, what string) {
|
||||||
|
t.Helper()
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("%s: status %d, want %d (%v)", what, got, want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verified := func(path, as string) bool {
|
||||||
|
t.Helper()
|
||||||
|
code, out := call("GET", path, as, nil)
|
||||||
|
expect(200, code, out, path)
|
||||||
|
if v, ok := out["verify"].(map[string]any); ok {
|
||||||
|
return v["verified"] == true
|
||||||
|
}
|
||||||
|
return out["verified"] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tables are found, and GroupVerify's defaults apply until something is set.
|
||||||
|
if _, out := call("GET", "/api/me", mod, nil); out["verify"] != true {
|
||||||
|
t.Fatalf("me.verify = %v, want true", out["verify"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings: Root only, and only real group IDs.
|
||||||
|
code, out := call("POST", "/api/admin/verify/settings", mod, map[string]any{"groupId": group})
|
||||||
|
expect(403, code, out, "mod sets the group")
|
||||||
|
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"groupId": "76561198000000003"})
|
||||||
|
expect(400, code, out, "a player's ID as the group")
|
||||||
|
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"groupId": group})
|
||||||
|
expect(200, code, out, "owner sets the group")
|
||||||
|
|
||||||
|
// Overrides need @zio/verify.
|
||||||
|
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 7})
|
||||||
|
expect(403, code, out, "mod without @zio/verify")
|
||||||
|
exec("INSERT INTO sa_groups_flags (group_id, flag) VALUES (2, '@zio/verify')")
|
||||||
|
srv.staff.invalidate()
|
||||||
|
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": mod, "days": 7})
|
||||||
|
expect(403, code, out, "mod overrides themselves")
|
||||||
|
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 7})
|
||||||
|
expect(200, code, out, "mod gives an override")
|
||||||
|
|
||||||
|
// A group member, and an override that has run out.
|
||||||
|
exec("INSERT INTO gv_verified (steamid64, source, `override`, added_by, created_at, updated_at) VALUES (?, 'group', 0, 'steam-group', NOW(), NOW())", member)
|
||||||
|
exec("INSERT INTO gv_verified (steamid64, source, `override`, expires_at, added_by, created_at, updated_at) VALUES (?, 'manual', 1, ?, 'x', NOW(), NOW())",
|
||||||
|
expired, time.Now().UTC().Add(-time.Hour).Format(time.DateTime))
|
||||||
|
|
||||||
|
code, out = call("GET", "/api/admin/verify", mod, nil)
|
||||||
|
expect(200, code, out, "verify page")
|
||||||
|
status := out["status"].(map[string]any)
|
||||||
|
if status["active"] != true || status["groupMembers"] != float64(1) || status["overrides"] != float64(1) || status["plugin"] != "unknown" {
|
||||||
|
t.Fatalf("status %v", status)
|
||||||
|
}
|
||||||
|
items := out["list"].(map[string]any)["items"].([]any)
|
||||||
|
if len(items) != 1 || items[0].(map[string]any)["steamid"] != charlie || items[0].(map[string]any)["name"] != "Charlie" {
|
||||||
|
t.Fatalf("overrides %v", items)
|
||||||
|
}
|
||||||
|
ends, _ := time.Parse(time.RFC3339, items[0].(map[string]any)["expires"].(string))
|
||||||
|
if d := time.Until(ends); d < 7*24*time.Hour-time.Minute || d > 7*24*time.Hour+time.Minute {
|
||||||
|
t.Fatalf("override ends in %v, want 7 days", d)
|
||||||
|
}
|
||||||
|
code, out = call("GET", "/api/admin/verify?view=group&q="+member, mod, nil)
|
||||||
|
expect(200, code, out, "group view")
|
||||||
|
if items := out["list"].(map[string]any)["items"].([]any); len(items) != 1 || items[0].(map[string]any)["steamid"] != member {
|
||||||
|
t.Fatalf("group members %v", items)
|
||||||
|
}
|
||||||
|
code, out = call("GET", "/api/admin/verify?q=Charl", mod, nil)
|
||||||
|
expect(200, code, out, "search by name")
|
||||||
|
if items := out["list"].(map[string]any)["items"].([]any); len(items) != 1 {
|
||||||
|
t.Fatalf("name search %v", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
for sid, want := range map[string]bool{charlie: true, member: true, expired: false, mod: false} {
|
||||||
|
if got := verified("/api/admin/players/"+sid, mod); got != want {
|
||||||
|
t.Errorf("staff view of %s: verified %v, want %v", sid, got, want)
|
||||||
|
}
|
||||||
|
if got := verified("/api/public/players/"+sid, ""); got != want {
|
||||||
|
t.Errorf("public view of %s: verified %v, want %v", sid, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing it, twice.
|
||||||
|
code, out = call("DELETE", "/api/admin/verify/overrides/"+charlie, mod, nil)
|
||||||
|
expect(200, code, out, "remove override")
|
||||||
|
code, out = call("DELETE", "/api/admin/verify/overrides/"+charlie, mod, nil)
|
||||||
|
expect(404, code, out, "remove it again")
|
||||||
|
if verified("/api/public/players/"+charlie, "") {
|
||||||
|
t.Error("still verified after removing the override")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turned off, nobody is verified, publicly or for staff.
|
||||||
|
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"enabled": false})
|
||||||
|
expect(200, code, out, "turn off")
|
||||||
|
if !strings.Contains(out["message"].(string), "off") {
|
||||||
|
t.Errorf("turn-off message %q", out["message"])
|
||||||
|
}
|
||||||
|
if verified("/api/public/players/"+member, "") || verified("/api/admin/players/"+member, mod) {
|
||||||
|
t.Error("verified while GroupVerify is off")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override times are written in GroupVerify's timezone.
|
||||||
|
exec("UPDATE gv_settings SET value = '1' WHERE name = 'enabled'")
|
||||||
|
exec("INSERT INTO gv_settings (name, value) VALUES ('timezone', 'Asia/Tokyo') ON DUPLICATE KEY UPDATE value = 'Asia/Tokyo'")
|
||||||
|
srv.verify.invalidate()
|
||||||
|
code, out = call("POST", "/api/admin/verify/overrides", mod, map[string]any{"steamid": charlie, "days": 1})
|
||||||
|
expect(200, code, out, "override in Tokyo time")
|
||||||
|
var raw string
|
||||||
|
if err := db.QueryRow("SELECT DATE_FORMAT(expires_at, '%Y-%m-%d %H:%i') FROM gv_verified WHERE steamid64 = ? AND source = 'manual'", charlie).Scan(&raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tokyo, _ := time.LoadLocation("Asia/Tokyo")
|
||||||
|
if want := time.Now().Add(24 * time.Hour).In(tokyo).Format("2006-01-02 15:04"); raw != want && raw != time.Now().Add(24*time.Hour+time.Minute).In(tokyo).Format("2006-01-02 15:04") {
|
||||||
|
t.Errorf("expires_at %s, want Tokyo wall time %s", raw, want)
|
||||||
|
}
|
||||||
|
if !verified("/api/public/players/"+charlie, "") {
|
||||||
|
t.Error("override in Tokyo time isn't counted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without GroupVerify's tables, there's no sign of it anywhere.
|
||||||
|
exec("RENAME TABLE gv_settings TO gv_settings_hidden")
|
||||||
|
defer db.Exec("RENAME TABLE gv_settings_hidden TO gv_settings")
|
||||||
|
srv.verify.invalidate()
|
||||||
|
if _, out := call("GET", "/api/me", mod, nil); out["verify"] != nil {
|
||||||
|
t.Errorf("me.verify = %v without the tables", out["verify"])
|
||||||
|
}
|
||||||
|
code, out = call("GET", "/api/admin/verify", mod, nil)
|
||||||
|
expect(404, code, out, "verify page without the tables")
|
||||||
|
if _, out := call("GET", "/api/admin/players/"+charlie, mod, nil); out["verify"] != nil {
|
||||||
|
t.Errorf("player drawer has verify %v without the tables", out["verify"])
|
||||||
|
}
|
||||||
|
if verified("/api/public/players/"+charlie, "") {
|
||||||
|
t.Error("publicly verified without the tables")
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/web/verify_test.go
Normal file
43
internal/web/verify_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zio.sh/cs2/simpleadmin-web/internal/live"
|
||||||
|
"git.zio.sh/cs2/simpleadmin-web/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsClanID(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
id uint64
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{103582791429521412, true}, // a group
|
||||||
|
{103582791429521408, false}, // account 0
|
||||||
|
{76561198012345678, false}, // a player
|
||||||
|
{0, false},
|
||||||
|
} {
|
||||||
|
if got := isClanID(c.id); got != c.want {
|
||||||
|
t.Errorf("isClanID(%d) = %v, want %v", c.id, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyShown(t *testing.T) {
|
||||||
|
on := store.VerifySettings{Enabled: true, GroupID: "103582791429521412"}
|
||||||
|
for _, c := range []struct {
|
||||||
|
info verifyInfo
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{verifyInfo{Tables: true, Settings: on, Plugin: live.PluginLoaded}, true},
|
||||||
|
{verifyInfo{Tables: true, Settings: on, Plugin: live.PluginUnknown}, true},
|
||||||
|
{verifyInfo{Tables: true, Settings: on, Plugin: live.PluginMissing}, false},
|
||||||
|
{verifyInfo{Tables: false, Settings: on, Plugin: live.PluginLoaded}, false},
|
||||||
|
{verifyInfo{Tables: true, Settings: store.VerifySettings{Enabled: false, GroupID: on.GroupID}, Plugin: live.PluginLoaded}, false},
|
||||||
|
{verifyInfo{Tables: true, Settings: store.VerifySettings{Enabled: true}, Plugin: live.PluginLoaded}, false},
|
||||||
|
} {
|
||||||
|
if got := c.info.Shown(); got != c.want {
|
||||||
|
t.Errorf("%+v: Shown() = %v, want %v", c.info, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue