added configs, lots of styling changes and fixed up performance issues

This commit is contained in:
peterino2 2026-02-02 19:49:16 -08:00
parent dd520491c2
commit f02147e302
8 changed files with 319 additions and 67 deletions

View File

@ -10,6 +10,15 @@ export function getSessionToken(req: Request): string | null {
return match ? match[1] : null;
}
export function getClientInfo(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): { userAgent: string; ipAddress: string } {
const userAgent = req.headers.get("user-agent") ?? "unknown";
const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
?? req.headers.get("x-real-ip")
?? server?.requestIP?.(req)?.address
?? "unknown";
return { userAgent, ipAddress };
}
export function getRequestMeta(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): { userAgent?: string; ipAddress?: string } {
const userAgent = req.headers.get("user-agent") ?? undefined;
const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()

8
config.json Normal file
View File

@ -0,0 +1,8 @@
{
"port": 3001,
"musicDir": "./music",
"allowGuests": true,
"defaultPermissions": {
"stream": ["listen", "control"]
}
}

55
db.ts
View File

@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
const DB_PATH = "./musicroom.db";
const SESSION_EXPIRY_DAYS = 7;
const GUEST_SESSION_EXPIRY_HOURS = 24;
const db = new Database(DB_PATH);
@ -12,10 +13,16 @@ db.run(`
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER DEFAULT 0,
is_guest INTEGER DEFAULT 0,
created_at INTEGER DEFAULT (unixepoch())
)
`);
// Migration: add is_guest column if it doesn't exist
try {
db.run(`ALTER TABLE users ADD COLUMN is_guest INTEGER DEFAULT 0`);
} catch {}
db.run(`
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
@ -54,6 +61,7 @@ export interface User {
username: string;
password_hash: string;
is_admin: boolean;
is_guest: boolean;
created_at: number;
}
@ -79,26 +87,38 @@ export async function createUser(username: string, password: string): Promise<Us
const password_hash = await Bun.password.hash(password);
// First user becomes admin
const userCount = db.query("SELECT COUNT(*) as count FROM users").get() as { count: number };
const userCount = db.query("SELECT COUNT(*) as count FROM users WHERE is_guest = 0").get() as { count: number };
const is_admin = userCount.count === 0 ? 1 : 0;
const result = db.query(
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?) RETURNING *"
"INSERT INTO users (username, password_hash, is_admin, is_guest) VALUES (?, ?, ?, 0) RETURNING *"
).get(username, password_hash, is_admin) as any;
return { ...result, is_admin: !!result.is_admin };
return { ...result, is_admin: !!result.is_admin, is_guest: false };
}
export function createGuestUser(ipAddress: string, userAgent: string): User {
const guestId = crypto.randomUUID().slice(0, 8);
const username = `guest_${guestId}`;
const password_hash = ""; // No password for guests
const result = db.query(
"INSERT INTO users (username, password_hash, is_admin, is_guest) VALUES (?, ?, 0, 1) RETURNING *"
).get(username, password_hash) as any;
return { ...result, is_admin: false, is_guest: true };
}
export function findUserById(id: number): User | null {
const result = db.query("SELECT * FROM users WHERE id = ?").get(id) as any;
if (!result) return null;
return { ...result, is_admin: !!result.is_admin };
return { ...result, is_admin: !!result.is_admin, is_guest: !!result.is_guest };
}
export function findUserByUsername(username: string): User | null {
const result = db.query("SELECT * FROM users WHERE username = ?").get(username) as any;
if (!result) return null;
return { ...result, is_admin: !!result.is_admin };
return { ...result, is_admin: !!result.is_admin, is_guest: !!result.is_guest };
}
export async function validatePassword(user: User, password: string): Promise<boolean> {
@ -106,9 +126,12 @@ export async function validatePassword(user: User, password: string): Promise<bo
}
// Session functions
export function createSession(userId: number, userAgent?: string, ipAddress?: string): string {
export function createSession(userId: number, userAgent?: string, ipAddress?: string, isGuest: boolean = false): string {
const token = crypto.randomUUID();
const expires_at = Math.floor(Date.now() / 1000) + (SESSION_EXPIRY_DAYS * 24 * 60 * 60);
const expirySeconds = isGuest
? GUEST_SESSION_EXPIRY_HOURS * 60 * 60
: SESSION_EXPIRY_DAYS * 24 * 60 * 60;
const expires_at = Math.floor(Date.now() / 1000) + expirySeconds;
db.query("INSERT INTO sessions (token, user_id, expires_at, user_agent, ip_address) VALUES (?, ?, ?, ?, ?)")
.run(token, userId, expires_at, userAgent ?? null, ipAddress ?? null);
@ -116,6 +139,12 @@ export function createSession(userId: number, userAgent?: string, ipAddress?: st
return token;
}
export function createGuestSession(userAgent?: string, ipAddress?: string): { user: User, token: string } {
const user = createGuestUser(ipAddress ?? "unknown", userAgent ?? "unknown");
const token = createSession(user.id, userAgent, ipAddress, true);
return { user, token };
}
export function validateSession(token: string, currentUserAgent?: string, currentIpAddress?: string): User | null {
const now = Math.floor(Date.now() / 1000);
const session = db.query(
@ -124,6 +153,9 @@ export function validateSession(token: string, currentUserAgent?: string, curren
if (!session) return null;
const user = findUserById(session.user_id);
if (!user) return null;
// Invalidate if BOTH ip and user agent changed (potential session hijack)
if (currentUserAgent && currentIpAddress && session.user_agent && session.ip_address) {
const ipChanged = session.ip_address !== currentIpAddress;
@ -136,7 +168,10 @@ export function validateSession(token: string, currentUserAgent?: string, curren
}
// Sliding expiration - extend on each use
const newExpiry = now + (SESSION_EXPIRY_DAYS * 24 * 60 * 60);
const expirySeconds = user.is_guest
? GUEST_SESSION_EXPIRY_HOURS * 60 * 60
: SESSION_EXPIRY_DAYS * 24 * 60 * 60;
const newExpiry = now + expirySeconds;
db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run(newExpiry, token);
return findUserById(session.user_id);
@ -203,8 +238,8 @@ export function getUserPermissions(userId: number): Permission[] {
}
export function getAllUsers(): Omit<User, 'password_hash'>[] {
const users = db.query("SELECT id, username, is_admin, created_at FROM users").all() as any[];
return users.map(u => ({ ...u, is_admin: !!u.is_admin }));
const users = db.query("SELECT id, username, is_admin, is_guest, created_at FROM users WHERE is_guest = 0").all() as any[];
return users.map(u => ({ ...u, is_admin: !!u.is_admin, is_guest: false }));
}
export function getUserSessions(userId: number): Omit<Session, 'token'>[] {

Binary file not shown.

View File

@ -8,12 +8,14 @@
let serverTrackDuration = 0;
let lastServerUpdate = 0;
let serverPaused = true;
let synced = false;
let wantSync = true; // User intent - do they want to be synced?
let synced = false; // Actual state - are we currently synced?
let preMuteVolume = 1;
let localTimestamp = 0;
let playlist = [];
let currentIndex = 0;
let currentUser = null;
let serverStatus = null;
let prefetchController = null;
let loadingSegments = new Set();
let trackCaches = new Map(); // Map of filename -> Set of cached segment indices
@ -140,11 +142,33 @@
if (currentUser) {
$("#login-panel").classList.add("hidden");
$("#player-content").classList.add("visible");
$("#current-username").textContent = currentUser.username;
if (currentUser.isGuest) {
$("#current-username").textContent = "Guest";
$("#btn-logout").textContent = "Sign In";
} else {
$("#current-username").textContent = currentUser.username;
$("#btn-logout").textContent = "Logout";
}
$("#admin-badge").style.display = currentUser.isAdmin ? "inline" : "none";
} else {
$("#login-panel").classList.remove("hidden");
$("#player-content").classList.remove("visible");
// Pause and unsync when login panel is shown
if (!audio.paused) {
localTimestamp = audio.currentTime;
audio.pause();
}
if (synced && ws) {
synced = false;
ws.close();
ws = null;
}
// Show guest button if server allows guests
if (serverStatus?.allowGuests) {
$("#guest-section").classList.remove("hidden");
} else {
$("#guest-section").classList.add("hidden");
}
}
updateUI();
}
@ -166,9 +190,10 @@
function updateUI() {
const isPlaying = synced ? !serverPaused : !audio.paused;
$("#btn-sync").classList.toggle("synced", synced);
$("#btn-sync").title = synced ? "Unsync" : "Sync";
$("#status").textContent = synced ? "Synced" : "Local";
$("#btn-sync").classList.toggle("synced", wantSync);
$("#btn-sync").classList.toggle("connected", synced);
$("#btn-sync").title = wantSync ? "Unsync" : "Sync";
$("#status").textContent = synced ? "Synced" : (wantSync ? "Connecting..." : "Local");
$("#sync-indicator").classList.toggle("visible", synced);
$("#progress-bar").classList.toggle("synced", synced);
$("#progress-bar").classList.toggle("local", !synced);
@ -181,6 +206,13 @@
$("#status-icon").style.cursor = hasControl || !synced ? "pointer" : "default";
}
// Track last values to avoid unnecessary DOM updates
let lastProgressPct = -1;
let lastTimeCurrent = "";
let lastTimeTotal = "";
let lastBufferPct = -1;
let lastSpeedText = "";
// Update progress bar and buffer segments
setInterval(() => {
if (serverTrackDuration <= 0) return;
@ -193,9 +225,21 @@
dur = audio.duration || serverTrackDuration;
}
const pct = Math.min((t / dur) * 100, 100);
$("#progress-bar").style.width = pct + "%";
$("#time-current").textContent = fmt(t);
$("#time-total").textContent = fmt(dur);
if (Math.abs(pct - lastProgressPct) > 0.1) {
$("#progress-bar").style.width = pct + "%";
lastProgressPct = pct;
}
const timeCurrent = fmt(t);
const timeTotal = fmt(dur);
if (timeCurrent !== lastTimeCurrent) {
$("#time-current").textContent = timeCurrent;
lastTimeCurrent = timeCurrent;
}
if (timeTotal !== lastTimeTotal) {
$("#time-total").textContent = timeTotal;
lastTimeTotal = timeTotal;
}
// Update buffer segments
const segments = $("#buffer-bar").children;
@ -217,8 +261,11 @@
}
}
if (available) availableCount++;
segments[i].classList.toggle("available", available);
segments[i].classList.toggle("loading", !available && loadingSegments.has(i));
const isAvailable = segments[i].classList.contains("available");
const isLoading = segments[i].classList.contains("loading");
const shouldBeLoading = !available && loadingSegments.has(i);
if (available !== isAvailable) segments[i].classList.toggle("available", available);
if (shouldBeLoading !== isLoading) segments[i].classList.toggle("loading", shouldBeLoading);
}
// Update download speed display
@ -228,7 +275,11 @@
if (kbps > 0) {
speedText = kbps >= 1024 ? ` | ${(kbps / 1024).toFixed(1)} Mb/s` : ` | ${Math.round(kbps)} kb/s`;
}
$("#download-speed").textContent = `${bufferPct}% buffered${speedText}`;
if (bufferPct !== lastBufferPct || speedText !== lastSpeedText) {
$("#download-speed").textContent = `${bufferPct}% buffered${speedText}`;
lastBufferPct = bufferPct;
lastSpeedText = speedText;
}
}, 250);
// Prefetch missing segments
@ -374,9 +425,12 @@
ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws");
ws.onmessage = (e) => handleUpdate(JSON.parse(e.data));
ws.onclose = () => {
if (synced && ws) {
$("#status").textContent = "Disconnected. Reconnecting...";
$("#sync-indicator").classList.add("disconnected");
synced = false;
ws = null;
$("#sync-indicator").classList.add("disconnected");
updateUI();
// Auto-reconnect if user wants to be synced
if (wantSync) {
setTimeout(() => connectStream(id), 3000);
}
};
@ -387,6 +441,15 @@
};
}
function flashPermissionDenied() {
const row = $("#progress-row");
row.classList.remove("denied");
// Trigger reflow to restart animation
void row.offsetWidth;
row.classList.add("denied");
setTimeout(() => row.classList.remove("denied"), 500);
}
function renderPlaylist() {
const container = $("#playlist");
container.innerHTML = "";
@ -397,11 +460,12 @@
div.innerHTML = `<span>${title}</span><span class="duration">${fmt(track.duration)}</span>`;
div.onclick = async () => {
if (synced && currentStreamId) {
fetch("/api/streams/" + currentStreamId + "/jump", {
const res = await fetch("/api/streams/" + currentStreamId + "/jump", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index: i })
});
if (res.status === 403) flashPermissionDenied();
} else {
currentIndex = i;
currentFilename = track.filename;
@ -480,17 +544,22 @@
}
$("#btn-sync").onclick = () => {
if (synced) {
// Unsync - go to local mode
synced = false;
localTimestamp = audio.currentTime || getServerTime();
if (ws) ws.close();
ws = null;
} else {
// Try to sync
wantSync = !wantSync;
if (wantSync) {
// User wants to sync - try to connect
if (currentStreamId) {
connectStream(currentStreamId);
}
} else {
// User wants local mode - disconnect
synced = false;
localTimestamp = audio.currentTime || getServerTime();
if (ws) {
const oldWs = ws;
ws = null;
oldWs.onclose = null;
oldWs.close();
}
}
updateUI();
};
@ -524,11 +593,12 @@
const newIndex = (index + playlist.length) % playlist.length;
if (synced && currentStreamId) {
fetch("/api/streams/" + currentStreamId + "/jump", {
const res = await fetch("/api/streams/" + currentStreamId + "/jump", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index: newIndex })
});
if (res.status === 403) flashPermissionDenied();
} else {
const track = playlist[newIndex];
currentIndex = newIndex;
@ -575,7 +645,7 @@
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timestamp: seekTime })
});
}).then(res => { if (res.status === 403) flashPermissionDenied(); });
} else {
if (!audio.src) {
audio.src = getTrackUrl(currentFilename);
@ -700,12 +770,47 @@
}
};
$("#btn-guest").onclick = async () => {
// Fetch /api/auth/me which will create a guest session
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
currentUser = data.user;
if (currentUser && data.permissions) {
currentUser.permissions = data.permissions;
}
updateAuthUI();
if (currentUser) loadStreams();
} catch (e) {
$("#auth-error").textContent = "Failed to continue as guest";
}
};
$("#btn-logout").onclick = async () => {
const wasGuest = currentUser?.isGuest;
await fetch("/api/auth/logout", { method: "POST" });
currentUser = null;
updateAuthUI();
if (wasGuest) {
// Guest clicking "Sign In" - show login panel
updateAuthUI();
} else {
// Regular user logging out - reload to get new guest session
updateAuthUI();
}
};
// Fetch server status
async function loadServerStatus() {
try {
const res = await fetch("/api/status");
serverStatus = await res.json();
console.log("Server status:", serverStatus);
} catch (e) {
console.warn("Failed to load server status");
serverStatus = null;
}
}
// Initialize storage and load cached tracks
async function initStorage() {
await TrackStorage.init();
@ -714,7 +819,7 @@
}
// Initialize
initStorage().then(() => {
Promise.all([initStorage(), loadServerStatus()]).then(() => {
loadCurrentUser().then(() => {
if (currentUser) loadStreams();
});

View File

@ -28,6 +28,10 @@
<button class="submit-btn" id="btn-signup">Sign Up</button>
<div id="signup-error"></div>
</div>
<div id="guest-section" class="hidden">
<div class="divider"><span>or</span></div>
<button class="guest-btn" id="btn-guest">Continue as Guest</button>
</div>
</div>
<div id="player-content">
@ -43,15 +47,15 @@
<div id="stream-name"></div>
<div id="track-name" class="empty">
<span id="track-title">Loading...</span>
<span id="btn-sync" title="Toggle sync">sync</span>
</div>
</div>
<div id="time"><span id="time-current">0:00</span><span id="time-total">0:00</span></div>
<div id="progress-row">
<span id="btn-sync" title="Toggle sync">sync</span>
<span id="btn-prev" title="Previous track"></span>
<span id="status-icon"></span>
<span id="btn-next" title="Next track"></span>
<div id="progress-container"><div id="progress-bar"></div><div id="seek-tooltip"></div></div>
<div id="time"><span id="time-current">0:00</span>/<span id="time-total">0:00</span></div>
</div>
<div id="buffer-bar"></div>
<div id="download-speed"></div>

View File

@ -9,12 +9,15 @@ h1 { font-size: 1.2rem; color: #888; margin-bottom: 1.5rem; display: flex; align
#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 0.8rem; border-radius: 4px; font-size: 0.9rem; }
#now-playing { margin-bottom: 0.5rem; }
#stream-name { font-size: 0.85rem; color: #888; margin-bottom: 0.2rem; }
#track-name { font-size: 1.4rem; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; }
#btn-sync { font-size: 0.75rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; text-transform: uppercase; letter-spacing: 0.05em; }
#track-name { font-size: 1.4rem; font-weight: 600; }
#btn-sync { font-size: 0.75rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; }
#btn-sync:hover { color: #888; }
#btn-sync.synced { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; }
#time { display: flex; justify-content: space-between; font-size: 0.8rem; color: #888; margin-bottom: 0.3rem; }
#btn-sync.synced { color: #eb0; text-shadow: 0 0 8px #eb0, 0 0 12px #eb0; }
#btn-sync.synced.connected { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; }
#time { font-size: 0.8rem; color: #888; margin: 0; line-height: 1; }
#progress-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
#progress-row.denied { animation: flash-red 0.5s ease-out; }
@keyframes flash-red { 0% { background: #e44; } 100% { background: transparent; } }
#btn-prev, #btn-next { font-size: 0.8rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }
#btn-prev:hover, #btn-next:hover { opacity: 1; }
#status-icon { font-size: 0.9rem; width: 1rem; text-align: center; cursor: pointer; }
@ -55,6 +58,12 @@ button:hover { background: #333; }
#login-panel .submit-btn { background: #4e8; color: #111; border: none; padding: 0.6rem; border-radius: 4px; font-size: 0.95rem; cursor: pointer; font-weight: 600; }
#login-panel .submit-btn:hover { background: #5fa; }
#auth-error, #signup-error { color: #e44; font-size: 0.8rem; }
#guest-section { margin-top: 1rem; }
#guest-section.hidden { display: none; }
#guest-section .divider { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; color: #666; font-size: 0.85rem; }
#guest-section .divider::before, #guest-section .divider::after { content: ""; flex: 1; height: 1px; background: #333; }
#guest-section .guest-btn { width: 100%; background: #333; color: #eee; border: 1px solid #444; padding: 0.6rem; border-radius: 4px; font-size: 0.95rem; cursor: pointer; }
#guest-section .guest-btn:hover { background: #444; }
#player-content { display: none; }
#player-content.visible { display: block; }
#playlist { margin-top: 1.5rem; max-height: 300px; overflow-y: auto; }

130
server.ts
View File

@ -2,12 +2,13 @@ import { file, serve, type ServerWebSocket } from "bun";
import { parseFile } from "music-metadata";
import { Stream, type Track } from "./stream";
import { readdir, stat } from "fs/promises";
import { join } from "path";
import { join, resolve } from "path";
import {
createUser,
findUserByUsername,
validatePassword,
createSession,
createGuestSession,
deleteSession,
validateSession,
hasPermission,
@ -23,12 +24,28 @@ import {
requirePermission,
setSessionCookie,
clearSessionCookie,
getClientInfo,
} from "./auth";
const MUSIC_DIR = join(import.meta.dir, "music");
// Load config
interface Config {
port: number;
musicDir: string;
allowGuests: boolean;
defaultPermissions: {
stream?: string[];
};
}
const CONFIG_PATH = join(import.meta.dir, "config.json");
const config: Config = await file(CONFIG_PATH).json();
const MUSIC_DIR = resolve(import.meta.dir, config.musicDir);
const PLAYLIST_PATH = join(import.meta.dir, "playlist.json");
const PUBLIC_DIR = join(import.meta.dir, "public");
console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`);
// Load track metadata
async function loadTrack(filename: string): Promise<Track> {
const filepath = join(MUSIC_DIR, filename);
@ -95,8 +112,42 @@ setInterval(() => {
type WsData = { streamId: string; userId: number | null };
// Helper to get or create guest session
function getOrCreateUser(req: Request, server: any): { user: ReturnType<typeof getUser>, headers?: Headers } {
let user = getUser(req, server);
if (user) return { user };
if (config.allowGuests) {
const { userAgent, ipAddress } = getClientInfo(req, server);
const guest = createGuestSession(userAgent, ipAddress);
console.log(`[AUTH] Guest session created: user="${guest.user.username}" id=${guest.user.id} ip=${ipAddress}`);
const headers = new Headers();
headers.set("Set-Cookie", setSessionCookie(guest.token));
return { user: guest.user, headers };
}
return { user: null };
}
// Check if user has permission (including default permissions)
function userHasPermission(user: ReturnType<typeof getUser>, resourceType: string, resourceId: string | null, permission: string): boolean {
if (!user) return false;
if (user.is_admin) return true;
// Guests can never control playback
if (user.is_guest && permission === "control") return false;
// Check default permissions from config
if (resourceType === "stream" && config.defaultPermissions.stream?.includes(permission)) {
return true;
}
// Check user-specific permissions
return hasPermission(user.id, resourceType, resourceId, permission);
}
serve({
port: parseInt("3001"),
port: config.port,
async fetch(req, server) {
const url = new URL(req.url);
const path = url.pathname;
@ -105,15 +156,27 @@ serve({
if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) {
const id = path.split("/")[3];
if (!streams.has(id)) return new Response("Stream not found", { status: 404 });
const user = getUser(req, server);
const { user } = getOrCreateUser(req, server);
const ok = server.upgrade(req, { data: { streamId: id, userId: user?.id ?? null } });
if (ok) return undefined;
return new Response("WebSocket upgrade failed", { status: 500 });
}
// API: list streams (requires auth)
// API: server status (public)
if (path === "/api/status") {
return Response.json({
name: "MusicRoom",
version: "1.0.0",
allowGuests: config.allowGuests,
allowSignups: true,
streamCount: streams.size,
defaultPermissions: config.defaultPermissions,
});
}
// API: list streams (requires auth or guest)
if (path === "/api/streams") {
const user = getUser(req, server);
const { user, headers } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
@ -122,7 +185,7 @@ serve({
name: s.name,
trackCount: s.playlist.length,
}));
return Response.json(list);
return Response.json(list, { headers });
}
// Auth: signup
@ -197,15 +260,30 @@ serve({
// Auth: get current user
if (path === "/api/auth/me") {
const user = getUser(req, server);
const { user, headers } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ user: null });
}
const permissions = getUserPermissions(user.id);
// Add default permissions for all users (except control for guests)
const effectivePermissions = [...permissions];
if (config.defaultPermissions.stream) {
for (const perm of config.defaultPermissions.stream) {
// Guests can never have control permission
if (user.is_guest && perm === "control") continue;
effectivePermissions.push({
id: 0,
user_id: user.id,
resource_type: "stream",
resource_id: null,
permission: perm,
});
}
}
return Response.json({
user: { id: user.id, username: user.username, isAdmin: user.is_admin },
permissions,
});
user: { id: user.id, username: user.username, isAdmin: user.is_admin, isGuest: user.is_guest },
permissions: effectivePermissions,
}, { headers });
}
// Admin: list users
@ -251,10 +329,9 @@ serve({
const jumpMatch = path.match(/^\/api\/streams\/([^/]+)\/jump$/);
if (jumpMatch && req.method === "POST") {
const streamId = jumpMatch[1];
try {
requirePermission(req, "stream", streamId, "control", server);
} catch (e) {
if (e instanceof Response) return e;
const { user } = getOrCreateUser(req, server);
if (!userHasPermission(user, "stream", streamId, "control")) {
return new Response("Forbidden", { status: 403 });
}
const stream = streams.get(streamId);
if (!stream) return new Response("Not found", { status: 404 });
@ -274,10 +351,9 @@ serve({
const seekMatch = path.match(/^\/api\/streams\/([^/]+)\/seek$/);
if (seekMatch && req.method === "POST") {
const streamId = seekMatch[1];
try {
requirePermission(req, "stream", streamId, "control", server);
} catch (e) {
if (e instanceof Response) return e;
const { user } = getOrCreateUser(req, server);
if (!userHasPermission(user, "stream", streamId, "control")) {
return new Response("Forbidden", { status: 403 });
}
const stream = streams.get(streamId);
if (!stream) return new Response("Not found", { status: 404 });
@ -301,10 +377,10 @@ serve({
return Response.json(stream.getState());
}
// API: serve audio file (requires auth)
// API: serve audio file (requires auth or guest)
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
if (trackMatch) {
const user = getUser(req, server);
const { user } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
@ -384,12 +460,18 @@ serve({
// Check permission for control actions
const userId = ws.data.userId;
if (!userId) return; // Not logged in
if (!userId) return;
const user = findUserById(userId);
if (!user) return;
const canControl = user.is_admin || hasPermission(userId, "stream", ws.data.streamId, "control");
// Guests can never control playback
if (user.is_guest) return;
// Check default permissions or user-specific permissions
const canControl = user.is_admin
|| config.defaultPermissions.stream?.includes("control")
|| hasPermission(userId, "stream", ws.data.streamId, "control");
if (!canControl) return;
try {
@ -401,4 +483,4 @@ serve({
},
});
console.log("MusicRoom running on http://localhost:3001");
console.log(`MusicRoom running on http://localhost:${config.port}`);