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`)
|
- Fetching audio (`/api/tracks/:trackId`)
|
||||||
- Checking cache status (`M.cachedTracks.has(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
|
## Client Caching System
|
||||||
|
|
||||||
### Segment-Based Buffering
|
### Segment-Based Buffering
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@
|
||||||
counts[name] = (counts[name] || 0) + 1;
|
counts[name] = (counts[name] || 0) + 1;
|
||||||
}
|
}
|
||||||
const listenersHtml = Object.entries(counts).map(([name, count]) =>
|
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("");
|
).join("");
|
||||||
|
|
||||||
// Show delete button for non-default channels if user is admin or creator
|
// Show delete button for non-default channels if user is admin or creator
|
||||||
|
|
@ -173,8 +173,8 @@
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="channel-header">
|
<div class="channel-header">
|
||||||
<span class="channel-name">${ch.name}</span>
|
<span class="channel-name">${M.escapeHtml(ch.name)}</span>
|
||||||
<input class="channel-name-input" type="text" value="${ch.name.replace(/"/g, '"')}" style="display:none;">
|
<input class="channel-name-input" type="text" value="${M.escapeHtml(ch.name)}" style="display:none;">
|
||||||
${renameBtn}
|
${renameBtn}
|
||||||
${deleteBtn}
|
${deleteBtn}
|
||||||
<span class="listener-count">${ch.listenerCount}</span>
|
<span class="listener-count">${ch.listenerCount}</span>
|
||||||
|
|
@ -498,7 +498,7 @@
|
||||||
const isNewTrack = trackId !== M.currentTrackId;
|
const isNewTrack = trackId !== M.currentTrackId;
|
||||||
if (isNewTrack) {
|
if (isNewTrack) {
|
||||||
M.currentTrackId = trackId;
|
M.currentTrackId = trackId;
|
||||||
M.setTrackTitle(data.track.title);
|
M.setTrackTitle(M.trackComponent.getTitle(data.track));
|
||||||
M.applyReplayGain && M.applyReplayGain(data.track);
|
M.applyReplayGain && M.applyReplayGain(data.track);
|
||||||
M.loadingSegments.clear();
|
M.loadingSegments.clear();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
M.currentIndex = newIndex;
|
M.currentIndex = newIndex;
|
||||||
M.currentTrackId = trackId;
|
M.currentTrackId = trackId;
|
||||||
M.serverTrackDuration = track.duration;
|
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.applyReplayGain && M.applyReplayGain(track);
|
||||||
M.loadingSegments.clear();
|
M.loadingSegments.clear();
|
||||||
const cachedUrl = await M.loadTrackBlob(trackId);
|
const cachedUrl = await M.loadTrackBlob(trackId);
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
// Set up and play track
|
// Set up and play track
|
||||||
M.currentTrackId = trackId;
|
M.currentTrackId = trackId;
|
||||||
M.serverTrackDuration = track.duration;
|
M.serverTrackDuration = track.duration;
|
||||||
M.setTrackTitle(track.title || track.filename);
|
M.setTrackTitle(M.trackComponent.getTitle(track));
|
||||||
M.applyReplayGain && M.applyReplayGain(track);
|
M.applyReplayGain && M.applyReplayGain(track);
|
||||||
M.loadingSegments.clear();
|
M.loadingSegments.clear();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
} else {
|
} else {
|
||||||
myContainer.innerHTML = myPlaylists.map(p => `
|
myContainer.innerHTML = myPlaylists.map(p => `
|
||||||
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
<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>' : ''}
|
${p.isPublic ? '<span class="playlist-public-icon" title="Public">🌐</span>' : ''}
|
||||||
<span class="playlist-count">${p.trackIds.length}</span>
|
<span class="playlist-count">${p.trackIds.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -54,8 +54,8 @@
|
||||||
} else {
|
} else {
|
||||||
sharedContainer.innerHTML = sharedPlaylists.map(p => `
|
sharedContainer.innerHTML = sharedPlaylists.map(p => `
|
||||||
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
<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>
|
||||||
<span class="playlist-owner">by ${escapeHtml(p.ownerName || 'Unknown')}</span>
|
<span class="playlist-owner">by ${M.escapeHtml(p.ownerName || 'Unknown')}</span>
|
||||||
<span class="playlist-count">${p.trackIds.length}</span>
|
<span class="playlist-count">${p.trackIds.length}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
@ -478,13 +478,6 @@
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return str.replace(/[&<>"']/g, c => ({
|
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
||||||
}[c]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function initPlaylists() {
|
function initPlaylists() {
|
||||||
// New playlist button
|
// New playlist button
|
||||||
const btnNew = $('#btn-new-playlist');
|
const btnNew = $('#btn-new-playlist');
|
||||||
|
|
|
||||||
|
|
@ -338,8 +338,8 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
|
const title = M.trackComponent.getTitle(track);
|
||||||
bar.innerHTML = `<span class="label">Now playing:</span> ${title}`;
|
bar.innerHTML = `<span class="label">Now playing:</span> ${M.escapeHtml(title)}`;
|
||||||
bar.title = title;
|
bar.title = title;
|
||||||
bar.classList.remove("hidden");
|
bar.classList.remove("hidden");
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@
|
||||||
(function() {
|
(function() {
|
||||||
const M = window.MusicRoom;
|
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)
|
* Render a track row element (pure rendering, no handlers)
|
||||||
* @param {Object} track - Track object with id, title, filename, duration
|
* @param {Object} track - Track object with id, title, filename, duration
|
||||||
|
|
@ -45,7 +50,7 @@
|
||||||
div.dataset.view = view;
|
div.dataset.view = view;
|
||||||
|
|
||||||
// Build title
|
// Build title
|
||||||
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
|
const title = getTitle(track);
|
||||||
div.title = title;
|
div.title = title;
|
||||||
|
|
||||||
// Build HTML
|
// Build HTML
|
||||||
|
|
@ -56,7 +61,7 @@
|
||||||
${checkmark}
|
${checkmark}
|
||||||
<span class="cache-indicator"></span>
|
<span class="cache-indicator"></span>
|
||||||
${trackNum}
|
${trackNum}
|
||||||
<span class="track-title">${escapeHtml(title)}</span>
|
<span class="track-title">${M.escapeHtml(title)}</span>
|
||||||
<span class="track-actions">
|
<span class="track-actions">
|
||||||
<span class="duration">${M.fmt(track.duration)}</span>
|
<span class="duration">${M.fmt(track.duration)}</span>
|
||||||
<button type="button" class="track-menu-btn" title="Track actions" aria-label="Track actions"></button>
|
<button type="button" class="track-menu-btn" title="Track actions" aria-label="Track actions"></button>
|
||||||
|
|
@ -70,17 +75,10 @@
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML escape helper
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return str.replace(/[&<>"']/g, c => ({
|
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
||||||
})[c]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
M.trackComponent = {
|
M.trackComponent = {
|
||||||
render,
|
render,
|
||||||
escapeHtml
|
getTitle,
|
||||||
|
escapeHtml: M.escapeHtml
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -597,7 +597,7 @@
|
||||||
|
|
||||||
async function playTrack(track, index) {
|
async function playTrack(track, index) {
|
||||||
const trackId = track.id || track.filename;
|
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') {
|
if (type === 'queue') {
|
||||||
// Jump to track in queue
|
// Jump to track in queue
|
||||||
|
|
@ -633,7 +633,7 @@
|
||||||
|
|
||||||
async function previewTrack(track) {
|
async function previewTrack(track) {
|
||||||
const trackId = track.id || track.filename;
|
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.currentTrackId = trackId;
|
||||||
M.serverTrackDuration = track.duration;
|
M.serverTrackDuration = track.duration;
|
||||||
|
|
@ -658,7 +658,7 @@
|
||||||
|
|
||||||
function showContextMenu(e, track, index, canEditQueue) {
|
function showContextMenu(e, track, index, canEditQueue) {
|
||||||
const trackId = track.id || track.filename;
|
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 sel = selection[type];
|
||||||
const hasSelection = sel.size > 0;
|
const hasSelection = sel.size > 0;
|
||||||
|
|
|
||||||
|
|
@ -304,14 +304,14 @@
|
||||||
|
|
||||||
for (const [playlistId, group] of byPlaylist) {
|
for (const [playlistId, group] of byPlaylist) {
|
||||||
if (group.name) {
|
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) => {
|
html += group.items.map((item, i) => {
|
||||||
const isNext = queuedItems.indexOf(item) === 0;
|
const isNext = queuedItems.indexOf(item) === 0;
|
||||||
return `
|
return `
|
||||||
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
|
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
|
||||||
<span class="slow-queue-item-icon">${isNext ? '⏳' : '·'}</span>
|
<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>
|
<button class="slow-queue-cancel" title="Cancel">✕</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,18 @@
|
||||||
// DOM selector helper
|
// DOM selector helper
|
||||||
M.$ = (s) => document.querySelector(s);
|
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
|
// Format seconds as m:ss
|
||||||
M.fmt = function(sec) {
|
M.fmt = function(sec) {
|
||||||
if (!sec || !isFinite(sec)) return "0:00";
|
if (!sec || !isFinite(sec)) return "0:00";
|
||||||
|
|
@ -110,7 +122,7 @@
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "history-item history-" + item.type;
|
div.className = "history-item history-" + item.type;
|
||||||
const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
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);
|
list.appendChild(div);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -146,7 +158,7 @@
|
||||||
document.title = title ? `${title} - MusicRoom` : "MusicRoom";
|
document.title = title ? `${title} - MusicRoom` : "MusicRoom";
|
||||||
|
|
||||||
// First set simple content to measure
|
// 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
|
// Check if title overflows and needs scrolling
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
@ -156,7 +168,7 @@
|
||||||
|
|
||||||
// Duplicate text for seamless wrap-around scrolling
|
// Duplicate text for seamless wrap-around scrolling
|
||||||
if (needsScroll) {
|
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";
|
import { PUBLIC_DIR } from "../config";
|
||||||
|
|
||||||
// Serve static files
|
// 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> {
|
export async function handleStatic(path: string): Promise<Response | null> {
|
||||||
if (path === "/" || path === "/index.html" || path.startsWith("/listen/")) {
|
if (path === "/" || path === "/index.html" || path.startsWith("/listen/")) {
|
||||||
return new Response(file(join(PUBLIC_DIR, "index.html")), {
|
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") {
|
if (path === "/styles.css") {
|
||||||
return new Response(file(join(PUBLIC_DIR, "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") {
|
if (path === "/favicon.ico") {
|
||||||
return new Response(file(join(PUBLIC_DIR, "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)));
|
const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
|
||||||
if (await jsFile.exists()) {
|
if (await jsFile.exists()) {
|
||||||
return new Response(jsFile, {
|
return new Response(jsFile, {
|
||||||
headers: { "Content-Type": "application/javascript" },
|
headers: { "Content-Type": "application/javascript", "Content-Security-Policy": CSP },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue