317 lines
8.6 KiB
JavaScript

const express = require("express");
const { z } = require("zod");
const prisma = require("./db");
const { parseServerImport } = require("./importParser");
const { asIntegerPort, normalizeEndpoint, normalizeIp, normalizeMethod, serviceTarget } = require("./utils");
const router = express.Router();
const serverSchema = z.object({
name: z.string().trim().min(1),
ipAddress: z.string().trim().min(1),
description: z.string().optional().nullable(),
});
const serviceSchema = z.object({
serverId: z.string().min(1),
name: z.string().trim().min(1),
port: z.coerce.number().int().min(1).max(65535),
endpoint: z.string().trim().min(1),
method: z.string().optional(),
requestTemplate: z.any().optional().nullable(),
notes: z.string().optional().nullable(),
});
function includeServices() {
return {
services: {
orderBy: [{ port: "asc" }, { name: "asc" }],
},
};
}
async function findConflicts(parsedServers) {
const conflicts = [];
const duplicates = [];
const newServers = [];
const newServices = [];
for (const server of parsedServers) {
const existingServer = await prisma.server.findUnique({
where: { ipAddress: server.ipAddress },
include: { services: true },
});
if (!existingServer) {
newServers.push(server);
newServices.push(...server.services.map((service) => ({ serverIp: server.ipAddress, ...service })));
continue;
}
const sameServer = existingServer.name === server.name && (existingServer.description || null) === (server.description || null);
if (!sameServer) {
conflicts.push({
type: "server",
ipAddress: server.ipAddress,
existing: {
id: existingServer.id,
name: existingServer.name,
description: existingServer.description,
},
incoming: {
name: server.name,
description: server.description,
},
});
}
for (const service of server.services) {
const existingService = existingServer.services.find(
(item) => item.port === service.port && item.endpoint === service.endpoint,
);
if (!existingService) {
newServices.push({ serverId: existingServer.id, serverIp: server.ipAddress, ...service });
continue;
}
const sameService =
existingService.name === service.name &&
existingService.method === service.method &&
JSON.stringify(existingService.requestTemplate || null) === JSON.stringify(service.requestTemplate || null);
if (sameService) {
duplicates.push({
type: "service",
serverIp: server.ipAddress,
name: service.name,
port: service.port,
endpoint: service.endpoint,
});
} else {
conflicts.push({
type: "service",
serverIp: server.ipAddress,
key: `${service.port} ${service.endpoint}`,
existing: existingService,
incoming: service,
});
}
}
}
return { conflicts, duplicates, newServers, newServices };
}
async function applyImport(parsedServers, mode) {
const result = {
serversCreated: 0,
serversUpdated: 0,
servicesCreated: 0,
servicesUpdated: 0,
duplicatesSkipped: 0,
};
for (const server of parsedServers) {
const existingServer = await prisma.server.findUnique({
where: { ipAddress: server.ipAddress },
include: { services: true },
});
let savedServer = existingServer;
if (!existingServer) {
savedServer = await prisma.server.create({
data: {
name: server.name,
ipAddress: server.ipAddress,
description: server.description,
},
});
result.serversCreated += 1;
} else if (mode === "overwrite") {
savedServer = await prisma.server.update({
where: { id: existingServer.id },
data: {
name: server.name,
description: server.description,
},
});
result.serversUpdated += 1;
}
for (const service of server.services) {
const existingService = existingServer?.services.find(
(item) => item.port === service.port && item.endpoint === service.endpoint,
);
if (!existingService) {
await prisma.service.create({
data: {
serverId: savedServer.id,
name: service.name,
port: service.port,
endpoint: service.endpoint,
method: service.method,
requestTemplate: service.requestTemplate,
notes: service.notes,
},
});
result.servicesCreated += 1;
} else if (mode === "overwrite") {
await prisma.service.update({
where: { id: existingService.id },
data: {
name: service.name,
method: service.method,
requestTemplate: service.requestTemplate,
notes: service.notes,
},
});
result.servicesUpdated += 1;
} else {
result.duplicatesSkipped += 1;
}
}
}
return result;
}
router.get("/health", (req, res) => {
res.json({ ok: true, service: "mtc-reverse-proxy-backend" });
});
router.get("/servers", async (req, res, next) => {
try {
const servers = await prisma.server.findMany({
include: includeServices(),
orderBy: { name: "asc" },
});
res.json(servers);
} catch (error) {
next(error);
}
});
router.post("/servers", async (req, res, next) => {
try {
const data = serverSchema.parse({
...req.body,
ipAddress: normalizeIp(req.body.ipAddress),
});
const server = await prisma.server.create({ data, include: includeServices() });
res.status(201).json(server);
} catch (error) {
next(error);
}
});
router.post("/services", async (req, res, next) => {
try {
const parsed = serviceSchema.parse(req.body);
const service = await prisma.service.create({
data: {
...parsed,
endpoint: normalizeEndpoint(parsed.endpoint),
method: normalizeMethod(parsed.method),
port: asIntegerPort(parsed.port),
},
});
res.status(201).json(service);
} catch (error) {
next(error);
}
});
router.post("/imports/preview", async (req, res, next) => {
try {
const { servers, errors } = parseServerImport(req.body);
const summary = await findConflicts(servers);
res.json({
parsed: servers,
errors,
...summary,
canApply: errors.length === 0 && summary.conflicts.length === 0,
});
} catch (error) {
next(error);
}
});
router.post("/imports/apply", async (req, res, next) => {
try {
const mode = req.body.mode === "overwrite" ? "overwrite" : "skip";
const payload = req.body.payload || req.body;
const { servers, errors } = parseServerImport(payload);
const summary = await findConflicts(servers);
if (errors.length > 0) {
res.status(400).json({ message: "Import JSON has validation errors.", errors });
return;
}
if (summary.conflicts.length > 0 && mode !== "overwrite") {
res.status(409).json({ message: "Import has conflicts that need confirmation.", ...summary });
return;
}
const applied = await applyImport(servers, mode);
res.json({ message: "Import applied.", applied });
} catch (error) {
next(error);
}
});
router.post("/services/:id/reverse-proxy", async (req, res, next) => {
try {
const service = await prisma.service.findUnique({
where: { id: req.params.id },
include: { server: true },
});
if (!service) {
res.status(404).json({ message: "Service not found." });
return;
}
const target = serviceTarget(service.server, service);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Number(process.env.REVERSE_PROXY_TIMEOUT_MS || 15000));
const method = normalizeMethod(req.body.method || service.method);
const body = req.body.body !== undefined ? req.body.body : service.requestTemplate;
const response = await fetch(target, {
method,
headers: {
"content-type": "application/json",
},
body: method === "GET" ? undefined : JSON.stringify(body || {}),
signal: controller.signal,
});
clearTimeout(timeout);
const text = await response.text();
let data = text;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
res.json({
target,
status: response.status,
ok: response.ok,
data,
});
} catch (error) {
if (error.name === "AbortError") {
res.status(504).json({ message: "Reverse proxy request timed out." });
return;
}
next(error);
}
});
module.exports = router;