Staff avatars from Steam, cached in memory
The public staff page, the staff panel's staff list and the signed-in badge show each member's Steam avatar over their initial. The panel fetches it from the profile XML (no Web API key), keeps it in memory for a day (failures for an hour, one shared fetch per player) and serves it from /avatar/<steamid> with a day's browser caching, so the CSP stays img-src 'self' and visitors never contact Steam. Only SimpleAdmin admins are served; anything else falls back to the initial.
This commit is contained in:
parent
6324fc7f81
commit
861692c534
6 changed files with 196 additions and 21 deletions
|
|
@ -282,7 +282,7 @@ async function staff() {
|
|||
const supporters = d.groups.filter((x) => x.supporter);
|
||||
|
||||
const memberRow = (p, groupName) => `<tr class="clickable" data-player="${esc(p.steamid)}">
|
||||
<td><div class="who"><span class="avatar">${initial(p.name)}</span><div><b>${esc(p.name)}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
|
||||
<td><div class="who">${avatar(p.steamid, p.name)}<div><b>${esc(p.name)}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
|
||||
<td class="hide-sm">${p.online ? '<span class="tag green">Online</span>' : p.lastSeen ? `<span class="muted">Seen ${fmtAgo(p.lastSeen)}</span>` : '<span class="faint">Never joined</span>'}</td>
|
||||
<td class="muted hide-sm">${p.ends ? `Until ${fmtDate(p.ends)}` : "No expiry"}</td>
|
||||
<td><div class="row-actions">${manage && p.steamid !== d.me ? `<button class="btn sm ghost" type="button" data-act="rank" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}" data-group="${esc(groupName)}">Change rank</button>` : ""}</div></td>
|
||||
|
|
@ -832,7 +832,7 @@ document.addEventListener("keydown", (e) => {
|
|||
document.querySelectorAll(".site-name").forEach((el) => {
|
||||
el.innerHTML = i > 0 ? `${esc(name.slice(0, i))}<span>.</span>${esc(name.slice(i + 1))}` : esc(name);
|
||||
});
|
||||
$("#me").innerHTML = `<span class="avatar">${initial(state.me.name)}</span>
|
||||
$("#me").innerHTML = `${avatar(state.me.steamid, state.me.name)}
|
||||
<div><b>${esc(state.me.name)}</b><small>${esc(state.me.rank?.label || "Staff")}</small></div>
|
||||
<button class="btn sm ghost" type="button" id="signout">Sign out</button>`;
|
||||
// Fill the sidebar counts once, even when starting on another page.
|
||||
|
|
|
|||
|
|
@ -82,6 +82,17 @@ function rankTag(rank) {
|
|||
return `<span class="rank" style="--c:${esc(rank.color)}"><i></i>${esc(rank.label)}</span>`;
|
||||
}
|
||||
|
||||
// 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 `<span class="avatar ${cls}">${initial(name)}<img src="/avatar/${encodeURIComponent(sid)}" alt="" loading="lazy"></span>`;
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ async function staff() {
|
|||
<ul class="crew">
|
||||
${g.members.map((p) => `<li>
|
||||
<a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">
|
||||
<span class="avatar">${initial(p.name)}</span>
|
||||
${avatar(p.steamid, p.name)}
|
||||
<span><b>${esc(p.name)}</b><small>${p.online ? '<span class="dot"></span>On the server' : "Steam profile"}</small></span>
|
||||
</a>
|
||||
</li>`).join("")}
|
||||
|
|
|
|||
|
|
@ -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 ---------- */
|
||||
|
||||
|
|
|
|||
159
internal/web/avatar.go
Normal file
159
internal/web/avatar.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ type Server struct {
|
|||
poll *live.Poller
|
||||
sacfg *saconfig.File // nil when no config path is set
|
||||
staff *staffCache
|
||||
avatars *avatarCache
|
||||
http *http.Client
|
||||
secure bool
|
||||
}
|
||||
|
|
@ -62,6 +63,7 @@ func New(cfg Config, st *store.Store, rc *rcon.Client, poll *live.Poller, sacfg
|
|||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue