blastoise-archive/server.ts

755 lines
26 KiB
TypeScript

import { file, serve, type ServerWebSocket } from "bun";
import { parseFile } from "music-metadata";
import { Stream, type Track } from "./stream";
import { readdir, stat } from "fs/promises";
import { join, 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: {
stream?: string[];
};
}
const CONFIG_PATH = join(import.meta.dir, "config.json");
const config: Config = await file(CONFIG_PATH).json();
const MUSIC_DIR = resolve(import.meta.dir, config.musicDir);
const PLAYLIST_PATH = join(import.meta.dir, "playlist.json");
const PUBLIC_DIR = join(import.meta.dir, "public");
console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`);
// Initialize library
const library = new Library(MUSIC_DIR);
// Load track metadata (for stream initialization - converts library tracks to stream format)
async function loadTrack(filename: string): Promise<Track> {
const filepath = join(MUSIC_DIR, filename);
try {
const metadata = await parseFile(filepath, { duration: true });
const duration = metadata.format.duration ?? 0;
const title = metadata.common.title?.trim() || filename.replace(/\.[^.]+$/, "");
console.log(`Track: ${filename} | duration: ${duration}s | title: ${title}`);
return { filename, title, duration };
} catch (e) {
console.warn(`Could not read metadata for ${filename}, skipping`);
return { filename, title: filename.replace(/\.[^.]+$/, ""), duration: 0 };
}
}
// 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 [];
}
}
// Initialize streams
async function init(): Promise<Map<string, Stream>> {
// Scan library first
await library.scan();
library.startWatching();
const playlistData = await file(PLAYLIST_PATH).json();
const streams = new Map<string, Stream>();
// Get all discovered files for streams
const allFiles = await discoverTracks();
for (const cfg of playlistData.streams) {
let trackFiles: string[] = cfg.tracks;
if (trackFiles.length === 0) {
trackFiles = allFiles;
console.log(`Stream "${cfg.id}": using all ${trackFiles.length} tracks`);
}
const tracks = await Promise.all(trackFiles.map(loadTrack));
const validTracks = tracks.filter((t) => t.duration > 0);
if (validTracks.length === 0) {
console.warn(`Stream "${cfg.id}" has no valid tracks, skipping`);
continue;
}
const stream = new Stream({ id: cfg.id, name: cfg.name, tracks: validTracks });
streams.set(cfg.id, stream);
console.log(`Stream "${cfg.id}": ${validTracks.length} tracks loaded`);
}
return streams;
}
const streams = await init();
// Broadcast to all connected clients across all streams
function broadcastToAll(message: object) {
const data = JSON.stringify(message);
for (const stream of streams.values()) {
for (const ws of stream.clients) {
ws.send(data);
}
}
}
// 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 stream of streams.values()) {
const changed = stream.tick();
if (!changed && tickCount % 30 === 0) {
stream.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 === "stream" && config.defaultPermissions.stream?.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
if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) {
const id = path.split("/")[3];
if (!streams.has(id)) return new Response("Stream not found", { status: 404 });
const { user } = getOrCreateUser(req, server);
const ok = server.upgrade(req, { data: { streamId: id, userId: user?.id ?? null } });
if (ok) return undefined;
return new Response("WebSocket upgrade failed", { status: 500 });
}
// API: server status (public)
if (path === "/api/status") {
return Response.json({
name: "MusicRoom",
version: "1.0.0",
allowGuests: config.allowGuests,
allowSignups: true,
streamCount: streams.size,
defaultPermissions: config.defaultPermissions,
});
}
// API: list streams (requires auth or guest)
if (path === "/api/streams") {
const { user, headers } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const list = [...streams.values()].map((s) => ({
id: s.id,
name: s.name,
trackCount: s.playlist.length,
}));
return Response.json(list, { headers });
}
// 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.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
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\/streams\/([^/]+)\/jump$/);
if (jumpMatch && req.method === "POST") {
const streamId = jumpMatch[1];
const { user } = getOrCreateUser(req, server);
if (!userHasPermission(user, "stream", streamId, "control")) {
return new Response("Forbidden", { status: 403 });
}
const stream = streams.get(streamId);
if (!stream) return new Response("Not found", { status: 404 });
try {
const body = await req.json();
if (typeof body.index === "number") {
stream.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 stream
const seekMatch = path.match(/^\/api\/streams\/([^/]+)\/seek$/);
if (seekMatch && req.method === "POST") {
const streamId = seekMatch[1];
const { user } = getOrCreateUser(req, server);
if (!userHasPermission(user, "stream", streamId, "control")) {
return new Response("Forbidden", { status: 403 });
}
const stream = streams.get(streamId);
if (!stream) return new Response("Not found", { status: 404 });
try {
const body = await req.json();
if (typeof body.timestamp === "number") {
stream.seek(body.timestamp);
return Response.json({ success: true });
}
return new Response("Invalid timestamp", { status: 400 });
} catch {
return new Response("Invalid JSON", { status: 400 });
}
}
// API: stream state
const streamMatch = path.match(/^\/api\/streams\/([^/]+)$/);
if (streamMatch) {
const stream = streams.get(streamMatch[1]);
if (!stream) return new Response("Not found", { status: 404 });
return Response.json(stream.getState());
}
// 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" },
});
}
if (path === "/trackStorage.js") {
return new Response(file(join(PUBLIC_DIR, "trackStorage.js")), {
headers: { "Content-Type": "application/javascript" },
});
}
if (path === "/app.js") {
return new Response(file(join(PUBLIC_DIR, "app.js")), {
headers: { "Content-Type": "application/javascript" },
});
}
return new Response("Not found", { status: 404 });
},
websocket: {
open(ws: ServerWebSocket<WsData>) {
const stream = streams.get(ws.data.streamId);
if (stream) stream.addClient(ws);
},
close(ws: ServerWebSocket<WsData>) {
const stream = streams.get(ws.data.streamId);
if (stream) stream.removeClient(ws);
},
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
const stream = streams.get(ws.data.streamId);
if (!stream) {
console.log("[WS] No stream found for:", ws.data.streamId);
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.stream?.includes("control")
|| hasPermission(userId, "stream", ws.data.streamId, "control");
if (!canControl) {
console.log("[WS] User lacks control permission:", user.username);
return;
}
try {
const data = JSON.parse(String(message));
console.log("[WS] Control action:", data.action, "from", user.username);
if (data.action === "pause") stream.pause();
else if (data.action === "unpause") stream.unpause();
} catch {}
},
},
});
console.log(`MusicRoom running on http://localhost:${config.port}`);