261 lines
14 KiB
JavaScript
261 lines
14 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 保持同步:25主武+17副武+13僚机+8被动=63) */
|
||
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", "loiter",
|
||
"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"]);
|
||
/* 存档白名单(与 js/data.js SHIPS、js/ui.js META 保持同步) */
|
||
const SHIP_KEYS = new Set(["falcon", "ghost", "bastion", "valkyrie", "asura"]);
|
||
const PASSIVE_KEYS = new Set(["fire", "crit", "shield", "nano", "mag", "bounty", "odcore", "ghost"]);
|
||
const META_MAX = { weapon: 2, armor: 2, hpup: 2, luck: 2, wallet: 3, mag2: 2, odstart: 1 };
|
||
const DEFAULT_STATE = { coins: 0, ships: ["falcon"], ship: "falcon", m: {}, best: { s0: 0, s1: 0, e0: 0, e1: 0, stage: 0 }, rush: {}, mute: false, music: true };
|
||
|
||
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`);
|
||
const qGetState = db.prepare("SELECT state FROM player_state WHERE user_id=?");
|
||
const qPutState = db.prepare(`INSERT INTO player_state(user_id,state,updated_at) VALUES(?,?,?)
|
||
ON CONFLICT(user_id) DO UPDATE SET state=excluded.state, updated_at=excluded.updated_at`);
|
||
const qGetSave = db.prepare("SELECT payload, updated_at FROM run_saves WHERE user_id=?");
|
||
const qPutSave = db.prepare(`INSERT INTO run_saves(user_id,payload,updated_at) VALUES(?,?,?)
|
||
ON CONFLICT(user_id) DO UPDATE SET payload=excluded.payload, updated_at=excluded.updated_at`);
|
||
const qDelSave = db.prepare("DELETE FROM run_saves WHERE user_id=?");
|
||
|
||
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);
|
||
}
|
||
function stateOf(userId) {
|
||
const r = qGetState.get(userId);
|
||
if (!r) return null; // null=从未同步(老账号首次登陆:客户端用本地存档初始化上传)
|
||
try { return JSON.parse(r.state); } catch (e) { return null; }
|
||
}
|
||
|
||
/* 存档白名单校验:只留合法字段并 clamp,任何字段非法则整体拒收 */
|
||
function vState(s) {
|
||
if (!s || typeof s !== "object" || Array.isArray(s)) return null;
|
||
if (!vInt(s.coins, 0, 1e7)) return null;
|
||
if (!Array.isArray(s.ships)) return null;
|
||
const ships = [...new Set(s.ships.filter((k) => SHIP_KEYS.has(k)))].slice(0, 8);
|
||
if (!ships.includes("falcon")) ships.unshift("falcon");
|
||
const ship = SHIP_KEYS.has(s.ship) ? s.ship : "falcon";
|
||
const m = {};
|
||
if (s.m && typeof s.m === "object") for (const k of Object.keys(META_MAX))
|
||
if (vInt(s.m[k], 0, META_MAX[k])) m[k] = s.m[k];
|
||
const best = { s0: 0, s1: 0, e0: 0, e1: 0, stage: 0 };
|
||
if (s.best && typeof s.best === "object") for (const k of Object.keys(best))
|
||
if (vInt(s.best[k], 0, 1e8)) best[k] = s.best[k];
|
||
const rush = {};
|
||
if (s.rush && typeof s.rush === "object") for (const k of Object.keys(s.rush).slice(0, 32))
|
||
if (/^\d{1,2}$/.test(k) && vInt(s.rush[k], 0, 8640000)) rush[k] = s.rush[k];
|
||
/* 音频开关:缺省=不静音/音乐开(老存档无此字段) */
|
||
const mute = s.mute === true;
|
||
const music = s.music !== false;
|
||
return { coins: s.coins, ships, ship, m, best, rush, mute, music };
|
||
}
|
||
|
||
/* 无尽模式断点存档校验(波次检查点快照;装备 key 复用图鉴白名单) */
|
||
function vEquipMap(o) {
|
||
if (!o || typeof o !== "object" || Array.isArray(o)) return null;
|
||
const out = {};
|
||
for (const k of Object.keys(o).slice(0, 8))
|
||
if (CODEX_KEYS.has(k) && vInt(o[k], 1, 5)) out[k] = o[k];
|
||
return out;
|
||
}
|
||
function vLoadout(a, max, src) {
|
||
if (!Array.isArray(a)) return null;
|
||
return [...new Set(a.filter((k) => typeof k === "string" && src[k]))].slice(0, max);
|
||
}
|
||
function vRunSave(s) {
|
||
if (!s || typeof s !== "object" || Array.isArray(s)) return null;
|
||
if (s.mode !== "endless") return null; // 断点续玩仅限无尽模式
|
||
if (!SHIP_KEYS.has(s.ship)) return null;
|
||
if (!vInt(s.diff, 0, 1)) return null;
|
||
if (!vInt(s.wave, 1, 9999) || !vInt(s.score, 0, 1e8) || !vInt(s.coins, 0, 1e7)) return null;
|
||
if (!vInt(s.kills, 0, 1e6) || !vInt(s.level, 1, 999) || !vInt(s.xp, 0, 1e6) || !vInt(s.xpNeed, 1, 1e9)) return null;
|
||
if (!vInt(s.lives, 0, 99) || !vInt(s.maxHp, 1, 9999) || !vInt(s.hp, 0, 9999)) return null;
|
||
if (!vInt(s.shield, 0, 9) || !vInt(s.od, 0, 100)) return null;
|
||
if (!vInt(s.bossN, 0, 9999) || !vInt(s.bossKills, 0, 60) || !vInt(s.time, 0, 5184000)) return null;
|
||
const weapons = vEquipMap(s.weapons), subs = vEquipMap(s.subs), drones = vEquipMap(s.drones);
|
||
if (!weapons || !subs || !drones) return null;
|
||
const loadout = vLoadout(s.loadout, 5, weapons);
|
||
const subLoadout = vLoadout(s.subLoadout, 5, subs);
|
||
const droneLoadout = vLoadout(s.droneLoadout, 3, drones);
|
||
if (!loadout || !loadout.length || !subLoadout || !droneLoadout) return null;
|
||
const u = {};
|
||
if (s.u && typeof s.u === "object") for (const k of PASSIVE_KEYS)
|
||
if (vInt(s.u[k], 0, 5)) u[k] = s.u[k];
|
||
const luck = vInt(s.luck, 0, 2) ? s.luck : 0;
|
||
return { v: 1, mode: "endless", diff: s.diff, ship: s.ship, wave: s.wave, score: s.score,
|
||
coins: s.coins, kills: s.kills, level: s.level, xp: s.xp, xpNeed: s.xpNeed, luck,
|
||
weapons, loadout, subs, subLoadout, drones, droneLoadout, u,
|
||
lives: s.lives, maxHp: s.maxHp, hp: Math.min(s.hp, s.maxHp),
|
||
shield: s.shield, od: s.od, bossN: s.bossN, bossKills: s.bossKills, time: s.time };
|
||
}
|
||
|
||
/* ---------- GET /api/run_save(无档返回 save:null) ---------- */
|
||
function getRunSave(req, res, ctx) {
|
||
const r = qGetSave.get(ctx.user.id);
|
||
if (!r) return ctx.ok({ save: null });
|
||
let save = null;
|
||
try { save = JSON.parse(r.payload); } catch (e) { /* 损坏按无档处理 */ }
|
||
return ctx.ok({ save, updatedAt: r.updated_at });
|
||
}
|
||
|
||
/* ---------- POST /api/run_save(每账号1栏位,新档覆盖旧档) ---------- */
|
||
function putRunSave(req, res, ctx, body) {
|
||
if (!rateLimit("rsave:" + ctx.user.id, 40, 5 * 60000)) return ctx.fail(429, "同步太频繁");
|
||
const save = vRunSave(body && body.save);
|
||
if (!save) return ctx.fail(400, "非法对局存档");
|
||
qPutSave.run(ctx.user.id, JSON.stringify(save), Date.now());
|
||
return ctx.ok({ saved: true });
|
||
}
|
||
|
||
/* ---------- DELETE /api/run_save(结算/死亡后清除) ---------- */
|
||
function delRunSave(req, res, ctx) {
|
||
qDelSave.run(ctx.user.id);
|
||
return ctx.ok({ deleted: true });
|
||
}
|
||
|
||
/* ---------- 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, state: DEFAULT_STATE });
|
||
}
|
||
|
||
/* ---------- 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, state: stateOf(u.id) });
|
||
}
|
||
|
||
/* ---------- 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, state: stateOf(u.id) });
|
||
}
|
||
|
||
/* ---------- 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, "非法击坠数");
|
||
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();
|
||
/* endless_high 只认本账号实际上报的无尽对局分数(不收 localStorage 历史值——浏览器级共享会串号,已踩坑) */
|
||
const endlessCand = b.mode === "endless" ? b.score : 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 });
|
||
}
|
||
|
||
/* ---------- POST /api/state ----------
|
||
存档云同步:星币/机体/选中机体/永久强化/本地纪录(防抖由客户端负责) */
|
||
function saveState(req, res, ctx, body) {
|
||
if (!rateLimit("state:" + ctx.user.id, 60, 5 * 60000)) return ctx.fail(429, "同步太频繁");
|
||
const st = vState(body && body.state);
|
||
if (!st) return ctx.fail(400, "非法存档数据");
|
||
qPutState.run(ctx.user.id, JSON.stringify(st), Date.now());
|
||
return ctx.ok({ saved: true });
|
||
}
|
||
|
||
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 },
|
||
"POST /api/state": { fn: saveState, auth: true },
|
||
"GET /api/run_save": { fn: getRunSave, auth: true },
|
||
"POST /api/run_save": { fn: putRunSave, auth: true },
|
||
"DELETE /api/run_save": { fn: delRunSave, auth: true },
|
||
};
|
||
|
||
module.exports = { ROUTES, CODEX_TOTAL };
|