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.
389 lines
12 KiB
Go
389 lines
12 KiB
Go
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))
|
|
}
|
|
}
|