pastilhas rendered on the Jellyfin side with nothing opposite it, labelled as belonging to nobody — reported by the owner, about their own name, which is the only line on that screen that could not possibly be true. The cause is a correct exclusion used for the wrong question. `listProvisionableUsers` leaves the owner out on purpose: they hold the admin account this flow authenticates as, and provisioning them would be a second admin nobody asked for. But this view is not the provisioning list, it is "who exists on each side", and an account absent from the input read as an account that does not exist. So the viewer is looked up directly and joined onto the account the connection signs in as — known exactly from `jellyfinUserId` on the server row, never matched by name. It gets its own state rather than being called "linked", because there is no row in jellyfin_accounts and there should not be: it is the credential, not a provisioned account. It is also excluded from the "Jellyfin only" count, which was counting the one account both sides agree on as belonging to neither. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
296 lines
12 KiB
TypeScript
296 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,
|
|
userId,
|
|
);
|
|
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);
|
|
}
|