Files
sori.studio/server/utils/analytics-request-limit.js
zenn de767095ba v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:05:40 +09:00

72 lines
2.0 KiB
JavaScript

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
}