"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 };