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(); 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(); 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 { const credential = await prisma.nodeCredential.findUnique({ where: { nodeId }, include: { node: true }, }); if (!credential || credential.node.revoked) return false; return credential.apiKeyHash === hashToken(apiKey); }