blastoise-archive/server.ts

687 lines
24 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,
} 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 queue 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)`, JSON.stringify(list.map(c => ({ id: c.id, listeners: c.listeners }))));
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);
// 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, username: user?.username ?? 'Guest' } });
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 });
}
// 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 queue
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);
// Broadcast updated channel list to all clients
broadcastChannelList();
}
},
close(ws: ServerWebSocket<WsData>) {
const channel = channels.get(ws.data.channelId);
if (channel) {
channel.removeClient(ws);
broadcastChannelList();
}
},
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
try {
const data = JSON.parse(String(message));
// 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 }));
broadcastChannelList();
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}`);