starraid/star-raid/server/util.js
JOJO ab403f4cc7 后端化:账号系统+排行榜+图鉴收集同步+飞行员大厅(零依赖Node+SQLite)
- 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全流程验证通过(注册/登陆/游戏/结算/图鉴/安全注入/限流);游戏视觉未验收(用户亲自)
2026-08-06 04:20:25 +08:00

81 lines
2.7 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";
/* ================= 工具:请求体 / 限流 / 输入校验 ================= */
/* 读取请求体(带大小上限,超限即毁连接) */
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 };