Files

167 lines
6.8 KiB
JavaScript

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 进程即可停止。 ");
});