693 lines
33 KiB
JavaScript
693 lines
33 KiB
JavaScript
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
import { createServer } from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
|
import { createArenaService } from "./arena-service.mjs";
|
|
import { publicManastormAdminConfig } from "./manastorm-config.mjs";
|
|
import { createOnlineSessionManager } from "./online-session.mjs";
|
|
import { upgradeArenaWebSocket } from "./websocket.mjs";
|
|
import { createBundledContentHandler } from "./bundled-content.mjs";
|
|
import { createBalanceLabJobService } from "./balance-lab-jobs.mjs";
|
|
|
|
const MIME_TYPES = new Map([
|
|
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
|
|
[".html", "text/html; charset=utf-8"], [".ico", "image/x-icon"],
|
|
[".jpeg", "image/jpeg"], [".jpg", "image/jpeg"],
|
|
[".js", "text/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"],
|
|
[".ktx2", "image/ktx2"], [".mp3", "audio/mpeg"], [".ogg", "audio/ogg"],
|
|
[".png", "image/png"], [".sqlite", "application/vnd.sqlite3"],
|
|
[".svg", "image/svg+xml"], [".wasm", "application/wasm"],
|
|
[".webm", "video/webm"], [".webp", "image/webp"],
|
|
]);
|
|
|
|
function json(response, status, body, extraHeaders = {}) {
|
|
const content = Buffer.from(JSON.stringify(body));
|
|
response.writeHead(status, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Content-Length": content.length,
|
|
"Cache-Control": "no-store",
|
|
...extraHeaders,
|
|
});
|
|
response.end(content);
|
|
}
|
|
|
|
function parseAllowedOrigins(value) {
|
|
return new Set(String(value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean));
|
|
}
|
|
|
|
function booleanFlag(value) {
|
|
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
|
|
}
|
|
|
|
function acceptsEncoding(value, requested) {
|
|
const encodings = new Map(String(value ?? "").split(",").map((entry) => {
|
|
const [name, ...parameters] = entry.trim().toLowerCase().split(";");
|
|
const quality = parameters.find((part) => part.trim().startsWith("q="));
|
|
return [name, quality ? Number(quality.trim().slice(2)) : 1];
|
|
}));
|
|
return (encodings.get(requested) ?? encodings.get("*") ?? 0) > 0;
|
|
}
|
|
|
|
function bearerToken(request) {
|
|
return /^Bearer\s+(.+)$/i.exec(String(request.headers.authorization ?? ""))?.[1] ?? "";
|
|
}
|
|
|
|
async function readJson(request, limit) {
|
|
const chunks = [];
|
|
let size = 0;
|
|
for await (const chunk of request) {
|
|
size += chunk.length;
|
|
if (size > limit) throw new GameDatabaseError("Request body is too large.", 413, "body_too_large");
|
|
chunks.push(chunk);
|
|
}
|
|
if (size === 0) return {};
|
|
try {
|
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
} catch {
|
|
throw new GameDatabaseError("Request body must be valid JSON.", 400, "invalid_json");
|
|
}
|
|
}
|
|
|
|
function resolveStaticFile(staticDir, pathname, spaFallback = true) {
|
|
let decoded;
|
|
try { decoded = decodeURIComponent(pathname); } catch { return null; }
|
|
const relative = decoded.replace(/^\/+/, "");
|
|
const candidate = path.resolve(staticDir, relative || "index.html");
|
|
if (candidate !== staticDir && !candidate.startsWith(`${staticDir}${path.sep}`)) return null;
|
|
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
if (spaFallback && !path.extname(relative)) {
|
|
const index = path.join(staticDir, "index.html");
|
|
if (existsSync(index)) return index;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseRange(value, size) {
|
|
const match = /^bytes=(\d*)-(\d*)$/.exec(String(value ?? ""));
|
|
if (!match) return null;
|
|
let start = match[1] ? Number(match[1]) : null;
|
|
let end = match[2] ? Number(match[2]) : null;
|
|
if (start === null && end !== null) {
|
|
start = Math.max(0, size - end);
|
|
end = size - 1;
|
|
} else {
|
|
start ??= 0;
|
|
end ??= size - 1;
|
|
}
|
|
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) return null;
|
|
return { start, end: Math.min(end, size - 1) };
|
|
}
|
|
|
|
function serveStatic(request, response, staticDir, pathname, options = {}) {
|
|
if (request.method !== "GET" && request.method !== "HEAD") return false;
|
|
const filePath = resolveStaticFile(staticDir, pathname, options.spaFallback !== false);
|
|
if (!filePath) return false;
|
|
const stats = statSync(filePath);
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
const range = request.headers.range ? parseRange(request.headers.range, stats.size) : null;
|
|
const canEncode = !range && !request.headers.range;
|
|
const encoding = canEncode && existsSync(`${filePath}.br`) && acceptsEncoding(request.headers["accept-encoding"], "br")
|
|
? "br"
|
|
: canEncode && existsSync(`${filePath}.gz`) && acceptsEncoding(request.headers["accept-encoding"], "gzip")
|
|
? "gzip"
|
|
: null;
|
|
const encodedPath = encoding ? `${filePath}.${encoding === "br" ? "br" : "gz"}` : null;
|
|
const servedPath = encodedPath ?? filePath;
|
|
const servedStats = encodedPath ? statSync(encodedPath) : stats;
|
|
const headers = {
|
|
"Content-Type": MIME_TYPES.get(extension) ?? "application/octet-stream",
|
|
"Accept-Ranges": "bytes",
|
|
"Cache-Control": options.immutable
|
|
? "public, max-age=31536000, immutable"
|
|
: ["index.html", "content-cache-sw.js"].includes(path.basename(filePath)) || options.noCache
|
|
? "no-cache"
|
|
: "public, max-age=3600",
|
|
"X-Content-Type-Options": "nosniff",
|
|
...(encodedPath ? { "Content-Encoding": encoding, Vary: "Accept-Encoding" } : {}),
|
|
};
|
|
if (request.headers.range && !range) {
|
|
response.writeHead(416, { ...headers, "Content-Range": `bytes */${stats.size}` });
|
|
response.end();
|
|
return true;
|
|
}
|
|
if (range) {
|
|
response.writeHead(206, {
|
|
...headers,
|
|
"Content-Length": range.end - range.start + 1,
|
|
"Content-Range": `bytes ${range.start}-${range.end}/${stats.size}`,
|
|
});
|
|
if (request.method === "HEAD") response.end();
|
|
else createReadStream(filePath, range).pipe(response);
|
|
return true;
|
|
}
|
|
response.writeHead(200, { ...headers, "Content-Length": servedStats.size });
|
|
if (request.method === "HEAD") response.end();
|
|
else createReadStream(servedPath).pipe(response);
|
|
return true;
|
|
}
|
|
|
|
function originHeaders(request, allowedOrigins) {
|
|
const origin = String(request.headers.origin ?? "");
|
|
if (!origin || !allowedOrigins.has(origin)) return {};
|
|
return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", Vary: "Origin" };
|
|
}
|
|
|
|
function createAuthLimiter() {
|
|
const buckets = new Map();
|
|
return (key) => {
|
|
const now = Date.now();
|
|
const recent = (buckets.get(key) ?? []).filter((timestamp) => now - timestamp < 60_000);
|
|
if (recent.length >= 12) return false;
|
|
recent.push(now);
|
|
buckets.set(key, recent);
|
|
if (buckets.size > 1_000) {
|
|
for (const [entry, timestamps] of buckets) {
|
|
if (!timestamps.some((timestamp) => now - timestamp < 60_000)) buckets.delete(entry);
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
}
|
|
|
|
export function createGameServer(options = {}) {
|
|
const staticDir = path.resolve(options.staticDir ?? process.env.STATIC_DIR ?? "dist");
|
|
const contentDir = path.resolve(options.contentDir ?? process.env.CONTENT_DIR ?? "content");
|
|
const serveBundledContent = createBundledContentHandler(staticDir);
|
|
const allowedOrigins = parseAllowedOrigins(options.corsOrigins ?? process.env.CORS_ORIGINS);
|
|
const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024));
|
|
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
|
|
const enableShelvedModes = options.enableShelvedModes ?? booleanFlag(process.env.HEALER_MAN_ENABLE_SHELVED_MODES);
|
|
const gameDatabase = options.database ?? openGameDatabase(options);
|
|
const balanceLabJobs = options.balanceLabJobs ?? createBalanceLabJobService({
|
|
dataDir: options.dataDir ?? path.dirname(gameDatabase.databasePath),
|
|
projectRoot: options.projectRoot,
|
|
pythonExecutable: options.pythonExecutable,
|
|
cooldownPolicyProvider: () => gameDatabase.getCompanionCooldownPolicy(),
|
|
readinessPolicyProvider: () => gameDatabase.getCompanionSpecReadiness(),
|
|
});
|
|
const onlineSessions = createOnlineSessionManager();
|
|
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
|
|
const allowAuthRequest = createAuthLimiter();
|
|
const allowReportRequest = createAuthLimiter();
|
|
const allowDungeonRunRequest = createAuthLimiter();
|
|
gameDatabase.pruneExpiredSessions();
|
|
|
|
const server = createServer(async (request, response) => {
|
|
const cors = originHeaders(request, allowedOrigins);
|
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
response.setHeader("X-Frame-Options", "DENY");
|
|
response.setHeader("Referrer-Policy", "same-origin");
|
|
for (const [name, value] of Object.entries(cors)) response.setHeader(name, value);
|
|
if (request.method === "OPTIONS") {
|
|
if (request.headers.origin && Object.keys(cors).length === 0) {
|
|
json(response, 403, { error: "Origin is not allowed.", code: "origin_not_allowed" });
|
|
return;
|
|
}
|
|
response.writeHead(204, {
|
|
...cors,
|
|
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, DELETE, OPTIONS",
|
|
"Access-Control-Max-Age": "86400",
|
|
});
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (url.pathname === "/api/health" && request.method === "GET") {
|
|
json(response, 200, { ok: true, service: "healer-man", time: new Date().toISOString() });
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/dungeon-route-approvals" && request.method === "GET") {
|
|
json(response, 200, gameDatabase.listDungeonRouteApprovals());
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/dungeon-routes" && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, gameDatabase.listDungeonRouteReviews(account));
|
|
return;
|
|
}
|
|
const dungeonRouteReviewMatch = /^\/api\/gm\/dungeon-routes\/([^/]+)\/([^/]+)\/review$/.exec(url.pathname);
|
|
if (dungeonRouteReviewMatch && request.method === "PUT") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, 64 * 1024);
|
|
const review = gameDatabase.putDungeonRouteReview(
|
|
account,
|
|
decodeURIComponent(dungeonRouteReviewMatch[1]),
|
|
decodeURIComponent(dungeonRouteReviewMatch[2]),
|
|
body,
|
|
);
|
|
json(response, 200, { review });
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/dungeon-sim/replays" && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, gameDatabase.listDungeonSimulationReplays(account, {
|
|
dungeonId: url.searchParams.get("dungeon"),
|
|
}));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/dungeon-sim/replays" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const replay = gameDatabase.saveDungeonSimulationReplay(account, await readJson(request, 64 * 1024 * 1024));
|
|
json(response, 201, { replay });
|
|
return;
|
|
}
|
|
const dungeonSimulationReportMatch = /^\/api\/gm\/dungeon-sim\/replays\/([^/]+)\/report$/.exec(url.pathname);
|
|
if (dungeonSimulationReportMatch && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, gameDatabase.getDungeonSimulationReport(account, dungeonSimulationReportMatch[1]), {
|
|
"Content-Disposition": `attachment; filename="dungeon-simulation-${dungeonSimulationReportMatch[1]}.json"`,
|
|
});
|
|
return;
|
|
}
|
|
const dungeonSimulationReplayMatch = /^\/api\/gm\/dungeon-sim\/replays\/([^/]+)$/.exec(url.pathname);
|
|
if (dungeonSimulationReplayMatch && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, gameDatabase.getDungeonSimulationReplay(account, dungeonSimulationReplayMatch[1]));
|
|
return;
|
|
}
|
|
if (dungeonSimulationReplayMatch && request.method === "DELETE") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, gameDatabase.deleteDungeonSimulationReplay(account, dungeonSimulationReplayMatch[1]));
|
|
return;
|
|
}
|
|
if (!enableShelvedModes && (
|
|
url.pathname === "/api/manastorm-config"
|
|
|| url.pathname.startsWith("/api/gm/manastorm-config")
|
|
|| url.pathname === "/api/arena"
|
|
|| url.pathname.startsWith("/api/arena/")
|
|
)) {
|
|
json(response, 503, { error: "This feature is temporarily unavailable.", code: "feature_disabled" });
|
|
return;
|
|
}
|
|
if ((url.pathname === "/api/auth/register" || url.pathname === "/api/auth/login") && request.method === "POST") {
|
|
const forwarded = trustProxy ? String(request.headers["x-forwarded-for"] ?? "").split(",")[0]?.trim() : "";
|
|
if (!allowAuthRequest(forwarded || request.socket.remoteAddress || "unknown")) {
|
|
throw new GameDatabaseError("Too many login attempts. Try again shortly.", 429, "rate_limited");
|
|
}
|
|
const body = await readJson(request, 64 * 1024);
|
|
const result = url.pathname.endsWith("register")
|
|
? await gameDatabase.register(body.username, body.password)
|
|
: await gameDatabase.login(body.username, body.password);
|
|
json(response, url.pathname.endsWith("register") ? 201 : 200, result);
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/auth/logout" && request.method === "POST") {
|
|
gameDatabase.logout(bearerToken(request));
|
|
json(response, 200, { ok: true });
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/me" && request.method === "GET") {
|
|
json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) });
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") {
|
|
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
|
|
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
|
|
bossDifficulty: url.searchParams.has("bossDifficulty") ? Number(url.searchParams.get("bossDifficulty")) : undefined,
|
|
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/dungeon-runs" && request.method === "POST") {
|
|
const token = bearerToken(request);
|
|
const account = token ? gameDatabase.authenticate(token) : null;
|
|
if (!allowDungeonRunRequest(account?.id ?? request.socket.remoteAddress))
|
|
throw new GameDatabaseError("Too many run submissions. Please try again shortly.", 429, "rate_limited");
|
|
json(response, 201, gameDatabase.submitDungeonRun(account, await readJson(request, 32 * 1024)));
|
|
return;
|
|
}
|
|
const dungeonRunMatch = /^\/api\/dungeon-runs\/([A-Za-z0-9_-]{8,100})$/.exec(url.pathname);
|
|
if (dungeonRunMatch && request.method === "GET") {
|
|
json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1]));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/boss-testing/reviews" && request.method === "GET") {
|
|
json(response, 200, gameDatabase.listBossTestingReviews());
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/boss-testing/reviews" && request.method === "POST") {
|
|
const token = bearerToken(request);
|
|
const account = token ? gameDatabase.authenticate(token) : null;
|
|
if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) {
|
|
throw new GameDatabaseError("Too many updates. Please wait a minute and try again.", 429, "rate_limited");
|
|
}
|
|
json(response, 200, { review: gameDatabase.saveBossTestingReview(account, await readJson(request, 8 * 1024)) });
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
|
|
json(response, 200, gameDatabase.listDungeonReports({
|
|
status: url.searchParams.get("status"),
|
|
dungeonId: url.searchParams.get("dungeon"),
|
|
}));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/dungeon-reports" && request.method === "POST") {
|
|
const token = bearerToken(request);
|
|
const account = token ? gameDatabase.authenticate(token) : null;
|
|
if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) {
|
|
throw new GameDatabaseError("Too many reports. Please wait a minute and try again.", 429, "rate_limited");
|
|
}
|
|
const body = await readJson(request, 8 * 1024);
|
|
json(response, 201, { report: gameDatabase.submitDungeonReport(account, body) });
|
|
return;
|
|
}
|
|
const reportMatch = /^\/api\/dungeon-reports\/([^/]+)$/.exec(url.pathname);
|
|
if (reportMatch && request.method === "PATCH") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, 8 * 1024);
|
|
json(response, 200, { report: gameDatabase.updateDungeonReport(account, reportMatch[1], body) });
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/cloud-save" && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, gameDatabase.getCloudSave(account.id));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/cloud-save" && request.method === "PUT") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, bodyLimit);
|
|
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/online-group" && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, gameDatabase.onlineGroupState(account.id));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/online-group/invite" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, 64 * 1024);
|
|
json(response, 201, gameDatabase.inviteOnlineGroupMember(account, body.username));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/online-group/invite/respond" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, 64 * 1024);
|
|
json(response, 200, gameDatabase.respondToOnlineGroupInvite(account.id, body.inviteId, body.accept === true));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/online-group/leave" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, gameDatabase.leaveOnlineGroup(account.id));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/online-group/remove" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, 64 * 1024);
|
|
json(response, 200, gameDatabase.removeOnlineGroupMember(account.id, body.accountId));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/online-group/member" && request.method === "PUT") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, 64 * 1024);
|
|
json(response, 200, gameDatabase.updateOnlineGroupMember(account.id, body.character, body.role));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/online-group/activity" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, 64 * 1024);
|
|
if (!enableShelvedModes && body.type === "manastorm") {
|
|
json(response, 503, { error: "Manastorm groups are temporarily unavailable.", code: "feature_disabled" });
|
|
return;
|
|
}
|
|
json(response, 200, gameDatabase.startOnlineGroupActivity(account.id, body));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/online-group/activity/clear" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, gameDatabase.clearOnlineGroupActivity(account.id));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/online-session/sync" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, bodyLimit);
|
|
const groupSnapshot = gameDatabase.onlineGroupState(account.id);
|
|
json(response, 200, onlineSessions.sync(account, groupSnapshot, body));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/arena/state" && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, arenaService.arenaState(account.id, url.searchParams.get("characterId")));
|
|
return;
|
|
}
|
|
const arenaLoadoutMatch = /^\/api\/arena\/loadouts\/(\d+)$/.exec(url.pathname);
|
|
if (arenaLoadoutMatch && request.method === "PUT") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, 64 * 1024);
|
|
json(response, 200, gameDatabase.putArenaLoadout(account.id, body.characterId, arenaLoadoutMatch[1], body));
|
|
return;
|
|
}
|
|
if (arenaLoadoutMatch && request.method === "DELETE") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, 16 * 1024);
|
|
json(response, 200, gameDatabase.deleteArenaLoadout(account.id, body.characterId, arenaLoadoutMatch[1]));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/arena/queue" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
const body = await readJson(request, 128 * 1024);
|
|
json(response, 201, arenaService.enqueue(account, body));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/arena/queue" && request.method === "DELETE") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, arenaService.leaveQueue(account.id));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/arena/ready" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 200, arenaService.ready(account.id));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/arena/socket-ticket" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
json(response, 201, arenaService.socketTicket(account.id));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
|
|
const current = gameDatabase.getManastormConfig();
|
|
json(response, 200, {
|
|
revision: current.revision,
|
|
updatedAt: current.updatedAt,
|
|
config: publicManastormAdminConfig(current.config),
|
|
});
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/manastorm-config" && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
const current = gameDatabase.getManastormConfig();
|
|
json(response, 200, {
|
|
revision: current.revision,
|
|
updatedAt: current.updatedAt,
|
|
config: current.config,
|
|
});
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/manastorm-config" && request.method === "PUT") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
console.warn(`Denied Manastorm GM write for ${account.username} (${account.id})`);
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
const body = await readJson(request, bodyLimit);
|
|
const result = gameDatabase.putManastormConfig(account, body.expectedRevision, body.config);
|
|
console.info(`Manastorm configuration revision ${result.revision} published by ${account.username}`);
|
|
json(response, 200, result);
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/companion-cooldown-policy" && request.method === "GET") {
|
|
const current = gameDatabase.getCompanionCooldownPolicy();
|
|
json(response, 200, {
|
|
revision: current.revision,
|
|
updatedAt: current.updatedAt,
|
|
config: current.config,
|
|
});
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/companion-cooldown-policy" && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
json(response, 200, gameDatabase.getCompanionCooldownPolicy());
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/companion-cooldown-policy" && request.method === "PUT") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
const body = await readJson(request, bodyLimit);
|
|
const result = gameDatabase.putCompanionCooldownPolicy(account, body.expectedRevision, body.config);
|
|
console.info(`Companion cooldown policy revision ${result.revision} published by ${account.username}`);
|
|
json(response, 200, result);
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/companion-spec-readiness" && request.method === "GET") {
|
|
const current = gameDatabase.getCompanionSpecReadiness();
|
|
json(response, 200, {
|
|
revision: current.revision,
|
|
updatedAt: current.updatedAt,
|
|
config: current.config,
|
|
});
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/companion-spec-readiness" && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
json(response, 200, gameDatabase.getCompanionSpecReadiness());
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/companion-spec-readiness" && request.method === "PUT") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
const body = await readJson(request, bodyLimit);
|
|
const result = gameDatabase.putCompanionSpecReadiness(account, body.expectedRevision, body.config);
|
|
console.info(`Companion readiness revision ${result.revision} published by ${account.username}`);
|
|
json(response, 200, result);
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
json(response, 200, { jobs: balanceLabJobs.list() });
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/balance-lab/runtime" && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
json(response, 200, { runtime: balanceLabJobs.runtime() });
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/balance-lab/jobs/batch" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
const jobs = balanceLabJobs.startMany(await readJson(request, 4 * 1024 * 1024), account);
|
|
console.info(`Balance Lab batch of ${jobs.length} jobs queued by ${account.username}`);
|
|
json(response, 202, { jobs });
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
const job = balanceLabJobs.start(await readJson(request, 32 * 1024), account);
|
|
console.info(`Balance Lab job ${job.id} queued by ${account.username}`);
|
|
json(response, 202, { job });
|
|
return;
|
|
}
|
|
const balanceLabArtifactMatch = /^\/api\/gm\/balance-lab\/jobs\/([0-9a-f-]{36})\/artifacts\/([A-Za-z0-9._-]+)$/.exec(url.pathname);
|
|
if (balanceLabArtifactMatch && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
const artifact = balanceLabJobs.artifact(balanceLabArtifactMatch[1], balanceLabArtifactMatch[2]);
|
|
response.writeHead(200, {
|
|
"Content-Type": artifact.contentType,
|
|
"Content-Length": artifact.size,
|
|
"Cache-Control": "no-store",
|
|
"Content-Disposition": `inline; filename="${balanceLabArtifactMatch[2]}"`,
|
|
"X-Content-Type-Options": "nosniff",
|
|
});
|
|
artifact.stream().pipe(response);
|
|
return;
|
|
}
|
|
const balanceLabJobMatch = /^\/api\/gm\/balance-lab\/jobs\/([0-9a-f-]{36})$/.exec(url.pathname);
|
|
if (balanceLabJobMatch && request.method === "GET") {
|
|
const account = gameDatabase.authenticate(bearerToken(request));
|
|
if (!gameDatabase.isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
json(response, 200, { job: balanceLabJobs.get(balanceLabJobMatch[1]) });
|
|
return;
|
|
}
|
|
if (url.pathname.startsWith("/api/")) {
|
|
json(response, 404, { error: "API route not found.", code: "not_found" });
|
|
return;
|
|
}
|
|
if (serveBundledContent(request, response, url.pathname)) return;
|
|
if (url.pathname === "/content/bootstrap-manifest.json" || url.pathname.startsWith("/content/bundled/")) {
|
|
if (serveStatic(request, response, staticDir, url.pathname, {
|
|
spaFallback: false,
|
|
immutable: url.pathname.startsWith("/content/bundled/"),
|
|
noCache: url.pathname === "/content/bootstrap-manifest.json",
|
|
})) return;
|
|
}
|
|
if (url.pathname === "/content" || url.pathname.startsWith("/content/")) {
|
|
const contentPath = url.pathname.slice("/content".length) || "/manifest.json";
|
|
const served = serveStatic(request, response, contentDir, contentPath, {
|
|
spaFallback: false,
|
|
immutable: contentPath.startsWith("/objects/"),
|
|
noCache: !contentPath.startsWith("/objects/"),
|
|
});
|
|
if (served) return;
|
|
json(response, 404, { error: "Content file not found.", code: "content_not_found" });
|
|
return;
|
|
}
|
|
const immutableViteAsset = /^\/assets\/.+-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/.test(url.pathname);
|
|
if (serveStatic(request, response, staticDir, url.pathname, { immutable: immutableViteAsset })) return;
|
|
json(response, 404, { error: "Not found.", code: "not_found" });
|
|
} catch (error) {
|
|
const known = error instanceof GameDatabaseError;
|
|
const status = known ? error.status : 500;
|
|
if (status >= 500) console.error(error);
|
|
if (!response.headersSent) json(response, status, {
|
|
error: known ? error.message : "The server could not complete the request.",
|
|
code: known ? error.code : "internal_error",
|
|
});
|
|
else response.destroy(error instanceof Error ? error : undefined);
|
|
}
|
|
});
|
|
|
|
server.on("upgrade", (request, socket, head) => {
|
|
if (!enableShelvedModes && new URL(request.url ?? "/", "http://localhost").pathname === "/ws/arena") {
|
|
const body = Buffer.from(JSON.stringify({
|
|
error: "This feature is temporarily unavailable.",
|
|
code: "feature_disabled",
|
|
}));
|
|
socket.end(`HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\n\r\n${body}`);
|
|
return;
|
|
}
|
|
if (!upgradeArenaWebSocket(request, socket, head, arenaService)) {
|
|
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
}
|
|
});
|
|
|
|
server.on("close", () => {
|
|
balanceLabJobs.close?.();
|
|
onlineSessions.clear();
|
|
arenaService.close();
|
|
gameDatabase.close();
|
|
});
|
|
return { server, database: gameDatabase, arenaService, balanceLabJobs, staticDir, contentDir, enableShelvedModes };
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const host = process.env.HOST ?? "0.0.0.0";
|
|
const port = Number(process.env.PORT ?? 4173);
|
|
const runtime = createGameServer();
|
|
runtime.server.listen(port, host, () => {
|
|
console.log(`Healer Man server listening on http://${host}:${port}`);
|
|
console.log(`Serving ${runtime.staticDir}`);
|
|
console.log(`Content directory: ${runtime.contentDir}`);
|
|
console.log(`SQLite database: ${runtime.database.databasePath}`);
|
|
});
|
|
}
|