Escape rendered suggestion content and validate URLs
This commit is contained in:
@@ -13,6 +13,25 @@ const truncate = (text, max) => {
|
||||
if (!text) return "";
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||
};
|
||||
const escapeHtml = (value) =>
|
||||
(value ?? "")
|
||||
.toString()
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
const safeUrl = (url) => {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol === "http:" || u.protocol === "https:") return u.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const cssEscapeUrl = (url) => url.replace(/['")\\]/g, "\\$&");
|
||||
|
||||
export function setAuthUI(isAuthed) {
|
||||
const main = document.querySelector("main");
|
||||
@@ -264,25 +283,30 @@ export function renderResults() {
|
||||
const row = document.createElement("tr");
|
||||
const podiumClass = rank === 1 ? "podium podium-1" : rank === 2 ? "podium podium-2" : rank === 3 ? "podium podium-3" : "";
|
||||
row.className = podiumClass;
|
||||
const safeName = escapeHtml(r.name);
|
||||
const safeAuthor = escapeHtml(r.author ?? "—");
|
||||
const safeShot = safeUrl(r.screenshotUrl);
|
||||
const safeGameUrl = safeUrl(r.gameUrl);
|
||||
const safeYoutubeUrl = safeUrl(r.youtubeUrl);
|
||||
row.innerHTML = `
|
||||
<td class="rank-cell"><span class="medal">${medal}</span></td>
|
||||
<td class="game-cell">
|
||||
${r.screenshotUrl ? `<img class="thumb clickable-thumb" src="${r.screenshotUrl}" alt="${r.name}">` : ''}
|
||||
${safeShot ? `<img class="thumb clickable-thumb" src="${safeShot}" alt="${safeName}">` : ''}
|
||||
<div class="game-meta">
|
||||
<div class="title-line">
|
||||
<span class="title-text">${r.name}</span>
|
||||
<span class="title-text">${safeName}</span>
|
||||
${renderLinkBadge(r)}
|
||||
</div>
|
||||
${buildResultMeta(r)}
|
||||
</div>
|
||||
</td>
|
||||
<td class="author-cell">${r.author ?? "—"}</td>
|
||||
<td class="author-cell">${safeAuthor || "—"}</td>
|
||||
<td>${r.average?.toFixed ? r.average.toFixed(1) : r.average}</td>
|
||||
<td>${formatVotes(r.votes)}</td>
|
||||
<td>${formatMyVote(r.myVote)}</td>
|
||||
<td>
|
||||
${r.gameUrl ? `<a class="link compact" href="${r.gameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ''}
|
||||
${r.youtubeUrl ? `<a class="link compact" href="${r.youtubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ''}
|
||||
${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ''}
|
||||
${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ''}
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
@@ -301,7 +325,7 @@ function buildResultMeta(r) {
|
||||
const players = hasPlayers
|
||||
? t("card.players", { min: r.minPlayers ?? "?", max: r.maxPlayers ?? "?" })
|
||||
: null;
|
||||
const bits = [r.genre, players].filter(Boolean);
|
||||
const bits = [r.genre ? escapeHtml(r.genre) : null, players].filter(Boolean);
|
||||
if (bits.length === 0) return "";
|
||||
return `<div class="muted small">${bits.join(" • ")}</div>`;
|
||||
}
|
||||
@@ -324,6 +348,13 @@ export function buildCard(
|
||||
const card = document.createElement("article");
|
||||
card.className = "game-card";
|
||||
const hasImage = !!s.screenshotUrl;
|
||||
const safeShot = safeUrl(s.screenshotUrl);
|
||||
const nameText = escapeHtml(s.name);
|
||||
const genreText = escapeHtml(s.genre);
|
||||
const descText = escapeHtml(s.description);
|
||||
const authorText = escapeHtml(s.author);
|
||||
const safeGameUrl = safeUrl(s.gameUrl);
|
||||
const safeYoutubeUrl = safeUrl(s.youtubeUrl);
|
||||
const linkedTitles = linkedPeerTitles(s);
|
||||
const linked = isLinked(s);
|
||||
const linkTooltip = linked
|
||||
@@ -331,11 +362,12 @@ export function buildCard(
|
||||
? t("card.linkedWith", { names: linkedTitles.join(", ") })
|
||||
: t("card.linked")
|
||||
: "";
|
||||
const linkTooltipSafe = escapeHtml(linkTooltip);
|
||||
const linkChip = linked
|
||||
? `<button class="chip icon link-chip${state.me?.isAdmin ? " link-chip-action" : ""}" data-unlink="${s.id}" type="button" title="${linkTooltip}">🔗</button>`
|
||||
? `<button class="chip icon link-chip${state.me?.isAdmin ? " link-chip-action" : ""}" data-unlink="${s.id}" type="button" title="${linkTooltipSafe}">🔗</button>`
|
||||
: "";
|
||||
const visual = hasImage
|
||||
? `<button class="card-visual" data-img="${s.screenshotUrl}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${s.screenshotUrl}')"></button>`
|
||||
const visual = hasImage && safeShot
|
||||
? `<button class="card-visual" data-img="${safeShot}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${cssEscapeUrl(safeShot)}')"></button>`
|
||||
: `<div class="card-visual"></div>`;
|
||||
const hasPlayers = s.minPlayers || s.maxPlayers;
|
||||
const players = hasPlayers
|
||||
@@ -346,36 +378,36 @@ export function buildCard(
|
||||
: "";
|
||||
const genreAndPlayers = s.genre
|
||||
? hasPlayers
|
||||
? `${s.genre} • ${players}`
|
||||
: s.genre
|
||||
? `${genreText} • ${players}`
|
||||
: genreText
|
||||
: hasPlayers
|
||||
? players
|
||||
: undefined;
|
||||
const hasExtraInfo = genreAndPlayers || s.gameUrl || s.youtubeUrl;
|
||||
const hasExtraInfo = genreAndPlayers || safeGameUrl || safeYoutubeUrl;
|
||||
card.innerHTML = `
|
||||
${visual}
|
||||
<div class="card-body">
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title" title="${s.name}">${s.name}</h3>
|
||||
<h3 class="card-title" title="${nameText}">${nameText}</h3>
|
||||
<div class="title-meta">
|
||||
${linkChip}
|
||||
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""}
|
||||
${showAuthor && s.author ? `<span class="chip">${authorText}</span>` : ""}
|
||||
${allowEdit ? `<button class="chip icon" data-edit="${s.id}" type="button" title="${t("card.edit")}">✏️</button>` : ""}
|
||||
${allowDelete ? `<button class="chip icon danger-chip" data-delete="${s.id}" type="button" title="${t("card.delete")}">✕</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
${hasExtraInfo ? `<p class="muted">` : ""}
|
||||
${genreAndPlayers ? genreAndPlayers : ""}
|
||||
${s.gameUrl ? `<a class="link compact" href="${s.gameUrl}" target="_blank" rel="noopener">${t("card.site")}</a>` : ""}
|
||||
${s.youtubeUrl ? `<a class="link compact" href="${s.youtubeUrl}" target="_blank" rel="noopener">${t("card.youtube")}</a>` : ""}
|
||||
${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("card.site")}</a>` : ""}
|
||||
${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("card.youtube")}</a>` : ""}
|
||||
${hasExtraInfo ? `</p>` : ""}
|
||||
${s.description ? `<p>${s.description}</p>` : ""}
|
||||
${s.description ? `<p>${descText}</p>` : ""}
|
||||
</div>
|
||||
`;
|
||||
if (hasImage) {
|
||||
const btn = card.querySelector(".card-visual");
|
||||
setupCardVisualHover(btn, s.screenshotUrl);
|
||||
btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name));
|
||||
setupCardVisualHover(btn, safeShot);
|
||||
btn.addEventListener("click", () => openLightbox(safeShot, s.name));
|
||||
}
|
||||
if (linked && state.me?.isAdmin) {
|
||||
const unlinkBtn = card.querySelector("[data-unlink]");
|
||||
@@ -587,11 +619,12 @@ export function openNewSuggestionModal() {
|
||||
export function openLightbox(url, title) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "lightbox";
|
||||
const safeTitle = escapeHtml(title || "");
|
||||
overlay.innerHTML = `
|
||||
<div class="lightbox-content">
|
||||
<button class="lightbox-close" aria-label="${t("lightbox.close")}">✕</button>
|
||||
<img src="${url}" alt="${title}" />
|
||||
<p>${title || ""}</p>
|
||||
<img src="${url}" alt="${safeTitle}" />
|
||||
<p>${safeTitle}</p>
|
||||
</div>
|
||||
`;
|
||||
overlay.addEventListener("click", (e) => {
|
||||
@@ -728,10 +761,12 @@ function renderAdminVoteStatus() {
|
||||
state.adminVoteStatus.voters.forEach((v) => {
|
||||
const tr = document.createElement("tr");
|
||||
const statusText = displayPlayerStatus(v);
|
||||
const gamesTooltip = (v.suggestionTitles || []).join(", ");
|
||||
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
|
||||
const nameText = escapeHtml(truncate(v.name, 28));
|
||||
const userText = escapeHtml(truncate(v.username, 24));
|
||||
tr.innerHTML = `
|
||||
<td title="${v.name}">${truncate(v.name, 28)}</td>
|
||||
<td class="muted small" title="${v.username}">${truncate(v.username, 24)}</td>
|
||||
<td title="${escapeHtml(v.name)}">${nameText}</td>
|
||||
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
|
||||
<td>${statusText}</td>
|
||||
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
|
||||
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></td>
|
||||
@@ -930,7 +965,7 @@ function linkTooltip(s) {
|
||||
|
||||
function renderLinkBadge(s) {
|
||||
if (!isLinked(s)) return "";
|
||||
return `<span class="chip icon link-chip" title="${linkTooltip(s)}">🔗</span>`;
|
||||
return `<span class="chip icon link-chip" title="${escapeHtml(linkTooltip(s))}">🔗</span>`;
|
||||
}
|
||||
|
||||
function buildLinkOptionLabel(s) {
|
||||
|
||||
Reference in New Issue
Block a user