import { realpath, stat } from "node:fs/promises"; import path from "node:path"; import { parseArgs } from "node:util"; import type { Metadata, VideoExtras } from "music-server-shared/types"; export const { values: args } = parseArgs({ args: Bun.argv, options: { serve: { type: "string" }, "hash-password": { type: "boolean" }, }, strict: true, allowPositionals: true, }); if (args["hash-password"]) { const password = (await Bun.stdin.text()).replace(/\r?\n$/, ""); if (!password) { console.error("No password on stdin. Use: echo -n 'your-password' | server --hash-password"); process.exit(1); } console.log(await Bun.password.hash(password)); process.exit(0); } export const coverRegex = process.env.COVER_REGEX === undefined ? /(cover|folder)\.(png|jpe?g)$/i : process.env.COVER_REGEX === "" ? "" : new RegExp(process.env.COVER_REGEX, "i"); export interface User { name: string; //root names this user may see, in the order they should be listed roots: string[]; //name -> absolute directory, precomputed so path resolution is a single lookup. null-prototype, so //a request for "constructor" or "toString" misses instead of finding an inherited property rootDirs: Record; } interface RawConfig { roots?: Record; users?: { name?: string; passwordHash?: string; roots?: string[] }[]; } function configError(message: string): never { console.error(`Invalid config: ${message}`); process.exit(1); } const configPath = process.env.CONFIG || "./config.json"; async function loadConfig(): Promise<{ users: User[]; passwordHashes: Map }> { let raw: RawConfig; try { raw = await Bun.file(configPath).json(); } catch (error) { //a parse error can quote the offending source line, which may be a password hash console.error(`Failed to read config file ${configPath}: ${error instanceof SyntaxError ? "invalid JSON" : error}`); process.exit(1); } if (typeof raw !== "object" || raw === null || Array.isArray(raw)) configError("not a JSON object"); const roots = raw.roots; if (!roots || typeof roots !== "object" || Object.keys(roots).length === 0) configError("no roots defined"); //assigning these as object keys is a silent no-op, which would drop the root without an error const reservedNames = ["__proto__", "constructor", "prototype"]; //resolved once here so the request path only has to realpath the target, not the root as well const rootRealPaths: Record = Object.create(null); for (const [name, dir] of Object.entries(roots)) { if (!name || name.includes("/")) configError(`root name ${JSON.stringify(name)} is empty or contains a slash`); if (reservedNames.includes(name)) configError(`root name ${JSON.stringify(name)} is reserved`); if (typeof dir !== "string" || !path.isAbsolute(dir)) configError(`root ${name} is not an absolute path`); const resolved = await realpath(dir).catch(() => undefined); if (resolved === undefined) configError(`root ${name} does not exist: ${dir}`); if (!(await stat(resolved)).isDirectory()) configError(`root ${name} is not a directory: ${dir}`); rootRealPaths[name] = resolved; } if (!raw.users || raw.users.length === 0) configError("no users defined"); const users: User[] = []; const passwordHashes = new Map(); for (const user of raw.users) { if (typeof user?.name !== "string" || !user.name) configError("a user has no name, or its name is not a string"); if (passwordHashes.has(user.name)) configError(`duplicate user ${user.name}`); if (typeof user.passwordHash !== "string" || !user.passwordHash) configError(`user ${user.name} has no passwordHash, or it is not a string`); //a malformed hash makes Bun.password.verify throw, which would turn every login into a 500. //authenticate() catches that too, but failing here tells the operator what is actually wrong if (!user.passwordHash.startsWith("$")) configError(`user ${user.name} has a passwordHash that is not a hash - generate it with --hash-password`); const userRoots = user.roots || []; if (!Array.isArray(userRoots) || userRoots.length === 0) configError(`user ${user.name} has no roots`); const rootDirs: Record = Object.create(null); for (const rootName of userRoots) { const dir = rootRealPaths[rootName]; if (!dir) configError(`user ${user.name} references unknown root ${rootName}`); rootDirs[rootName] = dir; } users.push({ name: user.name, roots: userRoots, rootDirs }); passwordHashes.set(user.name, user.passwordHash); } return { users, passwordHashes }; } const loaded = await loadConfig(); export const users = loaded.users; const dummyHash = await Bun.password.hash("dummy"); export async function authenticate(name: string, password: string): Promise { const hash = loaded.passwordHashes.get(name); const matches = await Bun.password.verify(password, hash ?? dummyHash).catch(() => false); return matches && hash ? users.find((user) => user.name === name) : undefined; } const authTokens = new Map(); export function issueAuthToken(token: string, expires: Date, user: User): void { authTokens.set(token, { expires, user }); } export function deleteAuthToken(token: string): void { authTokens.delete(token); } //checks presence *and* expiry - the daily sweep below only bounds memory, it is not an //enforcement mechanism, so a token must not stay valid past its end date while awaiting it export function userForToken(token: string): User | undefined { const session = authTokens.get(token); if (!session) return undefined; if (session.expires.getTime() < Date.now()) { authTokens.delete(token); return undefined; } return session.user; } //clear outdated tokens once a day setInterval( () => { for (const [token, session] of authTokens.entries()) { if (session.expires.getTime() < Date.now()) authTokens.delete(token); } }, 1000 * 60 * 60 * 24, ); export const excludeExtension = process.env.EXCLUDE_EXTENSION === undefined ? ["txt", "log", "nfo", "m3u", "htm", "html"] : process.env.EXCLUDE_EXTENSION.split(","); //number of parallel mediainfo processes used while scanning uncached folders export const scanConcurrency = (() => { if (process.env.SCAN_CONCURRENCY === undefined) return 8; const parsed = Number.parseInt(process.env.SCAN_CONCURRENCY, 10); if (Number.isNaN(parsed) || parsed <= 0) return 8; return parsed; })(); for (const command of ["ffmpeg", "ffprobe", "tar", "avifenc", "mediainfo"]) { if (Bun.which(command)) continue; console.error(`Required command ${command} not found in PATH`); process.exit(1); } if (!args.serve) { console.error("Missing --serve, set it to the directory containing the frontend code"); process.exit(1); } export const mediaTypes = ["audio", "video"]; export const allowedTypes = [...mediaTypes, "image"]; //simple cache with a per-entry TTL: stale entries are evicted on access and swept periodically export class TTLCache { private map = new Map(); constructor(private ttl: number) { //periodic sweep to bound memory even for entries that are never accessed again setInterval( () => { const now = Date.now(); for (const [key, entry] of this.map.entries()) { if (now - entry.timestamp > this.ttl) this.map.delete(key); } }, 1000 * 60 * 60, ); } get(key: string): V | undefined { const entry = this.map.get(key); if (!entry) return undefined; if (Date.now() - entry.timestamp > this.ttl) { this.map.delete(key); return undefined; } return entry.value; } set(key: string, value: V): void { this.map.set(key, { value, timestamp: Date.now() }); } clear(): void { this.map.clear(); } } const oneDay = 1000 * 60 * 60 * 24; export const fileTypeCache = new TTLCache(oneDay); export const probeCache = new TTLCache(oneDay); export const videoExtrasCache = new TTLCache(oneDay); export const generatedPlaylistIds = new Map(); export class PathInfo { constructor(public mimeType: string) {} } export class ServerError { constructor( public status: number, public error: string, ) {} }