import type { JellyfinServer } from 'officerdb'; import { createJellyfinServer, deleteJellyfinServer, getJellyfinCredentials, listJellyfinServers, recordJellyfinProbe, setActiveJellyfinServer, updateJellyfinServer, } from 'officerdb'; import { UpstreamError, authenticate, invalidateConfig, normalizeBase, probe, publicInfo } from './upstream'; // `/_config` — the Jellyfin server registry, driven from the app. // // The access token is WRITE-ONLY across this boundary, and it is never even written directly: the owner // supplies a URL, a username and a password, and the sidecar trades them for a token through // `POST /Users/AuthenticateByName`. The password lives for the duration of that one call. The list route // reports label, URL, account name, server name and version — it has no field that could carry a token, // masked or otherwise. // // A save is validated against the live instance before it is stored, in two steps that answer two different // questions: `/System/Info/Public` says "this URL is a Jellyfin, and it is called X", and the sign-in says // "these credentials work on it". Reporting them separately is what makes a typo'd port distinguishable from // a wrong password in the setup form. const bad = (error: string, status = 400) => Response.json({ error }, { status }); /** What the browser is allowed to know about the registry. Never includes a token. */ async function serverList(userId: number): Promise { const servers = await listJellyfinServers(userId); const active = servers.find((server) => server.isActive) ?? null; return Response.json({ configured: !!active, activeId: active?.id ?? null, servers }); } /** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */ export async function noteProbe(userId: number, id: number, version: string | null): Promise { await recordJellyfinProbe(userId, id, version).catch(() => { /* a stale lastSeenAt is not worth failing a request over */ }); } type ServerBody = { label?: unknown; url?: unknown; username?: unknown; password?: unknown }; const readBody = async (req: Request): Promise => ((await req.json().catch(() => null)) as ServerBody | null) ?? {}; const readLabel = (body: ServerBody): string => (typeof body.label === 'string' ? body.label.trim() : ''); const readUrl = (body: ServerBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : ''); const readUsername = (body: ServerBody): string => (typeof body.username === 'string' ? body.username.trim() : ''); const readPassword = (body: ServerBody): string => (typeof body.password === 'string' ? body.password : ''); const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); /** Sign-in and reachability failures, in the words of what the owner actually did. */ function upstreamError(err: unknown): string { if (err instanceof UpstreamError) return err.message; return `could not reach that URL (${err instanceof Error ? err.message : String(err)})`; } const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_jellyfin_servers_user_label'); /** * Add a server: the URL is identified, the credentials are traded for a token, then the token is stored * encrypted and the password is dropped. * * A fresh `DeviceId` is minted here and only here. It is what Jellyfin keys sessions and remembered devices * by, so it belongs to the ROW, not to a request — see schema/jellyfin.ts. */ async function addServer(req: Request, userId: number): Promise { const body = await readBody(req); const url = readUrl(body); const username = readUsername(body); const password = readPassword(body); let label = readLabel(body); if (!url) return bad('url is required'); if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); if (!username) return bad('username is required'); if (!password) return bad('password is required'); let info; try { info = await publicInfo(url); } catch (err) { return bad(upstreamError(err)); } const deviceId = crypto.randomUUID(); let auth; try { auth = await authenticate(url, deviceId, username, password); } catch (err) { return bad(upstreamError(err)); } // An unlabelled server takes the instance's own name, which is nearly always what the owner would type. // Falling back to the host keeps the switcher readable when the server is unnamed. if (!label) label = info.serverName ?? new URL(url).host; // The first server wins the selection: a registry with rows but nothing selected reads as "not connected". const existing = await listJellyfinServers(userId); const activate = existing.length === 0; try { const server = await createJellyfinServer({ userId, label, url, accessToken: auth.token, jellyfinUserId: auth.userId, jellyfinUsername: auth.username ?? username, deviceId, serverName: info.serverName, version: info.version, activate, }); invalidateConfig(userId); return Response.json({ server }); } catch (err) { if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`); throw err; } } /** * Edit one server. No password means "keep the stored token", so a rename never needs one re-typed. * * Re-authenticating deliberately keeps the row's existing `deviceId`: the point of signing in again is * usually that the token was revoked, and reusing the device id keeps the new session attached to the same * entry in Jellyfin's device list instead of adding a second ghost. */ async function editServer(req: Request, userId: number, id: number): Promise { const body = await readBody(req); const label = readLabel(body); const url = readUrl(body); const username = readUsername(body); const password = readPassword(body); const current = await getJellyfinCredentials(userId, id); if (!current) return bad('no such server', 404); if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://'); const base = url || normalizeBase(current.url); let accessToken: string | undefined; let jellyfinUserId: string | undefined; let jellyfinUsername: string | null | undefined; let serverName: string | null | undefined; let version: string | null | undefined; if (password) { if (!username) return bad('username is required to sign in again'); try { const info = await publicInfo(base); const auth = await authenticate(base, current.deviceId, username, password); accessToken = auth.token; jellyfinUserId = auth.userId; jellyfinUsername = auth.username ?? username; serverName = info.serverName; version = info.version; } catch (err) { return bad(upstreamError(err)); } } else if (url && base !== normalizeBase(current.url)) { // Moving a server to a new URL without re-authenticating is allowed — a reverse proxy in front of the // same instance keeps the token valid — but it has to be checked, because pointing at a DIFFERENT // Jellyfin would leave a row whose token belongs to another server. const result = await probe({ ...current, base, token: current.accessToken }); if (!result.ok) return bad(result.error ?? 'that URL did not accept the stored token'); serverName = result.serverName; version = result.version; } try { const server = await updateJellyfinServer(userId, id, { label: label || undefined, url: url || undefined, accessToken, jellyfinUserId, jellyfinUsername, serverName, version, }); if (!server) return bad('no such server', 404); invalidateConfig(userId); return Response.json({ server }); } catch (err) { if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`); throw err; } } /** Check one stored server without switching to it — what the "test" button on each row calls. */ async function testServer(userId: number, id: number): Promise { const creds = await getJellyfinCredentials(userId, id); if (!creds) return bad('no such server', 404); const started = Date.now(); const result = await probe({ ...creds, base: normalizeBase(creds.url), token: creds.accessToken }); const ms = Date.now() - started; if (!result.ok) return Response.json({ ok: false, error: result.error, ms }, { status: 502 }); await noteProbe(userId, id, result.version); return Response.json({ ok: true, version: result.version, serverName: result.serverName, ms }); } export type { JellyfinServer }; /** `subpath` is '' for /_config, or '/', '//activate', '//test'. */ export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { const [, rawId, action] = subpath.split('/'); if (!rawId) { if (req.method === 'GET') return serverList(userId); if (req.method === 'POST' || req.method === 'PUT') return addServer(req, userId); return bad('method not allowed', 405); } const id = Number(rawId); if (!Number.isInteger(id) || id <= 0) return bad('invalid server id', 404); if (action === 'activate') { if (req.method !== 'POST') return bad('method not allowed', 405); const server = await setActiveJellyfinServer(userId, id); if (!server) return bad('no such server', 404); invalidateConfig(userId); return serverList(userId); } if (action === 'test') { if (req.method !== 'POST' && req.method !== 'GET') return bad('method not allowed', 405); return testServer(userId, id); } if (action) return bad('not found', 404); if (req.method === 'PATCH' || req.method === 'PUT') return editServer(req, userId, id); if (req.method === 'DELETE') { const removed = await deleteJellyfinServer(userId, id); if (!removed) return bad('no such server', 404); invalidateConfig(userId); return serverList(userId); } return bad('method not allowed', 405); }