859 lines
30 KiB
TypeScript
859 lines
30 KiB
TypeScript
import { file, serve, type ServerWebSocket } from "bun";
|
|
import { Channel, type Track, type WsData } from "./channel";
|
|
import { readdir } from "fs/promises";
|
|
import { join, resolve } from "path";
|
|
import {
|
|
createUser,
|
|
findUserByUsername,
|
|
validatePassword,
|
|
createSession,
|
|
createGuestSession,
|
|
deleteSession,
|
|
validateSession,
|
|
hasPermission,
|
|
getUserPermissions,
|
|
getAllUsers,
|
|
grantPermission,
|
|
revokePermission,
|
|
findUserById,
|
|
createPlaylist,
|
|
getPlaylist,
|
|
updatePlaylist,
|
|
deletePlaylist,
|
|
getVisiblePlaylists,
|
|
canViewPlaylist,
|
|
canEditPlaylist,
|
|
getPlaylistTracks,
|
|
addTrackToPlaylist,
|
|
removeTrackFromPlaylist,
|
|
reorderPlaylistTrack,
|
|
type Playlist,
|
|
} from "./db";
|
|
import {
|
|
getUser,
|
|
requireUser,
|
|
requirePermission,
|
|
setSessionCookie,
|
|
clearSessionCookie,
|
|
getClientInfo,
|
|
} from "./auth";
|
|
import { Library } from "./library";
|
|
|
|
// Load config
|
|
interface Config {
|
|
port: number;
|
|
musicDir: string;
|
|
allowGuests: boolean;
|
|
defaultPermissions: {
|
|
channel?: 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 PUBLIC_DIR = join(import.meta.dir, "public");
|
|
|
|
console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`);
|
|
|
|
// Initialize library
|
|
const library = new Library(MUSIC_DIR);
|
|
|
|
// Auto-discover tracks if playlist is empty
|
|
async function discoverTracks(): Promise<string[]> {
|
|
try {
|
|
const files = await readdir(MUSIC_DIR);
|
|
return files.filter((f) => /\.(mp3|ogg|flac|wav|m4a|aac)$/i.test(f)).sort();
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Generate unique channel ID
|
|
function generateChannelId(): string {
|
|
return Math.random().toString(36).slice(2, 10);
|
|
}
|
|
|
|
// Initialize channels - create default channel with full library
|
|
const channels = new Map<string, Channel>();
|
|
|
|
async function init(): Promise<void> {
|
|
// Scan library first
|
|
await library.scan();
|
|
library.startWatching();
|
|
|
|
// Create default channel with full library
|
|
const allTracks = library.getAllTracks();
|
|
const tracks: Track[] = allTracks
|
|
.filter(t => t.duration > 0)
|
|
.map(t => ({
|
|
id: t.id,
|
|
filename: t.filename,
|
|
title: t.title || t.filename.replace(/\.[^.]+$/, ""),
|
|
duration: t.duration,
|
|
}));
|
|
|
|
const defaultChannel = new Channel({
|
|
id: "main",
|
|
name: "Main Channel",
|
|
description: "All tracks from the library",
|
|
tracks,
|
|
isDefault: true,
|
|
createdBy: null,
|
|
});
|
|
|
|
channels.set("main", defaultChannel);
|
|
console.log(`Default channel created: ${tracks.length} tracks`);
|
|
}
|
|
|
|
await init();
|
|
|
|
// Broadcast to all connected clients across all channels
|
|
function broadcastToAll(message: object) {
|
|
const data = JSON.stringify(message);
|
|
let clientCount = 0;
|
|
for (const channel of channels.values()) {
|
|
for (const ws of channel.clients) {
|
|
ws.send(data);
|
|
clientCount++;
|
|
}
|
|
}
|
|
console.log(`[Broadcast] Sent to ${clientCount} clients`);
|
|
}
|
|
|
|
// Broadcast channel list to all clients
|
|
function broadcastChannelList() {
|
|
const list = [...channels.values()].map(c => c.getListInfo());
|
|
console.log(`[Broadcast] Sending channel_list to all clients (${list.length} channels)`);
|
|
broadcastToAll({ type: "channel_list", channels: list });
|
|
}
|
|
|
|
// Listen for library changes and notify clients
|
|
library.on("added", (track) => {
|
|
console.log(`New track detected: ${track.title}`);
|
|
const allTracks = library.getAllTracks().map(t => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
duration: t.duration
|
|
}));
|
|
broadcastToAll({
|
|
type: "track_added",
|
|
track: { id: track.id, title: track.title, duration: track.duration },
|
|
library: allTracks
|
|
});
|
|
});
|
|
|
|
library.on("removed", (track) => {
|
|
console.log(`Track removed: ${track.title}`);
|
|
const allTracks = library.getAllTracks().map(t => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
duration: t.duration
|
|
}));
|
|
broadcastToAll({
|
|
type: "track_removed",
|
|
track: { id: track.id, title: track.title },
|
|
library: allTracks
|
|
});
|
|
});
|
|
|
|
// Tick interval: advance tracks when needed, broadcast every 30s
|
|
let tickCount = 0;
|
|
setInterval(() => {
|
|
tickCount++;
|
|
for (const channel of channels.values()) {
|
|
const changed = channel.tick();
|
|
if (changed) {
|
|
console.log(`[Tick] Channel "${channel.name}" advanced to track ${channel.currentIndex}`);
|
|
}
|
|
if (!changed && tickCount % 30 === 0) {
|
|
console.log(`[Tick] Broadcasting state for channel "${channel.name}" (${channel.clients.size} clients)`);
|
|
channel.broadcast();
|
|
}
|
|
}
|
|
}, 1000);
|
|
|
|
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 === "channel" && config.defaultPermissions.channel?.includes(permission)) {
|
|
return true;
|
|
}
|
|
|
|
// Check user-specific permissions
|
|
return hasPermission(user.id, resourceType, resourceId, permission);
|
|
}
|
|
|
|
serve({
|
|
port: config.port,
|
|
async fetch(req, server) {
|
|
const url = new URL(req.url);
|
|
const path = url.pathname;
|
|
|
|
// WebSocket upgrade for channels
|
|
if (path.match(/^\/api\/channels\/([^/]+)\/ws$/)) {
|
|
const id = path.split("/")[3];
|
|
if (!channels.has(id)) return new Response("Channel not found", { status: 404 });
|
|
const { user } = getOrCreateUser(req, server);
|
|
const ok = server.upgrade(req, { data: { channelId: id, userId: user?.id ?? null } });
|
|
if (ok) return undefined;
|
|
return new Response("WebSocket upgrade failed", { status: 500 });
|
|
}
|
|
|
|
// API: server status (public)
|
|
if (path === "/api/status") {
|
|
return Response.json({
|
|
name: "MusicRoom",
|
|
version: "1.0.0",
|
|
allowGuests: config.allowGuests,
|
|
allowSignups: true,
|
|
channelCount: channels.size,
|
|
defaultPermissions: config.defaultPermissions,
|
|
});
|
|
}
|
|
|
|
// API: list channels (requires auth or guest)
|
|
if (path === "/api/channels" && req.method === "GET") {
|
|
const { user, headers } = getOrCreateUser(req, server);
|
|
if (!user) {
|
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
const list = [...channels.values()].map(c => c.getListInfo());
|
|
return Response.json(list, { headers });
|
|
}
|
|
|
|
// API: create channel
|
|
if (path === "/api/channels" && req.method === "POST") {
|
|
const { user } = getOrCreateUser(req, server);
|
|
if (!user) {
|
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
if (user.is_guest) {
|
|
return Response.json({ error: "Guests cannot create channels" }, { status: 403 });
|
|
}
|
|
try {
|
|
const { name, description, trackIds } = await req.json();
|
|
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
|
return Response.json({ error: "Name is required" }, { status: 400 });
|
|
}
|
|
|
|
// Build track list from trackIds or default to full library
|
|
let tracks: Track[];
|
|
if (trackIds && Array.isArray(trackIds) && trackIds.length > 0) {
|
|
tracks = [];
|
|
for (const tid of trackIds) {
|
|
const libTrack = library.getTrack(tid);
|
|
if (libTrack) {
|
|
tracks.push({
|
|
id: libTrack.id,
|
|
filename: libTrack.filename,
|
|
title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""),
|
|
duration: libTrack.duration,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
// Default to full library
|
|
tracks = library.getAllTracks()
|
|
.filter(t => t.duration > 0)
|
|
.map(t => ({
|
|
id: t.id,
|
|
filename: t.filename,
|
|
title: t.title || t.filename.replace(/\.[^.]+$/, ""),
|
|
duration: t.duration,
|
|
}));
|
|
}
|
|
|
|
const channelId = generateChannelId();
|
|
const channel = new Channel({
|
|
id: channelId,
|
|
name: name.trim(),
|
|
description: description || "",
|
|
tracks,
|
|
createdBy: user.id,
|
|
isDefault: false,
|
|
});
|
|
channels.set(channelId, channel);
|
|
console.log(`[Channel] Created "${name.trim()}" (id=${channelId}) by user ${user.id}`);
|
|
broadcastChannelList();
|
|
return Response.json(channel.getListInfo(), { status: 201 });
|
|
} catch {
|
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
// API: delete channel
|
|
const channelDeleteMatch = path.match(/^\/api\/channels\/([^/]+)$/);
|
|
if (channelDeleteMatch && req.method === "DELETE") {
|
|
const channelId = channelDeleteMatch[1];
|
|
const { user } = getOrCreateUser(req, server);
|
|
if (!user) {
|
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
const channel = channels.get(channelId);
|
|
if (!channel) {
|
|
return Response.json({ error: "Channel not found" }, { status: 404 });
|
|
}
|
|
if (channel.isDefault) {
|
|
return Response.json({ error: "Cannot delete default channel" }, { status: 403 });
|
|
}
|
|
if (!user.is_admin && channel.createdBy !== user.id) {
|
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
channels.delete(channelId);
|
|
broadcastChannelList();
|
|
return Response.json({ success: true });
|
|
}
|
|
|
|
// API: get channel state
|
|
const channelMatch = path.match(/^\/api\/channels\/([^/]+)$/);
|
|
if (channelMatch && req.method === "GET") {
|
|
const channel = channels.get(channelMatch[1]);
|
|
if (!channel) return new Response("Not found", { status: 404 });
|
|
return Response.json(channel.getState());
|
|
}
|
|
|
|
// API: get library (all tracks)
|
|
if (path === "/api/library") {
|
|
const { user, headers } = getOrCreateUser(req, server);
|
|
if (!user) {
|
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
const tracks = library.getAllTracks().map(t => ({
|
|
id: t.id,
|
|
filename: t.filename,
|
|
title: t.title,
|
|
artist: t.artist,
|
|
album: t.album,
|
|
duration: t.duration,
|
|
available: t.available,
|
|
}));
|
|
return Response.json(tracks, { headers });
|
|
}
|
|
|
|
// Playlist API: list playlists
|
|
if (path === "/api/playlists" && req.method === "GET") {
|
|
const { user, headers } = getOrCreateUser(req, server);
|
|
if (!user) {
|
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
const playlists = getVisiblePlaylists(user.id, user.is_guest);
|
|
return Response.json(playlists, { headers });
|
|
}
|
|
|
|
// Playlist API: create playlist
|
|
if (path === "/api/playlists" && req.method === "POST") {
|
|
const { user } = getOrCreateUser(req, server);
|
|
if (!user) {
|
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
if (user.is_guest) {
|
|
return Response.json({ error: "Guests cannot create playlists" }, { status: 403 });
|
|
}
|
|
try {
|
|
const { name, description, visibility } = await req.json();
|
|
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
|
return Response.json({ error: "Name is required" }, { status: 400 });
|
|
}
|
|
const playlist = createPlaylist(user.id, name.trim(), visibility || "private", description);
|
|
return Response.json(playlist, { status: 201 });
|
|
} catch {
|
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
// Playlist API: get/update/delete single playlist
|
|
const playlistMatch = path.match(/^\/api\/playlists\/([^/]+)$/);
|
|
if (playlistMatch) {
|
|
const playlistId = playlistMatch[1];
|
|
const { user, headers } = getOrCreateUser(req, server);
|
|
if (!user) {
|
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
const playlist = getPlaylist(playlistId);
|
|
if (!playlist) {
|
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
|
}
|
|
|
|
if (req.method === "GET") {
|
|
if (!canViewPlaylist(playlist, user.id, user.is_guest)) {
|
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
const tracks = getPlaylistTracks(playlistId);
|
|
return Response.json({ ...playlist, tracks }, { headers });
|
|
}
|
|
|
|
if (req.method === "PUT") {
|
|
if (!canEditPlaylist(playlist, user.id)) {
|
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
try {
|
|
const { name, description, visibility } = await req.json();
|
|
updatePlaylist(playlistId, { name, description, visibility });
|
|
const updated = getPlaylist(playlistId);
|
|
return Response.json(updated);
|
|
} catch {
|
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
if (req.method === "DELETE") {
|
|
if (!canEditPlaylist(playlist, user.id)) {
|
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
deletePlaylist(playlistId);
|
|
return Response.json({ success: true });
|
|
}
|
|
}
|
|
|
|
// Playlist API: add tracks
|
|
const playlistTracksMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks$/);
|
|
if (playlistTracksMatch && req.method === "POST") {
|
|
const playlistId = playlistTracksMatch[1];
|
|
const { user } = getOrCreateUser(req, server);
|
|
if (!user) {
|
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
const playlist = getPlaylist(playlistId);
|
|
if (!playlist) {
|
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
|
}
|
|
if (!canEditPlaylist(playlist, user.id)) {
|
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
try {
|
|
const { trackIds, position } = await req.json();
|
|
if (!Array.isArray(trackIds) || trackIds.length === 0) {
|
|
return Response.json({ error: "trackIds array required" }, { status: 400 });
|
|
}
|
|
for (const trackId of trackIds) {
|
|
if (!library.getTrack(trackId)) {
|
|
return Response.json({ error: `Track not found: ${trackId}` }, { status: 404 });
|
|
}
|
|
}
|
|
let insertPos = position;
|
|
for (const trackId of trackIds) {
|
|
addTrackToPlaylist(playlistId, trackId, user.id, insertPos);
|
|
if (insertPos !== undefined) insertPos++;
|
|
}
|
|
const tracks = getPlaylistTracks(playlistId);
|
|
return Response.json({ tracks }, { status: 201 });
|
|
} catch {
|
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
// Playlist API: remove track
|
|
const playlistTrackMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks\/(\d+)$/);
|
|
if (playlistTrackMatch && req.method === "DELETE") {
|
|
const playlistId = playlistTrackMatch[1];
|
|
const position = parseInt(playlistTrackMatch[2]);
|
|
const { user } = getOrCreateUser(req, server);
|
|
if (!user) {
|
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
const playlist = getPlaylist(playlistId);
|
|
if (!playlist) {
|
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
|
}
|
|
if (!canEditPlaylist(playlist, user.id)) {
|
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
removeTrackFromPlaylist(playlistId, position);
|
|
return Response.json({ success: true });
|
|
}
|
|
|
|
// Playlist API: reorder tracks
|
|
const playlistReorderMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks\/reorder$/);
|
|
if (playlistReorderMatch && req.method === "PUT") {
|
|
const playlistId = playlistReorderMatch[1];
|
|
const { user } = getOrCreateUser(req, server);
|
|
if (!user) {
|
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
const playlist = getPlaylist(playlistId);
|
|
if (!playlist) {
|
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
|
}
|
|
if (!canEditPlaylist(playlist, user.id)) {
|
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
try {
|
|
const { from, to } = await req.json();
|
|
if (typeof from !== "number" || typeof to !== "number") {
|
|
return Response.json({ error: "from and to positions required" }, { status: 400 });
|
|
}
|
|
reorderPlaylistTrack(playlistId, from, to);
|
|
const tracks = getPlaylistTracks(playlistId);
|
|
return Response.json({ tracks });
|
|
} catch {
|
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
// Auth: signup
|
|
if (path === "/api/auth/signup" && req.method === "POST") {
|
|
try {
|
|
const { username, password } = await req.json();
|
|
if (!username || !password) {
|
|
return Response.json({ error: "Username and password required" }, { status: 400 });
|
|
}
|
|
if (username.length < 3 || password.length < 6) {
|
|
return Response.json({ error: "Username min 3 chars, password min 6 chars" }, { status: 400 });
|
|
}
|
|
const existing = findUserByUsername(username);
|
|
if (existing) {
|
|
return Response.json({ error: "Username already taken" }, { status: 400 });
|
|
}
|
|
const user = await createUser(username, password);
|
|
const userAgent = req.headers.get("user-agent") ?? undefined;
|
|
const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
|
?? req.headers.get("x-real-ip")
|
|
?? server.requestIP(req)?.address
|
|
?? undefined;
|
|
const token = createSession(user.id, userAgent, ipAddress);
|
|
console.log(`[AUTH] Signup: user="${username}" id=${user.id} admin=${user.is_admin} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`);
|
|
return Response.json(
|
|
{ user: { id: user.id, username: user.username, isAdmin: user.is_admin } },
|
|
{ headers: { "Set-Cookie": setSessionCookie(token) } }
|
|
);
|
|
} catch (e) {
|
|
return Response.json({ error: "Signup failed" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// Auth: login
|
|
if (path === "/api/auth/login" && req.method === "POST") {
|
|
try {
|
|
const { username, password } = await req.json();
|
|
const user = findUserByUsername(username);
|
|
if (!user || !(await validatePassword(user, password))) {
|
|
console.log(`[AUTH] Login failed: user="${username}"`);
|
|
return Response.json({ error: "Invalid username or password" }, { status: 401 });
|
|
}
|
|
const userAgent = req.headers.get("user-agent") ?? undefined;
|
|
const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
|
?? req.headers.get("x-real-ip")
|
|
?? server.requestIP(req)?.address
|
|
?? undefined;
|
|
const token = createSession(user.id, userAgent, ipAddress);
|
|
console.log(`[AUTH] Login: user="${username}" id=${user.id} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`);
|
|
return Response.json(
|
|
{ user: { id: user.id, username: user.username, isAdmin: user.is_admin } },
|
|
{ headers: { "Set-Cookie": setSessionCookie(token) } }
|
|
);
|
|
} catch (e) {
|
|
return Response.json({ error: "Login failed" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// Auth: logout
|
|
if (path === "/api/auth/logout" && req.method === "POST") {
|
|
const token = req.headers.get("cookie")?.match(/musicroom_session=([^;]+)/)?.[1];
|
|
if (token) {
|
|
const user = validateSession(token);
|
|
console.log(`[AUTH] Logout: user="${user?.username ?? "unknown"}" session=${token}`);
|
|
deleteSession(token);
|
|
}
|
|
return Response.json(
|
|
{ success: true },
|
|
{ headers: { "Set-Cookie": clearSessionCookie() } }
|
|
);
|
|
}
|
|
|
|
// Auth: get current user
|
|
if (path === "/api/auth/me") {
|
|
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.channel) {
|
|
for (const perm of config.defaultPermissions.channel) {
|
|
// Guests can never have control permission
|
|
if (user.is_guest && perm === "control") continue;
|
|
effectivePermissions.push({
|
|
id: 0,
|
|
user_id: user.id,
|
|
resource_type: "channel",
|
|
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
|
|
if (path === "/api/admin/users" && req.method === "GET") {
|
|
try {
|
|
requirePermission(req, "global", null, "admin", server);
|
|
return Response.json(getAllUsers());
|
|
} catch (e) {
|
|
if (e instanceof Response) return e;
|
|
return Response.json({ error: "Failed" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// Admin: grant permission
|
|
if (path.match(/^\/api\/admin\/users\/(\d+)\/permissions$/) && req.method === "POST") {
|
|
try {
|
|
requirePermission(req, "global", null, "admin", server);
|
|
const userId = parseInt(path.split("/")[4]);
|
|
const { resourceType, resourceId, permission } = await req.json();
|
|
grantPermission(userId, resourceType, resourceId, permission);
|
|
return Response.json({ success: true });
|
|
} catch (e) {
|
|
if (e instanceof Response) return e;
|
|
return Response.json({ error: "Failed" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// Admin: revoke permission
|
|
if (path.match(/^\/api\/admin\/users\/(\d+)\/permissions$/) && req.method === "DELETE") {
|
|
try {
|
|
requirePermission(req, "global", null, "admin", server);
|
|
const userId = parseInt(path.split("/")[4]);
|
|
const { resourceType, resourceId, permission } = await req.json();
|
|
revokePermission(userId, resourceType, resourceId, permission);
|
|
return Response.json({ success: true });
|
|
} catch (e) {
|
|
if (e instanceof Response) return e;
|
|
return Response.json({ error: "Failed" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// API: jump to track in playlist
|
|
const jumpMatch = path.match(/^\/api\/channels\/([^/]+)\/jump$/);
|
|
if (jumpMatch && req.method === "POST") {
|
|
const channelId = jumpMatch[1];
|
|
const { user } = getOrCreateUser(req, server);
|
|
if (!userHasPermission(user, "channel", channelId, "control")) {
|
|
return new Response("Forbidden", { status: 403 });
|
|
}
|
|
const channel = channels.get(channelId);
|
|
if (!channel) return new Response("Not found", { status: 404 });
|
|
try {
|
|
const body = await req.json();
|
|
if (typeof body.index === "number") {
|
|
channel.jumpTo(body.index);
|
|
return Response.json({ success: true });
|
|
}
|
|
return new Response("Invalid index", { status: 400 });
|
|
} catch {
|
|
return new Response("Invalid JSON", { status: 400 });
|
|
}
|
|
}
|
|
|
|
// API: seek in channel
|
|
const seekMatch = path.match(/^\/api\/channels\/([^/]+)\/seek$/);
|
|
if (seekMatch && req.method === "POST") {
|
|
const channelId = seekMatch[1];
|
|
const { user } = getOrCreateUser(req, server);
|
|
if (!userHasPermission(user, "channel", channelId, "control")) {
|
|
return new Response("Forbidden", { status: 403 });
|
|
}
|
|
const channel = channels.get(channelId);
|
|
if (!channel) return new Response("Not found", { status: 404 });
|
|
try {
|
|
const body = await req.json();
|
|
if (typeof body.timestamp === "number") {
|
|
channel.seek(body.timestamp);
|
|
return Response.json({ success: true });
|
|
}
|
|
return new Response("Invalid timestamp", { status: 400 });
|
|
} catch {
|
|
return new Response("Invalid JSON", { status: 400 });
|
|
}
|
|
}
|
|
|
|
// API: serve audio file (requires auth or guest)
|
|
// Supports both filename and track ID (sha256:...)
|
|
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
|
|
if (trackMatch) {
|
|
const { user } = getOrCreateUser(req, server);
|
|
if (!user) {
|
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
const identifier = decodeURIComponent(trackMatch[1]);
|
|
if (identifier.includes("..")) return new Response("Forbidden", { status: 403 });
|
|
|
|
let filepath: string;
|
|
if (identifier.startsWith("sha256:")) {
|
|
// Track ID - look up in library
|
|
const trackPath = library.getFilePath(identifier);
|
|
if (!trackPath) return new Response("Not found", { status: 404 });
|
|
filepath = trackPath;
|
|
} else {
|
|
// Filename - direct path
|
|
filepath = join(MUSIC_DIR, identifier);
|
|
}
|
|
|
|
const f = file(filepath);
|
|
if (!(await f.exists())) return new Response("Not found", { status: 404 });
|
|
|
|
const size = f.size;
|
|
const range = req.headers.get("range");
|
|
|
|
if (range) {
|
|
const match = range.match(/bytes=(\d+)-(\d*)/);
|
|
if (match) {
|
|
const start = parseInt(match[1]);
|
|
const end = match[2] ? parseInt(match[2]) : size - 1;
|
|
const chunk = f.slice(start, end + 1);
|
|
return new Response(chunk, {
|
|
status: 206,
|
|
headers: {
|
|
"Content-Range": `bytes ${start}-${end}/${size}`,
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": String(end - start + 1),
|
|
"Content-Type": f.type || "audio/mpeg",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return new Response(f, {
|
|
headers: {
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": String(size),
|
|
"Content-Type": f.type || "audio/mpeg",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Serve static client
|
|
if (path === "/" || path === "/index.html") {
|
|
return new Response(file(join(PUBLIC_DIR, "index.html")), {
|
|
headers: { "Content-Type": "text/html" },
|
|
});
|
|
}
|
|
if (path === "/styles.css") {
|
|
return new Response(file(join(PUBLIC_DIR, "styles.css")), {
|
|
headers: { "Content-Type": "text/css" },
|
|
});
|
|
}
|
|
// Serve JS files from public directory
|
|
if (path.endsWith(".js")) {
|
|
const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
|
|
if (await jsFile.exists()) {
|
|
return new Response(jsFile, {
|
|
headers: { "Content-Type": "application/javascript" },
|
|
});
|
|
}
|
|
}
|
|
|
|
return new Response("Not found", { status: 404 });
|
|
},
|
|
|
|
websocket: {
|
|
open(ws: ServerWebSocket<WsData>) {
|
|
const channel = channels.get(ws.data.channelId);
|
|
if (channel) {
|
|
channel.addClient(ws);
|
|
// Send channel list on connect
|
|
const list = [...channels.values()].map(c => c.getListInfo());
|
|
ws.send(JSON.stringify({ type: "channel_list", channels: list }));
|
|
}
|
|
},
|
|
close(ws: ServerWebSocket<WsData>) {
|
|
const channel = channels.get(ws.data.channelId);
|
|
if (channel) channel.removeClient(ws);
|
|
},
|
|
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
|
|
try {
|
|
const data = JSON.parse(String(message));
|
|
console.log("[WS] Received message:", data.action, "from streamId:", ws.data.streamId);
|
|
|
|
// Handle channel switching
|
|
if (data.action === "switch" && data.channelId) {
|
|
const oldChannel = channels.get(ws.data.channelId);
|
|
const newChannel = channels.get(data.channelId);
|
|
if (!newChannel) {
|
|
ws.send(JSON.stringify({ type: "error", message: "Channel not found" }));
|
|
return;
|
|
}
|
|
if (oldChannel) oldChannel.removeClient(ws);
|
|
ws.data.channelId = data.channelId;
|
|
newChannel.addClient(ws);
|
|
ws.send(JSON.stringify({ type: "switched", channelId: data.channelId }));
|
|
return;
|
|
}
|
|
|
|
const channel = channels.get(ws.data.channelId);
|
|
if (!channel) {
|
|
console.log("[WS] No channel found for:", ws.data.channelId);
|
|
return;
|
|
}
|
|
|
|
// Check permission for control actions
|
|
const userId = ws.data.userId;
|
|
if (!userId) {
|
|
console.log("[WS] No userId on connection");
|
|
return;
|
|
}
|
|
|
|
const user = findUserById(userId);
|
|
if (!user) {
|
|
console.log("[WS] User not found:", userId);
|
|
return;
|
|
}
|
|
|
|
// Guests can never control playback
|
|
if (user.is_guest) {
|
|
console.log("[WS] Guest cannot control playback");
|
|
return;
|
|
}
|
|
|
|
// Check default permissions or user-specific permissions
|
|
const canControl = user.is_admin
|
|
|| config.defaultPermissions.channel?.includes("control")
|
|
|| hasPermission(userId, "channel", ws.data.channelId, "control");
|
|
if (!canControl) {
|
|
console.log("[WS] User lacks control permission:", user.username);
|
|
return;
|
|
}
|
|
|
|
console.log("[WS] Control action:", data.action, "from", user.username);
|
|
if (data.action === "pause") channel.pause();
|
|
else if (data.action === "unpause") channel.unpause();
|
|
else if (data.action === "seek" && typeof data.timestamp === "number") channel.seek(data.timestamp);
|
|
else if (data.action === "jump" && typeof data.index === "number") channel.jumpTo(data.index);
|
|
} catch {}
|
|
},
|
|
},
|
|
});
|
|
|
|
console.log(`MusicRoom running on http://localhost:${config.port}`);
|