summaryrefslogtreecommitdiff
path: root/lib/network
diff options
context:
space:
mode:
Diffstat (limited to 'lib/network')
-rw-r--r--lib/network/get-client-ip.ts116
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