stremio/apps/master-api/src/nodes/routes.ts
Jos Vooges | STH 6437e42c30 Add one-click remote node upgrade via WebSocket (v1.3.3).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 03:08:36 +02:00

585 lines
19 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client";
import { loadConfig } from "../config";
import {
generateApiKey,
generateEnrollmentToken,
hashToken,
} from "../security/crypto";
import { AppError } from "../security/errors";
import { requireAdmin } from "../auth/routes";
import {
MEDIA_NODE_TARGET_VERSION,
buildUpgradeCommand,
mediaNodeBinaryForArch,
nodeSupportsRemoteUpgrade,
} from "./version";
import { randomUUID } from "crypto";
export async function registerNodeRoutes(app: FastifyInstance) {
app.post("/api/v1/nodes/enroll", async (request) => {
const body = request.body as {
token?: string;
name?: string;
location?: string;
hostname?: string;
architecture?: string;
version?: string;
publicStreamUrl?: string;
};
if (!body.token || !body.name || !body.publicStreamUrl) {
throw new AppError("INVALID_REQUEST", "token, name and publicStreamUrl required");
}
const tokenRecord = await prisma.nodeEnrollmentToken.findUnique({
where: { tokenHash: hashToken(body.token) },
});
if (!tokenRecord || tokenRecord.usedAt || tokenRecord.expiresAt < new Date()) {
throw new AppError("INVALID_TOKEN", "Enrollment token is invalid or expired", 403);
}
if (tokenRecord.name && tokenRecord.name !== body.name) {
throw new AppError("INVALID_TOKEN", "Enrollment token name mismatch", 403);
}
let locationId = tokenRecord.locationId;
if (!locationId && body.location) {
const location = await prisma.location.upsert({
where: { name: body.location },
create: { name: body.location },
update: {},
});
locationId = location.id;
}
const apiKey = generateApiKey();
const node = await prisma.$transaction(async (tx) => {
await tx.nodeEnrollmentToken.update({
where: { id: tokenRecord.id },
data: { usedAt: new Date() },
});
const created = await tx.node.create({
data: {
name: body.name!,
hostname: body.hostname,
locationId,
publicStreamUrl: body.publicStreamUrl!,
version: body.version,
architecture: body.architecture,
status: "OFFLINE",
},
});
await tx.nodeCredential.create({
data: {
nodeId: created.id,
apiKeyHash: hashToken(apiKey),
},
});
return created;
});
return {
nodeId: node.id,
apiKey,
name: node.name,
};
});
app.post(
"/api/v1/admin/nodes/enrollment-tokens",
{ preHandler: requireAdmin },
async (request) => {
const body = request.body as {
name?: string;
location?: string;
expiresInMinutes?: number;
};
const expiresInMinutes = body.expiresInMinutes ?? 30;
const token = generateEnrollmentToken();
let locationId: string | undefined;
if (body.location) {
const location = await prisma.location.upsert({
where: { name: body.location },
create: { name: body.location },
update: {},
});
locationId = location.id;
}
await prisma.nodeEnrollmentToken.create({
data: {
tokenHash: hashToken(token),
name: body.name,
locationId,
expiresAt: new Date(Date.now() + expiresInMinutes * 60 * 1000),
},
});
return { token, expiresInMinutes };
}
);
app.get("/api/v1/admin/nodes", { preHandler: requireAdmin }, async () => {
const { ensureDefaultShelves } = await import("../library/shelves");
await ensureDefaultShelves();
const { nodeConnectionManager } = await import("../websocket/manager");
const scanStatuses = nodeConnectionManager.getAllScanStatuses();
const nodes = await prisma.node.findMany({
include: {
location: true,
scanRoots: { include: { shelf: true }, orderBy: { path: "asc" } },
},
orderBy: { name: "asc" },
});
return {
nodes: nodes.map((n) => ({
...serializeNode(n),
scanStatus: scanStatuses[n.id] ?? (n.status === "ONLINE" ? "idle" : "offline"),
})),
targetNodeVersion: MEDIA_NODE_TARGET_VERSION,
};
});
app.get("/api/v1/admin/nodes/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.findUnique({
where: { id },
include: {
location: true,
scanRoots: { include: { shelf: true }, orderBy: { path: "asc" } },
},
});
if (!node) throw new AppError("NOT_FOUND", "Node not found", 404);
return { node: serializeNode(node) };
});
app.patch("/api/v1/admin/nodes/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as {
name?: string;
location?: string;
locationType?: "LOCAL" | "REMOTE";
publicStreamUrl?: string;
fullScanInterval?: string;
fullScanAt?: string;
moviesPaths?: string[];
seriesPaths?: string[];
scanRoots?: Array<{ path: string; shelfId: string }>;
};
let locationId: string | undefined | null = undefined;
if (body.location !== undefined) {
if (body.location === "") {
locationId = null;
} else {
const location = await prisma.location.upsert({
where: { name: body.location },
create: { name: body.location },
update: {},
});
locationId = location.id;
}
}
if (body.fullScanInterval !== undefined) {
const v = body.fullScanInterval.trim().toLowerCase();
if (v !== "off" && !/^\d+[smhd]$/.test(v)) {
throw new AppError(
"INVALID_REQUEST",
"fullScanInterval must be 'off' or a duration like 6h / 24h",
400
);
}
}
if (body.fullScanAt !== undefined) {
const v = body.fullScanAt.trim();
if (v.toLowerCase() !== "off" && !/^\d{1,2}:\d{2}$/.test(v)) {
throw new AppError("INVALID_REQUEST", "fullScanAt must be HH:MM or 'off'", 400);
}
}
const {
ensureDefaultShelves,
DEFAULT_MOVIE_SHELF_ID,
DEFAULT_SERIES_SHELF_ID,
deriveLegacyPaths,
getNodeScanRootsPayload,
} = await import("../library/shelves");
await ensureDefaultShelves();
let replacedRoots = false;
if (body.scanRoots !== undefined) {
const cleaned: Array<{ path: string; shelfId: string }> = [];
const seen = new Set<string>();
for (const row of body.scanRoots) {
const path = row.path.trim().replace(/\/+$/, "") || row.path.trim();
if (!path || !row.shelfId || seen.has(path)) continue;
seen.add(path);
cleaned.push({ path, shelfId: row.shelfId });
}
for (const row of cleaned) {
const shelf = await prisma.libraryShelf.findUnique({ where: { id: row.shelfId } });
if (!shelf) throw new AppError("INVALID_REQUEST", `Onbekende plank: ${row.shelfId}`, 400);
}
await prisma.$transaction(async (tx) => {
await tx.nodeScanRoot.deleteMany({ where: { nodeId: id } });
if (cleaned.length) {
await tx.nodeScanRoot.createMany({
data: cleaned.map((r) => ({ nodeId: id, path: r.path, shelfId: r.shelfId })),
});
}
});
replacedRoots = true;
} else if (body.moviesPaths !== undefined || body.seriesPaths !== undefined) {
// Legacy path lists → bind to default Films/Series shelves
const movies =
body.moviesPaths !== undefined
? normalizePathList(body.moviesPaths)
: undefined;
const series =
body.seriesPaths !== undefined
? normalizePathList(body.seriesPaths)
: undefined;
const existing = await prisma.nodeScanRoot.findMany({ where: { nodeId: id } });
const next: Array<{ path: string; shelfId: string }> = [];
if (movies) {
for (const p of movies) next.push({ path: p, shelfId: DEFAULT_MOVIE_SHELF_ID });
} else {
for (const r of existing) {
const shelf = await prisma.libraryShelf.findUnique({ where: { id: r.shelfId } });
if (shelf?.kind === "MOVIE") next.push({ path: r.path, shelfId: r.shelfId });
}
}
if (series) {
for (const p of series) next.push({ path: p, shelfId: DEFAULT_SERIES_SHELF_ID });
} else {
for (const r of existing) {
const shelf = await prisma.libraryShelf.findUnique({ where: { id: r.shelfId } });
if (shelf?.kind === "SERIES") next.push({ path: r.path, shelfId: r.shelfId });
}
}
await prisma.$transaction(async (tx) => {
await tx.nodeScanRoot.deleteMany({ where: { nodeId: id } });
if (next.length) {
await tx.nodeScanRoot.createMany({
data: next.map((r) => ({ nodeId: id, path: r.path, shelfId: r.shelfId })),
});
}
});
replacedRoots = true;
}
const rootsPayload = await getNodeScanRootsPayload(id);
const legacy = deriveLegacyPaths(rootsPayload);
const node = await prisma.node.update({
where: { id },
data: {
...(body.name ? { name: body.name } : {}),
...(locationId !== undefined ? { locationId } : {}),
...(body.locationType ? { locationType: body.locationType } : {}),
...(body.publicStreamUrl ? { publicStreamUrl: body.publicStreamUrl } : {}),
...(body.fullScanInterval !== undefined
? { fullScanInterval: body.fullScanInterval.trim().toLowerCase() }
: {}),
...(body.fullScanAt !== undefined ? { fullScanAt: body.fullScanAt.trim() } : {}),
...(replacedRoots
? { moviesPaths: legacy.moviesPaths, seriesPaths: legacy.seriesPaths }
: {}),
},
include: {
location: true,
scanRoots: { include: { shelf: true }, orderBy: { path: "asc" } },
},
});
const shouldPushConfig =
body.fullScanInterval !== undefined ||
body.fullScanAt !== undefined ||
replacedRoots;
if (shouldPushConfig) {
const { nodeConnectionManager } = await import("../websocket/manager");
nodeConnectionManager.sendConfigUpdate(id, {
fullScanInterval: node.fullScanInterval,
fullScanAt: node.fullScanAt,
moviesPaths: legacy.moviesPaths,
seriesPaths: legacy.seriesPaths,
scanRoots: rootsPayload.map((r) => ({
path: r.path,
shelfId: r.shelfId,
kind: r.kind,
})),
});
// Ensure a full inventory pass so shelfIds are rewritten for existing files.
if (replacedRoots) {
nodeConnectionManager.sendRescan(id, true);
}
}
return { node: serializeNode(node) };
});
app.post("/api/v1/admin/nodes/:id/disable", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
await prisma.node.update({
where: { id },
data: { status: "OFFLINE" },
});
await prisma.mediaFile.updateMany({
where: { nodeId: id },
data: { available: false },
});
const { nodeConnectionManager } = await import("../websocket/manager");
nodeConnectionManager.disconnect(id);
return { ok: true };
});
app.post("/api/v1/admin/nodes/:id/rotate-credentials", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const apiKey = generateApiKey();
await prisma.nodeCredential.update({
where: { nodeId: id },
data: { apiKeyHash: hashToken(apiKey), rotatedAt: new Date() },
});
const { nodeConnectionManager } = await import("../websocket/manager");
nodeConnectionManager.disconnect(id);
return { apiKey, warning: "Node must be reconfigured with the new API key" };
});
app.post("/api/v1/admin/nodes/:id/rescan", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.findUnique({ where: { id } });
if (!node) throw new AppError("NOT_FOUND", "Node not found", 404);
const { nodeConnectionManager } = await import("../websocket/manager");
const sent = nodeConnectionManager.sendRescan(id, true);
if (!sent) throw new AppError("NODE_OFFLINE", "Node is not connected", 503);
return { ok: true };
});
app.post("/api/v1/admin/nodes/:id/restart", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.findUnique({ where: { id } });
if (!node) throw new AppError("NOT_FOUND", "Node not found", 404);
const { nodeConnectionManager } = await import("../websocket/manager");
const sent = nodeConnectionManager.sendRestart(id, "admin");
if (!sent) throw new AppError("NODE_OFFLINE", "Node is not connected", 503);
return { ok: true };
});
app.get("/api/v1/admin/nodes/:id/upgrade-command", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.findUnique({
where: { id },
include: { scanRoots: { select: { path: true } } },
});
if (!node) throw new AppError("NOT_FOUND", "Node not found", 404);
const paths = [
...node.scanRoots.map((r) => r.path),
...node.moviesPaths,
...node.seriesPaths,
];
const masterUrl = loadConfig().PUBLIC_URL.replace(/\/$/, "");
const { command, appdata, platform } = buildUpgradeCommand(masterUrl, paths);
return {
command,
appdata,
platform,
currentVersion: node.version,
targetVersion: MEDIA_NODE_TARGET_VERSION,
needsUpgrade: node.version !== MEDIA_NODE_TARGET_VERSION,
};
});
app.post("/api/v1/admin/nodes/:id/upgrade", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.findUnique({
where: { id },
include: { scanRoots: { select: { path: true } } },
});
if (!node) throw new AppError("NOT_FOUND", "Node not found", 404);
const paths = [
...node.scanRoots.map((r) => r.path),
...node.moviesPaths,
...node.seriesPaths,
];
const masterUrl = loadConfig().PUBLIC_URL.replace(/\/$/, "");
const { command, platform } = buildUpgradeCommand(masterUrl, paths);
if (!nodeSupportsRemoteUpgrade(node.version)) {
return {
ok: false,
manualRequired: true,
command,
platform,
currentVersion: node.version,
targetVersion: MEDIA_NODE_TARGET_VERSION,
message:
"Deze node ondersteunt nog geen remote upgrade. Voer het commando éénmalig handmatig uit; daarna werkt Upgrade vanuit admin.",
};
}
const { nodeConnectionManager } = await import("../websocket/manager");
if (!nodeConnectionManager.isOnline(id)) {
throw new AppError("NODE_OFFLINE", "Node is niet verbonden", 503);
}
const binary = mediaNodeBinaryForArch(node.architecture);
const upgradeId = randomUUID();
const result = await nodeConnectionManager.upgradeAcked(id, {
upgradeId,
url: `${masterUrl}/install/${binary}`,
});
if (!result.ok) {
throw new AppError("UPGRADE_FAILED", result.error ?? "Upgrade mislukt", 500);
}
return {
ok: true,
upgradeId,
currentVersion: node.version,
targetVersion: MEDIA_NODE_TARGET_VERSION,
message: "Upgrade voltooid; node herstart.",
};
});
app.post("/api/v1/admin/nodes/:id/revoke", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.update({
where: { id },
data: { revoked: true, status: "REVOKED" },
});
const { nodeConnectionManager } = await import("../websocket/manager");
nodeConnectionManager.disconnect(id);
await prisma.mediaFile.updateMany({
where: { nodeId: id },
data: { available: false },
});
await prisma.playbackSession.updateMany({
where: { nodeId: id, status: "ACTIVE" },
data: { status: "REVOKED", revoked: true },
});
return { node: { id: node.id, status: node.status } };
});
app.delete("/api/v1/admin/nodes/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const existing = await prisma.node.findUnique({ where: { id } });
if (!existing) throw new AppError("NOT_FOUND", "Node not found", 404);
const { nodeConnectionManager } = await import("../websocket/manager");
nodeConnectionManager.disconnect(id);
await prisma.playbackSession.deleteMany({ where: { nodeId: id } });
await prisma.mediaFile.deleteMany({ where: { nodeId: id } });
await prisma.nodeCredential.deleteMany({ where: { nodeId: id } });
await prisma.node.delete({ where: { id } });
return { ok: true };
});
}
function serializeNode(n: {
id: string;
name: string;
hostname: string | null;
location: { name: string } | null;
locationType: string;
status: string;
version: string | null;
architecture: string | null;
publicStreamUrl: string;
lastSeen: Date | null;
totalStorage: bigint;
freeStorage: bigint;
activeStreams: number;
currentBandwidth: bigint;
libraryFileCount: number;
fullScanInterval?: string;
fullScanAt?: string;
moviesPaths?: string[];
seriesPaths?: string[];
revoked: boolean;
scanRoots?: Array<{
id: string;
path: string;
shelfId: string;
shelf: { id: string; name: string; slug: string; kind: string };
}>;
}) {
const scanRoots =
n.scanRoots?.map((r) => ({
id: r.id,
path: r.path,
shelfId: r.shelfId,
shelfName: r.shelf.name,
shelfSlug: r.shelf.slug,
kind: r.shelf.kind === "SERIES" ? ("series" as const) : ("movie" as const),
})) ?? [];
return {
id: n.id,
name: n.name,
hostname: n.hostname,
location: n.location?.name ?? null,
locationType: n.locationType,
status: n.status,
version: n.version,
architecture: n.architecture,
publicStreamUrl: n.publicStreamUrl,
lastSeen: n.lastSeen,
totalStorage: n.totalStorage.toString(),
freeStorage: n.freeStorage.toString(),
activeStreams: n.activeStreams,
currentBandwidth: n.currentBandwidth.toString(),
libraryFileCount: n.libraryFileCount,
fullScanInterval: n.fullScanInterval ?? "off",
fullScanAt: n.fullScanAt ?? "03:30",
moviesPaths: n.moviesPaths ?? [],
seriesPaths: n.seriesPaths ?? [],
scanRoots,
revoked: n.revoked,
};
}
function normalizePathList(raw: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const item of raw) {
const p = item.trim().replace(/\/+$/, "") || item.trim();
if (!p || seen.has(p)) continue;
seen.add(p);
out.push(p);
}
return out;
}
export async function authenticateNode(
nodeId: string,
apiKey: string
): Promise<boolean> {
const credential = await prisma.nodeCredential.findUnique({
where: { nodeId },
include: { node: true },
});
if (!credential || credential.node.revoked) return false;
return credential.apiKeyHash === hashToken(apiKey);
}