| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461 |
- "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,
- };
|