diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js
index 6befcc7..320a3f8 100644
--- a/internal/web/assets/admin.js
+++ b/internal/web/assets/admin.js
@@ -282,7 +282,7 @@ async function staff() {
const supporters = d.groups.filter((x) => x.supporter);
const memberRow = (p, groupName) => `
- ${initial(p.name)} ${esc(p.name)} ${esc(p.steamid)}
+ ${avatar(p.steamid, p.name)}
${esc(p.name)} ${esc(p.steamid)}
${p.online ? 'Online ' : p.lastSeen ? `Seen ${fmtAgo(p.lastSeen)} ` : 'Never joined '}
${p.ends ? `Until ${fmtDate(p.ends)}` : "No expiry"}
${manage && p.steamid !== d.me ? `Change rank ` : ""}
@@ -832,7 +832,7 @@ document.addEventListener("keydown", (e) => {
document.querySelectorAll(".site-name").forEach((el) => {
el.innerHTML = i > 0 ? `${esc(name.slice(0, i))}. ${esc(name.slice(i + 1))}` : esc(name);
});
- $("#me").innerHTML = `${initial(state.me.name)}
+ $("#me").innerHTML = `${avatar(state.me.steamid, state.me.name)}
${esc(state.me.name)} ${esc(state.me.rank?.label || "Staff")}
Sign out `;
// Fill the sidebar counts once, even when starting on another page.
diff --git a/internal/web/assets/common.js b/internal/web/assets/common.js
index 130ee86..36b044f 100644
--- a/internal/web/assets/common.js
+++ b/internal/web/assets/common.js
@@ -82,6 +82,17 @@ function rankTag(rank) {
return ` ${esc(rank.label)} `;
}
+// A player's Steam avatar over their initial. /avatar only serves staff; for anyone else, or if
+// Steam has none, the image fails and the initial shows (see the error listener below).
+function avatar(sid, name, cls = "") {
+ return `${initial(name)} `;
+}
+
+// Error events don't bubble, so catch them on the way down; the CSP rules out inline onerror.
+document.addEventListener("error", (e) => {
+ if (e.target instanceof HTMLImageElement && e.target.parentElement?.classList.contains("avatar")) e.target.remove();
+}, true);
+
function initial(name) { return esc(String(name || "").replace(/[^a-z0-9]/gi, "").charAt(0).toUpperCase() || "?"); }
// Time served against the full term: the bar under each length.
diff --git a/internal/web/assets/public.js b/internal/web/assets/public.js
index dd0adf6..bb6562b 100644
--- a/internal/web/assets/public.js
+++ b/internal/web/assets/public.js
@@ -161,7 +161,7 @@ async function staff() {
${g.members.map((p) => `
- ${initial(p.name)}
+ ${avatar(p.steamid, p.name)}
${esc(p.name)} ${p.online ? ' On the server' : "Steam profile"}
`).join("")}
diff --git a/internal/web/assets/styles.css b/internal/web/assets/styles.css
index f8b40e2..f8b9829 100644
--- a/internal/web/assets/styles.css
+++ b/internal/web/assets/styles.css
@@ -167,6 +167,8 @@ a { color: inherit; }
}
.avatar.lg { width: 56px; height: 56px; font-size: 24px; }
+.avatar { position: relative; overflow: hidden; }
+.avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* ---------- Main ---------- */
diff --git a/internal/web/avatar.go b/internal/web/avatar.go
new file mode 100644
index 0000000..34822e1
--- /dev/null
+++ b/internal/web/avatar.go
@@ -0,0 +1,159 @@
+package web
+
+import (
+ "context"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "git.zio.sh/cs2/simpleadmin-web/internal/steam"
+)
+
+// avatarCache fetches staff members' Steam avatars and keeps them in memory. Pages load them from
+// the panel rather than from Steam (the CSP only allows images from 'self', and visitors' browsers
+// never contact Steam), and Steam sees about one request per member a day. The profile XML needs no
+// Web API key.
+type avatarCache struct {
+ http *http.Client
+ ttl time.Duration // how long a fetched avatar is kept
+ missTTL time.Duration // how long a failed fetch is remembered before trying again
+
+ mu sync.Mutex
+ items map[string]*avatarEntry
+}
+
+type avatarEntry struct {
+ done chan struct{} // closed once the fetch finishes
+ img []byte // nil if the fetch failed
+ ctype string
+ fetched time.Time
+}
+
+// Avatars larger than this are refused; Steam's medium avatars are a few KB.
+const maxAvatarBytes = 512 << 10
+
+func newAvatarCache(client *http.Client) *avatarCache {
+ return &avatarCache{http: client, ttl: 24 * time.Hour, missTTL: time.Hour, items: map[string]*avatarEntry{}}
+}
+
+// get returns the avatar for steamid, fetching it if it isn't cached or has gone stale. Concurrent
+// requests for the same player share one fetch.
+func (c *avatarCache) get(ctx context.Context, steamid string) ([]byte, string, bool) {
+ c.mu.Lock()
+ e := c.items[steamid]
+ if e != nil {
+ select {
+ case <-e.done:
+ ttl := c.ttl
+ if e.img == nil {
+ ttl = c.missTTL
+ }
+ if time.Since(e.fetched) > ttl {
+ e = nil
+ }
+ default: // another request is fetching it
+ }
+ }
+ if e == nil {
+ e = &avatarEntry{done: make(chan struct{})}
+ c.items[steamid] = e
+ c.mu.Unlock()
+ // Detached from the request, so a client giving up doesn't fail the fetch for everyone.
+ fctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ e.img, e.ctype, _ = c.fetch(fctx, steamid)
+ cancel()
+ e.fetched = time.Now()
+ close(e.done)
+ } else {
+ c.mu.Unlock()
+ }
+
+ select {
+ case <-e.done:
+ return e.img, e.ctype, e.img != nil
+ case <-ctx.Done():
+ return nil, "", false
+ }
+}
+
+func (c *avatarCache) fetch(ctx context.Context, steamid string) ([]byte, string, error) {
+ var profile struct {
+ Avatar string `xml:"avatarMedium"`
+ }
+ body, _, err := c.download(ctx, "https://steamcommunity.com/profiles/"+steamid+"/?xml=1", 1<<20)
+ if err != nil {
+ return nil, "", err
+ }
+ if err := xml.Unmarshal(body, &profile); err != nil {
+ return nil, "", err
+ }
+ u, err := url.Parse(strings.TrimSpace(profile.Avatar))
+ if err != nil || u.Scheme != "https" || !strings.HasSuffix(u.Hostname(), ".steamstatic.com") {
+ return nil, "", fmt.Errorf("unexpected avatar URL %q", profile.Avatar)
+ }
+ img, ctype, err := c.download(ctx, u.String(), maxAvatarBytes)
+ if err != nil {
+ return nil, "", err
+ }
+ if !strings.HasPrefix(ctype, "image/") {
+ return nil, "", fmt.Errorf("avatar isn't an image (%s)", ctype)
+ }
+ return img, ctype, nil
+}
+
+func (c *avatarCache) download(ctx context.Context, url string, limit int64) ([]byte, string, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, "", fmt.Errorf("%s: %s", url, resp.Status)
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
+ if err != nil {
+ return nil, "", err
+ }
+ if int64(len(body)) > limit {
+ return nil, "", errors.New("response too large")
+ }
+ return body, resp.Header.Get("Content-Type"), nil
+}
+
+// avatar serves a staff member's Steam avatar. Anyone else gets a 404, so the panel can't be used
+// to fetch arbitrary players' avatars; pages fall back to the initial.
+func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
+ steamid := r.PathValue("steamid")
+ if !steam.ValidID(steamid) {
+ http.NotFound(w, r)
+ return
+ }
+ x, err := s.staffIdx(r.Context())
+ if err != nil {
+ internal(w, r, err)
+ return
+ }
+ if _, ok := x.bySteam[steamid]; !ok {
+ http.NotFound(w, r)
+ return
+ }
+ img, ctype, ok := s.avatars.get(r.Context(), steamid)
+ if !ok {
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", ctype)
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ _, _ = w.Write(img)
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index 42d9cad..9bdee52 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -34,15 +34,16 @@ type Config struct {
}
type Server struct {
- cfg Config
- site Site
- st *store.Store
- rc *rcon.Client
- poll *live.Poller
- sacfg *saconfig.File // nil when no config path is set
- staff *staffCache
- http *http.Client
- secure bool
+ cfg Config
+ site Site
+ st *store.Store
+ rc *rcon.Client
+ poll *live.Poller
+ sacfg *saconfig.File // nil when no config path is set
+ staff *staffCache
+ avatars *avatarCache
+ http *http.Client
+ secure bool
}
func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg *saconfig.File) (*Server, error) {
@@ -55,15 +56,16 @@ func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg
return nil, errors.New("base URL must be absolute, like https://bans.example.com")
}
return &Server{
- cfg: cfg,
- site: site,
- st: st,
- rc: rc,
- poll: poll,
- sacfg: sacfg,
- staff: &staffCache{st: st, ttl: 15 * time.Second},
- http: &http.Client{Timeout: 10 * time.Second},
- secure: u.Scheme == "https",
+ cfg: cfg,
+ site: site,
+ st: st,
+ rc: rc,
+ poll: poll,
+ sacfg: sacfg,
+ staff: &staffCache{st: st, ttl: 15 * time.Second},
+ avatars: newAvatarCache(&http.Client{Timeout: 10 * time.Second}),
+ http: &http.Client{Timeout: 10 * time.Second},
+ secure: u.Scheme == "https",
}, nil
}
@@ -88,6 +90,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/public/comms", s.publicComms)
mux.HandleFunc("GET /api/public/staff", s.publicStaff)
mux.HandleFunc("GET /api/public/players/{steamid}", s.publicPlayer)
+ mux.HandleFunc("GET /avatar/{steamid}", s.avatar)
admin := func(pattern string, h adminHandler) { mux.HandleFunc(pattern, s.requireStaff(h)) }
admin("GET /api/admin/overview", s.adminOverview)