starraid/star-raid/server/server.js

106 lines
4.6 KiB
JavaScript
Raw Permalink 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";
/* ================= Star Raid 服务端入口 =================
零依赖node:http + node:sqlite。静态资源白名单 + /api 路由 + 安全头 */
const http = require("node:http");
require("./env"); // 必须先于 db 加载:数据库目录等配置来自 .env
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/jsserver/、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})`);
});