Root sets GroupVerify's clan_tag (the scoreboard tag CS2-Tags gives verified players without a rank) next to the Steam group. Validated like the plugin (12 UTF-16 units, no quotes, ; or \), saved to gv_settings and sent as css_gv_clantag when the plugin is loaded. Only changed fields are sent. GroupVerify 0.3.0 checks players with the Steam Web API as they join, so the group list is now "Confirmed group members" with first/last confirmed dates.
423 lines
13 KiB
Go
423 lines
13 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf16"
|
|
|
|
"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"`
|
|
ClanTag string `json:"clanTag"`
|
|
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(),
|
|
ClanTag: info.Settings.ClanTag,
|
|
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
|
|
}
|
|
|
|
// clanTagMax matches GroupVerify (and Steam's own group tags). It counts UTF-16 units, as C# does.
|
|
const clanTagMax = 12
|
|
|
|
// validClanTag is GroupVerify's rule, plus no backslashes, which the console would mangle.
|
|
func validClanTag(tag string) bool {
|
|
if len(utf16.Encode([]rune(tag))) > clanTagMax {
|
|
return false
|
|
}
|
|
for _, r := range tag {
|
|
if unicode.IsControl(r) || r == '"' || r == ';' || r == '\\' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// saveVerifySettings turns GroupVerify on or off, or changes its group or clan tag:
|
|
// {enabled?, groupId?, clanTag?}. 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"`
|
|
ClanTag *string `json:"clanTag"`
|
|
}
|
|
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 && req.ClanTag == 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.ClanTag != nil {
|
|
tag := strings.TrimSpace(*req.ClanTag)
|
|
if !validClanTag(tag) {
|
|
fail(w, http.StatusBadRequest, fmt.Sprintf("A clan tag is at most %d characters, without quotes, ; or \\.", clanTagMax))
|
|
return
|
|
}
|
|
cmd := `css_gv_clantag "` + tag + `"`
|
|
if tag == "" {
|
|
cmd = "css_gv_clantag -"
|
|
}
|
|
changes = append(changes, change{"clan_tag", tag, cmd})
|
|
}
|
|
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))
|
|
}
|
|
}
|