372 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
const dgram = require("dgram");
const fs = require("fs");
const http = require("http");
const https = require("https");
const path = require("path");
const { URL } = require("url");
const HOST = process.env.GUIDE_WEB_HOST || "0.0.0.0";
const PORT = Number(process.env.GUIDE_WEB_PORT || 8890);
const AUTO_EXIT_MS = Math.max(0, Number(process.env.GUIDE_WEB_AUTO_EXIT_MS || 0));
const APP_ID = "airport-guide-dashboard";
const ROOT = __dirname;
const CONFIG_PATH = path.resolve(process.env.GUIDE_WEB_CONFIG_PATH || path.join(ROOT, "config.json"));
const CONFIG_TEMP_PATH = `${CONFIG_PATH}.tmp`;
const ALLOWED_ORIGINS = (process.env.GUIDE_WEB_ALLOWED_ORIGINS || "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
const serverStartedAt = Date.now();
let activeBrowserSessions = 0;
let browserSessionSeen = false;
let lastSessionEndedAt = serverStartedAt;
const DEFAULT_CONFIG = {
mode: "http",
multicastAddress: "239.255.0.1",
udpPort: 8800,
targetId: "T001",
parkId: "P01",
httpUrl: "http://127.0.0.1:8801/api/parking/status",
pollIntervalMs: 1000,
flvUrl: ""
};
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".css": "text/css; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".ico": "image/x-icon"
};
function sendJson(res, statusCode, value) {
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store"
});
res.end(JSON.stringify(value));
}
function handleHealth(res) {
sendJson(res, 200, {
app: APP_ID,
status: "ok",
port: PORT,
autoExit: AUTO_EXIT_MS > 0,
browserSessions: activeBrowserSessions
});
}
function handleBrowserSession(req, res) {
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-store",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
});
res.write("event: ready\ndata: {}\n\n");
activeBrowserSessions += 1;
browserSessionSeen = true;
const keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 15000);
let closed = false;
const close = () => {
if (closed) return;
closed = true;
clearInterval(keepAlive);
activeBrowserSessions = Math.max(0, activeBrowserSessions - 1);
lastSessionEndedAt = Date.now();
};
req.on("close", close);
res.on("close", close);
}
function applyCors(req, res) {
const origin = req.headers.origin;
let allowedOrigin = "";
if (ALLOWED_ORIGINS.includes("*")) {
allowedOrigin = "*";
} else if (origin && ALLOWED_ORIGINS.includes(origin)) {
allowedOrigin = origin;
} else if (origin && ALLOWED_ORIGINS.length === 0) {
try {
const originHost = new URL(origin).hostname;
const requestHost = new URL(`http://${req.headers.host || ""}`).hostname;
const loopbackHosts = new Set(["localhost", "127.0.0.1", "::1"]);
if (originHost === requestHost || (loopbackHosts.has(originHost) && loopbackHosts.has(requestHost))) {
allowedOrigin = origin;
}
} catch {}
}
if (allowedOrigin) res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept");
res.setHeader("Access-Control-Max-Age", "86400");
res.setHeader("Access-Control-Allow-Private-Network", "true");
}
function cleanText(value, fallback, maxLength = 2048) {
const text = typeof value === "string" ? value.trim() : "";
return (text || fallback).slice(0, maxLength);
}
function sanitizeConfig(raw = {}) {
const port = Number(raw.udpPort);
const interval = Number(raw.pollIntervalMs);
const address = cleanText(raw.multicastAddress, DEFAULT_CONFIG.multicastAddress, 64);
const validMulticast = /^((22[4-9]|23\d)\.)/.test(address);
return {
mode: raw.mode === "udp" ? "udp" : "http",
multicastAddress: validMulticast ? address : DEFAULT_CONFIG.multicastAddress,
udpPort: Number.isInteger(port) && port >= 1 && port <= 65535 ? port : DEFAULT_CONFIG.udpPort,
targetId: cleanText(raw.targetId, DEFAULT_CONFIG.targetId, 128),
parkId: cleanText(raw.parkId, DEFAULT_CONFIG.parkId, 128),
httpUrl: cleanText(raw.httpUrl, DEFAULT_CONFIG.httpUrl),
pollIntervalMs: Number.isFinite(interval) ? Math.max(300, Math.round(interval)) : DEFAULT_CONFIG.pollIntervalMs,
flvUrl: cleanText(raw.flvUrl, "")
};
}
function readSavedConfig() {
try {
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
return {
...sanitizeConfig(raw),
configured: raw.configured === true
};
} catch {
return { ...DEFAULT_CONFIG, configured: false };
}
}
function handleConfig(req, res) {
if (req.method === "GET") {
sendJson(res, 200, readSavedConfig());
return;
}
if (req.method !== "POST") {
sendJson(res, 405, { error: "仅支持 GET 或 POST" });
return;
}
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
if (body.length > 64 * 1024) req.destroy(new Error("配置数据过大"));
});
req.on("end", () => {
try {
const saved = { ...sanitizeConfig(JSON.parse(body)), configured: true };
fs.writeFileSync(CONFIG_TEMP_PATH, `${JSON.stringify(saved, null, 2)}\n`, "utf8");
fs.renameSync(CONFIG_TEMP_PATH, CONFIG_PATH);
sendJson(res, 200, saved);
} catch (error) {
try { fs.rmSync(CONFIG_TEMP_PATH, { force: true }); } catch {}
sendJson(res, 400, { error: `配置保存失败:${error.message}` });
}
});
req.on("error", (error) => {
if (!res.headersSent) sendJson(res, 400, { error: error.message });
});
}
function requestTarget(rawUrl, onResponse, onError) {
let target;
try {
target = new URL(rawUrl);
} catch {
onError(new Error("无效的目标地址"));
return null;
}
if (!["http:", "https:"].includes(target.protocol)) {
onError(new Error("仅支持 HTTP 或 HTTPS 地址"));
return null;
}
const transport = target.protocol === "https:" ? https : http;
const request = transport.get(target, {
timeout: 6000,
headers: {
"User-Agent": "AirportGuideDashboard/1.0",
"Accept": "*/*",
"Connection": "keep-alive"
}
}, onResponse);
request.on("timeout", () => request.destroy(new Error("连接超时")));
request.on("error", onError);
return request;
}
function handleHttpStatus(url, res) {
const targetUrl = url.searchParams.get("url") || "";
requestTarget(targetUrl, (upstream) => {
let body = "";
upstream.setEncoding("utf8");
upstream.on("data", (chunk) => {
body += chunk;
if (body.length > 1024 * 1024) upstream.destroy(new Error("响应数据过大"));
});
upstream.on("end", () => {
if (upstream.statusCode !== 200) {
sendJson(res, 502, { error: `上游 HTTP ${upstream.statusCode}` });
return;
}
try {
const parsed = JSON.parse(body);
sendJson(res, 200, parsed);
} catch {
sendJson(res, 502, { error: "上游返回的内容不是有效 JSON" });
}
});
}, (error) => sendJson(res, 502, { error: error.message }));
}
function handleFlvProxy(url, req, res) {
const targetUrl = url.searchParams.get("url") || "";
const upstreamRequest = requestTarget(targetUrl, (upstream) => {
if (upstream.statusCode !== 200) {
sendJson(res, 502, { error: `视频源 HTTP ${upstream.statusCode}` });
upstream.resume();
return;
}
res.writeHead(200, {
"Content-Type": upstream.headers["content-type"] || "video/x-flv",
"Cache-Control": "no-store, no-cache, must-revalidate",
"Connection": "keep-alive"
});
upstream.pipe(res);
upstream.on("error", () => res.destroy());
}, (error) => {
if (!res.headersSent) sendJson(res, 502, { error: error.message });
else res.destroy();
});
req.on("close", () => upstreamRequest?.destroy());
}
function handleUdpStream(url, req, res) {
const address = url.searchParams.get("address") || "239.255.0.1";
const port = Number(url.searchParams.get("port") || 8800);
if (!/^((22[4-9]|23\d)\.)/.test(address) || !Number.isInteger(port) || port < 1 || port > 65535) {
sendJson(res, 400, { error: "组播地址或端口无效" });
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-store",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
});
res.write(`event: ready\ndata: ${JSON.stringify({ address, port })}\n\n`);
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
const keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 15000);
socket.on("message", (buffer) => {
const text = buffer.toString("utf8");
try {
JSON.parse(text);
res.write(`data: ${text.replace(/\r?\n/g, "")}\n\n`);
} catch {
res.write(`event: packet-error\ndata: ${JSON.stringify({ error: "收到非 JSON 组播数据" })}\n\n`);
}
});
socket.on("error", (error) => {
res.write(`event: bridge-error\ndata: ${JSON.stringify({ error: error.message })}\n\n`);
socket.close();
});
socket.bind(port, "0.0.0.0", () => {
try {
socket.addMembership(address);
} catch (error) {
res.write(`event: bridge-error\ndata: ${JSON.stringify({ error: error.message })}\n\n`);
socket.close();
}
});
const close = () => {
clearInterval(keepAlive);
try { socket.dropMembership(address); } catch {}
try { socket.close(); } catch {}
};
req.on("close", close);
res.on("close", close);
}
function serveStatic(url, res) {
const requested = url.pathname === "/" ? "/index.html" : decodeURIComponent(url.pathname);
const resolved = path.resolve(ROOT, `.${requested}`);
const relative = path.relative(ROOT, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
sendJson(res, 403, { error: "禁止访问" });
return;
}
fs.stat(resolved, (error, stat) => {
if (error || !stat.isFile()) {
sendJson(res, 404, { error: "文件不存在" });
return;
}
res.writeHead(200, {
"Content-Type": MIME_TYPES[path.extname(resolved).toLowerCase()] || "application/octet-stream",
"Cache-Control": resolved.endsWith("index.html") ? "no-store" : "public, max-age=86400"
});
fs.createReadStream(resolved).pipe(res);
});
}
const server = http.createServer((req, res) => {
applyCors(req, res);
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
if (url.pathname === "/api/health") return handleHealth(res);
if (url.pathname === "/api/session") return handleBrowserSession(req, res);
if (url.pathname === "/api/config") return handleConfig(req, res);
if (url.pathname === "/api/http-status") return handleHttpStatus(url, res);
if (url.pathname === "/api/flv-proxy") return handleFlvProxy(url, req, res);
if (url.pathname === "/api/udp-stream") return handleUdpStream(url, req, res);
return serveStatic(url, res);
});
server.on("error", (error) => {
if (error.code === "EADDRINUSE") {
console.error(`端口 ${PORT} 已被占用。请关闭已运行的监控服务,或设置其他端口后重试:`);
console.error("$env:GUIDE_WEB_PORT=8891; npm start");
} else {
console.error(`服务启动失败:${error.message}`);
}
process.exitCode = 1;
});
server.listen(PORT, HOST, () => {
console.log(`停机引导 Web 监控已启动http://127.0.0.1:${PORT}`);
console.log("按 Ctrl+C 停止服务");
});
if (AUTO_EXIT_MS > 0) {
const autoExitTimer = setInterval(() => {
if (activeBrowserSessions > 0) return;
const idleSince = browserSessionSeen ? lastSessionEndedAt : serverStartedAt;
if (Date.now() - idleSince <= AUTO_EXIT_MS) return;
clearInterval(autoExitTimer);
console.log("浏览器页面已关闭,停机引导服务自动退出");
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 2000).unref();
}, Math.min(5000, Math.max(1000, Math.round(AUTO_EXIT_MS / 4))));
autoExitTimer.unref();
}