added configs, lots of styling changes and fixed up performance issues
This commit is contained in:
parent
dd520491c2
commit
f02147e302
9
auth.ts
9
auth.ts
|
|
@ -10,6 +10,15 @@ export function getSessionToken(req: Request): string | null {
|
||||||
return match ? match[1] : 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 } {
|
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 userAgent = req.headers.get("user-agent") ?? undefined;
|
||||||
const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"port": 3001,
|
||||||
|
"musicDir": "./music",
|
||||||
|
"allowGuests": true,
|
||||||
|
"defaultPermissions": {
|
||||||
|
"stream": ["listen", "control"]
|
||||||
|
}
|
||||||
|
}
|
||||||
55
db.ts
55
db.ts
|
|
@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
|
||||||
|
|
||||||
const DB_PATH = "./musicroom.db";
|
const DB_PATH = "./musicroom.db";
|
||||||
const SESSION_EXPIRY_DAYS = 7;
|
const SESSION_EXPIRY_DAYS = 7;
|
||||||
|
const GUEST_SESSION_EXPIRY_HOURS = 24;
|
||||||
|
|
||||||
const db = new Database(DB_PATH);
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
|
@ -12,10 +13,16 @@ db.run(`
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
is_admin INTEGER DEFAULT 0,
|
is_admin INTEGER DEFAULT 0,
|
||||||
|
is_guest INTEGER DEFAULT 0,
|
||||||
created_at INTEGER DEFAULT (unixepoch())
|
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(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
|
|
@ -54,6 +61,7 @@ export interface User {
|
||||||
username: string;
|
username: string;
|
||||||
password_hash: string;
|
password_hash: string;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
|
is_guest: boolean;
|
||||||
created_at: number;
|
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);
|
const password_hash = await Bun.password.hash(password);
|
||||||
|
|
||||||
// First user becomes admin
|
// 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 is_admin = userCount.count === 0 ? 1 : 0;
|
||||||
|
|
||||||
const result = db.query(
|
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;
|
).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 {
|
export function findUserById(id: number): User | null {
|
||||||
const result = db.query("SELECT * FROM users WHERE id = ?").get(id) as any;
|
const result = db.query("SELECT * FROM users WHERE id = ?").get(id) as any;
|
||||||
if (!result) return null;
|
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 {
|
export function findUserByUsername(username: string): User | null {
|
||||||
const result = db.query("SELECT * FROM users WHERE username = ?").get(username) as any;
|
const result = db.query("SELECT * FROM users WHERE username = ?").get(username) as any;
|
||||||
if (!result) return null;
|
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> {
|
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
|
// 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 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 (?, ?, ?, ?, ?)")
|
db.query("INSERT INTO sessions (token, user_id, expires_at, user_agent, ip_address) VALUES (?, ?, ?, ?, ?)")
|
||||||
.run(token, userId, expires_at, userAgent ?? null, ipAddress ?? null);
|
.run(token, userId, expires_at, userAgent ?? null, ipAddress ?? null);
|
||||||
|
|
@ -116,6 +139,12 @@ export function createSession(userId: number, userAgent?: string, ipAddress?: st
|
||||||
return token;
|
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 {
|
export function validateSession(token: string, currentUserAgent?: string, currentIpAddress?: string): User | null {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const session = db.query(
|
const session = db.query(
|
||||||
|
|
@ -124,6 +153,9 @@ export function validateSession(token: string, currentUserAgent?: string, curren
|
||||||
|
|
||||||
if (!session) return null;
|
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)
|
// Invalidate if BOTH ip and user agent changed (potential session hijack)
|
||||||
if (currentUserAgent && currentIpAddress && session.user_agent && session.ip_address) {
|
if (currentUserAgent && currentIpAddress && session.user_agent && session.ip_address) {
|
||||||
const ipChanged = session.ip_address !== currentIpAddress;
|
const ipChanged = session.ip_address !== currentIpAddress;
|
||||||
|
|
@ -136,7 +168,10 @@ export function validateSession(token: string, currentUserAgent?: string, curren
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sliding expiration - extend on each use
|
// 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);
|
db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run(newExpiry, token);
|
||||||
|
|
||||||
return findUserById(session.user_id);
|
return findUserById(session.user_id);
|
||||||
|
|
@ -203,8 +238,8 @@ export function getUserPermissions(userId: number): Permission[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllUsers(): Omit<User, 'password_hash'>[] {
|
export function getAllUsers(): Omit<User, 'password_hash'>[] {
|
||||||
const users = db.query("SELECT id, username, is_admin, created_at FROM users").all() as any[];
|
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 }));
|
return users.map(u => ({ ...u, is_admin: !!u.is_admin, is_guest: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserSessions(userId: number): Omit<Session, 'token'>[] {
|
export function getUserSessions(userId: number): Omit<Session, 'token'>[] {
|
||||||
|
|
|
||||||
BIN
musicroom.db
BIN
musicroom.db
Binary file not shown.
149
public/app.js
149
public/app.js
|
|
@ -8,12 +8,14 @@
|
||||||
let serverTrackDuration = 0;
|
let serverTrackDuration = 0;
|
||||||
let lastServerUpdate = 0;
|
let lastServerUpdate = 0;
|
||||||
let serverPaused = true;
|
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 preMuteVolume = 1;
|
||||||
let localTimestamp = 0;
|
let localTimestamp = 0;
|
||||||
let playlist = [];
|
let playlist = [];
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
|
let serverStatus = null;
|
||||||
let prefetchController = null;
|
let prefetchController = null;
|
||||||
let loadingSegments = new Set();
|
let loadingSegments = new Set();
|
||||||
let trackCaches = new Map(); // Map of filename -> Set of cached segment indices
|
let trackCaches = new Map(); // Map of filename -> Set of cached segment indices
|
||||||
|
|
@ -140,11 +142,33 @@
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
$("#login-panel").classList.add("hidden");
|
$("#login-panel").classList.add("hidden");
|
||||||
$("#player-content").classList.add("visible");
|
$("#player-content").classList.add("visible");
|
||||||
|
if (currentUser.isGuest) {
|
||||||
|
$("#current-username").textContent = "Guest";
|
||||||
|
$("#btn-logout").textContent = "Sign In";
|
||||||
|
} else {
|
||||||
$("#current-username").textContent = currentUser.username;
|
$("#current-username").textContent = currentUser.username;
|
||||||
|
$("#btn-logout").textContent = "Logout";
|
||||||
|
}
|
||||||
$("#admin-badge").style.display = currentUser.isAdmin ? "inline" : "none";
|
$("#admin-badge").style.display = currentUser.isAdmin ? "inline" : "none";
|
||||||
} else {
|
} else {
|
||||||
$("#login-panel").classList.remove("hidden");
|
$("#login-panel").classList.remove("hidden");
|
||||||
$("#player-content").classList.remove("visible");
|
$("#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();
|
updateUI();
|
||||||
}
|
}
|
||||||
|
|
@ -166,9 +190,10 @@
|
||||||
|
|
||||||
function updateUI() {
|
function updateUI() {
|
||||||
const isPlaying = synced ? !serverPaused : !audio.paused;
|
const isPlaying = synced ? !serverPaused : !audio.paused;
|
||||||
$("#btn-sync").classList.toggle("synced", synced);
|
$("#btn-sync").classList.toggle("synced", wantSync);
|
||||||
$("#btn-sync").title = synced ? "Unsync" : "Sync";
|
$("#btn-sync").classList.toggle("connected", synced);
|
||||||
$("#status").textContent = synced ? "Synced" : "Local";
|
$("#btn-sync").title = wantSync ? "Unsync" : "Sync";
|
||||||
|
$("#status").textContent = synced ? "Synced" : (wantSync ? "Connecting..." : "Local");
|
||||||
$("#sync-indicator").classList.toggle("visible", synced);
|
$("#sync-indicator").classList.toggle("visible", synced);
|
||||||
$("#progress-bar").classList.toggle("synced", synced);
|
$("#progress-bar").classList.toggle("synced", synced);
|
||||||
$("#progress-bar").classList.toggle("local", !synced);
|
$("#progress-bar").classList.toggle("local", !synced);
|
||||||
|
|
@ -181,6 +206,13 @@
|
||||||
$("#status-icon").style.cursor = hasControl || !synced ? "pointer" : "default";
|
$("#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
|
// Update progress bar and buffer segments
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (serverTrackDuration <= 0) return;
|
if (serverTrackDuration <= 0) return;
|
||||||
|
|
@ -193,9 +225,21 @@
|
||||||
dur = audio.duration || serverTrackDuration;
|
dur = audio.duration || serverTrackDuration;
|
||||||
}
|
}
|
||||||
const pct = Math.min((t / dur) * 100, 100);
|
const pct = Math.min((t / dur) * 100, 100);
|
||||||
|
if (Math.abs(pct - lastProgressPct) > 0.1) {
|
||||||
$("#progress-bar").style.width = pct + "%";
|
$("#progress-bar").style.width = pct + "%";
|
||||||
$("#time-current").textContent = fmt(t);
|
lastProgressPct = pct;
|
||||||
$("#time-total").textContent = fmt(dur);
|
}
|
||||||
|
|
||||||
|
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
|
// Update buffer segments
|
||||||
const segments = $("#buffer-bar").children;
|
const segments = $("#buffer-bar").children;
|
||||||
|
|
@ -217,8 +261,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (available) availableCount++;
|
if (available) availableCount++;
|
||||||
segments[i].classList.toggle("available", available);
|
const isAvailable = segments[i].classList.contains("available");
|
||||||
segments[i].classList.toggle("loading", !available && loadingSegments.has(i));
|
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
|
// Update download speed display
|
||||||
|
|
@ -228,7 +275,11 @@
|
||||||
if (kbps > 0) {
|
if (kbps > 0) {
|
||||||
speedText = kbps >= 1024 ? ` | ${(kbps / 1024).toFixed(1)} Mb/s` : ` | ${Math.round(kbps)} kb/s`;
|
speedText = kbps >= 1024 ? ` | ${(kbps / 1024).toFixed(1)} Mb/s` : ` | ${Math.round(kbps)} kb/s`;
|
||||||
}
|
}
|
||||||
|
if (bufferPct !== lastBufferPct || speedText !== lastSpeedText) {
|
||||||
$("#download-speed").textContent = `${bufferPct}% buffered${speedText}`;
|
$("#download-speed").textContent = `${bufferPct}% buffered${speedText}`;
|
||||||
|
lastBufferPct = bufferPct;
|
||||||
|
lastSpeedText = speedText;
|
||||||
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
// Prefetch missing segments
|
// Prefetch missing segments
|
||||||
|
|
@ -374,9 +425,12 @@
|
||||||
ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws");
|
ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws");
|
||||||
ws.onmessage = (e) => handleUpdate(JSON.parse(e.data));
|
ws.onmessage = (e) => handleUpdate(JSON.parse(e.data));
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (synced && ws) {
|
synced = false;
|
||||||
$("#status").textContent = "Disconnected. Reconnecting...";
|
ws = null;
|
||||||
$("#sync-indicator").classList.add("disconnected");
|
$("#sync-indicator").classList.add("disconnected");
|
||||||
|
updateUI();
|
||||||
|
// Auto-reconnect if user wants to be synced
|
||||||
|
if (wantSync) {
|
||||||
setTimeout(() => connectStream(id), 3000);
|
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() {
|
function renderPlaylist() {
|
||||||
const container = $("#playlist");
|
const container = $("#playlist");
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
|
|
@ -397,11 +460,12 @@
|
||||||
div.innerHTML = `<span>${title}</span><span class="duration">${fmt(track.duration)}</span>`;
|
div.innerHTML = `<span>${title}</span><span class="duration">${fmt(track.duration)}</span>`;
|
||||||
div.onclick = async () => {
|
div.onclick = async () => {
|
||||||
if (synced && currentStreamId) {
|
if (synced && currentStreamId) {
|
||||||
fetch("/api/streams/" + currentStreamId + "/jump", {
|
const res = await fetch("/api/streams/" + currentStreamId + "/jump", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ index: i })
|
body: JSON.stringify({ index: i })
|
||||||
});
|
});
|
||||||
|
if (res.status === 403) flashPermissionDenied();
|
||||||
} else {
|
} else {
|
||||||
currentIndex = i;
|
currentIndex = i;
|
||||||
currentFilename = track.filename;
|
currentFilename = track.filename;
|
||||||
|
|
@ -480,17 +544,22 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#btn-sync").onclick = () => {
|
$("#btn-sync").onclick = () => {
|
||||||
if (synced) {
|
wantSync = !wantSync;
|
||||||
// Unsync - go to local mode
|
if (wantSync) {
|
||||||
synced = false;
|
// User wants to sync - try to connect
|
||||||
localTimestamp = audio.currentTime || getServerTime();
|
|
||||||
if (ws) ws.close();
|
|
||||||
ws = null;
|
|
||||||
} else {
|
|
||||||
// Try to sync
|
|
||||||
if (currentStreamId) {
|
if (currentStreamId) {
|
||||||
connectStream(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();
|
updateUI();
|
||||||
};
|
};
|
||||||
|
|
@ -524,11 +593,12 @@
|
||||||
const newIndex = (index + playlist.length) % playlist.length;
|
const newIndex = (index + playlist.length) % playlist.length;
|
||||||
|
|
||||||
if (synced && currentStreamId) {
|
if (synced && currentStreamId) {
|
||||||
fetch("/api/streams/" + currentStreamId + "/jump", {
|
const res = await fetch("/api/streams/" + currentStreamId + "/jump", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ index: newIndex })
|
body: JSON.stringify({ index: newIndex })
|
||||||
});
|
});
|
||||||
|
if (res.status === 403) flashPermissionDenied();
|
||||||
} else {
|
} else {
|
||||||
const track = playlist[newIndex];
|
const track = playlist[newIndex];
|
||||||
currentIndex = newIndex;
|
currentIndex = newIndex;
|
||||||
|
|
@ -575,7 +645,7 @@
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ timestamp: seekTime })
|
body: JSON.stringify({ timestamp: seekTime })
|
||||||
});
|
}).then(res => { if (res.status === 403) flashPermissionDenied(); });
|
||||||
} else {
|
} else {
|
||||||
if (!audio.src) {
|
if (!audio.src) {
|
||||||
audio.src = getTrackUrl(currentFilename);
|
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 () => {
|
$("#btn-logout").onclick = async () => {
|
||||||
|
const wasGuest = currentUser?.isGuest;
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
currentUser = null;
|
currentUser = null;
|
||||||
|
if (wasGuest) {
|
||||||
|
// Guest clicking "Sign In" - show login panel
|
||||||
updateAuthUI();
|
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
|
// Initialize storage and load cached tracks
|
||||||
async function initStorage() {
|
async function initStorage() {
|
||||||
await TrackStorage.init();
|
await TrackStorage.init();
|
||||||
|
|
@ -714,7 +819,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
initStorage().then(() => {
|
Promise.all([initStorage(), loadServerStatus()]).then(() => {
|
||||||
loadCurrentUser().then(() => {
|
loadCurrentUser().then(() => {
|
||||||
if (currentUser) loadStreams();
|
if (currentUser) loadStreams();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@
|
||||||
<button class="submit-btn" id="btn-signup">Sign Up</button>
|
<button class="submit-btn" id="btn-signup">Sign Up</button>
|
||||||
<div id="signup-error"></div>
|
<div id="signup-error"></div>
|
||||||
</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>
|
||||||
|
|
||||||
<div id="player-content">
|
<div id="player-content">
|
||||||
|
|
@ -43,15 +47,15 @@
|
||||||
<div id="stream-name"></div>
|
<div id="stream-name"></div>
|
||||||
<div id="track-name" class="empty">
|
<div id="track-name" class="empty">
|
||||||
<span id="track-title">Loading...</span>
|
<span id="track-title">Loading...</span>
|
||||||
<span id="btn-sync" title="Toggle sync">sync</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="time"><span id="time-current">0:00</span><span id="time-total">0:00</span></div>
|
|
||||||
<div id="progress-row">
|
<div id="progress-row">
|
||||||
|
<span id="btn-sync" title="Toggle sync">sync</span>
|
||||||
<span id="btn-prev" title="Previous track">⏮</span>
|
<span id="btn-prev" title="Previous track">⏮</span>
|
||||||
<span id="status-icon">▶</span>
|
<span id="status-icon">▶</span>
|
||||||
<span id="btn-next" title="Next track">⏭</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="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>
|
||||||
<div id="buffer-bar"></div>
|
<div id="buffer-bar"></div>
|
||||||
<div id="download-speed"></div>
|
<div id="download-speed"></div>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
#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; }
|
#now-playing { margin-bottom: 0.5rem; }
|
||||||
#stream-name { font-size: 0.85rem; color: #888; margin-bottom: 0.2rem; }
|
#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; }
|
#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; text-transform: uppercase; letter-spacing: 0.05em; }
|
#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:hover { color: #888; }
|
||||||
#btn-sync.synced { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; }
|
#btn-sync.synced { color: #eb0; text-shadow: 0 0 8px #eb0, 0 0 12px #eb0; }
|
||||||
#time { display: flex; justify-content: space-between; font-size: 0.8rem; color: #888; margin-bottom: 0.3rem; }
|
#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 { 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, #btn-next { font-size: 0.8rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }
|
||||||
#btn-prev:hover, #btn-next:hover { opacity: 1; }
|
#btn-prev:hover, #btn-next:hover { opacity: 1; }
|
||||||
#status-icon { font-size: 0.9rem; width: 1rem; text-align: center; cursor: pointer; }
|
#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 { 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; }
|
#login-panel .submit-btn:hover { background: #5fa; }
|
||||||
#auth-error, #signup-error { color: #e44; font-size: 0.8rem; }
|
#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 { display: none; }
|
||||||
#player-content.visible { display: block; }
|
#player-content.visible { display: block; }
|
||||||
#playlist { margin-top: 1.5rem; max-height: 300px; overflow-y: auto; }
|
#playlist { margin-top: 1.5rem; max-height: 300px; overflow-y: auto; }
|
||||||
|
|
|
||||||
130
server.ts
130
server.ts
|
|
@ -2,12 +2,13 @@ import { file, serve, type ServerWebSocket } from "bun";
|
||||||
import { parseFile } from "music-metadata";
|
import { parseFile } from "music-metadata";
|
||||||
import { Stream, type Track } from "./stream";
|
import { Stream, type Track } from "./stream";
|
||||||
import { readdir, stat } from "fs/promises";
|
import { readdir, stat } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join, resolve } from "path";
|
||||||
import {
|
import {
|
||||||
createUser,
|
createUser,
|
||||||
findUserByUsername,
|
findUserByUsername,
|
||||||
validatePassword,
|
validatePassword,
|
||||||
createSession,
|
createSession,
|
||||||
|
createGuestSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
validateSession,
|
validateSession,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
|
|
@ -23,12 +24,28 @@ import {
|
||||||
requirePermission,
|
requirePermission,
|
||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
|
getClientInfo,
|
||||||
} from "./auth";
|
} 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 PLAYLIST_PATH = join(import.meta.dir, "playlist.json");
|
||||||
const PUBLIC_DIR = join(import.meta.dir, "public");
|
const PUBLIC_DIR = join(import.meta.dir, "public");
|
||||||
|
|
||||||
|
console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`);
|
||||||
|
|
||||||
// Load track metadata
|
// Load track metadata
|
||||||
async function loadTrack(filename: string): Promise<Track> {
|
async function loadTrack(filename: string): Promise<Track> {
|
||||||
const filepath = join(MUSIC_DIR, filename);
|
const filepath = join(MUSIC_DIR, filename);
|
||||||
|
|
@ -95,8 +112,42 @@ setInterval(() => {
|
||||||
|
|
||||||
type WsData = { streamId: string; userId: number | null };
|
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({
|
serve({
|
||||||
port: parseInt("3001"),
|
port: config.port,
|
||||||
async fetch(req, server) {
|
async fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
|
|
@ -105,15 +156,27 @@ serve({
|
||||||
if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) {
|
if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) {
|
||||||
const id = path.split("/")[3];
|
const id = path.split("/")[3];
|
||||||
if (!streams.has(id)) return new Response("Stream not found", { status: 404 });
|
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 } });
|
const ok = server.upgrade(req, { data: { streamId: id, userId: user?.id ?? null } });
|
||||||
if (ok) return undefined;
|
if (ok) return undefined;
|
||||||
return new Response("WebSocket upgrade failed", { status: 500 });
|
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") {
|
if (path === "/api/streams") {
|
||||||
const user = getUser(req, server);
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +185,7 @@ serve({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
trackCount: s.playlist.length,
|
trackCount: s.playlist.length,
|
||||||
}));
|
}));
|
||||||
return Response.json(list);
|
return Response.json(list, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth: signup
|
// Auth: signup
|
||||||
|
|
@ -197,16 +260,31 @@ serve({
|
||||||
|
|
||||||
// Auth: get current user
|
// Auth: get current user
|
||||||
if (path === "/api/auth/me") {
|
if (path === "/api/auth/me") {
|
||||||
const user = getUser(req, server);
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return Response.json({ user: null });
|
return Response.json({ user: null });
|
||||||
}
|
}
|
||||||
const permissions = getUserPermissions(user.id);
|
const permissions = getUserPermissions(user.id);
|
||||||
return Response.json({
|
// Add default permissions for all users (except control for guests)
|
||||||
user: { id: user.id, username: user.username, isAdmin: user.is_admin },
|
const effectivePermissions = [...permissions];
|
||||||
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, isGuest: user.is_guest },
|
||||||
|
permissions: effectivePermissions,
|
||||||
|
}, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
// Admin: list users
|
// Admin: list users
|
||||||
if (path === "/api/admin/users" && req.method === "GET") {
|
if (path === "/api/admin/users" && req.method === "GET") {
|
||||||
|
|
@ -251,10 +329,9 @@ serve({
|
||||||
const jumpMatch = path.match(/^\/api\/streams\/([^/]+)\/jump$/);
|
const jumpMatch = path.match(/^\/api\/streams\/([^/]+)\/jump$/);
|
||||||
if (jumpMatch && req.method === "POST") {
|
if (jumpMatch && req.method === "POST") {
|
||||||
const streamId = jumpMatch[1];
|
const streamId = jumpMatch[1];
|
||||||
try {
|
const { user } = getOrCreateUser(req, server);
|
||||||
requirePermission(req, "stream", streamId, "control", server);
|
if (!userHasPermission(user, "stream", streamId, "control")) {
|
||||||
} catch (e) {
|
return new Response("Forbidden", { status: 403 });
|
||||||
if (e instanceof Response) return e;
|
|
||||||
}
|
}
|
||||||
const stream = streams.get(streamId);
|
const stream = streams.get(streamId);
|
||||||
if (!stream) return new Response("Not found", { status: 404 });
|
if (!stream) return new Response("Not found", { status: 404 });
|
||||||
|
|
@ -274,10 +351,9 @@ serve({
|
||||||
const seekMatch = path.match(/^\/api\/streams\/([^/]+)\/seek$/);
|
const seekMatch = path.match(/^\/api\/streams\/([^/]+)\/seek$/);
|
||||||
if (seekMatch && req.method === "POST") {
|
if (seekMatch && req.method === "POST") {
|
||||||
const streamId = seekMatch[1];
|
const streamId = seekMatch[1];
|
||||||
try {
|
const { user } = getOrCreateUser(req, server);
|
||||||
requirePermission(req, "stream", streamId, "control", server);
|
if (!userHasPermission(user, "stream", streamId, "control")) {
|
||||||
} catch (e) {
|
return new Response("Forbidden", { status: 403 });
|
||||||
if (e instanceof Response) return e;
|
|
||||||
}
|
}
|
||||||
const stream = streams.get(streamId);
|
const stream = streams.get(streamId);
|
||||||
if (!stream) return new Response("Not found", { status: 404 });
|
if (!stream) return new Response("Not found", { status: 404 });
|
||||||
|
|
@ -301,10 +377,10 @@ serve({
|
||||||
return Response.json(stream.getState());
|
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\/(.+)$/);
|
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
|
||||||
if (trackMatch) {
|
if (trackMatch) {
|
||||||
const user = getUser(req, server);
|
const { user } = getOrCreateUser(req, server);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
@ -384,12 +460,18 @@ serve({
|
||||||
|
|
||||||
// Check permission for control actions
|
// Check permission for control actions
|
||||||
const userId = ws.data.userId;
|
const userId = ws.data.userId;
|
||||||
if (!userId) return; // Not logged in
|
if (!userId) return;
|
||||||
|
|
||||||
const user = findUserById(userId);
|
const user = findUserById(userId);
|
||||||
if (!user) return;
|
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;
|
if (!canControl) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -401,4 +483,4 @@ serve({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("MusicRoom running on http://localhost:3001");
|
console.log(`MusicRoom running on http://localhost:${config.port}`);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue