diff options
Diffstat (limited to 'lib/network/get-client-ip.ts')
| -rw-r--r-- | lib/network/get-client-ip.ts | 116 |
1 files changed, 116 insertions, 0 deletions
diff --git a/lib/network/get-client-ip.ts b/lib/network/get-client-ip.ts new file mode 100644 index 00000000..566700e9 --- /dev/null +++ b/lib/network/get-client-ip.ts @@ -0,0 +1,116 @@ +import { NextRequest } from 'next/server'; + +export interface GetClientIpOptions { + /** + * Which index to take from a comma-separated forwarded-for list. + * 0 = leftmost (original client; default). + * -1 = rightmost (closest hop). + */ + forwardedIndex?: number; + + /** + * Custom header priority override. Default uses common reverse-proxy headers. + */ + headerPriority?: readonly string[]; + + /** + * Value to return when nothing found. + * Default: 'unknown' + */ + fallback?: string; +} + +const DEFAULT_HEADER_PRIORITY = [ + 'x-forwarded-for', + 'x-real-ip', + 'cf-connecting-ip', + 'vercel-forwarded-for', +] as const; + +export function getClientIp( + req: NextRequest, + opts: GetClientIpOptions = {} +): string { + const { + forwardedIndex = 0, + headerPriority = DEFAULT_HEADER_PRIORITY, + fallback = 'unknown', + } = opts; + + for (const h of headerPriority) { + const raw = req.headers.get(h); + if (!raw) continue; + + // headers like x-forwarded-for can be CSV + const parts = raw.split(',').map(p => p.trim()).filter(Boolean); + if (parts.length === 0) continue; + + const idx = + forwardedIndex >= 0 + ? forwardedIndex + : parts.length + forwardedIndex; // support -1 end indexing + + const sel = parts[idx] ?? parts[0]; // safe fallback to 0 + const norm = normalizeIp(sel); + if (norm) return norm; + } + + return fallback; +} + +/** + * Normalize IPv4/IPv6/port-suffixed forms to a canonical-ish string. + */ +export function normalizeIp(raw: string | null | undefined): string { + if (!raw) return ''; + + let ip = raw.trim(); + + // Strip brackets: [2001:db8::1] + if (ip.startsWith('[') && ip.endsWith(']')) { + ip = ip.slice(1, -1); + } + + // IPv4:port (because some proxies send ip:port) + // Heuristic: if '.' present (IPv4-ish) and ':' present, drop port after last colon. + if (ip.includes('.') && ip.includes(':')) { + ip = ip.slice(0, ip.lastIndexOf(':')); + } + + // IPv4-mapped IPv6 ::ffff:203.0.113.5 + if (ip.startsWith('::ffff:')) { + ip = ip.substring(7); + } + + return ip; +} + + +export interface RequestInfo { + ip: string; + userAgent: string; + referer?: string; + requestId: string; + method: string; + url: string; + } + + export function getRequestInfo(req: NextRequest, ipOpts?: GetClientIpOptions): RequestInfo { + const ip = getClientIp(req, ipOpts); + const userAgent = req.headers.get('user-agent')?.trim() || 'unknown'; + + const refererRaw = req.headers.get('referer')?.trim(); + const referer = refererRaw && refererRaw.length > 0 ? refererRaw : undefined; + + const { href, pathname } = req.nextUrl; + + return { + ip, + userAgent, + referer, + requestId: (globalThis.crypto?.randomUUID?.() ?? crypto.randomUUID()), + method: req.method, + url: href || pathname, + }; + } +
\ No newline at end of file |
