starraid/star-raid/server/api.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

143 lines
7.6 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";
/* ================= API 处理器 =================
所有 SQL 均为 prepared statement禁止字符串拼接用户输入 */
const db = require("./db");
const auth = require("./auth");
const { vUsername, vEmail, vPassword, vInt, rateLimit, clientIp } = require("./util");
/* 图鉴合法 key 白名单(与 js/data.js 保持同步24主武+17副武+13僚机+8被动=62 */
const CODEX_KEYS = new Set([
"vulcan", "shotgun", "needle", "flame", "shotgun2", "tesla", "railgun", "deathray",
"crescent", "fission", "frost", "swarm", "bubble", "starfall", "boomerang", "singularity",
"wavecannon", "holy", "thundercall", "arcshu", "volt", "dawn", "proto", "nebula",
"broad", "sweep", "leech", "orbit", "sats", "pd", "slowfield", "flares", "seeker",
"beamdrone", "frostnova", "thorns", "sticky", "magnetar", "executor", "airstrike", "repair",
"gunner", "gunner2", "sniperd", "beamer", "bomber", "frostd", "teslaD", "rammer",
"healer", "shieldd", "missileD", "bladeD", "sweeper",
"fire", "crit", "shield", "nano", "mag", "bounty", "odcore", "ghost",
]);
const CODEX_TOTAL = CODEX_KEYS.size;
const RUN_MODES = new Set(["stage", "endless", "rush"]);
const qInsUser = db.prepare("INSERT INTO users(username,email,pass_hash,created_at) VALUES(?,?,?,?)");
const qInsStats = db.prepare("INSERT OR IGNORE INTO user_stats(user_id) VALUES(?)");
const qByName = db.prepare("SELECT * FROM users WHERE username=?");
const qByEmail = db.prepare("SELECT * FROM users WHERE email=?");
const qCodex = db.prepare("SELECT entry_key FROM codex_unlocks WHERE user_id=?");
const qInsCodex = db.prepare("INSERT OR IGNORE INTO codex_unlocks(user_id,entry_key,unlocked_at) VALUES(?,?,?)");
const qStats = db.prepare("SELECT * FROM user_stats WHERE user_id=?");
const qInsRun = db.prepare("INSERT INTO runs(user_id,mode,diff,score,wave,stage,time_sec,boss_kills,win,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)");
const qUpdStats = db.prepare(`INSERT INTO user_stats(user_id,boss_kills,endless_high,runs,updated_at) VALUES(?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
boss_kills=boss_kills+excluded.boss_kills,
endless_high=MAX(endless_high,excluded.endless_high),
runs=runs+1,
updated_at=excluded.updated_at`);
function statsOf(userId) {
const s = qStats.get(userId) || { boss_kills: 0, endless_high: 0, runs: 0 };
return { bossKills: s.boss_kills, endlessHigh: s.endless_high, runs: s.runs };
}
function codexOf(userId) {
return qCodex.all(userId).map((r) => r.entry_key);
}
/* ---------- POST /api/register ---------- */
function register(req, res, ctx, body) {
if (!rateLimit("reg:" + ctx.ip, 10, 5 * 60000)) return ctx.fail(429, "操作太频繁,请稍后再试");
const username = body && body.username, email = body && body.email, password = body && body.password;
if (!vUsername(username)) return ctx.fail(400, "用户名需为 2-16 位(中文/英文/数字/下划线)");
if (!vEmail(email)) return ctx.fail(400, "邮箱格式不正确");
if (!vPassword(password)) return ctx.fail(400, "密码需为 8-72 位");
if (qByName.get(username)) return ctx.fail(409, "用户名已被注册");
if (qByEmail.get(email)) return ctx.fail(409, "邮箱已被注册");
const info = qInsUser.run(username.trim(), email.trim().toLowerCase(), auth.hashPassword(password), Date.now());
const uid = Number(info.lastInsertRowid);
qInsStats.run(uid);
const token = auth.createSession(db, uid);
ctx.setCookie = auth.sessionCookie(token, ctx.secure);
return ctx.ok({ user: { username, email: email.trim().toLowerCase() }, stats: statsOf(uid), codex: [], codexTotal: CODEX_TOTAL });
}
/* ---------- POST /api/login ---------- */
function login(req, res, ctx, body) {
if (!rateLimit("login:" + ctx.ip, 20, 5 * 60000)) return ctx.fail(429, "尝试太频繁,请稍后再试");
const id = body && body.id, password = body && body.password;
if (typeof id !== "string" || typeof password !== "string" || id.length > 64 || password.length > 72)
return ctx.fail(401, "用户名或密码错误");
const u = id.includes("@") ? qByEmail.get(id.trim().toLowerCase()) : qByName.get(id.trim());
/* 统一错误信息,不泄露账号是否存在;对不存在账号也跑一次哈希,防时序探测 */
const hash = u ? u.pass_hash : "0".repeat(32) + ":" + "0".repeat(128);
if (!auth.verifyPassword(password, hash) || !u) return ctx.fail(401, "用户名或密码错误");
qInsStats.run(u.id);
const token = auth.createSession(db, u.id);
ctx.setCookie = auth.sessionCookie(token, ctx.secure);
return ctx.ok({ user: { username: u.username, email: u.email }, stats: statsOf(u.id), codex: codexOf(u.id), codexTotal: CODEX_TOTAL });
}
/* ---------- POST /api/logout ---------- */
function logout(req, res, ctx) {
auth.destroySession(db, ctx.token);
ctx.setCookie = auth.clearCookie(ctx.secure);
return ctx.ok({ bye: true });
}
/* ---------- GET /api/me ---------- */
function me(req, res, ctx) {
const u = ctx.user;
return ctx.ok({ user: { username: u.username, email: u.email, createdAt: u.created_at }, stats: statsOf(u.id), codex: codexOf(u.id), codexTotal: CODEX_TOTAL });
}
/* ---------- GET /api/leaderboard?sort=endless|boss|codex ---------- */
const LB_COLS = { endless: "endless_high", boss: "boss_kills", codex: "codex_n" };
function leaderboard(req, res, ctx, _body, query) {
const col = LB_COLS[(query && query.get("sort")) || ""] || "endless_high";
/* col 来自上方白名单映射,非用户输入直接拼接 */
const rows = db.prepare(`
SELECT u.username AS username, s.boss_kills AS bossKills, s.endless_high AS endlessHigh,
(SELECT COUNT(*) FROM codex_unlocks c WHERE c.user_id=u.id) AS codexN
FROM users u JOIN user_stats s ON s.user_id=u.id
ORDER BY ${col} DESC, u.id ASC LIMIT 100`).all();
return ctx.ok({ rows, codexTotal: CODEX_TOTAL, sort: col });
}
/* ---------- POST /api/run ----------
结算上报:基础合理性校验(客户端游戏无法杜绝作弊,只挡低级伪造) */
function run(req, res, ctx, body) {
if (!rateLimit("run:" + ctx.user.id, 20, 5 * 60000)) return ctx.fail(429, "上报太频繁");
const b = body || {};
if (!RUN_MODES.has(b.mode)) return ctx.fail(400, "非法模式");
if (!vInt(b.diff, 0, 1)) return ctx.fail(400, "非法难度");
if (!vInt(b.score, 0, 1e8)) return ctx.fail(400, "非法分数");
if (!vInt(b.wave, 0, 9999) || !vInt(b.stage, 0, 99)) return ctx.fail(400, "非法进度");
if (!vInt(b.timeSec, 0, 86400)) return ctx.fail(400, "非法时长");
if (!vInt(b.bossKills, 0, 60)) return ctx.fail(400, "非法击坠数");
if (!vInt(b.bestEndless === undefined ? 0 : b.bestEndless, 0, 1e8)) return ctx.fail(400, "非法纪录");
const keys = Array.isArray(b.codex) ? b.codex.slice(0, 100).filter((k) => CODEX_KEYS.has(k)) : [];
const uid = ctx.user.id, now = Date.now();
const endlessCand = Math.max(b.mode === "endless" ? b.score : 0, b.bestEndless || 0);
db.exec("BEGIN");
try {
qInsRun.run(uid, b.mode, b.diff, b.score, b.wave, b.stage, b.timeSec, b.bossKills, b.win ? 1 : 0, now);
qUpdStats.run(uid, b.bossKills, endlessCand, 1, now);
for (const k of keys) qInsCodex.run(uid, k, now);
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
return ctx.ok({ stats: statsOf(uid), codex: codexOf(uid), codexTotal: CODEX_TOTAL });
}
const ROUTES = {
"POST /api/register": { fn: register, auth: false },
"POST /api/login": { fn: login, auth: false },
"POST /api/logout": { fn: logout, auth: true },
"GET /api/me": { fn: me, auth: true },
"GET /api/leaderboard": { fn: leaderboard, auth: true },
"POST /api/run": { fn: run, auth: true },
};
module.exports = { ROUTES, CODEX_TOTAL };