144 lines
7.7 KiB
JavaScript
144 lines
7.7 KiB
JavaScript
"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(未登录返回 user:null,不产生 401 控制台噪音) ---------- */
|
||
function me(req, res, ctx) {
|
||
const u = ctx.user;
|
||
if (!u) return ctx.ok({ user: null });
|
||
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: "codexN" };
|
||
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: false },
|
||
"GET /api/leaderboard": { fn: leaderboard, auth: true },
|
||
"POST /api/run": { fn: run, auth: true },
|
||
};
|
||
|
||
module.exports = { ROUTES, CODEX_TOTAL };
|