Add visitor tracking and combat selection features
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user