shared.ts
⎇
Raw
1import { realpath, stat } from "node:fs/promises";
2import path from "node:path";
3import { parseArgs } from "node:util";
4import type { Metadata, VideoExtras } from "music-server-shared/types";
5
6export 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
16if (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
26export 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
33export 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
42interface RawConfig {
43 roots?: Record<string, string>;
44 users?: { name?: string; passwordHash?: string; roots?: string[] }[];
45}
46
47function configError(message: string): never {
48 console.error(`Invalid config: ${message}`);
49 process.exit(1);
50}
51
52const configPath = process.env.CONFIG || "./config.json";
53
54async 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
107const loaded = await loadConfig();
108export const users = loaded.users;
109
110const dummyHash = await Bun.password.hash("dummy");
111
112export 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
118const authTokens = new Map<string, { expires: Date; user: User }>();
119
120export function issueAuthToken(token: string, expires: Date, user: User): void {
121 authTokens.set(token, { expires, user });
122}
123
124export 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
130export 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
141setInterval(
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
150export 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
156export 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
163for (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
169if (!args.serve) {
170 console.error("Missing --serve, set it to the directory containing the frontend code");
171 process.exit(1);
172}
173
174export const mediaTypes = ["audio", "video"];
175export const allowedTypes = [...mediaTypes, "image"];
176
177//simple cache with a per-entry TTL: stale entries are evicted on access and swept periodically
178export 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
213const oneDay = 1000 * 60 * 60 * 24;
214export const fileTypeCache = new TTLCache<string>(oneDay);
215export const probeCache = new TTLCache<Metadata>(oneDay);
216export const videoExtrasCache = new TTLCache<VideoExtras>(oneDay);
217export const generatedPlaylistIds = new Map<string, string[]>();
218
219export class PathInfo {
220 constructor(public mimeType: string) {}
221}
222
223export class ServerError {
224 constructor(
225 public status: number,
226 public error: string,
227 ) {}
228}
229