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) }