arena/server/visitors.js

118 lines
2.8 KiB
JavaScript

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,
);
}