feat: About 다이얼로그 추가, UI 최적화 및 서버 캐싱 강화
- About 다이얼로그 추가 (개발자 정보 및 개인정보처리방침) - Markdown 렌더러 구현 (Bold, Italic, Code, Blockquote 지원) - 전투 화면 하단 'About' 및 방문자 카운팅 UI 재배치 및 디자인 통일 - 프로덕션 환경에서 정적 파일 강력 캐싱 설정 (7일 유지) - 파비콘 404 오류 해결을 위한 이모지 데이터 URI 추가 - 모바일 전투 화면 레이아웃 최적화 및 승리 연출 개선 - 일일 운영 지표(Daily Metrics) 수집 API 및 로직 추가
This commit is contained in:
+179
@@ -0,0 +1,179 @@
|
||||
import { getConfig } from "./config.js";
|
||||
import { getDb, hasMongoConfig } from "./db.js";
|
||||
|
||||
const DEFAULT_ABOUT_COLLECTION_NAME = "about_content";
|
||||
const DEVELOPER_INFO_ID = "developer-info";
|
||||
const PRIVACY_POLICY_ID = "privacy-policy";
|
||||
|
||||
const DEFAULT_DEVELOPER_INFO = {
|
||||
alias: "horoli",
|
||||
email: "sunha321@gmail.com",
|
||||
github: "https://github.com/Horoli",
|
||||
};
|
||||
|
||||
const DEFAULT_PRIVACY_POLICY_MARKDOWN = `
|
||||
### 개인정보처리방침 (초안)
|
||||
|
||||
**Arena Picker**는 이용자의 개인정보를 최소한으로 수집하며, 투명하게 관리하기 위해 노력합니다.
|
||||
|
||||
#### 1. 수집하는 개인정보 항목 및 방법
|
||||
본 서비스는 별도의 회원가입 없이 이용 가능하며, 서비스 운영 지표 측정을 위해 아래와 같은 정보를 수집합니다.
|
||||
- **수집 항목**: 방문자 식별값 (브라우저 쿠키를 기반으로 생성된 암호화된 UUID 해시), 방문 일시, 서비스 이용 기록 (전투 시작/종료, 버튼 클릭 등)
|
||||
- **수집 방법**: 서비스 접속 시 자동으로 생성 및 서버로 전송
|
||||
|
||||
#### 2. 개인정보의 수집 및 이용 목적
|
||||
수집된 정보는 오직 서비스 품질 개선 및 통계 분석을 위해서만 활용됩니다.
|
||||
- 중복되지 않는 일일 방문자 수 측정
|
||||
- 서비스 이용 통계 (전투 횟수, 선호 캐릭터 등) 분석
|
||||
- 서비스 안정성 확인 및 버그 진단
|
||||
|
||||
#### 3. 개인정보의 보유 및 이용 기간
|
||||
- 수집된 활동 로그 및 통계 데이터는 수집일로부터 **60일**간 보관 후 복구 불가능한 방법으로 파기됩니다.
|
||||
|
||||
#### 4. 개인정보의 제3자 제공
|
||||
본 서비스는 이용자의 개인정보를 외부에 제공하거나 공유하지 않습니다.
|
||||
|
||||
#### 5. 이용자의 권리
|
||||
이용자는 브라우저의 쿠키를 삭제함으로써 언제든지 식별 정보를 초기화할 수 있습니다.
|
||||
|
||||
**공고일자**: 2024년 5월 23일
|
||||
**시행일자**: 2024년 5월 23일
|
||||
`;
|
||||
|
||||
let aboutCache;
|
||||
let aboutIndexesReady;
|
||||
let aboutWarmupPromise;
|
||||
|
||||
export async function aboutRoutes(fastify) {
|
||||
fastify.get("/about", async () => getAboutContent());
|
||||
fastify.get("/about/", async () => getAboutContent());
|
||||
}
|
||||
|
||||
export async function warmAboutContent() {
|
||||
if (!hasMongoConfig()) {
|
||||
aboutCache = formatAboutContent();
|
||||
return aboutCache;
|
||||
}
|
||||
|
||||
if (!aboutWarmupPromise) {
|
||||
aboutWarmupPromise = loadAboutContent()
|
||||
.then((content) => {
|
||||
aboutCache = content;
|
||||
return content;
|
||||
})
|
||||
.finally(() => {
|
||||
aboutWarmupPromise = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return aboutWarmupPromise;
|
||||
}
|
||||
|
||||
async function getAboutContent() {
|
||||
if (aboutCache) {
|
||||
return aboutCache;
|
||||
}
|
||||
|
||||
return warmAboutContent();
|
||||
}
|
||||
|
||||
async function loadAboutContent() {
|
||||
const collection = await getAboutCollection();
|
||||
await ensureAboutDefaults(collection);
|
||||
|
||||
const [developerInfo, privacyPolicy] = await Promise.all([
|
||||
collection.findOne({ _id: DEVELOPER_INFO_ID }),
|
||||
collection.findOne({ _id: PRIVACY_POLICY_ID }),
|
||||
]);
|
||||
|
||||
return formatAboutContent(developerInfo, privacyPolicy);
|
||||
}
|
||||
|
||||
async function getAboutCollection() {
|
||||
const db = await getDb();
|
||||
const collection = db.collection(
|
||||
getConfig().MONGODB_ABOUT_COLLECTION || DEFAULT_ABOUT_COLLECTION_NAME,
|
||||
);
|
||||
|
||||
await ensureAboutIndexes(collection);
|
||||
return collection;
|
||||
}
|
||||
|
||||
async function ensureAboutIndexes(collection) {
|
||||
if (!aboutIndexesReady) {
|
||||
aboutIndexesReady = collection.createIndex({ type: 1 });
|
||||
}
|
||||
|
||||
return aboutIndexesReady;
|
||||
}
|
||||
|
||||
async function ensureAboutDefaults(collection) {
|
||||
const now = new Date();
|
||||
|
||||
await collection.bulkWrite(
|
||||
[
|
||||
{
|
||||
updateOne: {
|
||||
filter: { _id: DEVELOPER_INFO_ID },
|
||||
update: {
|
||||
$setOnInsert: {
|
||||
_id: DEVELOPER_INFO_ID,
|
||||
type: "developerInfo",
|
||||
...DEFAULT_DEVELOPER_INFO,
|
||||
createdAt: now,
|
||||
},
|
||||
},
|
||||
upsert: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
updateOne: {
|
||||
filter: { _id: PRIVACY_POLICY_ID },
|
||||
update: {
|
||||
$set: {
|
||||
markdown: DEFAULT_PRIVACY_POLICY_MARKDOWN,
|
||||
updatedAt: now,
|
||||
},
|
||||
$setOnInsert: {
|
||||
_id: PRIVACY_POLICY_ID,
|
||||
type: "privacyPolicy",
|
||||
createdAt: now,
|
||||
},
|
||||
},
|
||||
upsert: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
{ ordered: false },
|
||||
);
|
||||
}
|
||||
|
||||
function formatAboutContent(developerInfo = {}, privacyPolicy = {}) {
|
||||
return {
|
||||
developer: normalizeDeveloperInfo(developerInfo),
|
||||
privacyPolicy: {
|
||||
markdown: stringValue(
|
||||
privacyPolicy?.markdown,
|
||||
DEFAULT_PRIVACY_POLICY_MARKDOWN,
|
||||
),
|
||||
updatedAt: dateString(privacyPolicy?.updatedAt || privacyPolicy?.createdAt),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDeveloperInfo(document = {}) {
|
||||
return {
|
||||
alias: stringValue(document?.alias, DEFAULT_DEVELOPER_INFO.alias),
|
||||
email: stringValue(document?.email, DEFAULT_DEVELOPER_INFO.email),
|
||||
github: stringValue(document?.github, DEFAULT_DEVELOPER_INFO.github),
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(...values) {
|
||||
const value = values.find((candidate) => typeof candidate === "string");
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function dateString(value) {
|
||||
return value?.toISOString?.() ?? null;
|
||||
}
|
||||
@@ -11,10 +11,15 @@ const DEFAULT_CONFIG = {
|
||||
MONGODB_PASS: "",
|
||||
MONGODB_URI: "",
|
||||
MONGODB_VISITOR_COLLECTION: "visitors",
|
||||
MONGODB_ABOUT_COLLECTION: "about_content",
|
||||
MONGODB_DAILY_DEATH_COLLECTION: "daily_death_stats",
|
||||
MONGODB_DAILY_METRICS_COLLECTION: "daily_metrics",
|
||||
MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION: "daily_visitor_activity",
|
||||
MONGODB_MAX_POOL_SIZE: 10,
|
||||
MONGODB_SERVER_SELECTION_TIMEOUT_MS: 5000,
|
||||
DEATH_STATS_TIME_ZONE: "Asia/Seoul",
|
||||
ANALYTICS_TIME_ZONE: "Asia/Seoul",
|
||||
DAILY_ACTIVITY_RETENTION_DAYS: 60,
|
||||
COOKIE_SECURE: false,
|
||||
};
|
||||
|
||||
@@ -77,11 +82,26 @@ function normalizeConfig(rawConfig) {
|
||||
mongodb.visitorCollection,
|
||||
DEFAULT_CONFIG.MONGODB_VISITOR_COLLECTION,
|
||||
),
|
||||
MONGODB_ABOUT_COLLECTION: stringValue(
|
||||
rawConfig.MONGODB_ABOUT_COLLECTION,
|
||||
mongodb.aboutCollection,
|
||||
DEFAULT_CONFIG.MONGODB_ABOUT_COLLECTION,
|
||||
),
|
||||
MONGODB_DAILY_DEATH_COLLECTION: stringValue(
|
||||
rawConfig.MONGODB_DAILY_DEATH_COLLECTION,
|
||||
mongodb.dailyDeathCollection,
|
||||
DEFAULT_CONFIG.MONGODB_DAILY_DEATH_COLLECTION,
|
||||
),
|
||||
MONGODB_DAILY_METRICS_COLLECTION: stringValue(
|
||||
rawConfig.MONGODB_DAILY_METRICS_COLLECTION,
|
||||
mongodb.dailyMetricsCollection,
|
||||
DEFAULT_CONFIG.MONGODB_DAILY_METRICS_COLLECTION,
|
||||
),
|
||||
MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION: stringValue(
|
||||
rawConfig.MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION,
|
||||
mongodb.dailyVisitorActivityCollection,
|
||||
DEFAULT_CONFIG.MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION,
|
||||
),
|
||||
MONGODB_MAX_POOL_SIZE: numberValue(
|
||||
rawConfig.MONGODB_MAX_POOL_SIZE,
|
||||
mongodb.maxPoolSize,
|
||||
@@ -98,6 +118,17 @@ function normalizeConfig(rawConfig) {
|
||||
mongodb.deathStatsTimeZone,
|
||||
DEFAULT_CONFIG.DEATH_STATS_TIME_ZONE,
|
||||
),
|
||||
ANALYTICS_TIME_ZONE: stringValue(
|
||||
rawConfig.ANALYTICS_TIME_ZONE,
|
||||
rawConfig.TIME_ZONE,
|
||||
mongodb.analyticsTimeZone,
|
||||
DEFAULT_CONFIG.ANALYTICS_TIME_ZONE,
|
||||
),
|
||||
DAILY_ACTIVITY_RETENTION_DAYS: numberValue(
|
||||
rawConfig.DAILY_ACTIVITY_RETENTION_DAYS,
|
||||
mongodb.dailyActivityRetentionDays,
|
||||
DEFAULT_CONFIG.DAILY_ACTIVITY_RETENTION_DAYS,
|
||||
),
|
||||
COOKIE_SECURE: booleanValue(rawConfig.COOKIE_SECURE, server.cookieSecure, DEFAULT_CONFIG.COOKIE_SECURE),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { getConfig } from "./config.js";
|
||||
import { getDb } from "./db.js";
|
||||
import { readVisitorCookie, isValidVisitorId } from "./visitorCookie.js";
|
||||
|
||||
const DEFAULT_DAILY_METRICS_COLLECTION_NAME = "daily_metrics";
|
||||
const DEFAULT_DAILY_VISITOR_ACTIVITY_COLLECTION_NAME = "daily_visitor_activity";
|
||||
const DEFAULT_ACTIVITY_RETENTION_DAYS = 60;
|
||||
|
||||
const METRIC_FIELDS = [
|
||||
"uniqueVisitors",
|
||||
"totalVisits",
|
||||
"totalMatchStarts",
|
||||
"totalMatchFinishes",
|
||||
"visitorsWithTwoOrMoreMatches",
|
||||
"donationClicks",
|
||||
];
|
||||
|
||||
const EVENT_CONFIG = {
|
||||
"match-started": {
|
||||
metricField: "totalMatchStarts",
|
||||
activityField: "matchStarts",
|
||||
},
|
||||
"match-finished": {
|
||||
metricField: "totalMatchFinishes",
|
||||
activityField: "matchFinishes",
|
||||
},
|
||||
"donation-clicked": {
|
||||
metricField: "donationClicks",
|
||||
activityField: "donationClicks",
|
||||
},
|
||||
};
|
||||
|
||||
let metricsIndexesReady;
|
||||
let activityIndexesReady;
|
||||
|
||||
export async function dailyMetricsRoutes(fastify) {
|
||||
fastify.get("/today", async () => {
|
||||
return getTodayDailyMetrics();
|
||||
});
|
||||
|
||||
fastify.post("/match-started", async (request) => {
|
||||
return recordDailyMetricEvent("match-started", {
|
||||
visitorId: readVisitorCookie(request),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/match-finished", async (request) => {
|
||||
return recordDailyMetricEvent("match-finished", {
|
||||
visitorId: readVisitorCookie(request),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/donation-clicked", async (request) => {
|
||||
return recordDailyMetricEvent("donation-clicked", {
|
||||
visitorId: readVisitorCookie(request),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordDailyVisit(visitorId, { now = new Date() } = {}) {
|
||||
const date = dayKey(now);
|
||||
let uniqueVisitors = 0;
|
||||
|
||||
if (isValidVisitorId(visitorId)) {
|
||||
const activityCollection = await getDailyVisitorActivityCollection();
|
||||
const activityId = await ensureDailyVisitorActivity(
|
||||
activityCollection,
|
||||
date,
|
||||
visitorId,
|
||||
now,
|
||||
);
|
||||
const uniqueResult = await activityCollection.updateOne(
|
||||
{
|
||||
_id: activityId,
|
||||
dailyUniqueCounted: { $ne: true },
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
dailyUniqueCounted: true,
|
||||
lastSeenAt: now,
|
||||
},
|
||||
$inc: {
|
||||
visits: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (uniqueResult.modifiedCount > 0) {
|
||||
uniqueVisitors = 1;
|
||||
} else {
|
||||
await activityCollection.updateOne(
|
||||
{ _id: activityId },
|
||||
{
|
||||
$set: {
|
||||
lastSeenAt: now,
|
||||
},
|
||||
$inc: {
|
||||
visits: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return updateDailyMetrics(date, now, {
|
||||
totalVisits: 1,
|
||||
uniqueVisitors,
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordDailyMetricEvent(eventType, { visitorId, now = new Date() } = {}) {
|
||||
const eventConfig = EVENT_CONFIG[eventType];
|
||||
|
||||
if (!eventConfig) {
|
||||
throw new Error(`Unknown daily metric event: ${eventType}`);
|
||||
}
|
||||
|
||||
const date = dayKey(now);
|
||||
const increments = {
|
||||
[eventConfig.metricField]: 1,
|
||||
};
|
||||
|
||||
if (isValidVisitorId(visitorId)) {
|
||||
const countedSecondMatch = await recordDailyVisitorEvent(
|
||||
date,
|
||||
visitorId,
|
||||
eventConfig.activityField,
|
||||
now,
|
||||
);
|
||||
|
||||
if (countedSecondMatch) {
|
||||
increments.visitorsWithTwoOrMoreMatches = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return updateDailyMetrics(date, now, increments);
|
||||
}
|
||||
|
||||
async function recordDailyVisitorEvent(date, visitorId, activityField, now) {
|
||||
const activityCollection = await getDailyVisitorActivityCollection();
|
||||
const activityId = await ensureDailyVisitorActivity(
|
||||
activityCollection,
|
||||
date,
|
||||
visitorId,
|
||||
now,
|
||||
);
|
||||
|
||||
if (activityField !== "matchStarts") {
|
||||
await activityCollection.updateOne(
|
||||
{ _id: activityId },
|
||||
{
|
||||
$set: {
|
||||
lastSeenAt: now,
|
||||
},
|
||||
$inc: {
|
||||
[activityField]: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const secondMatchResult = await activityCollection.updateOne(
|
||||
{
|
||||
_id: activityId,
|
||||
matchStarts: 1,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
lastSeenAt: now,
|
||||
},
|
||||
$inc: {
|
||||
matchStarts: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (secondMatchResult.modifiedCount > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await activityCollection.updateOne(
|
||||
{ _id: activityId },
|
||||
{
|
||||
$set: {
|
||||
lastSeenAt: now,
|
||||
},
|
||||
$inc: {
|
||||
matchStarts: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function ensureDailyVisitorActivity(collection, date, visitorId, now) {
|
||||
const visitorHash = dailyVisitorHash(date, visitorId);
|
||||
const activityId = `${date}:${visitorHash}`;
|
||||
|
||||
await collection.updateOne(
|
||||
{ _id: activityId },
|
||||
{
|
||||
$setOnInsert: {
|
||||
_id: activityId,
|
||||
date,
|
||||
visitorHash,
|
||||
dailyUniqueCounted: false,
|
||||
visits: 0,
|
||||
matchStarts: 0,
|
||||
matchFinishes: 0,
|
||||
donationClicks: 0,
|
||||
firstSeenAt: now,
|
||||
expireAt: retentionDate(now),
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
|
||||
return activityId;
|
||||
}
|
||||
|
||||
async function updateDailyMetrics(date, now, increments) {
|
||||
const collection = await getDailyMetricsCollection();
|
||||
const normalizedIncrements = normalizeIncrements(increments);
|
||||
|
||||
await collection.updateOne(
|
||||
{ _id: date },
|
||||
{
|
||||
$setOnInsert: {
|
||||
_id: date,
|
||||
date,
|
||||
createdAt: now,
|
||||
},
|
||||
$set: {
|
||||
updatedAt: now,
|
||||
},
|
||||
$inc: normalizedIncrements,
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
|
||||
const today = await collection.findOne({ _id: date });
|
||||
return formatDailyMetrics(today, date);
|
||||
}
|
||||
|
||||
async function getTodayDailyMetrics(now = new Date()) {
|
||||
const date = dayKey(now);
|
||||
const collection = await getDailyMetricsCollection();
|
||||
const today = await collection.findOne({ _id: date });
|
||||
|
||||
return formatDailyMetrics(today, date);
|
||||
}
|
||||
|
||||
async function getDailyMetricsCollection() {
|
||||
const db = await getDb();
|
||||
const collection = db.collection(
|
||||
getConfig().MONGODB_DAILY_METRICS_COLLECTION || DEFAULT_DAILY_METRICS_COLLECTION_NAME,
|
||||
);
|
||||
|
||||
await ensureDailyMetricsIndexes(collection);
|
||||
return collection;
|
||||
}
|
||||
|
||||
async function getDailyVisitorActivityCollection() {
|
||||
const db = await getDb();
|
||||
const collection = db.collection(
|
||||
getConfig().MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION
|
||||
|| DEFAULT_DAILY_VISITOR_ACTIVITY_COLLECTION_NAME,
|
||||
);
|
||||
|
||||
await ensureDailyVisitorActivityIndexes(collection);
|
||||
return collection;
|
||||
}
|
||||
|
||||
async function ensureDailyMetricsIndexes(collection) {
|
||||
if (!metricsIndexesReady) {
|
||||
metricsIndexesReady = collection.createIndex({ updatedAt: -1 });
|
||||
}
|
||||
|
||||
return metricsIndexesReady;
|
||||
}
|
||||
|
||||
async function ensureDailyVisitorActivityIndexes(collection) {
|
||||
if (!activityIndexesReady) {
|
||||
activityIndexesReady = Promise.all([
|
||||
collection.createIndex({ date: 1 }),
|
||||
collection.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 }),
|
||||
]);
|
||||
}
|
||||
|
||||
return activityIndexesReady;
|
||||
}
|
||||
|
||||
function normalizeIncrements(increments) {
|
||||
return Object.entries(increments).reduce((result, [field, value]) => {
|
||||
const numericValue = Math.max(0, Math.round(Number(value) || 0));
|
||||
|
||||
if (METRIC_FIELDS.includes(field) && numericValue > 0) {
|
||||
result[field] = numericValue;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function formatDailyMetrics(document, date) {
|
||||
return {
|
||||
date,
|
||||
uniqueVisitors: metricNumber(document?.uniqueVisitors),
|
||||
totalVisits: metricNumber(document?.totalVisits),
|
||||
totalMatchStarts: metricNumber(document?.totalMatchStarts),
|
||||
totalMatchFinishes: metricNumber(document?.totalMatchFinishes),
|
||||
visitorsWithTwoOrMoreMatches: metricNumber(document?.visitorsWithTwoOrMoreMatches),
|
||||
donationClicks: metricNumber(document?.donationClicks),
|
||||
updatedAt: document?.updatedAt?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function metricNumber(value) {
|
||||
return Math.max(0, Math.round(Number(value) || 0));
|
||||
}
|
||||
|
||||
function dailyVisitorHash(date, visitorId) {
|
||||
return createHash("sha256")
|
||||
.update(`${date}:${visitorId}`)
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function retentionDate(now) {
|
||||
const retentionDays = getConfig().DAILY_ACTIVITY_RETENTION_DAYS || DEFAULT_ACTIVITY_RETENTION_DAYS;
|
||||
return new Date(now.getTime() + retentionDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function dayKey(date) {
|
||||
const appConfig = getConfig();
|
||||
const timeZone = appConfig.ANALYTICS_TIME_ZONE || appConfig.DEATH_STATS_TIME_ZONE || "Asia/Seoul";
|
||||
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
})
|
||||
.formatToParts(date)
|
||||
.reduce((result, part) => {
|
||||
result[part.type] = part.value;
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
} catch {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -4,6 +4,8 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getConfig } from "./config.js";
|
||||
import { closeMongoConnection, getMongoClient, hasMongoConfig } from "./db.js";
|
||||
import { aboutRoutes, warmAboutContent } from "./about.js";
|
||||
import { dailyMetricsRoutes } from "./dailyMetrics.js";
|
||||
import { deathStatsRoutes } from "./deathStats.js";
|
||||
import { visitorRoutes } from "./visitors.js";
|
||||
|
||||
@@ -45,12 +47,19 @@ if (!isProduction) {
|
||||
}
|
||||
|
||||
await app.register(visitorRoutes, { prefix: "/api/visitors" });
|
||||
await app.register(aboutRoutes, { prefix: "/api" });
|
||||
await app.register(deathStatsRoutes, { prefix: "/api/death-stats" });
|
||||
await app.register(dailyMetricsRoutes, { prefix: "/api/daily-metrics" });
|
||||
|
||||
if (isProduction) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: distPath,
|
||||
prefix: "/",
|
||||
cacheControl: true,
|
||||
maxAge: 3600000 * 24 * 7, // 7일간 캐시 유지
|
||||
immutable: true,
|
||||
lastModified: true,
|
||||
etag: true,
|
||||
});
|
||||
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
@@ -108,11 +117,17 @@ console.log(`Arena Picker listening on http://localhost:${port}`);
|
||||
|
||||
if (hasMongoConfig()) {
|
||||
getMongoClient()
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
console.log("MongoDB connection pool is ready.");
|
||||
try {
|
||||
await warmAboutContent();
|
||||
console.log("About content cache is ready.");
|
||||
} catch (error) {
|
||||
console.error("About content cache warmup failed. API route will retry on request.", error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("MongoDB connection failed. Visitor API will retry on request.", error);
|
||||
console.error("MongoDB connection failed. API routes will retry on request.", error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export const VISITOR_COOKIE_NAME = "arena_visitor_id";
|
||||
export const VISITOR_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 2;
|
||||
|
||||
export function readVisitorCookie(request) {
|
||||
return readCookie(request, VISITOR_COOKIE_NAME);
|
||||
}
|
||||
|
||||
export function writeVisitorCookie(reply, visitorId, { secure = false } = {}) {
|
||||
const secureFlag = secure ? "; Secure" : "";
|
||||
|
||||
reply.header(
|
||||
"Set-Cookie",
|
||||
`${VISITOR_COOKIE_NAME}=${encodeURIComponent(visitorId)}; Path=/; Max-Age=${VISITOR_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax; HttpOnly${secureFlag}`,
|
||||
);
|
||||
}
|
||||
|
||||
export 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,
|
||||
);
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
+10
-39
@@ -1,9 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getConfig } from "./config.js";
|
||||
import { getDb } from "./db.js";
|
||||
import { recordDailyVisit } from "./dailyMetrics.js";
|
||||
import { isValidVisitorId, readVisitorCookie, writeVisitorCookie } from "./visitorCookie.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;
|
||||
|
||||
@@ -28,7 +28,7 @@ async function recordVisitor(request, reply) {
|
||||
const collection = await getVisitorCollection();
|
||||
await ensureVisitorIndexes(collection);
|
||||
|
||||
let visitorId = readCookie(request, COOKIE_NAME);
|
||||
let visitorId = readVisitorCookie(request);
|
||||
const hadValidCookie = isValidVisitorId(visitorId);
|
||||
|
||||
if (!hadValidCookie) {
|
||||
@@ -57,7 +57,13 @@ async function recordVisitor(request, reply) {
|
||||
);
|
||||
|
||||
if (!hadValidCookie || result.upsertedCount > 0) {
|
||||
writeVisitorCookie(reply, visitorId);
|
||||
writeVisitorCookie(reply, visitorId, { secure: getConfig().COOKIE_SECURE });
|
||||
}
|
||||
|
||||
try {
|
||||
await recordDailyVisit(visitorId, { now });
|
||||
} catch (error) {
|
||||
request.log.warn({ err: error }, "Daily visit metrics update failed.");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -80,38 +86,3 @@ async function ensureVisitorIndexes(collection) {
|
||||
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