585 lines
19 KiB
TypeScript
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);
|
|
}
|