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:
Astra 2026-09-28 11:36:05 +01:00
parent 6324fc7f81
commit 861692c534
6 changed files with 196 additions and 21 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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("")}

View file

@ -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 ---------- */