167 lines
6.8 KiB
JavaScript
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 进程即可停止。 ");
|
|
});
|