105 lines
4.6 KiB
JavaScript
105 lines
4.6 KiB
JavaScript
"use strict";
|
||
/* ================= Star Raid 服务端入口 =================
|
||
零依赖:node:http + node:sqlite。静态资源白名单 + /api 路由 + 安全头 */
|
||
const http = require("node:http");
|
||
const fs = require("node:fs");
|
||
const path = require("node:path");
|
||
const db = require("./db");
|
||
const auth = require("./auth");
|
||
const { ROUTES } = require("./api");
|
||
const { readJson, parseCookies, clientIp } = require("./util");
|
||
|
||
const PORT = Number(process.env.PORT || 8643);
|
||
const HOST = process.env.HOST || "0.0.0.0";
|
||
const SECURE = process.env.COOKIE_SECURE === "1"; // HTTPS 反代部署时置 1
|
||
const ROOT = path.join(__dirname, ".."); // star-raid/
|
||
|
||
/* 静态资源白名单:只允许入口页与 css/js(server/、data/、demo/ 一律不放行) */
|
||
const STATIC_PREFIXES = ["/css/", "/js/"];
|
||
const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".ico": "image/x-icon" };
|
||
|
||
function secHeaders(res) {
|
||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||
res.setHeader("X-Frame-Options", "DENY");
|
||
res.setHeader("Referrer-Policy", "no-referrer");
|
||
res.setHeader("Content-Security-Policy",
|
||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'");
|
||
}
|
||
function sendJson(res, status, obj, setCookie) {
|
||
const body = JSON.stringify(obj);
|
||
res.writeHead(status, {
|
||
"Content-Type": "application/json; charset=utf-8",
|
||
"Cache-Control": "no-store",
|
||
...(setCookie ? { "Set-Cookie": setCookie } : {}),
|
||
});
|
||
res.end(body);
|
||
}
|
||
|
||
/* 请求上下文:统一成功/失败出口 */
|
||
function makeCtx(req, res) {
|
||
return {
|
||
ip: clientIp(req),
|
||
secure: SECURE,
|
||
token: parseCookies(req)[auth.COOKIE_NAME],
|
||
user: null,
|
||
setCookie: null,
|
||
ok(data) { sendJson(res, 200, data, this.setCookie); return true; },
|
||
fail(status, error) { sendJson(res, status, { error }, this.setCookie); return false; },
|
||
};
|
||
}
|
||
|
||
async function handleApi(req, res, pathname, query) {
|
||
const ctx = makeCtx(req, res);
|
||
const route = ROUTES[req.method + " " + pathname];
|
||
if (!route) return ctx.fail(404, "not found");
|
||
/* 有 session 就识别身份;route.auth 才强制要求登录 */
|
||
if (ctx.token) ctx.user = auth.getSessionUser(db, ctx.token);
|
||
if (route.auth && !ctx.user) return ctx.fail(401, "未登录或会话已过期");
|
||
try {
|
||
let body = null;
|
||
/* logout 等无 body 的 POST 不强制 JSON;有 body 的必须 application/json */
|
||
if (req.method === "POST" && Number(req.headers["content-length"] || 0) > 0) body = await readJson(req);
|
||
return route.fn(req, res, ctx, body, query);
|
||
} catch (e) {
|
||
const status = e && e.status && Number.isInteger(e.status) ? e.status : 500;
|
||
if (status === 500) console.error("[api]", pathname, e);
|
||
return ctx.fail(status, status === 500 ? "服务器开小差了" : e.message);
|
||
}
|
||
}
|
||
|
||
function handleStatic(req, res, pathname) {
|
||
if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405); return res.end(); }
|
||
let p = pathname;
|
||
if (p === "/") p = "/index.html";
|
||
const allowed = p === "/index.html" || STATIC_PREFIXES.some((pre) => p.startsWith(pre));
|
||
if (!allowed || p.includes("..")) { res.writeHead(404); return res.end("not found"); }
|
||
const full = path.normalize(path.join(ROOT, p));
|
||
if (!full.startsWith(ROOT + path.sep)) { res.writeHead(404); return res.end("not found"); }
|
||
const ext = path.extname(full).toLowerCase();
|
||
const mime = MIME[ext];
|
||
if (!mime) { res.writeHead(404); return res.end("not found"); }
|
||
fs.readFile(full, (err, data) => {
|
||
if (err) { res.writeHead(404); return res.end("not found"); }
|
||
res.writeHead(200, { "Content-Type": mime, "Cache-Control": "no-cache" });
|
||
if (req.method === "HEAD") return res.end();
|
||
res.end(data);
|
||
});
|
||
}
|
||
|
||
const server = http.createServer((req, res) => {
|
||
secHeaders(res);
|
||
let url;
|
||
try { url = new URL(req.url, "http://x"); } catch (e) { res.writeHead(400); return res.end(); }
|
||
const pathname = url.pathname;
|
||
if (pathname.startsWith("/api/")) {
|
||
if (pathname === "/api/health" && req.method === "GET") return sendJson(res, 200, { ok: true, t: Date.now() });
|
||
handleApi(req, res, pathname, url.searchParams);
|
||
} else {
|
||
handleStatic(req, res, pathname);
|
||
}
|
||
});
|
||
|
||
server.listen(PORT, HOST, () => {
|
||
console.log(`[star-raid] server listening on http://${HOST}:${PORT} (secure-cookie=${SECURE})`);
|
||
});
|