import { createHash, createHmac, randomBytes } from 'node:crypto'; import ipaddr from 'ipaddr.js'; import type { IncomingHttpHeaders } from 'node:http'; export class ApiError extends Error { constructor( public status: number, public code: string, message: string, ) { super(message); } } export const fail = (status: number, code: string, message: string): never => { throw new ApiError(status, code, message); }; export const randomToken = () => randomBytes(32).toString('base64url'); export const hash = (value: string) => createHash('sha256').update(value).digest('hex'); export const hmac = (secret: string, value: string) => createHmac('sha256', secret).update(value).digest('hex'); export function normalizeIp(value: string): string | null { try { return ipaddr.process(value).toString(); } catch { return null; } } export function clientIp( remote: string | undefined, headers: IncomingHttpHeaders, trusted: string[], ): string | null { const first = remote ? normalizeIp(remote) : null; if (!first) return null; const trusts = (v: string) => trusted.some((rule) => { try { const a = ipaddr.process(v); const cidr = rule.includes('/') ? ipaddr.parseCIDR(rule) : ([ipaddr.process(rule), a.kind() === 'ipv4' ? 32 : 128] as [ ipaddr.IPv4 | ipaddr.IPv6, number, ]); return a.kind() === cidr[0].kind() && a.match(cidr); } catch { return false; } }); if (!trusts(first)) return first; const raw = headers['x-forwarded-for']; if (typeof raw !== 'string') return null; const chain = raw.split(',').map((v) => normalizeIp(v.trim())); if (chain.some((v) => !v)) return null; let current = first; for (const next of chain.reverse()) { if (!trusts(current)) break; current = next!; } return current; } export function publicIp(ip: string | null): boolean { if (!ip) return false; try { return ipaddr.process(ip).range() === 'unicast'; } catch { return false; } } export class Limiter { private windows = new Map(); hit(key: string, max: number, windowMs = 60000) { const now = Date.now(); let w = this.windows.get(key); if (!w || w.until <= now) { w = { count: 0, until: now + windowMs }; this.windows.set(key, w); } if (++w.count > max) fail(429, 'RATE_LIMIT', '요청이 많습니다. 잠시 후 다시 시도해 주세요.'); } prune() { const now = Date.now(); for (const [key, w] of this.windows) if (w.until <= now) this.windows.delete(key); } }