- server/:node:http+node:sqlite 零依赖后端(users/sessions/user_stats/codex_unlocks/runs 五表) - 安全:scrypt密码哈希/服务端session/全prepared statement/输入白名单/限流/CSP等安全头/静态白名单防穿越 - 前端:o-auth登陆注册 + o-home飞行员大厅(排行榜Top100三排序+图鉴展柜背景:随机3主3副2僚机+刷怪巡游) - 图鉴改为收集点亮制(未收集=剪影+???),服务端codex_unlocks与本地并集同步 - 修复:回标题/大厅后背景卡在上场残影——render按battleUI门控+离场G=null;空闲=星空 - 结算上报:endRun/rush胜利两处submitRun,Boss击杀计数入排行榜 - playwright全流程验证通过(注册/登陆/游戏/结算/图鉴/安全注入/限流);游戏视觉未验收(用户亲自)
81 lines
2.7 KiB
JavaScript
81 lines
2.7 KiB
JavaScript
"use strict";
|
||
/* ================= 工具:请求体 / 限流 / 输入校验 ================= */
|
||
|
||
/* 读取请求体(带大小上限,超限即毁连接) */
|
||
function readBody(req, limit = 16384) {
|
||
return new Promise((resolve, reject) => {
|
||
let size = 0;
|
||
const chunks = [];
|
||
req.on("data", (c) => {
|
||
size += c.length;
|
||
if (size > limit) {
|
||
reject(Object.assign(new Error("body too large"), { status: 413 }));
|
||
req.destroy();
|
||
} else chunks.push(c);
|
||
});
|
||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||
req.on("error", reject);
|
||
});
|
||
}
|
||
|
||
/* 读取 JSON:强制 content-type(配合 SameSite=Lax 防 CSRF),限长 */
|
||
async function readJson(req, limit) {
|
||
const ct = String(req.headers["content-type"] || "");
|
||
if (!ct.includes("application/json"))
|
||
throw Object.assign(new Error("Content-Type must be application/json"), { status: 415 });
|
||
const buf = await readBody(req, limit);
|
||
try {
|
||
return JSON.parse(buf.toString("utf8"));
|
||
} catch (e) {
|
||
throw Object.assign(new Error("invalid JSON"), { status: 400 });
|
||
}
|
||
}
|
||
|
||
/* ---------------- 限流(内存桶,按 key) ---------------- */
|
||
const buckets = new Map();
|
||
/** 返回 true=放行;false=超限 */
|
||
function rateLimit(key, max, winMs) {
|
||
const now = Date.now();
|
||
let b = buckets.get(key);
|
||
if (!b || now > b.reset) {
|
||
b = { count: 0, reset: now + winMs };
|
||
buckets.set(key, b);
|
||
}
|
||
b.count++;
|
||
return b.count <= max;
|
||
}
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [k, b] of buckets) if (now > b.reset) buckets.delete(k);
|
||
}, 60000).unref();
|
||
|
||
/* ---------------- 输入校验(白名单式) ---------------- */
|
||
const RE_USER = /^[A-Za-z0-9_一-龥]{2,16}$/;
|
||
const RE_EMAIL = /^[^\s@]{1,40}@[^\s@]{1,40}\.[^\s@]{1,20}$/;
|
||
const vUsername = (u) => typeof u === "string" && RE_USER.test(u);
|
||
const vEmail = (e) => typeof e === "string" && e.length <= 64 && RE_EMAIL.test(e);
|
||
const vPassword = (p) => typeof p === "string" && p.length >= 8 && p.length <= 72;
|
||
const vInt = (n, min, max) => Number.isInteger(n) && n >= min && n <= max;
|
||
|
||
/* 客户端 IP(反代后取 X-Forwarded-For 首段,仅用于限流) */
|
||
function clientIp(req) {
|
||
const xf = req.headers["x-forwarded-for"];
|
||
if (xf) return String(xf).split(",")[0].trim().slice(0, 64);
|
||
return (req.socket && req.socket.remoteAddress) || "unknown";
|
||
}
|
||
|
||
/* 解析 Cookie 头 */
|
||
function parseCookies(req) {
|
||
const out = {};
|
||
const raw = req.headers.cookie;
|
||
if (!raw) return out;
|
||
for (const part of String(raw).split(";")) {
|
||
const i = part.indexOf("=");
|
||
if (i < 0) continue;
|
||
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
||
}
|
||
return out;
|
||
}
|
||
|
||
module.exports = { readJson, rateLimit, vUsername, vEmail, vPassword, vInt, clientIp, parseCookies };
|