package web import ( "context" "log/slog" "net/http" "slices" "strings" "sync" "time" "git.zio.sh/cs2/simpleadmin-web/internal/live" "git.zio.sh/cs2/simpleadmin-web/internal/playtime" "git.zio.sh/cs2/simpleadmin-web/internal/steam" ) // PlaytimeHistory (plugins/PlaytimeHistory in the cs2 repo) records who played when, in its own // database (SAW_PT_DSN). Without that database, or its tables, nothing about playtime shows // anywhere. With tables but nothing recorded yet, only staff see that it's there. // // Anyone sees playtime, stats and maps. Staff with playtimeStaffPerm also see who's on with their // location, staff activity, disconnect reasons, countries, and a player's names, sessions and ping: // on the staff panel's Playtime page, and on the public site when they're signed in. const playtimePlugin = "PlaytimeHistory" // playtimeStaffPerm matches PlaytimeHistory's own default for looking up other players. const playtimeStaffPerm = "@css/generic" // Playtime states for /api/me. const ( ptOff = "" // not configured, the tables are missing, or the database is down ptEmpty = "empty" // the tables are there, but nothing's recorded yet ptOn = "on" ) type ptCache struct { pt *playtime.Store ttl time.Duration mu sync.Mutex state string fetched time.Time logged string // the last state or error logged, so a down database logs once overviews map[string]ptOverview } type ptOverview struct { at time.Time view playtime.Overview } func (c *ptCache) get(ctx context.Context) string { if c == nil { return ptOff } c.mu.Lock() defer c.mu.Unlock() if !c.fetched.IsZero() && time.Since(c.fetched) < c.ttl { return c.state } tables, data, err := c.pt.State(ctx) state, note := ptOff, "" switch { case err != nil: note = "PlaytimeHistory's database isn't answering; playtime is hidden: " + err.Error() case !tables: note = "PlaytimeHistory's tables aren't in its database; playtime is hidden" case !data: state, note = ptEmpty, "PlaytimeHistory hasn't recorded anything yet" default: state, note = ptOn, "PlaytimeHistory found" } if note != c.logged { slog.Info("playtime", "state", state, "note", note) c.logged = note } c.state, c.fetched = state, time.Now() return state } // overview caches the public page per time zone for a minute: it's the heaviest query here. func (c *ptCache) overview(ctx context.Context, loc *time.Location) (playtime.Overview, error) { c.mu.Lock() if o, ok := c.overviews[loc.String()]; ok && time.Since(o.at) < c.ttl { c.mu.Unlock() return o.view, nil } c.mu.Unlock() v, err := c.pt.Overview(ctx, time.Now(), loc) if err != nil { return v, err } c.mu.Lock() if c.overviews == nil || len(c.overviews) > 64 { c.overviews = map[string]ptOverview{} } c.overviews[loc.String()] = ptOverview{at: time.Now(), view: v} c.mu.Unlock() return v, nil } func (s *Server) ptState(ctx context.Context) string { return s.ptc.get(ctx) } // ptStaff is whether this viewer may see the staff-only playtime figures. func (s *Server) ptStaff(ctx context.Context, id *Identity) bool { return id != nil && id.Has(playtimeStaffPerm) && s.ptState(ctx) != ptOff } // viewerZone is the browser's time zone (?tz=Europe/London), for days and hours of the day. func viewerZone(r *http.Request) *time.Location { name := r.URL.Query().Get("tz") if name == "" || len(name) > 64 || strings.Contains(name, "..") { return time.UTC } loc, err := time.LoadLocation(name) if err != nil { return time.UTC } return loc } // periodSince reads ?period=7d|30d|all; all is nil. func periodSince(r *http.Request, allowAll bool) (string, *time.Time) { return periodParam(r.URL.Query().Get("period"), allowAll) } // periodParam turns 7d, 30d or all into when it starts; all is nil. Anything else is 7d. func periodParam(p string, allowAll bool) (string, *time.Time) { now := time.Now() switch { case p == "30d": t := now.Add(-30 * 24 * time.Hour) return p, &t case p == "all" && allowAll: return p, nil default: t := now.Add(-7 * 24 * time.Hour) return "7d", &t } } type leaderView struct { playtime.Leader Rank *rankView `json:"rank,omitempty"` } // publicPlaytime is the public Playtime page: ?period= for the leaderboard, ?tz= for the charts. func (s *Server) publicPlaytime(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if s.ptState(ctx) != ptOn { fail(w, http.StatusNotFound, "Playtime isn't being recorded.") return } o, err := s.ptc.overview(ctx, viewerZone(r)) if err != nil { internal(w, r, err) return } period, since := periodSince(r, true) leaders, err := s.pt.Leaders(ctx, since, 10) if err != nil { internal(w, r, err) return } x, err := s.staffIdx(ctx) if err != nil { internal(w, r, err) return } lv := make([]leaderView, 0, len(leaders)) for _, l := range leaders { lv = append(lv, leaderView{Leader: l, Rank: s.rankOf(x, l.SteamID)}) } resp := map[string]any{"overview": o, "period": period, "leaders": lv} // Records (?records=7d|30d|all) need pt_events, from PlaytimeHistory 0.2.0. if s.pt.HasEvents() { rp, rsince := periodParam(r.URL.Query().Get("records"), true) rec, err := s.pt.Records(ctx, rsince, 5) if err != nil { internal(w, r, err) return } resp["records"], resp["recordsPeriod"] = rec, rp } writeJSON(w, http.StatusOK, resp) } type onlineView struct { SteamID string `json:"steamid"` Name string `json:"name"` Rank *rankView `json:"rank,omitempty"` Team int `json:"team"` Ping int `json:"ping"` playtime.Online } type activityView struct { SteamID string `json:"steamid"` Name string `json:"name"` Rank *rankView `json:"rank,omitempty"` Online bool `json:"online"` playtime.Activity } // adminPlaytime is the staff-only half of the Playtime page (?period=7d|30d for staff activity). // The public half comes from publicPlaytime. func (s *Server) adminPlaytime(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { ctx := r.Context() if !id.Has(playtimeStaffPerm) { fail(w, http.StatusForbidden, "You need "+playtimeStaffPerm+" to see staff playtime.") return } state := s.ptState(ctx) if state == ptOff { fail(w, http.StatusNotFound, "The panel can't reach PlaytimeHistory's database, or its tables aren't there.") return } now := time.Now() month := now.Add(-30 * 24 * time.Hour) st := s.poll.Current() // Who's on comes from the live status, which leaves out admins hidden with css_hide. humans := st.Humans() ids := make([]uint64, 0, len(humans)) for _, p := range humans { if n, err := playtime.ParseID(p.SteamID); err == nil { ids = append(ids, n) } } open, err := s.pt.Online(ctx, ids) if err != nil { internal(w, r, err) return } online := make([]onlineView, 0, len(humans)) for _, p := range humans { online = append(online, onlineView{SteamID: p.SteamID, Name: p.Name, Rank: s.rankOf(x, p.SteamID), Team: p.Team, Ping: p.Ping, Online: open[p.SteamID]}) } // Longest on first; anyone without a recorded session last. slices.SortStableFunc(online, func(a, b onlineView) int { switch { case a.Since == nil || b.Since == nil: return boolInt(a.Since == nil) - boolInt(b.Since == nil) default: return a.Since.Compare(*b.Since) } }) period, since := periodSince(r, false) var staffIDs []uint64 for _, a := range x.admins { if n, err := playtime.ParseID(a.SteamID); err == nil && s.isStaff(x, a.SteamID) { staffIDs = append(staffIDs, n) } } act, err := s.pt.Activity(ctx, staffIDs, *since) if err != nil { internal(w, r, err) return } staffList := []activityView{} for _, a := range x.admins { if !s.isStaff(x, a.SteamID) { continue } _, on := st.Find(a.SteamID) staffList = append(staffList, activityView{SteamID: a.SteamID, Name: a.Name, Rank: s.rankOf(x, a.SteamID), Online: on, Activity: act[a.SteamID]}) } slices.SortStableFunc(staffList, func(a, b activityView) int { if a.Hours != b.Hours { if a.Hours > b.Hours { return -1 } return 1 } return x.groupIndex(x.primaryGroup(x.bySteam[a.SteamID])) - x.groupIndex(x.primaryGroup(x.bySteam[b.SteamID])) }) reasons, err := s.pt.Reasons(ctx, month) if err != nil { internal(w, r, err) return } countries, err := s.pt.Countries(ctx) if err != nil { internal(w, r, err) return } ends, err := s.pt.EndKinds(ctx, month) if err != nil { internal(w, r, err) return } resp := map[string]any{ "state": state, "period": period, "online": online, "staff": staffList, "reasons": reasons, "countries": countries, "endKinds": map[string]int{ "open": ends[playtime.EndOpen], "disconnect": ends[playtime.EndDisconnect], "mapChange": ends[playtime.EndMapChange], "unload": ends[playtime.EndUnload], "stale": ends[playtime.EndStale], }, } // The recorder's own status can carry database errors, so it's for Root, as css_pt_status is. if id.Has("@css/root") { resp["recorder"] = s.ptRecorder() } writeJSON(w, http.StatusOK, resp) } func boolInt(b bool) int { if b { return 1 } return 0 } // ptRecorder is whether PlaytimeHistory is loaded and, if it is, what css_pt_status says. func (s *Server) ptRecorder() map[string]any { state, version := s.poll.Plugin(playtimePlugin) out := map[string]any{"plugin": state, "version": version, "lines": []string{}} if state != live.PluginLoaded { return out } res, err := s.rc.Exec("css_pt_status") if err != nil { return out } var lines []string for _, l := range strings.Split(res, "\n") { if l = strings.TrimSpace(l); strings.HasPrefix(l, "[Playtime] ") { lines = append(lines, strings.TrimPrefix(l, "[Playtime] ")) } } if lines != nil { out["lines"] = lines } return out } // playerPlaytime is the Playtime part of a player's drawer, or nil when playtime isn't shown. // Errors are logged and leave it out: playtime is extra, never worth failing the drawer. func (s *Server) playerPlaytime(r *http.Request, steamid string, staff bool) map[string]any { ctx := r.Context() state := s.ptState(ctx) if state == ptOff || (state == ptEmpty && !staff) || !steam.ValidID(steamid) { return nil } n, err := playtime.ParseID(steamid) if err != nil { return nil } now := time.Now() p, err := s.pt.Player(ctx, n, now, viewerZone(r)) if err != nil { slog.Warn("playtime for a player", "steamid", steamid, "err", err) return nil } out := map[string]any{"player": p} if staff { extra, err := s.pt.PlayerStaff(ctx, n) if err != nil { slog.Warn("staff playtime for a player", "steamid", steamid, "err", err) return out } out["staff"] = extra } return out }