Busiest hours and the heatmap count the different players on T or CT in each hour, open segments included, instead of averaging player-hours, so one player for part of an hour is 1, not 0.38. Their descriptions end in the span covered (Last 30 days, or Since <date>). Side-by-side chart panels match heights, and Players per day notes its busiest day. Dropped the page descriptions on Bans, Gags & mutes, Staff and Playtime, and the Verified badges on the staff Overview scoreboard and drawer header; the Verification page and the drawer's Verification section still show it. The GroupVerify DB test now checks the staff drawer, since the public site says nothing about verification.
391 lines
12 KiB
Go
391 lines
12 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
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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))
|
|
}
|
|
}
|