shared.ts
⎇
Raw
1import { realpath, stat } from "node:fs/promises";
2import path from "node:path";
3import { parseArgs } from "node:util";
4import commandExists from "command-exists";
5import type { Metadata, VideoExtras } from "music-server-shared/types";
6
7export 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
17if (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
27export 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
34export 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
43interface RawConfig {
44 roots?: Record<string, string>;
45 users?: { name?: string; passwordHash?: string; roots?: string[] }[];
46}
47
48function configError(message: string): never {
49 console.error(`Invalid config: ${message}`);
50 process.exit(1);
51}
52
53const configPath = process.env.CONFIG || "./config.json";
54
55async 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
108const loaded = await loadConfig();
109export const users = loaded.users;
110
111const dummyHash = await Bun.password.hash("dummy");
112
113export 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
119const authTokens = new Map<string, { expires: Date; user: User }>();
120
121export function issueAuthToken(token: string, expires: Date, user: User): void {
122 authTokens.set(token, { expires, user });
123}
124
125export 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
131export 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
142setInterval(
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
151export 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
157export 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
164if (!commandExists.sync("ffmpeg")) {
165 console.error("Required command ffmpeg not found in PATH");
166 process.exit(1);
167}
168if (!commandExists.sync("tar")) {
169 console.error("Required command tar not found in PATH");
170 process.exit(1);
171}
172if (!commandExists.sync("avifenc")) {
173 console.error("Required command avifenc not found in PATH");
174 process.exit(1);
175}
176if (!commandExists.sync("mediainfo")) {
177 console.error("Required command mediainfo not found in PATH");
178 process.exit(1);
179}
180
181if (!args.serve) {
182 console.error("Missing --serve, set it to the directory containing the frontend code");
183 process.exit(1);
184}
185
186export const mediaTypes = ["audio", "video"];
187export const allowedTypes = [...mediaTypes, "image"];
188
189//simple cache with a per-entry TTL: stale entries are evicted on access and swept periodically
190export 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
225const oneDay = 1000 * 60 * 60 * 24;
226export const fileTypeCache = new TTLCache<string>(oneDay);
227export const probeCache = new TTLCache<Metadata>(oneDay);
228export const videoExtrasCache = new TTLCache<VideoExtras>(oneDay);
229export const generatedPlaylistIds = new Map<string, string[]>();
230
231export class PathInfo {
232 constructor(public mimeType: string) {}
233}
234
235export class ServerError {
236 constructor(
237 public status: number,
238 public error: string,
239 ) {}
240}
241