Files
i-want-to-heal-mmo/server/game-api.mjs
T
2026-07-12 23:20:15 -04:00

401 lines
15 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.
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
const MAX_JSON_BYTES = 1024 * 1024;
const AUTH_WINDOW_MS = 15 * 60 * 1000;
const AUTH_ATTEMPTS_PER_WINDOW = 20;
const authAttempts = new Map();
function apiError(message, status = 400) {
const error = new Error(message);
error.status = status;
return error;
}
function sendJson(response, status, body) {
response.statusCode = status;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
response.end(JSON.stringify(body));
}
function configuredCorsOrigins() {
return String(process.env.CORS_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
}
function setCorsHeaders(request, response) {
const origin = request.headers.origin;
if (!origin) return;
const configured = configuredCorsOrigins();
if (!configured.includes("*") && !configured.includes(origin)) return;
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type");
response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
response.setHeader("Access-Control-Max-Age", "86400");
response.setHeader("Vary", "Origin");
}
async function readJson(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_JSON_BYTES) throw apiError("Request body is too large.", 413);
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw apiError("Request body must be valid JSON.");
}
}
function canonicalUsername(value) {
return String(value ?? "").trim().toLocaleLowerCase();
}
function validateUsername(value) {
const username = String(value ?? "").trim();
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
throw apiError("Username must be 320 letters, numbers, or underscores.");
}
return username;
}
function validatePassword(value) {
const password = String(value ?? "");
if (password.length < 10 || password.length > 128) {
throw apiError("Password must be 10128 characters.");
}
return password;
}
function passwordDigest(password, salt) {
return scryptSync(password, salt, 64).toString("hex");
}
function verifyPassword(password, account) {
const actual = Buffer.from(passwordDigest(password, account.passwordSalt), "hex");
const expected = Buffer.from(account.passwordHash, "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
function tokenHash(token) {
return createHash("sha256").update(token).digest("hex");
}
function bearerToken(request) {
const authorization = String(request.headers.authorization ?? "");
return authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
}
function createSession(database, accountId) {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS).toISOString();
database.prepare(`
INSERT INTO sessions (account_id, token_hash, expires_at)
VALUES (?, ?, ?)
`).run(accountId, tokenHash(token), expiresAt);
return token;
}
function currentSession(database, request) {
const token = bearerToken(request);
if (!token) return null;
return database.prepare(`
SELECT accounts.id AS accountId, accounts.username
FROM sessions
JOIN accounts ON accounts.id = sessions.account_id
WHERE sessions.token_hash = ? AND sessions.expires_at > CURRENT_TIMESTAMP
`).get(tokenHash(token)) ?? null;
}
function requireSession(database, request) {
const session = currentSession(database, request);
if (!session) throw apiError("Sign in required.", 401);
return session;
}
function clientAddress(request) {
return request.socket?.remoteAddress ?? "unknown";
}
function enforceAuthRateLimit(request) {
const now = Date.now();
const key = clientAddress(request);
const existing = authAttempts.get(key);
const bucket = existing && now - existing.startedAt < AUTH_WINDOW_MS
? existing
: { startedAt: now, count: 0 };
bucket.count += 1;
authAttempts.set(key, bucket);
if (bucket.count > AUTH_ATTEMPTS_PER_WINDOW) {
throw apiError("Too many authentication attempts. Try again later.", 429);
}
}
function register(database, payload) {
const username = validateUsername(payload?.username);
const password = validatePassword(payload?.password);
const canonical = canonicalUsername(username);
if (database.prepare("SELECT id FROM accounts WHERE canonical_username = ?").get(canonical)) {
throw apiError("Account already exists.", 409);
}
const salt = randomBytes(16).toString("hex");
const result = database.prepare(`
INSERT INTO accounts (username, canonical_username, password_hash, password_salt)
VALUES (?, ?, ?, ?)
`).run(username, canonical, passwordDigest(password, salt), salt);
const accountId = Number(result.lastInsertRowid);
return { account: { id: accountId, username }, token: createSession(database, accountId) };
}
function login(database, payload) {
const canonical = canonicalUsername(payload?.username);
const password = String(payload?.password ?? "");
const account = database.prepare(`
SELECT id, username, password_hash AS passwordHash, password_salt AS passwordSalt
FROM accounts WHERE canonical_username = ?
`).get(canonical);
if (!account || !verifyPassword(password, account)) {
throw apiError("Username or password is incorrect.", 401);
}
return {
account: { id: account.id, username: account.username },
token: createSession(database, account.id),
};
}
function validateSlotId(value) {
const slotId = Number(value);
if (!Number.isInteger(slotId) || slotId < 1 || slotId > 3) throw apiError("Invalid save slot.");
return slotId;
}
function validateSave(value, slotId) {
if (!value || typeof value !== "object" || Number(value.schemaVersion) !== 5) {
throw apiError("Save snapshot is invalid.");
}
if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request.");
if (typeof value.hunterName !== "string" || !value.hunterName.trim()) {
throw apiError("Save snapshot has no hunter name.");
}
return value;
}
function normalizeNonNegativeInteger(value) {
const number = Math.floor(Number(value));
return Number.isFinite(number) ? Math.max(0, number) : 0;
}
function syncLeaderboardStats(database, accountId, slotId, save) {
database.prepare("DELETE FROM boss_kill_records WHERE account_id = ? AND slot_id = ?").run(accountId, slotId);
const insertBoss = database.prepare(`
INSERT INTO boss_kill_records (account_id, slot_id, boss_id, kills, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
`);
const bossKills = save.stats?.bossKills && typeof save.stats.bossKills === "object"
? save.stats.bossKills
: {};
for (const [bossId, rawKills] of Object.entries(bossKills)) {
if (!/^[a-z0-9-]{1,64}$/.test(bossId)) continue;
const kills = normalizeNonNegativeInteger(rawKills);
if (kills > 0) insertBoss.run(accountId, slotId, bossId, kills);
}
const highestRound = normalizeNonNegativeInteger(save.stats?.highestRoguelikeRound);
database.prepare(`
INSERT INTO roguelike_records (account_id, slot_id, highest_round, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_round = excluded.highest_round,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestRound);
}
function writeSave(database, accountId, slotId, rawSave) {
const save = validateSave(rawSave, slotId);
const serialized = JSON.stringify(save);
if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413);
database.exec("BEGIN IMMEDIATE");
try {
database.prepare(`
INSERT INTO hunter_saves (account_id, slot_id, hunter_name, save_json, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
hunter_name = excluded.hunter_name,
save_json = excluded.save_json,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, save.hunterName.trim().slice(0, 20), serialized);
syncLeaderboardStats(database, accountId, slotId, save);
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return save;
}
function readSave(database, accountId, slotId) {
const row = database.prepare(`
SELECT save_json AS saveJson FROM hunter_saves WHERE account_id = ? AND slot_id = ?
`).get(accountId, slotId);
if (!row) return null;
try { return JSON.parse(row.saveJson); } catch { return null; }
}
function listSaves(database, accountId) {
return database.prepare(`
SELECT slot_id AS slotId, save_json AS saveJson, updated_at AS updatedAt
FROM hunter_saves WHERE account_id = ? ORDER BY slot_id
`).all(accountId).flatMap((row) => {
try { return [{ slotId: row.slotId, save: JSON.parse(row.saveJson), updatedAt: row.updatedAt }]; }
catch { return []; }
});
}
function leaderboardEntry(row, valueKey) {
return {
rank: row.rank,
username: row.username,
hunterName: row.hunterName,
slotId: row.slotId,
value: row[valueKey],
};
}
function bossLeaderboard(database, accountId, slotId, bossId) {
if (!/^[a-z0-9-]{1,64}$/.test(bossId)) throw apiError("Invalid boss.");
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.kills DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.kills,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM boss_kill_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE records.boss_id = ?
)
SELECT * FROM ranked ORDER BY kills DESC, updatedAt ASC, accountId ASC, slotId ASC
`).all(bossId);
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "boss",
bossId,
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "kills")),
current: current ? leaderboardEntry(current, "kills") : null,
};
}
function roguelikeLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.highest_round DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_round AS highestRound,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM roguelike_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE records.highest_round > 0
)
SELECT * FROM ranked ORDER BY highestRound DESC, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "roguelike",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestRound")),
current: current ? leaderboardEntry(current, "highestRound") : null,
};
}
export function createGameApiHandler(options = {}) {
const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data");
mkdirSync(dataDirectory, { recursive: true });
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8"));
async function handle(request, response, next) {
if (!request.url?.startsWith("/api/")) return next();
setCorsHeaders(request, response);
if (request.method === "OPTIONS") {
response.statusCode = 204;
return response.end();
}
try {
database.prepare("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP").run();
const url = new URL(request.url, "http://localhost");
const path = url.pathname;
if (path === "/api/health" && request.method === "GET") {
return sendJson(response, 200, { ok: true, database: "ready" });
}
if (path === "/api/auth/register" && request.method === "POST") {
enforceAuthRateLimit(request);
return sendJson(response, 201, register(database, await readJson(request)));
}
if (path === "/api/auth/login" && request.method === "POST") {
enforceAuthRateLimit(request);
return sendJson(response, 200, login(database, await readJson(request)));
}
if (path === "/api/auth/session" && request.method === "GET") {
const session = currentSession(database, request);
return sendJson(response, session ? 200 : 401, session
? { account: { id: session.accountId, username: session.username } }
: { error: "Sign in required." });
}
if (path === "/api/auth/logout" && request.method === "POST") {
const token = bearerToken(request);
if (token) database.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash(token));
return sendJson(response, 200, { ok: true });
}
const session = requireSession(database, request);
if (path === "/api/saves" && request.method === "GET") {
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
}
const saveMatch = path.match(/^\/api\/saves\/([1-3])$/);
if (saveMatch && request.method === "GET") {
return sendJson(response, 200, { save: readSave(database, session.accountId, validateSlotId(saveMatch[1])) });
}
if (saveMatch && request.method === "PUT") {
const slotId = validateSlotId(saveMatch[1]);
const payload = await readJson(request);
return sendJson(response, 200, { save: writeSave(database, session.accountId, slotId, payload?.save) });
}
const bossMatch = path.match(/^\/api\/leaderboards\/boss\/([a-z0-9-]+)$/);
if (bossMatch && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, bossLeaderboard(database, session.accountId, slotId, bossMatch[1]));
}
if (path === "/api/leaderboards/roguelike" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId));
}
return sendJson(response, 404, { error: "API route not found." });
} catch (error) {
const status = Number(error?.status) || 500;
const message = status >= 500 ? "Server error." : error.message;
if (status >= 500) console.error(error);
return sendJson(response, status, { error: message });
}
}
return { handle, close: () => database.close() };
}