Add visitor tracking and combat selection features

This commit is contained in:
2026-05-22 13:22:26 +09:00
parent 104bf4fe48
commit 1509b0c5dd
20 changed files with 1049 additions and 1220 deletions
+129
View File
@@ -0,0 +1,129 @@
import fs from "node:fs";
import path from "node:path";
const DEFAULT_CONFIG = {
SERVER_HOST: "0.0.0.0",
SERVER_PORT: 9736,
MONGODB_HOST: "",
MONGODB_PORT: 27017,
MONGODB_DB: "arena",
MONGODB_USER: "",
MONGODB_PASS: "",
MONGODB_URI: "",
MONGODB_VISITOR_COLLECTION: "visitors",
MONGODB_MAX_POOL_SIZE: 10,
MONGODB_SERVER_SELECTION_TIMEOUT_MS: 5000,
COOKIE_SECURE: false,
};
let config;
export function getConfig() {
if (!config) {
config = loadConfig();
}
return config;
}
export function loadConfig(filePath = path.resolve(process.cwd(), "config.json")) {
const rawConfig = fs.existsSync(filePath)
? JSON.parse(fs.readFileSync(filePath, "utf8"))
: {};
return normalizeConfig(rawConfig);
}
export function hasMongoConfig() {
const appConfig = getConfig();
return Boolean(appConfig.MONGODB_URI || appConfig.MONGODB_HOST);
}
export function getMongoUri() {
const appConfig = getConfig();
if (appConfig.MONGODB_URI) {
return appConfig.MONGODB_URI;
}
if (!appConfig.MONGODB_HOST) {
throw new Error("MongoDB configuration is required for visitor tracking.");
}
const host = appConfig.MONGODB_HOST;
const port = Number(appConfig.MONGODB_PORT || DEFAULT_CONFIG.MONGODB_PORT);
const credentials = mongoCredentials(appConfig);
return `mongodb://${credentials}${host}:${port}`;
}
function normalizeConfig(rawConfig) {
const server = rawConfig.server || {};
const mongodb = rawConfig.mongodb || {};
return {
SERVER_HOST: stringValue(rawConfig.SERVER_HOST, server.host, DEFAULT_CONFIG.SERVER_HOST),
SERVER_PORT: numberValue(rawConfig.SERVER_PORT, server.port, DEFAULT_CONFIG.SERVER_PORT),
MONGODB_HOST: stringValue(rawConfig.MONGODB_HOST, mongodb.host, DEFAULT_CONFIG.MONGODB_HOST),
MONGODB_PORT: numberValue(rawConfig.MONGODB_PORT, mongodb.port, DEFAULT_CONFIG.MONGODB_PORT),
MONGODB_DB: stringValue(rawConfig.MONGODB_DB, mongodb.db, DEFAULT_CONFIG.MONGODB_DB),
MONGODB_USER: stringValue(rawConfig.MONGODB_USER, mongodb.user, DEFAULT_CONFIG.MONGODB_USER),
MONGODB_PASS: stringValue(rawConfig.MONGODB_PASS, mongodb.pass, DEFAULT_CONFIG.MONGODB_PASS),
MONGODB_URI: stringValue(rawConfig.MONGODB_URI, mongodb.uri, DEFAULT_CONFIG.MONGODB_URI),
MONGODB_VISITOR_COLLECTION: stringValue(
rawConfig.MONGODB_VISITOR_COLLECTION,
mongodb.visitorCollection,
DEFAULT_CONFIG.MONGODB_VISITOR_COLLECTION,
),
MONGODB_MAX_POOL_SIZE: numberValue(
rawConfig.MONGODB_MAX_POOL_SIZE,
mongodb.maxPoolSize,
DEFAULT_CONFIG.MONGODB_MAX_POOL_SIZE,
),
MONGODB_SERVER_SELECTION_TIMEOUT_MS: numberValue(
rawConfig.MONGODB_SERVER_SELECTION_TIMEOUT_MS,
mongodb.serverSelectionTimeoutMs,
DEFAULT_CONFIG.MONGODB_SERVER_SELECTION_TIMEOUT_MS,
),
COOKIE_SECURE: booleanValue(rawConfig.COOKIE_SECURE, server.cookieSecure, DEFAULT_CONFIG.COOKIE_SECURE),
};
}
function mongoCredentials(appConfig) {
if (!appConfig.MONGODB_USER) {
return "";
}
const user = encodeURIComponent(appConfig.MONGODB_USER);
const pass = encodeURIComponent(appConfig.MONGODB_PASS || "");
return `${user}:${pass}@`;
}
function stringValue(...values) {
const value = values.find((candidate) => typeof candidate === "string" && candidate.length > 0);
return value ?? "";
}
function numberValue(...values) {
const value = values.find((candidate) => {
const numericValue = Number(candidate);
return Number.isFinite(numericValue) && numericValue > 0;
});
return Number(value);
}
function booleanValue(...values) {
const value = values.find((candidate) => typeof candidate === "boolean" || candidate === "true" || candidate === "false");
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
return Boolean(value);
}
+52
View File
@@ -0,0 +1,52 @@
import { MongoClient } from "mongodb";
import { getConfig, getMongoUri, hasMongoConfig } from "./config.js";
const DEFAULT_DB_NAME = "arena_picker";
let mongoClient;
let mongoClientPromise;
export { hasMongoConfig };
export async function getMongoClient() {
if (mongoClient) {
return mongoClient;
}
if (!mongoClientPromise) {
const appConfig = getConfig();
const client = new MongoClient(getMongoUri(), {
maxPoolSize: appConfig.MONGODB_MAX_POOL_SIZE,
serverSelectionTimeoutMS: appConfig.MONGODB_SERVER_SELECTION_TIMEOUT_MS,
});
mongoClientPromise = client
.connect()
.then((connectedClient) => {
mongoClient = connectedClient;
return connectedClient;
})
.catch((error) => {
mongoClientPromise = undefined;
throw error;
});
}
return mongoClientPromise;
}
export async function getDb() {
const client = await getMongoClient();
return client.db(getConfig().MONGODB_DB || DEFAULT_DB_NAME);
}
export async function closeMongoConnection() {
if (!mongoClient && !mongoClientPromise) {
return;
}
const client = mongoClient || (await mongoClientPromise);
mongoClient = undefined;
mongoClientPromise = undefined;
await client.close();
}
+112
View File
@@ -0,0 +1,112 @@
import fastifyMiddie from "@fastify/middie";
import fastifyStatic from "@fastify/static";
import Fastify from "fastify";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getConfig } from "./config.js";
import { closeMongoConnection, getMongoClient, hasMongoConfig } from "./db.js";
import { visitorRoutes } from "./visitors.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const distPath = path.join(root, "dist");
const isProduction = process.env.NODE_ENV === "production" || process.argv.includes("--production");
const appConfig = getConfig();
const port = appConfig.SERVER_PORT;
const host = appConfig.SERVER_HOST;
const app = Fastify({
bodyLimit: 16 * 1024,
});
app.addContentTypeParser("*", { parseAs: "string" }, (request, body, done) => {
done(null, body);
});
app.get("/api/health", async () => {
return {
ok: true,
dbConfigured: hasMongoConfig(),
};
});
await app.register(visitorRoutes, { prefix: "/api/visitors" });
if (isProduction) {
await app.register(fastifyStatic, {
root: distPath,
prefix: "/",
});
app.setNotFoundHandler((request, reply) => {
const acceptsHtml = String(request.headers.accept || "").includes("text/html");
if (request.method !== "GET" || request.url.startsWith("/api/") || !acceptsHtml) {
reply.code(404).send({ error: "not_found" });
return;
}
reply.sendFile("index.html");
});
} else {
await app.register(fastifyMiddie);
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
root,
server: {
middlewareMode: true,
hmr: {
server: app.server,
},
},
appType: "spa",
});
app.use((request, response, next) => {
if (request.url?.startsWith("/api/")) {
next();
return;
}
vite.middlewares(request, response, next);
});
}
app.setErrorHandler((error, request, reply) => {
const isMissingMongoConfig = error.message.includes("MongoDB configuration");
const status = isMissingMongoConfig ? 503 : 500;
console.error(error);
reply.code(status).send({
error: isMissingMongoConfig ? "mongodb_not_configured" : "internal_server_error",
message: isProduction ? "Visitor tracking is unavailable." : error.message,
});
});
await app.listen({ port, host });
console.log(`Arena Picker listening on http://localhost:${port}`);
if (hasMongoConfig()) {
getMongoClient()
.then(() => {
console.log("MongoDB connection pool is ready.");
})
.catch((error) => {
console.error("MongoDB connection failed. Visitor API will retry on request.", error);
});
}
["SIGINT", "SIGTERM"].forEach((signal) => {
process.on(signal, () => {
shutdown(signal);
});
});
function shutdown(signal) {
console.log(`${signal} received. Closing server.`);
app.close()
.then(closeMongoConnection)
.finally(() => {
process.exit(0);
});
}
+117
View File
@@ -0,0 +1,117 @@
import { randomUUID } from "node:crypto";
import { getConfig } from "./config.js";
import { getDb } from "./db.js";
const COOKIE_NAME = "arena_visitor_id";
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 2;
const DEFAULT_COLLECTION_NAME = "visitors";
const USER_AGENT_LIMIT = 500;
let indexesReady;
export async function visitorRoutes(fastify) {
fastify.post("/check", async (request, reply) => {
return recordVisitor(request, reply);
});
fastify.get("/stats", async () => {
const collection = await getVisitorCollection();
await ensureVisitorIndexes(collection);
return {
uniqueVisitors: await collection.countDocuments(),
};
});
}
async function recordVisitor(request, reply) {
const collection = await getVisitorCollection();
await ensureVisitorIndexes(collection);
let visitorId = readCookie(request, COOKIE_NAME);
const hadValidCookie = isValidVisitorId(visitorId);
if (!hadValidCookie) {
visitorId = randomUUID();
}
const now = new Date();
const userAgent = String(request.headers["user-agent"] || "").slice(0, USER_AGENT_LIMIT);
const result = await collection.updateOne(
{ _id: visitorId },
{
$setOnInsert: {
_id: visitorId,
firstSeenAt: now,
firstUserAgent: userAgent,
},
$set: {
lastSeenAt: now,
lastUserAgent: userAgent,
},
$inc: {
visits: 1,
},
},
{ upsert: true },
);
if (!hadValidCookie || result.upsertedCount > 0) {
writeVisitorCookie(reply, visitorId);
}
return {
isNewVisitor: result.upsertedCount > 0,
uniqueVisitors: await collection.countDocuments(),
checkedAt: now.toISOString(),
};
}
async function getVisitorCollection() {
const db = await getDb();
return db.collection(getConfig().MONGODB_VISITOR_COLLECTION || DEFAULT_COLLECTION_NAME);
}
async function ensureVisitorIndexes(collection) {
if (!indexesReady) {
indexesReady = collection.createIndex({ lastSeenAt: -1 });
}
return indexesReady;
}
function readCookie(request, name) {
const cookieHeader = request.headers.cookie;
if (!cookieHeader) {
return "";
}
const cookies = cookieHeader.split(";").map((cookie) => cookie.trim());
const matchedCookie = cookies.find((cookie) => cookie.startsWith(`${name}=`));
if (!matchedCookie) {
return "";
}
try {
return decodeURIComponent(matchedCookie.slice(name.length + 1));
} catch {
return "";
}
}
function writeVisitorCookie(reply, visitorId) {
const secureFlag = getConfig().COOKIE_SECURE ? "; Secure" : "";
reply.header(
"Set-Cookie",
`${COOKIE_NAME}=${encodeURIComponent(visitorId)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax; HttpOnly${secureFlag}`,
);
}
function isValidVisitorId(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
}