import { createHash } from 'node:crypto' import { getClientIp } from './request-ip' const ANALYTICS_WINDOW_MS = 60 * 1000 const ANALYTICS_PAGEVIEW_LIMIT = 120 const ANALYTICS_HEARTBEAT_LIMIT = 240 const ANALYTICS_BUCKET_LIMIT = 20000 const analyticsBuckets = new Map() let lastCleanupAt = 0 /** * 통계 제한 키에 사용할 IP 해시를 생성한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {'pageview'|'heartbeat'} kind - 통계 요청 종류 * @returns {string} 제한 버킷 키 */ const createAnalyticsLimitKey = (event, kind) => createHash('sha256') .update(`${kind}:${getClientIp(event)}`) .digest('hex') /** * 만료된 통계 제한 버킷을 정리한다. * @param {number} now - 현재 시각 * @returns {void} */ const cleanupAnalyticsBuckets = (now) => { if (now - lastCleanupAt < ANALYTICS_WINDOW_MS && analyticsBuckets.size < ANALYTICS_BUCKET_LIMIT) { return } lastCleanupAt = now for (const [key, bucket] of analyticsBuckets) { if (bucket.expiresAt <= now) { analyticsBuckets.delete(key) } } while (analyticsBuckets.size >= ANALYTICS_BUCKET_LIMIT) { const oldestKey = analyticsBuckets.keys().next().value if (!oldestKey) { break } analyticsBuckets.delete(oldestKey) } } /** * 현재 IP의 통계 쓰기 요청을 허용할지 확인한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {'pageview'|'heartbeat'} kind - 통계 요청 종류 * @returns {boolean} 허용 여부 */ export const shouldAcceptAnalyticsRequest = (event, kind) => { const now = Date.now() const limit = kind === 'heartbeat' ? ANALYTICS_HEARTBEAT_LIMIT : ANALYTICS_PAGEVIEW_LIMIT const key = createAnalyticsLimitKey(event, kind) cleanupAnalyticsBuckets(now) const current = analyticsBuckets.get(key) if (!current || current.expiresAt <= now) { analyticsBuckets.set(key, { count: 1, expiresAt: now + ANALYTICS_WINDOW_MS }) return true } current.count += 1 return current.count <= limit }