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