"use strict"; const http = require("http"); const fs = require("fs"); const fsp = fs.promises; const path = require("path"); const { URL } = require("url"); const { requireAdmin } = require("./auth"); const { SseHub } = require("./sse"); const { TaskRepository } = require("./data/repository"); const { pruneConversationContext } = require("./data/tree-pruner"); const HOST = process.env.HOST || "127.0.0.1"; const PORT = Number(process.env.PORT) || 3001; const FRONTEND_DIR = path.join(__dirname, "..", "frontend"); const DATA_FILE = process.env.TASK_BOARD_DATA_FILE || path.join(__dirname, "..", "tasks.json"); const MAX_BODY_SIZE = 2 * 1024 * 1024; const PUBLIC_RATE_LIMIT = Number.isFinite(Number(process.env.TASK_BOARD_PUBLIC_RATE_LIMIT)) ? Math.max(20, Number(process.env.TASK_BOARD_PUBLIC_RATE_LIMIT)) : 180; const PUBLIC_RATE_WINDOW_MS = 60 * 1000; const PUBLIC_ORIGIN_LIST = String(process.env.TASK_BOARD_PUBLIC_ORIGINS || "*") .split(",") .map((item) => item.trim()) .filter(Boolean); const MIME_TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", }; const repository = new TaskRepository({ filePath: DATA_FILE }); const sseHub = new SseHub(); const publicRateMap = new Map(); function nowIso() { return new Date().toISOString(); } function sendJson(res, statusCode, payload, extraHeaders = {}) { res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...extraHeaders, }); res.end(JSON.stringify(payload)); } function sendText(res, statusCode, text, extraHeaders = {}) { res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8", ...extraHeaders, }); res.end(text); } function jsonError(res, statusCode, message) { sendJson(res, statusCode, { error: message }); } function isOriginAllowed(origin) { if (!origin) return false; if (PUBLIC_ORIGIN_LIST.includes("*")) return true; return PUBLIC_ORIGIN_LIST.includes(origin); } function applyCors(req, res) { const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""; if (origin && isOriginAllowed(origin)) { res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Vary", "Origin"); } else if (!origin && PUBLIC_ORIGIN_LIST.includes("*")) { res.setHeader("Access-Control-Allow-Origin", "*"); } else if (PUBLIC_ORIGIN_LIST.includes("*")) { res.setHeader("Access-Control-Allow-Origin", "*"); } res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization"); res.setHeader("Access-Control-Expose-Headers", "Content-Type,Cache-Control"); } function getClientIp(req) { const forwarded = req.headers["x-forwarded-for"]; if (typeof forwarded === "string" && forwarded.trim()) { return forwarded.split(",")[0].trim(); } return req.socket.remoteAddress || "unknown"; } function checkPublicRateLimit(req, res) { const ip = getClientIp(req); const now = Date.now(); const entry = publicRateMap.get(ip); if (!entry || now >= entry.resetAt) { publicRateMap.set(ip, { count: 1, resetAt: now + PUBLIC_RATE_WINDOW_MS, }); return true; } entry.count += 1; if (entry.count <= PUBLIC_RATE_LIMIT) { return true; } const retrySec = Math.max(1, Math.ceil((entry.resetAt - now) / 1000)); sendJson( res, 429, { error: "Too Many Requests", retryAfterSec: retrySec, }, { "Retry-After": String(retrySec) }, ); return false; } function parseBody(req) { return new Promise((resolve, reject) => { let body = ""; let received = 0; req.on("data", (chunk) => { received += chunk.length; if (received > MAX_BODY_SIZE) { reject(new Error("Body too large")); req.destroy(); return; } body += chunk.toString("utf8"); }); req.on("end", () => { if (!body.trim()) { resolve({}); return; } try { const parsed = JSON.parse(body); resolve(parsed && typeof parsed === "object" ? parsed : {}); } catch { reject(new Error("Invalid JSON")); } }); req.on("error", reject); }); } async function resolveStaticPath(urlPath) { const safePath = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, ""); if (safePath === "/" || safePath === ".") { return path.join(FRONTEND_DIR, "index.html"); } const fullPath = path.join(FRONTEND_DIR, safePath); if (!fullPath.startsWith(FRONTEND_DIR)) return null; return fullPath; } function mapNodeToLegacyTask(node) { return { id: node.id, name: node.name, parentId: node.parentId, status: node.status, createdAt: node.timestamps.created, updatedAt: node.updatedAt, startTime: node.timestamps.started, endTime: node.timestamps.completed, plannedApproach: node.plannedApproach || "", bugDetails: node.bugDetails || "", notes: node.notes || node.content || "", }; } async function handlePublicApi(req, res, pathname) { if (!checkPublicRateLimit(req, res)) return; if (req.method === "GET" && pathname === "/api/public/conversations") { const conversations = await repository.listConversations(); sendJson(res, 200, conversations); return; } if (req.method === "GET" && pathname.startsWith("/api/public/tasks/")) { const conversationId = decodeURIComponent(pathname.replace("/api/public/tasks/", "").trim()); if (!conversationId) { jsonError(res, 400, "conversationId 不能为空"); return; } const graph = await repository.getConversationGraph(conversationId); sendJson(res, 200, graph); return; } if (req.method === "GET" && pathname.startsWith("/api/public/stream/")) { const conversationId = decodeURIComponent(pathname.replace("/api/public/stream/", "").trim()) || "*"; sseHub.subscribe(conversationId, req, res); return; } if (req.method === "GET" && pathname.startsWith("/api/public/pruned/")) { const conversationId = decodeURIComponent(pathname.replace("/api/public/pruned/", "").trim()); const reqUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); const focusNodeId = String(reqUrl.searchParams.get("focusNodeId") || "").trim(); if (!conversationId || !focusNodeId) { jsonError(res, 400, "conversationId 和 focusNodeId 必填"); return; } const graph = await repository.getConversationGraph(conversationId); const pruned = pruneConversationContext(graph, focusNodeId); sendJson(res, 200, pruned); return; } jsonError(res, 404, "Public API 路径不存在"); } function resolveConversationIdForNode(node) { return node && node.conversationId ? node.conversationId : null; } async function handleAdminApi(req, res, pathname) { if (!requireAdmin(req, res)) return; if (req.method === "POST" && pathname === "/api/admin/nodes") { const body = await parseBody(req); const { result } = await repository.upsertNode(body); sseHub.publishConversation(resolveConversationIdForNode(result), { reason: "node_upsert", nodeId: result.id, }); sendJson(res, 201, result); return; } if (req.method === "PUT" && pathname.startsWith("/api/admin/nodes/")) { const nodeId = decodeURIComponent(pathname.replace("/api/admin/nodes/", "").trim()); if (!nodeId) { jsonError(res, 400, "节点 ID 无效"); return; } const body = await parseBody(req); const { result } = await repository.upsertNode({ ...body, id: nodeId }); sseHub.publishConversation(resolveConversationIdForNode(result), { reason: "node_update", nodeId: result.id, }); sendJson(res, 200, result); return; } if (req.method === "DELETE" && pathname.startsWith("/api/admin/nodes/")) { const nodeId = decodeURIComponent(pathname.replace("/api/admin/nodes/", "").trim()); if (!nodeId) { jsonError(res, 400, "节点 ID 无效"); return; } const current = await repository.getNodeById(nodeId); const conversationId = resolveConversationIdForNode(current); const { result } = await repository.deleteNode(nodeId); sseHub.publishConversation(conversationId, { reason: "node_delete", nodeId, deletedCount: result.deletedCount, }); sendJson(res, 200, result); return; } if (req.method === "POST" && pathname === "/api/admin/edges") { const body = await parseBody(req); const { result } = await repository.upsertEdge(body); const fromNode = await repository.getNodeById(result.from); const conversationId = resolveConversationIdForNode(fromNode); sseHub.publishConversation(conversationId, { reason: "edge_upsert", edgeId: result.id, from: result.from, to: result.to, }); sendJson(res, 201, result); return; } if (req.method === "DELETE" && pathname === "/api/admin/edges") { const reqUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); const from = String(reqUrl.searchParams.get("from") || "").trim(); const to = String(reqUrl.searchParams.get("to") || "").trim(); const type = String(reqUrl.searchParams.get("type") || "").trim() || null; if (!from || !to) { jsonError(res, 400, "from 和 to 必填"); return; } const { result } = await repository.deleteEdge({ from, to, type }); const fromNode = await repository.getNodeById(from); const conversationId = resolveConversationIdForNode(fromNode); sseHub.publishConversation(conversationId, { reason: "edge_delete", from, to, type, deletedCount: result.deletedCount, }); sendJson(res, 200, result); return; } if (req.method === "GET" && pathname.startsWith("/api/admin/nodes/")) { const nodeId = decodeURIComponent(pathname.replace("/api/admin/nodes/", "").trim()); if (!nodeId) { jsonError(res, 400, "节点 ID 无效"); return; } const node = await repository.getNodeById(nodeId); if (!node) { jsonError(res, 404, "节点不存在"); return; } sendJson(res, 200, node); return; } jsonError(res, 404, "Admin API 路径不存在"); } async function handleCompatApi(req, res, pathname) { // Read-only compatibility endpoint for old scripts. if (req.method === "GET" && pathname === "/api/tasks") { const conversations = await repository.listConversations(); const allTasks = []; for (const conv of conversations) { const graph = await repository.getConversationGraph(conv.conversationId); allTasks.push(...graph.nodes.map(mapNodeToLegacyTask)); } sendJson(res, 200, allTasks); return; } if (pathname.startsWith("/api/tasks")) { jsonError(res, 410, "Legacy write API 已废弃,请改用 /api/admin/*"); return; } } async function serveStatic(req, res, pathname) { const staticPath = await resolveStaticPath(pathname); if (!staticPath) { sendText(res, 403, "Forbidden"); return; } let filePath = staticPath; try { let stat = await fsp.stat(filePath); if (stat.isDirectory()) { filePath = path.join(filePath, "index.html"); stat = await fsp.stat(filePath); } const ext = path.extname(filePath).toLowerCase(); const type = MIME_TYPES[ext] || "application/octet-stream"; const stream = fs.createReadStream(filePath); res.writeHead(200, { "Content-Type": type, "Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=60", "Content-Length": stat.size, }); stream.pipe(res); } catch { try { const fallback = await fsp.readFile(path.join(FRONTEND_DIR, "index.html"), "utf8"); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(fallback); } catch { sendText(res, 404, "Not Found"); } } } function isPublicApi(pathname) { return pathname.startsWith("/api/public/"); } function isAdminApi(pathname) { return pathname.startsWith("/api/admin/"); } function isCompatApi(pathname) { return pathname.startsWith("/api/tasks"); } const server = http.createServer(async (req, res) => { try { applyCors(req, res); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } const reqUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); const pathname = reqUrl.pathname; if (pathname === "/health") { sendJson(res, 200, { ok: true, now: nowIso() }); return; } if (isPublicApi(pathname)) { await handlePublicApi(req, res, pathname); return; } if (isAdminApi(pathname)) { await handleAdminApi(req, res, pathname); return; } if (isCompatApi(pathname)) { await handleCompatApi(req, res, pathname); return; } if (req.method !== "GET" && req.method !== "HEAD") { sendText(res, 405, "Method Not Allowed"); return; } await serveStatic(req, res, pathname); } catch (error) { const message = error instanceof Error ? error.message : String(error); sendJson(res, 500, { error: "服务器内部错误", detail: message }); } }); async function start() { await repository.ensureFile(); server.listen(PORT, HOST, () => { console.log(`[task-board-v2] listening on http://${HOST}:${PORT}`); console.log(`[task-board-v2] data file: ${DATA_FILE}`); console.log(`[task-board-v2] public origins: ${PUBLIC_ORIGIN_LIST.join(",") || "*"}`); }); } if (require.main === module) { void start(); } module.exports = { start, server, };