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.
159 lines
4.3 KiB
Go
159 lines
4.3 KiB
Go
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)
|
|
}
|