Provisioning was invisible. Connecting a server can create accounts, adopt accounts and leave accounts alone, and the only record of which happened was a JSON field in a response nobody reads and rows in a table nobody can see. "Did it work" could only be answered by opening Jellyfin and counting. The nav entry is Settings rather than Connection, since it is now more than the connection. The join is computed in the sidecar and shares `normalize` with the reconcile planner, so the screen cannot disagree with the matching rule about whether Diogo and diogo are one person — a settings page saying two would be lying about what the next connection will do. It joins by the link ROW first and by folded name only where none exists: a recorded pairing is a fact, a matching name is a candidate. Four states, because they mean different things — linked (adopted or created), names match but not recorded yet, Officer account with no Jellyfin one, and a Jellyfin account with no Officer user. The last is never touched, and a linked pairing whose Jellyfin account has vanished is shown as such rather than silently re-matched by name. "Not known" is kept distinct from "none": if the platform has not sent the account list, or the server cannot be reached, it says so instead of rendering an empty table that would read as a fact about the server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
import type { JellyfinServer } from '../db';
|
|
import {
|
|
createJellyfinServer,
|
|
deleteJellyfinServer,
|
|
getJellyfinCredentials,
|
|
listJellyfinServers,
|
|
recordJellyfinProbe,
|
|
setActiveJellyfinServer,
|
|
updateJellyfinServer,
|
|
} from '../db';
|
|
import { UpstreamError, authenticate, invalidateConfig, normalizeBase, probe, publicInfo } from './upstream';
|
|
import { runProvisioning } from './provision-run';
|
|
import { buildAccountView } from './account-view';
|
|
|
|
// `/_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<Response> {
|
|
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<void> {
|
|
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<ServerBody> =>
|
|
((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<Response> {
|
|
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);
|
|
|
|
// ── Provision, if this connection can ──
|
|
//
|
|
// The trigger is registering an ADMIN server, not installing the plugin: at install time there is no
|
|
// credential, so there is nothing to provision with. This is also the right moment for a re-run — a
|
|
// token revoked and re-issued, or the server moving, both come back through here.
|
|
//
|
|
// Awaited, so the response says what happened rather than the owner discovering it in a log. It can
|
|
// take a few seconds against a server with many accounts; that is the honest cost of telling the truth
|
|
// in the reply.
|
|
const provisioning = await runProvisioning({
|
|
userId,
|
|
serverId: server.id,
|
|
base: url,
|
|
token: auth.token,
|
|
jellyfinUserId: auth.userId,
|
|
deviceId,
|
|
});
|
|
return Response.json({ server, provisioning });
|
|
} 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<Response> {
|
|
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<Response> {
|
|
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 '/<id>', '/<id>/activate', '/<id>/test'. */
|
|
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
|
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);
|
|
}
|
|
|
|
// Who exists on each side, and which pairs are the same person. Read-only and computed on demand —
|
|
// nothing is cached, because the interesting case is somebody adding or removing an account in
|
|
// Jellyfin's own UI and wondering why Officer disagrees.
|
|
if (action === 'users') {
|
|
if (req.method !== 'GET') return bad('method not allowed', 405);
|
|
const creds = await getJellyfinCredentials(userId, id);
|
|
if (!creds) return bad('no such server', 404);
|
|
try {
|
|
const view = await buildAccountView(
|
|
{
|
|
base: normalizeBase(creds.url),
|
|
token: creds.accessToken,
|
|
jellyfinUserId: creds.jellyfinUserId,
|
|
deviceId: creds.deviceId,
|
|
},
|
|
id,
|
|
);
|
|
return Response.json(view);
|
|
} catch (err) {
|
|
// The server being unreachable is the common one, and it is not this route's fault. Reported as a
|
|
// reason rather than a 500 so the screen can say so beside the connection details.
|
|
return Response.json({ known: false, reason: err instanceof Error ? err.message : String(err) });
|
|
}
|
|
}
|
|
|
|
// GET only. It probes one server and reports whether it answered — nothing is written, and nothing is
|
|
// switched (that is `activate`, below). It accepted POST as well until 2026-08-06, which made the
|
|
// method meaningless for deciding whether this is a read; the permission model needs that answer.
|
|
if (action === 'test') {
|
|
if (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);
|
|
}
|