saving
This commit is contained in:
parent
0c30f7a252
commit
b557cb678f
|
|
@ -77,6 +77,15 @@ The client uses `track.id` for:
|
|||
- Fetching audio (`/api/tracks/:trackId`)
|
||||
- Checking cache status (`M.cachedTracks.has(trackId)`)
|
||||
|
||||
## XSS Prevention
|
||||
|
||||
All server-controlled strings (channel names, usernames, track titles, playlist names, toast messages) must be escaped before reaching `innerHTML`:
|
||||
|
||||
- **`M.escapeHtml(str)`** (`public/utils.js`) — the single shared escaping helper. Do not define local copies.
|
||||
- **`M.trackComponent.getTitle(track)`** (`public/trackComponent.js`) — the single source of truth for a track's display title. Use it instead of inline `track.title || track.filename` fallbacks.
|
||||
|
||||
A Content-Security-Policy header is set on all static responses in `routes/static.ts` (`script-src 'self'`, no inline scripts) as defense-in-depth.
|
||||
|
||||
## Client Caching System
|
||||
|
||||
### Segment-Based Buffering
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@
|
|||
counts[name] = (counts[name] || 0) + 1;
|
||||
}
|
||||
const listenersHtml = Object.entries(counts).map(([name, count]) =>
|
||||
`<div class="listener">${name}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
|
||||
`<div class="listener">${M.escapeHtml(name)}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
|
||||
).join("");
|
||||
|
||||
// Show delete button for non-default channels if user is admin or creator
|
||||
|
|
@ -173,8 +173,8 @@
|
|||
|
||||
div.innerHTML = `
|
||||
<div class="channel-header">
|
||||
<span class="channel-name">${ch.name}</span>
|
||||
<input class="channel-name-input" type="text" value="${ch.name.replace(/"/g, '"')}" style="display:none;">
|
||||
<span class="channel-name">${M.escapeHtml(ch.name)}</span>
|
||||
<input class="channel-name-input" type="text" value="${M.escapeHtml(ch.name)}" style="display:none;">
|
||||
${renameBtn}
|
||||
${deleteBtn}
|
||||
<span class="listener-count">${ch.listenerCount}</span>
|
||||
|
|
@ -498,7 +498,7 @@
|
|||
const isNewTrack = trackId !== M.currentTrackId;
|
||||
if (isNewTrack) {
|
||||
M.currentTrackId = trackId;
|
||||
M.setTrackTitle(data.track.title);
|
||||
M.setTrackTitle(M.trackComponent.getTitle(data.track));
|
||||
M.applyReplayGain && M.applyReplayGain(data.track);
|
||||
M.loadingSegments.clear();
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@
|
|||
M.currentIndex = newIndex;
|
||||
M.currentTrackId = trackId;
|
||||
M.serverTrackDuration = track.duration;
|
||||
M.setTrackTitle(track.title?.trim() || track.filename?.replace(/\.[^.]+$/, "") || "Unknown");
|
||||
M.setTrackTitle(M.trackComponent.getTitle(track));
|
||||
M.applyReplayGain && M.applyReplayGain(track);
|
||||
M.loadingSegments.clear();
|
||||
const cachedUrl = await M.loadTrackBlob(trackId);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
// Set up and play track
|
||||
M.currentTrackId = trackId;
|
||||
M.serverTrackDuration = track.duration;
|
||||
M.setTrackTitle(track.title || track.filename);
|
||||
M.setTrackTitle(M.trackComponent.getTitle(track));
|
||||
M.applyReplayGain && M.applyReplayGain(track);
|
||||
M.loadingSegments.clear();
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
} else {
|
||||
myContainer.innerHTML = myPlaylists.map(p => `
|
||||
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
||||
<span class="playlist-name">${escapeHtml(p.name)}</span>
|
||||
<span class="playlist-name">${M.escapeHtml(p.name)}</span>
|
||||
${p.isPublic ? '<span class="playlist-public-icon" title="Public">🌐</span>' : ''}
|
||||
<span class="playlist-count">${p.trackIds.length}</span>
|
||||
</div>
|
||||
|
|
@ -54,8 +54,8 @@
|
|||
} else {
|
||||
sharedContainer.innerHTML = sharedPlaylists.map(p => `
|
||||
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
||||
<span class="playlist-name">${escapeHtml(p.name)}</span>
|
||||
<span class="playlist-owner">by ${escapeHtml(p.ownerName || 'Unknown')}</span>
|
||||
<span class="playlist-name">${M.escapeHtml(p.name)}</span>
|
||||
<span class="playlist-owner">by ${M.escapeHtml(p.ownerName || 'Unknown')}</span>
|
||||
<span class="playlist-count">${p.trackIds.length}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
|
@ -478,13 +478,6 @@
|
|||
}));
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function initPlaylists() {
|
||||
// New playlist button
|
||||
const btnNew = $('#btn-new-playlist');
|
||||
|
|
|
|||
|
|
@ -338,8 +338,8 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
|
||||
bar.innerHTML = `<span class="label">Now playing:</span> ${title}`;
|
||||
const title = M.trackComponent.getTitle(track);
|
||||
bar.innerHTML = `<span class="label">Now playing:</span> ${M.escapeHtml(title)}`;
|
||||
bar.title = title;
|
||||
bar.classList.remove("hidden");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@
|
|||
(function() {
|
||||
const M = window.MusicRoom;
|
||||
|
||||
// Single source of truth for a track's display title
|
||||
function getTitle(track) {
|
||||
return track?.title?.trim() || (track?.filename || track?.id || "Unknown").replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a track row element (pure rendering, no handlers)
|
||||
* @param {Object} track - Track object with id, title, filename, duration
|
||||
|
|
@ -45,7 +50,7 @@
|
|||
div.dataset.view = view;
|
||||
|
||||
// Build title
|
||||
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
|
||||
const title = getTitle(track);
|
||||
div.title = title;
|
||||
|
||||
// Build HTML
|
||||
|
|
@ -56,7 +61,7 @@
|
|||
${checkmark}
|
||||
<span class="cache-indicator"></span>
|
||||
${trackNum}
|
||||
<span class="track-title">${escapeHtml(title)}</span>
|
||||
<span class="track-title">${M.escapeHtml(title)}</span>
|
||||
<span class="track-actions">
|
||||
<span class="duration">${M.fmt(track.duration)}</span>
|
||||
<button type="button" class="track-menu-btn" title="Track actions" aria-label="Track actions"></button>
|
||||
|
|
@ -70,17 +75,10 @@
|
|||
return div;
|
||||
}
|
||||
|
||||
// HTML escape helper
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[c]);
|
||||
}
|
||||
|
||||
// Export
|
||||
M.trackComponent = {
|
||||
render,
|
||||
escapeHtml
|
||||
getTitle,
|
||||
escapeHtml: M.escapeHtml
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -597,7 +597,7 @@
|
|||
|
||||
async function playTrack(track, index) {
|
||||
const trackId = track.id || track.filename;
|
||||
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
|
||||
const title = M.trackComponent.getTitle(track);
|
||||
|
||||
if (type === 'queue') {
|
||||
// Jump to track in queue
|
||||
|
|
@ -633,7 +633,7 @@
|
|||
|
||||
async function previewTrack(track) {
|
||||
const trackId = track.id || track.filename;
|
||||
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
|
||||
const title = M.trackComponent.getTitle(track);
|
||||
|
||||
M.currentTrackId = trackId;
|
||||
M.serverTrackDuration = track.duration;
|
||||
|
|
@ -658,7 +658,7 @@
|
|||
|
||||
function showContextMenu(e, track, index, canEditQueue) {
|
||||
const trackId = track.id || track.filename;
|
||||
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
|
||||
const title = M.trackComponent.getTitle(track);
|
||||
|
||||
const sel = selection[type];
|
||||
const hasSelection = sel.size > 0;
|
||||
|
|
|
|||
|
|
@ -304,14 +304,14 @@
|
|||
|
||||
for (const [playlistId, group] of byPlaylist) {
|
||||
if (group.name) {
|
||||
html += `<div class="slow-queue-playlist-header">📁 ${group.name}</div>`;
|
||||
html += `<div class="slow-queue-playlist-header">📁 ${M.escapeHtml(group.name)}</div>`;
|
||||
}
|
||||
html += group.items.map((item, i) => {
|
||||
const isNext = queuedItems.indexOf(item) === 0;
|
||||
return `
|
||||
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
|
||||
<span class="slow-queue-item-icon">${isNext ? '⏳' : '·'}</span>
|
||||
<span class="slow-queue-item-title">${item.title}</span>
|
||||
<span class="slow-queue-item-title">${M.escapeHtml(item.title)}</span>
|
||||
<button class="slow-queue-cancel" title="Cancel">✕</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,18 @@
|
|||
// DOM selector helper
|
||||
M.$ = (s) => document.querySelector(s);
|
||||
|
||||
// Shared HTML escaping helper - use for any server-controlled string
|
||||
// interpolated into innerHTML (text and attribute contexts)
|
||||
M.escapeHtml = function(str) {
|
||||
if (str == null) return "";
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
// Format seconds as m:ss
|
||||
M.fmt = function(sec) {
|
||||
if (!sec || !isFinite(sec)) return "0:00";
|
||||
|
|
@ -110,7 +122,7 @@
|
|||
const div = document.createElement("div");
|
||||
div.className = "history-item history-" + item.type;
|
||||
const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
div.innerHTML = `<span class="history-time">${time}</span> ${item.message}`;
|
||||
div.innerHTML = `<span class="history-time">${time}</span> ${M.escapeHtml(item.message)}`;
|
||||
list.appendChild(div);
|
||||
}
|
||||
};
|
||||
|
|
@ -146,7 +158,7 @@
|
|||
document.title = title ? `${title} - MusicRoom` : "MusicRoom";
|
||||
|
||||
// First set simple content to measure
|
||||
marqueeEl.innerHTML = `<span id="track-title">${title}</span>`;
|
||||
marqueeEl.innerHTML = `<span id="track-title">${M.escapeHtml(title)}</span>`;
|
||||
|
||||
// Check if title overflows and needs scrolling
|
||||
requestAnimationFrame(() => {
|
||||
|
|
@ -156,7 +168,7 @@
|
|||
|
||||
// Duplicate text for seamless wrap-around scrolling
|
||||
if (needsScroll) {
|
||||
marqueeEl.innerHTML = `<span id="track-title">${title}</span><span class="marquee-spacer"> • </span><span>${title}</span><span class="marquee-spacer"> • </span>`;
|
||||
marqueeEl.innerHTML = `<span id="track-title">${M.escapeHtml(title)}</span><span class="marquee-spacer"> • </span><span>${M.escapeHtml(title)}</span><span class="marquee-spacer"> • </span>`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,22 +3,27 @@ import { join } from "path";
|
|||
import { PUBLIC_DIR } from "../config";
|
||||
|
||||
// Serve static files
|
||||
// Defense-in-depth CSP: no inline scripts, same-origin + ws(s) connections,
|
||||
// blob: media for cached audio, data:/blob: images, inline styles allowed
|
||||
// (style attributes are used throughout the client).
|
||||
const CSP = "default-src 'self'; script-src 'self'; connect-src 'self' ws: wss:; media-src 'self' blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'";
|
||||
|
||||
export async function handleStatic(path: string): Promise<Response | null> {
|
||||
if (path === "/" || path === "/index.html" || path.startsWith("/listen/")) {
|
||||
return new Response(file(join(PUBLIC_DIR, "index.html")), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
headers: { "Content-Type": "text/html", "Content-Security-Policy": CSP },
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/styles.css") {
|
||||
return new Response(file(join(PUBLIC_DIR, "styles.css")), {
|
||||
headers: { "Content-Type": "text/css" },
|
||||
headers: { "Content-Type": "text/css", "Content-Security-Policy": CSP },
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/favicon.ico") {
|
||||
return new Response(file(join(PUBLIC_DIR, "favicon.ico")), {
|
||||
headers: { "Content-Type": "image/x-icon" },
|
||||
headers: { "Content-Type": "image/x-icon", "Content-Security-Policy": CSP },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -26,7 +31,7 @@ export async function handleStatic(path: string): Promise<Response | null> {
|
|||
const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
|
||||
if (await jsFile.exists()) {
|
||||
return new Response(jsFile, {
|
||||
headers: { "Content-Type": "application/javascript" },
|
||||
headers: { "Content-Type": "application/javascript", "Content-Security-Policy": CSP },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue