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
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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue