shared.ts
| 1 | import { realpath, stat } from "node:fs/promises"; |
| 2 | import path from "node:path"; |
| 3 | import { parseArgs } from "node:util"; |
| 4 | import type { Metadata, VideoExtras } from "music-server-shared/types"; |
| 5 | |
| 6 | export const { values: args } = parseArgs({ |
| 7 | args: Bun.argv, |
| 8 | options: { |
| 9 | serve: { type: "string" }, |
| 10 | "hash-password": { type: "boolean" }, |
| 11 | }, |
| 12 | strict: true, |
| 13 | allowPositionals: true, |
| 14 | }); |
| 15 | |
| 16 | if (args["hash-password"]) { |
| 17 | const password = (await Bun.stdin.text()).replace(/\r?\n$/, ""); |
| 18 | if (!password) { |
| 19 | console.error("No password on stdin. Use: echo -n 'your-password' | server --hash-password"); |
| 20 | process.exit(1); |
| 21 | } |
| 22 | console.log(await Bun.password.hash(password)); |
| 23 | process.exit(0); |
| 24 | } |
| 25 | |
| 26 | export const coverRegex = |
| 27 | process.env.COVER_REGEX === undefined |
| 28 | ? /(cover|folder)\.(png|jpe?g)$/i |
| 29 | : process.env.COVER_REGEX === "" |
| 30 | ? "" |
| 31 | : new RegExp(process.env.COVER_REGEX, "i"); |
| 32 | |
| 33 | export interface User { |
| 34 | name: string; |
| 35 | //root names this user may see, in the order they should be listed |
| 36 | roots: string[]; |
| 37 | //name -> absolute directory, precomputed so path resolution is a single lookup. null-prototype, so |
| 38 | //a request for "constructor" or "toString" misses instead of finding an inherited property |
| 39 | rootDirs: Record<string, string>; |
| 40 | } |
| 41 | |
| 42 | interface RawConfig { |
| 43 | roots?: Record<string, string>; |
| 44 | users?: { name?: string; passwordHash?: string; roots?: string[] }[]; |
| 45 | } |
| 46 | |
| 47 | function configError(message: string): never { |
| 48 | console.error(`Invalid config: ${message}`); |
| 49 | process.exit(1); |
| 50 | } |
| 51 | |
| 52 | const configPath = process.env.CONFIG || "./config.json"; |
| 53 | |
| 54 | async function loadConfig(): Promise<{ users: User[]; passwordHashes: Map<string, string> }> { |
| 55 | let raw: RawConfig; |
| 56 | try { |
| 57 | raw = await Bun.file(configPath).json(); |
| 58 | } catch (error) { |
| 59 | //a parse error can quote the offending source line, which may be a password hash |
| 60 | console.error(`Failed to read config file ${configPath}: ${error instanceof SyntaxError ? "invalid JSON" : error}`); |
| 61 | process.exit(1); |
| 62 | } |
| 63 | if (typeof raw !== "object" || raw === null || Array.isArray(raw)) configError("not a JSON object"); |
| 64 | |
| 65 | const roots = raw.roots; |
| 66 | if (!roots || typeof roots !== "object" || Object.keys(roots).length === 0) configError("no roots defined"); |
| 67 | //assigning these as object keys is a silent no-op, which would drop the root without an error |
| 68 | const reservedNames = ["__proto__", "constructor", "prototype"]; |
| 69 | //resolved once here so the request path only has to realpath the target, not the root as well |
| 70 | const rootRealPaths: Record<string, string> = Object.create(null); |
| 71 | for (const [name, dir] of Object.entries(roots)) { |
| 72 | if (!name || name.includes("/")) configError(`root name ${JSON.stringify(name)} is empty or contains a slash`); |
| 73 | if (reservedNames.includes(name)) configError(`root name ${JSON.stringify(name)} is reserved`); |
| 74 | if (typeof dir !== "string" || !path.isAbsolute(dir)) configError(`root ${name} is not an absolute path`); |
| 75 | const resolved = await realpath(dir).catch(() => undefined); |
| 76 | if (resolved === undefined) configError(`root ${name} does not exist: ${dir}`); |
| 77 | if (!(await stat(resolved)).isDirectory()) configError(`root ${name} is not a directory: ${dir}`); |
| 78 | rootRealPaths[name] = resolved; |
| 79 | } |
| 80 | |
| 81 | if (!raw.users || raw.users.length === 0) configError("no users defined"); |
| 82 | const users: User[] = []; |
| 83 | const passwordHashes = new Map<string, string>(); |
| 84 | for (const user of raw.users) { |
| 85 | if (typeof user?.name !== "string" || !user.name) configError("a user has no name, or its name is not a string"); |
| 86 | if (passwordHashes.has(user.name)) configError(`duplicate user ${user.name}`); |
| 87 | if (typeof user.passwordHash !== "string" || !user.passwordHash) |
| 88 | configError(`user ${user.name} has no passwordHash, or it is not a string`); |
| 89 | //a malformed hash makes Bun.password.verify throw, which would turn every login into a 500. |
| 90 | //authenticate() catches that too, but failing here tells the operator what is actually wrong |
| 91 | if (!user.passwordHash.startsWith("$")) |
| 92 | configError(`user ${user.name} has a passwordHash that is not a hash - generate it with --hash-password`); |
| 93 | const userRoots = user.roots || []; |
| 94 | if (!Array.isArray(userRoots) || userRoots.length === 0) configError(`user ${user.name} has no roots`); |
| 95 | const rootDirs: Record<string, string> = Object.create(null); |
| 96 | for (const rootName of userRoots) { |
| 97 | const dir = rootRealPaths[rootName]; |
| 98 | if (!dir) configError(`user ${user.name} references unknown root ${rootName}`); |
| 99 | rootDirs[rootName] = dir; |
| 100 | } |
| 101 | users.push({ name: user.name, roots: userRoots, rootDirs }); |
| 102 | passwordHashes.set(user.name, user.passwordHash); |
| 103 | } |
| 104 | return { users, passwordHashes }; |
| 105 | } |
| 106 | |
| 107 | const loaded = await loadConfig(); |
| 108 | export const users = loaded.users; |
| 109 | |
| 110 | const dummyHash = await Bun.password.hash("dummy"); |
| 111 | |
| 112 | export async function authenticate(name: string, password: string): Promise<User | undefined> { |
| 113 | const hash = loaded.passwordHashes.get(name); |
| 114 | const matches = await Bun.password.verify(password, hash ?? dummyHash).catch(() => false); |
| 115 | return matches && hash ? users.find((user) => user.name === name) : undefined; |
| 116 | } |
| 117 | |
| 118 | const authTokens = new Map<string, { expires: Date; user: User }>(); |
| 119 | |
| 120 | export function issueAuthToken(token: string, expires: Date, user: User): void { |
| 121 | authTokens.set(token, { expires, user }); |
| 122 | } |
| 123 | |
| 124 | export function deleteAuthToken(token: string): void { |
| 125 | authTokens.delete(token); |
| 126 | } |
| 127 | |
| 128 | //checks presence *and* expiry - the daily sweep below only bounds memory, it is not an |
| 129 | //enforcement mechanism, so a token must not stay valid past its end date while awaiting it |
| 130 | export function userForToken(token: string): User | undefined { |
| 131 | const session = authTokens.get(token); |
| 132 | if (!session) return undefined; |
| 133 | if (session.expires.getTime() < Date.now()) { |
| 134 | authTokens.delete(token); |
| 135 | return undefined; |
| 136 | } |
| 137 | return session.user; |
| 138 | } |
| 139 | |
| 140 | //clear outdated tokens once a day |
| 141 | setInterval( |
| 142 | () => { |
| 143 | for (const [token, session] of authTokens.entries()) { |
| 144 | if (session.expires.getTime() < Date.now()) authTokens.delete(token); |
| 145 | } |
| 146 | }, |
| 147 | 1000 * 60 * 60 * 24, |
| 148 | ); |
| 149 | |
| 150 | export const excludeExtension = |
| 151 | process.env.EXCLUDE_EXTENSION === undefined |
| 152 | ? ["txt", "log", "nfo", "m3u", "htm", "html"] |
| 153 | : process.env.EXCLUDE_EXTENSION.split(","); |
| 154 | |
| 155 | //number of parallel mediainfo processes used while scanning uncached folders |
| 156 | export const scanConcurrency = (() => { |
| 157 | if (process.env.SCAN_CONCURRENCY === undefined) return 8; |
| 158 | const parsed = Number.parseInt(process.env.SCAN_CONCURRENCY, 10); |
| 159 | if (Number.isNaN(parsed) || parsed <= 0) return 8; |
| 160 | return parsed; |
| 161 | })(); |
| 162 | |
| 163 | for (const command of ["ffmpeg", "ffprobe", "tar", "avifenc", "mediainfo"]) { |
| 164 | if (Bun.which(command)) continue; |
| 165 | console.error(`Required command ${command} not found in PATH`); |
| 166 | process.exit(1); |
| 167 | } |
| 168 | |
| 169 | if (!args.serve) { |
| 170 | console.error("Missing --serve, set it to the directory containing the frontend code"); |
| 171 | process.exit(1); |
| 172 | } |
| 173 | |
| 174 | export const mediaTypes = ["audio", "video"]; |
| 175 | export const allowedTypes = [...mediaTypes, "image"]; |
| 176 | |
| 177 | //simple cache with a per-entry TTL: stale entries are evicted on access and swept periodically |
| 178 | export class TTLCache<V> { |
| 179 | private map = new Map<string, { value: V; timestamp: number }>(); |
| 180 | |
| 181 | constructor(private ttl: number) { |
| 182 | //periodic sweep to bound memory even for entries that are never accessed again |
| 183 | setInterval( |
| 184 | () => { |
| 185 | const now = Date.now(); |
| 186 | for (const [key, entry] of this.map.entries()) { |
| 187 | if (now - entry.timestamp > this.ttl) this.map.delete(key); |
| 188 | } |
| 189 | }, |
| 190 | 1000 * 60 * 60, |
| 191 | ); |
| 192 | } |
| 193 | |
| 194 | get(key: string): V | undefined { |
| 195 | const entry = this.map.get(key); |
| 196 | if (!entry) return undefined; |
| 197 | if (Date.now() - entry.timestamp > this.ttl) { |
| 198 | this.map.delete(key); |
| 199 | return undefined; |
| 200 | } |
| 201 | return entry.value; |
| 202 | } |
| 203 | |
| 204 | set(key: string, value: V): void { |
| 205 | this.map.set(key, { value, timestamp: Date.now() }); |
| 206 | } |
| 207 | |
| 208 | clear(): void { |
| 209 | this.map.clear(); |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | const oneDay = 1000 * 60 * 60 * 24; |
| 214 | export const fileTypeCache = new TTLCache<string>(oneDay); |
| 215 | export const probeCache = new TTLCache<Metadata>(oneDay); |
| 216 | export const videoExtrasCache = new TTLCache<VideoExtras>(oneDay); |
| 217 | export const generatedPlaylistIds = new Map<string, string[]>(); |
| 218 | |
| 219 | export class PathInfo { |
| 220 | constructor(public mimeType: string) {} |
| 221 | } |
| 222 | |
| 223 | export class ServerError { |
| 224 | constructor( |
| 225 | public status: number, |
| 226 | public error: string, |
| 227 | ) {} |
| 228 | } |
| 229 |