From 717e45c11f773bf5580d19c980fb8ba79df074aa Mon Sep 17 00:00:00 2001 From: MOHAN Date: Sat, 1 Aug 2026 18:28:15 +0530 Subject: [PATCH] Fix import uniqueness and add detailed request logging --- prisma/schema.prisma | 4 +- src/importParser.js | 55 ++++++++- src/routes.js | 273 ++++++++++++++++++++++++++++++++----------- src/server.js | 31 +++++ 4 files changed, 293 insertions(+), 70 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 48069cc..3c8aa04 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,6 +11,8 @@ model Server { name String ipAddress String @unique description String? + proxyPort Int? + proxyPath String? services Service[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -29,6 +31,6 @@ model Service { updatedAt DateTime @updatedAt server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) - @@unique([serverId, port, endpoint]) + @@unique([serverId, name, port]) @@index([serverId]) } diff --git a/src/importParser.js b/src/importParser.js index e6338ea..66e61a5 100644 --- a/src/importParser.js +++ b/src/importParser.js @@ -1,5 +1,12 @@ const { asIntegerPort, normalizeEndpoint, normalizeIp, normalizeMethod } = require("./utils"); +const DEFAULT_SERVER = { + name: process.env.IMPORT_SERVER_NAME || "Metatron Host", + ipAddress: process.env.IMPORT_SERVER_IP || "147.93.40.215", + proxyPort: Number(process.env.IMPORT_SERVER_PROXY_PORT || 9999), + proxyPath: process.env.IMPORT_SERVER_PROXY_PATH || "/api/nginx/app", +}; + function pickFirst(source, keys) { for (const key of keys) { if (source && source[key] !== undefined && source[key] !== null) return source[key]; @@ -11,7 +18,7 @@ function arrayFromJson(input) { if (Array.isArray(input)) return input; if (Array.isArray(input.servers)) return input.servers; if (Array.isArray(input.items)) return input.items; - if (input.server || input.ip || input.ipAddress || input.host) return [input]; + if (input.server || input.ip || input.ipAddress || input.host || input.domain) return [input]; return []; } @@ -38,12 +45,58 @@ function normalizeService(rawService, parentServer) { }; } +function parseDomainServer(rawServer, serverIndex, errors) { + const domain = String(rawServer.domain || "").trim(); + if (!domain) { + errors.push({ index: serverIndex, message: "Domain is required for service import entries.", raw: rawServer }); + return; + } + + const services = []; + rawServer.ports.forEach((port, portIndex) => { + const normalizedPort = asIntegerPort(port); + if (!normalizedPort) { + errors.push({ + index: serverIndex, + serviceIndex: portIndex, + message: "Each port must be a valid number between 1 and 65535.", + raw: port, + }); + return; + } + services.push({ + name: domain, + port: normalizedPort, + endpoint: `http://${domain}:${normalizedPort}/`, + method: "POST", + requestTemplate: null, + notes: rawServer.is_backup ? "backup" : null, + }); + }); + + return { + name: DEFAULT_SERVER.name, + ipAddress: DEFAULT_SERVER.ipAddress, + description: null, + proxyPort: DEFAULT_SERVER.proxyPort, + proxyPath: DEFAULT_SERVER.proxyPath, + multiplePorts: services.length > 1, + services, + }; +} + function parseServerImport(payload) { const sourceServers = arrayFromJson(payload); const servers = []; const errors = []; sourceServers.forEach((rawServer, serverIndex) => { + if (Array.isArray(rawServer.ports)) { + const parsed = parseDomainServer(rawServer, serverIndex, errors); + if (parsed) servers.push(parsed); + return; + } + const ipAddress = normalizeIp(pickFirst(rawServer, ["ipAddress", "ip", "host", "serverIp"])); const name = String(pickFirst(rawServer, ["name", "serverName", "title"]) || ipAddress || "Unnamed server").trim(); const serviceList = Array.isArray(rawServer.services) diff --git a/src/routes.js b/src/routes.js index b947693..b63a5ec 100644 --- a/src/routes.js +++ b/src/routes.js @@ -10,14 +10,16 @@ const serverSchema = z.object({ name: z.string().trim().min(1), ipAddress: z.string().trim().min(1), description: z.string().optional().nullable(), + proxyPort: z.coerce.number().int().min(1).max(65535).optional().nullable(), + proxyPath: z.string().trim().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(), + endpoint: z.string().trim().optional().nullable(), + method: z.string().optional().nullable(), requestTemplate: z.any().optional().nullable(), notes: z.string().optional().nullable(), }); @@ -44,66 +46,91 @@ async function findConflicts(parsedServers) { if (!existingServer) { newServers.push(server); - newServices.push(...server.services.map((service) => ({ serverIp: server.ipAddress, ...service }))); - continue; + } else { + 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, + }, + }); + } } - 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, - }, - }); - } + const batch = new Map(); for (const service of server.services) { - const existingService = existingServer.services.find( - (item) => item.port === service.port && item.endpoint === service.endpoint, - ); + const prior = batch.get(service.name); + if (prior) { + if (prior.port === service.port) { + duplicates.push({ + type: "service", + serverIp: server.ipAddress, + name: service.name, + port: service.port, + }); + continue; + } + conflicts.push({ + type: "service", + serverIp: server.ipAddress, + key: service.name, + existing: { name: prior.name, port: prior.port }, + incoming: { name: service.name, port: service.port }, + }); + newServices.push({ serverId: existingServer?.id, serverIp: server.ipAddress, ...service }); + continue; + } + batch.set(service.name, service); - if (!existingService) { - newServices.push({ serverId: existingServer.id, serverIp: server.ipAddress, ...service }); + if (!existingServer) { + newServices.push({ 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) { + const existingSamePort = existingServer.services.find( + (item) => item.name === service.name && item.port === service.port, + ); + if (existingSamePort) { duplicates.push({ type: "service", serverIp: server.ipAddress, name: service.name, port: service.port, - endpoint: service.endpoint, }); - } else { + continue; + } + + const existingDiffPort = existingServer.services.find( + (item) => item.name === service.name && item.port !== service.port, + ); + if (existingDiffPort) { conflicts.push({ type: "service", serverIp: server.ipAddress, - key: `${service.port} ${service.endpoint}`, - existing: existingService, - incoming: service, + key: service.name, + existing: { name: existingDiffPort.name, port: existingDiffPort.port, method: existingDiffPort.method }, + incoming: { name: service.name, port: service.port, method: service.method }, }); } + + newServices.push({ serverId: existingServer.id, serverIp: server.ipAddress, ...service }); } } return { conflicts, duplicates, newServers, newServices }; } -async function applyImport(parsedServers, mode) { +async function applyImport(parsedServers, mode, client = prisma) { const result = { serversCreated: 0, serversUpdated: 0, @@ -112,61 +139,91 @@ async function applyImport(parsedServers, mode) { duplicatesSkipped: 0, }; + const guard = async (operation) => { + try { + return await operation(); + } catch (error) { + if (error.code === "P2002") { + result.duplicatesSkipped += 1; + return null; + } + console.error("applyImport guard failure:", error.code || error.name, error.message); + throw error; + } + }; + for (const server of parsedServers) { - const existingServer = await prisma.server.findUnique({ + const existingServer = await client.server.findUnique({ where: { ipAddress: server.ipAddress }, include: { services: true }, }); let savedServer = existingServer; if (!existingServer) { - savedServer = await prisma.server.create({ + savedServer = await client.server.create({ data: { name: server.name, ipAddress: server.ipAddress, description: server.description, + proxyPort: server.proxyPort ?? null, + proxyPath: server.proxyPath ?? null, }, }); result.serversCreated += 1; } else if (mode === "overwrite") { - savedServer = await prisma.server.update({ + savedServer = await client.server.update({ where: { id: existingServer.id }, data: { name: server.name, description: server.description, + proxyPort: server.proxyPort ?? undefined, + proxyPath: server.proxyPath ?? undefined, }, }); result.serversUpdated += 1; } + const seen = new Set(); + for (const service of server.services) { - const existingService = existingServer?.services.find( - (item) => item.port === service.port && item.endpoint === service.endpoint, + const key = `${service.name}:${service.port}`; + if (seen.has(key)) { + result.duplicatesSkipped += 1; + continue; + } + seen.add(key); + + const existingSamePort = existingServer?.services.find( + (item) => item.name === service.name && item.port === service.port, ); - 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, - }, - }); + if (!existingSamePort) { + await guard(() => + client.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, - }, - }); + await guard(() => + client.service.update({ + where: { id: existingSamePort.id }, + data: { + name: service.name, + method: service.method, + requestTemplate: service.requestTemplate, + notes: service.notes, + }, + }), + ); result.servicesUpdated += 1; } else { result.duplicatesSkipped += 1; @@ -195,10 +252,11 @@ router.get("/servers", async (req, res, next) => { router.post("/servers", async (req, res, next) => { try { - const data = serverSchema.parse({ + const parsed = serverSchema.parse({ ...req.body, ipAddress: normalizeIp(req.body.ipAddress), }); + const data = { ...parsed, proxyPath: parsed.proxyPath || null }; const server = await prisma.server.create({ data, include: includeServices() }); res.status(201).json(server); } catch (error) { @@ -212,7 +270,7 @@ router.post("/services", async (req, res, next) => { const service = await prisma.service.create({ data: { ...parsed, - endpoint: normalizeEndpoint(parsed.endpoint), + endpoint: normalizeEndpoint(parsed.endpoint || "/"), method: normalizeMethod(parsed.method), port: asIntegerPort(parsed.port), }, @@ -223,6 +281,65 @@ router.post("/services", async (req, res, next) => { } }); +router.patch("/servers/:id", async (req, res, next) => { + try { + const parsed = serverSchema.partial().parse({ + ...req.body, + ipAddress: req.body.ipAddress ? normalizeIp(req.body.ipAddress) : undefined, + }); + const data = Object.fromEntries(Object.entries(parsed).filter(([, value]) => value !== undefined)); + if (data.proxyPath === "") data.proxyPath = null; + const server = await prisma.server.update({ + where: { id: req.params.id }, + data, + include: includeServices(), + }); + res.json(server); + } catch (error) { + next(error); + } +}); + +router.delete("/servers/:id", async (req, res, next) => { + try { + await prisma.server.delete({ where: { id: req.params.id } }); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +router.patch("/services/:id", async (req, res, next) => { + try { + const parsed = serviceSchema.partial().parse(req.body); + const data = Object.fromEntries( + Object.entries({ + ...parsed, + endpoint: parsed.endpoint ? normalizeEndpoint(parsed.endpoint) : undefined, + method: parsed.method !== undefined ? normalizeMethod(parsed.method) : undefined, + port: parsed.port !== undefined ? asIntegerPort(parsed.port) : undefined, + }).filter(([, value]) => value !== undefined), + ); + const service = await prisma.service.update({ + where: { id: req.params.id }, + data, + include: { server: true }, + }); + res.json(service); + } catch (error) { + next(error); + } +}); + +router.delete("/services/:id", async (req, res, next) => { + try { + await prisma.service.delete({ where: { id: req.params.id } }); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + router.post("/imports/preview", async (req, res, next) => { try { const { servers, errors } = parseServerImport(req.body); @@ -255,7 +372,10 @@ router.post("/imports/apply", async (req, res, next) => { return; } - const applied = await applyImport(servers, mode); + const applied = await prisma.$transaction( + (tx) => applyImport(servers, mode, tx), + { maxWait: 15000, timeout: 60000 }, + ); res.json({ message: "Import applied.", applied }); } catch (error) { next(error); @@ -274,11 +394,28 @@ router.post("/services/:id/reverse-proxy", async (req, res, next) => { return; } - const target = serviceTarget(service.server, service); + const server = service.server; + const gateway = + server.proxyPort != null + ? { + port: server.proxyPort, + path: normalizeEndpoint(server.proxyPath || "/api/nginx/app"), + } + : null; + const target = gateway + ? `http://${server.ipAddress}:${gateway.port}${gateway.path}` + : serviceTarget(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 suppliedBody = req.body.body !== undefined ? req.body.body : service.requestTemplate; + const body = gateway + ? { + ...(suppliedBody && typeof suppliedBody === "object" && !Array.isArray(suppliedBody) ? suppliedBody : {}), + domain: service.name, + port: service.port, + } + : suppliedBody; const response = await fetch(target, { method, diff --git a/src/server.js b/src/server.js index 35d5f22..416f244 100644 --- a/src/server.js +++ b/src/server.js @@ -24,6 +24,30 @@ app.use( ); app.use(express.json({ limit: "2mb" })); +function summarizeBody(body) { + const json = JSON.stringify(body); + if (!json) return undefined; + return json.length > 400 ? `${json.slice(0, 400)}…(+${json.length - 400} chars)` : json; +} + +app.use((req, res, next) => { + const started = process.hrtime.bigint(); + res.on("finish", () => { + const durationMs = Number(process.hrtime.bigint() - started) / 1e6; + const entry = { + ts: new Date().toISOString(), + method: req.method, + path: req.originalUrl, + status: res.statusCode, + durationMs: Number(durationMs.toFixed(2)), + bytes: Number(res.getHeader("content-length")) || undefined, + body: req.method !== "GET" && req.body && Object.keys(req.body).length > 0 ? summarizeBody(req.body) : undefined, + }; + console.log(`[req] ${JSON.stringify(entry)}`); + }); + next(); +}); + app.use("/api", routes); app.use((error, req, res, next) => { @@ -32,6 +56,8 @@ app.use((error, req, res, next) => { return; } + console.error(`[err] ${req.method} ${req.originalUrl} — ${error.code || error.name}: ${error.message}`); + if (error.name === "ZodError") { res.status(400).json({ message: "Validation failed.", issues: error.issues }); return; @@ -42,6 +68,11 @@ app.use((error, req, res, next) => { return; } + if (error.code === "P2025") { + res.status(404).json({ message: "Record not found." }); + return; + } + res.status(500).json({ message: error.message || "Server error." }); });