import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"; import { validateBook } from "./core.mjs"; const ROOT = path.resolve(fileURLToPath(new URL("../../", import.meta.url))); const BOOK_PATH = path.join(ROOT, "data", "recipe-book.json"); const BACKUP_DIR = path.join(ROOT, "data", "backups"); const TECH_PATH = path.join(ROOT, "docs", "tech-tree-nodes-v0.2.csv"); const DEFAULT_PORT = 8765; const MAX_BODY_BYTES = 5 * 1024 * 1024; const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".csv": "text/csv; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", }; function json(response, statusCode, payload) { const body = `${JSON.stringify(payload, null, 2)}\n`; response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }); response.end(body); } function safeStaticPath(urlPath) { const decoded = decodeURIComponent(urlPath).replace(/^\/+/, ""); const relative = decoded || "tools/recipe-tree-editor/index.html"; const webPath = relative.replaceAll("\\", "/"); const allowed = webPath.startsWith("tools/recipe-tree-editor/") || webPath === "data/recipe-book.json" || webPath === "docs/tech-tree-nodes-v0.2.csv"; if (!allowed) return null; const target = path.resolve(ROOT, relative); return target === ROOT || target.startsWith(`${ROOT}${path.sep}`) ? target : null; } async function readTechnologyIds() { try { const csv = await readFile(TECH_PATH, "utf8"); return new Set(csv.split(/\r?\n/).slice(1).map((line) => line.split(",", 1)[0].trim()).filter(Boolean)); } catch { return null; } } async function readBody(request) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > MAX_BODY_BYTES) throw Object.assign(new Error("数据超过 5 MB 限制。"), { statusCode: 413 }); chunks.push(chunk); } return Buffer.concat(chunks).toString("utf8"); } async function saveBook(request, response) { if (!String(request.headers["content-type"] || "").toLowerCase().includes("application/json")) { return json(response, 415, { ok: false, message: "保存接口只接受 JSON。" }); } let book; try { book = JSON.parse(await readBody(request)); } catch (error) { return json(response, error.statusCode || 400, { ok: false, message: error.message || "JSON 无法解析。" }); } const technologyIds = await readTechnologyIds(); const issues = validateBook(book, { technologyIds }); const errors = issues.filter((entry) => entry.severity === "error"); if (errors.length) return json(response, 422, { ok: false, message: `存在 ${errors.length} 个校验错误,未写入文件。`, errors }); const timestamp = new Date().toISOString().replace(/[.:]/g, "-").replace("T", "_"); const backupName = `recipe-book_${timestamp}.json`; const backupPath = path.join(BACKUP_DIR, backupName); const temporaryPath = `${BOOK_PATH}.${process.pid}.tmp`; try { await mkdir(BACKUP_DIR, { recursive: true }); await copyFile(BOOK_PATH, backupPath); await writeFile(temporaryPath, `${JSON.stringify(book, null, 2)}\n`, "utf8"); await rename(temporaryPath, BOOK_PATH); } catch (error) { await unlink(temporaryPath).catch(() => {}); return json(response, 500, { ok: false, message: `写入失败:${error.message}` }); } return json(response, 200, { ok: true, savedAt: new Date().toISOString(), backup: `data/backups/${backupName}`, warnings: issues.filter((entry) => entry.severity === "warning").length, }); } async function serveStatic(request, response, pathname) { const filePath = safeStaticPath(pathname); if (!filePath) return json(response, 403, { ok: false, message: "路径不在项目内。" }); try { const fileStat = await stat(filePath); if (!fileStat.isFile()) throw new Error("not a file"); const content = await readFile(filePath); response.writeHead(200, { "Content-Type": MIME[path.extname(filePath).toLowerCase()] || "application/octet-stream", "Content-Length": content.length, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self'", }); if (request.method === "HEAD") response.end(); else response.end(content); } catch { json(response, 404, { ok: false, message: "文件不存在。" }); } } const server = http.createServer(async (request, response) => { try { const url = new URL(request.url || "/", "http://127.0.0.1"); if (request.method === "GET" && url.pathname === "/api/status") { return json(response, 200, { ok: true, service: "magic-foundry-recipe-editor", root: ROOT }); } if (request.method === "GET" && url.pathname === "/api/book") { const book = JSON.parse(await readFile(BOOK_PATH, "utf8")); return json(response, 200, book); } if (request.method === "POST" && url.pathname === "/api/book") return await saveBook(request, response); if (request.method === "GET" && url.pathname === "/") { response.writeHead(302, { Location: "/tools/recipe-tree-editor/" }); return response.end(); } if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/tools/recipe-tree-editor/") { return await serveStatic(request, response, "/tools/recipe-tree-editor/index.html"); } if (request.method === "GET" || request.method === "HEAD") return await serveStatic(request, response, url.pathname); return json(response, 405, { ok: false, message: "不支持的请求方式。" }); } catch (error) { return json(response, 500, { ok: false, message: error.message }); } }); const portIndex = process.argv.indexOf("--port"); const requestedPort = portIndex >= 0 ? Number(process.argv[portIndex + 1]) : DEFAULT_PORT; const port = Number.isInteger(requestedPort) && requestedPort > 0 && requestedPort < 65536 ? requestedPort : DEFAULT_PORT; server.on("error", (error) => { if (error.code === "EADDRINUSE") { console.error(`端口 ${port} 已被占用;如果工作台已经打开,可以直接继续使用。`); } else console.error(error); process.exitCode = 1; }); server.listen(port, "127.0.0.1", () => { console.log(`魔力工坊配方树工作台:http://127.0.0.1:${port}/tools/recipe-tree-editor/`); console.log("服务只监听本机;关闭对应 node 进程即可停止。 "); });